Merged in feature/fix_test_timeouts (pull request #194)

text extractions tests

* increase timeout

for slow ci/cd systems

* add tests
This commit is contained in:
Jay Brown
2025-12-04 22:45:15 +00:00
parent 3bdc27f4de
commit a94637ab13
14 changed files with 2071 additions and 270 deletions
+244 -211
View File
@@ -728,6 +728,10 @@ type Folder struct {
// Id Folder ID
Id openapi_types.UUID `json:"id"`
// Metrics Processing metrics for this folder. Only populated when metrics=true
// is specified in the request. Null otherwise.
Metrics nullable.Nullable[FolderMetricsInline] `json:"metrics,omitempty"`
// ParentId Parent folder ID
ParentId nullable.Nullable[openapi_types.UUID] `json:"parentId,omitempty"`
@@ -770,6 +774,15 @@ type FolderMetrics struct {
TotalDocuments int32 `json:"totalDocuments"`
}
// FolderMetricsInline Inline processing metrics for a folder (excludes folderId since it's on parent)
type FolderMetricsInline struct {
// ByLabel Document counts by label
ByLabel map[string]int32 `json:"byLabel"`
// TotalDocuments Total number of documents in folder
TotalDocuments int32 `json:"totalDocuments"`
}
// FolderRename Request to rename an existing folder
type FolderRename struct {
// Path New folder path (must start with /). Use "/" for root folder.
@@ -1040,6 +1053,13 @@ type UploadDocumentBatchMultipartBody struct {
Archive openapi_types.File `json:"archive"`
}
// ListClientFoldersParams defines parameters for ListClientFolders.
type ListClientFoldersParams struct {
// Metrics When true, include processing metrics for each folder (document counts by label).
// Defaults to false for performance reasons.
Metrics *bool `form:"metrics,omitempty" json:"metrics,omitempty"`
}
// GetCurrentFieldExtractionParams defines parameters for GetCurrentFieldExtraction.
type GetCurrentFieldExtractionParams struct {
// DocumentId The document ID
@@ -1185,7 +1205,7 @@ type ServerInterface interface {
TriggerExport(ctx echo.Context, id ClientID) error
// List folders for a client
// (GET /client/{id}/folders)
ListClientFolders(ctx echo.Context, id ClientID) error
ListClientFolders(ctx echo.Context, id ClientID, params ListClientFoldersParams) error
// Get client sync status
// (GET /client/{id}/status)
GetStatusByClientId(ctx echo.Context, id ClientID) error
@@ -1702,8 +1722,17 @@ func (w *ServerInterfaceWrapper) ListClientFolders(ctx echo.Context) error {
ctx.Set(JwtAuthScopes, []string{})
// Parameter object where we will unmarshal all parameters from the context
var params ListClientFoldersParams
// ------------- Optional query parameter "metrics" -------------
err = runtime.BindQueryParameter("form", true, false, "metrics", ctx.QueryParams(), &params.Metrics)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter metrics: %s", err))
}
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.ListClientFolders(ctx, id)
err = w.Handler.ListClientFolders(ctx, id, params)
return err
}
@@ -2191,215 +2220,219 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"mArJi+O7lcm/fvj6/wIAAP//vFXFz1tGAQA=",
"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",
}
// GetSwagger returns the content of the embedded swagger specification file
+19 -1
View File
@@ -95,10 +95,15 @@ func (s *Controllers) GetFolderDocuments(ctx echo.Context, folderId openapi_type
// ListClientFolders retrieves all folders for a client.
// GET /client/{id}/folders
// Parameters:
// - id: Client ID
// - params: Query parameters including optional metrics flag
//
// Returns a FolderList containing all folders for the client.
// Each folder includes its ID, path, and parentId, allowing clients to reconstruct the folder hierarchy.
// Root-level folders will have parentId set to null.
func (s *Controllers) ListClientFolders(ctx echo.Context, id ClientID) error {
// When params.Metrics is true, each folder includes processing metrics (document counts by label).
func (s *Controllers) ListClientFolders(ctx echo.Context, id ClientID, params ListClientFoldersParams) error {
folders, err := s.svc.Folder.GetFoldersByClient(ctx.Request().Context(), string(id))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
@@ -108,6 +113,19 @@ func (s *Controllers) ListClientFolders(ctx echo.Context, id ClientID) error {
apiFolders := make([]Folder, len(folders))
for i, f := range folders {
apiFolders[i] = convertRepoFolderToAPI(f)
// Populate metrics if requested
if params.Metrics != nil && *params.Metrics {
metrics, metricsErr := s.svc.Folder.GetFolderMetrics(ctx.Request().Context(), f.ID)
if metricsErr != nil {
// Log error but continue - metrics are optional enhancement
continue
}
apiFolders[i].Metrics = nullable.NewNullableWithValue(FolderMetricsInline{
TotalDocuments: metrics.TotalDocuments,
ByLabel: metrics.ByLabel,
})
}
}
return ctx.JSON(http.StatusOK, FolderList{
+129 -2
View File
@@ -495,7 +495,8 @@ func TestListClientFolders_Success(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
err = ctrl.ListClientFolders(c, queryapi.ClientID(clientID))
// Call with empty params (metrics=false by default)
err = ctrl.ListClientFolders(c, queryapi.ClientID(clientID), queryapi.ListClientFoldersParams{})
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
@@ -510,6 +511,11 @@ func TestListClientFolders_Success(t *testing.T) {
assert.Equal(t, "/folder1", resp.Folders[0].Path)
assert.Equal(t, "/folder1/subfolder", resp.Folders[1].Path)
assert.Equal(t, "/folder2", resp.Folders[2].Path)
// Metrics should not be specified when metrics=false
for _, f := range resp.Folders {
assert.False(t, f.Metrics.IsSpecified(), "metrics should not be specified when metrics param is false/omitted")
}
}
func TestListClientFolders_EmptyResult(t *testing.T) {
@@ -520,7 +526,7 @@ func TestListClientFolders_EmptyResult(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
err := ctrl.ListClientFolders(c, queryapi.ClientID(clientID))
err := ctrl.ListClientFolders(c, queryapi.ClientID(clientID), queryapi.ListClientFoldersParams{})
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
@@ -531,3 +537,124 @@ func TestListClientFolders_EmptyResult(t *testing.T) {
// Should have empty folders array
assert.Len(t, resp.Folders, 0)
}
func TestListClientFolders_WithMetrics(t *testing.T) {
ctrl, cfg, clientID := setupFoldersTestController(t)
ctx := t.Context()
// Create a folder
folderRec, err := cfg.GetDBQueries().CreateFolder(ctx, &repository.CreateFolderParams{
Path: "/metrics-folder",
Clientid: clientID,
Createdby: "admin@example.com",
})
require.NoError(t, err)
// Create documents in the folder
filename1 := "doc1.pdf"
docID1, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientID,
Hash: fmt.Sprintf("listmetricshash1-%s", uuid.New().String()[:8]),
Filename: &filename1,
Folderid: &folderRec.ID,
})
require.NoError(t, err)
filename2 := "doc2.pdf"
docID2, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientID,
Hash: fmt.Sprintf("listmetricshash2-%s", uuid.New().String()[:8]),
Filename: &filename2,
Folderid: &folderRec.ID,
})
require.NoError(t, err)
// Apply labels
_, err = cfg.GetDBQueries().ApplyLabel(ctx, &repository.ApplyLabelParams{
Documentid: docID1,
Label: "Ingested",
Appliedby: "user@example.com",
})
require.NoError(t, err)
_, err = cfg.GetDBQueries().ApplyLabel(ctx, &repository.ApplyLabelParams{
Documentid: docID2,
Label: "Ingested",
Appliedby: "user@example.com",
})
require.NoError(t, err)
_, err = cfg.GetDBQueries().ApplyLabel(ctx, &repository.ApplyLabelParams{
Documentid: docID2,
Label: "OCR_Processed",
Appliedby: "user@example.com",
})
require.NoError(t, err)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/client/%s/folders?metrics=true", clientID), nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// Call with metrics=true
metricsTrue := true
err = ctrl.ListClientFolders(c, queryapi.ClientID(clientID), queryapi.ListClientFoldersParams{
Metrics: &metricsTrue,
})
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.FolderList
err = json.Unmarshal(rec.Body.Bytes(), &resp)
require.NoError(t, err)
// Should have 1 folder
require.Len(t, resp.Folders, 1)
// Metrics should be populated
folder := resp.Folders[0]
assert.True(t, folder.Metrics.IsSpecified(), "metrics should be specified when metrics=true")
metricsVal, err := folder.Metrics.Get()
require.NoError(t, err)
assert.Equal(t, int32(2), metricsVal.TotalDocuments)
assert.Equal(t, int32(2), metricsVal.ByLabel["Ingested"])
assert.Equal(t, int32(1), metricsVal.ByLabel["OCR_Processed"])
}
func TestListClientFolders_MetricsFalseExplicit(t *testing.T) {
ctrl, cfg, clientID := setupFoldersTestController(t)
ctx := t.Context()
// Create a folder
_, err := cfg.GetDBQueries().CreateFolder(ctx, &repository.CreateFolderParams{
Path: "/no-metrics-folder",
Clientid: clientID,
Createdby: "admin@example.com",
})
require.NoError(t, err)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/client/%s/folders?metrics=false", clientID), nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// Call with metrics=false explicitly
metricsFalse := false
err = ctrl.ListClientFolders(c, queryapi.ClientID(clientID), queryapi.ListClientFoldersParams{
Metrics: &metricsFalse,
})
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var resp queryapi.FolderList
err = json.Unmarshal(rec.Body.Bytes(), &resp)
require.NoError(t, err)
// Should have 1 folder
require.Len(t, resp.Folders, 1)
// Metrics should NOT be specified
folder := resp.Folders[0]
assert.False(t, folder.Metrics.IsSpecified(), "metrics should not be specified when metrics=false")
}
+280
View File
@@ -359,6 +359,88 @@ Cancels a batch upload that is currently processing.
## Folder Operations (FolderService)
### Folder Path Constraints
All folder paths must comply with the following constraints when creating or renaming folders:
- **Must start with `/`**: All paths must begin with a forward slash
- **Maximum path length**: 1000 characters
- **Maximum segment length**: 255 characters per path segment
- **Maximum depth**: 20 levels of nesting
- **Valid characters only**: Alphanumeric (`a-z`, `A-Z`, `0-9`), hyphen (`-`), underscore (`_`), period (`.`), and space (` `)
- **Segment must start with alphanumeric**: Each path segment must begin with a letter or number
- **No empty segments**: Double slashes (`//`) or trailing slashes are not allowed
- **No path traversal**: Sequences like `..` or `./` are blocked
- **No hidden folders**: Segments cannot start with a period (e.g., `/.hidden`)
- **No leading/trailing whitespace**: Spaces within segment names are allowed, but not at the start or end
- **No control characters**: ASCII control characters (0x00-0x1F, 0x7F) are blocked
- **No Windows reserved names**: Names like `CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9` are blocked
- **Root folder restrictions**: The root path `/` can only exist once per client and cannot be renamed
- **Path uniqueness**: Each path must be unique within a client (enforced by database)
- **Unicode normalization**: Paths are normalized to NFC form
**Valid path examples**:
- `/documents`
- `/documents/2024/Q1`
- `/client-data/reports_2024`
- `/My Documents/Archive`
**Invalid path examples**:
- `documents` (missing leading `/`)
- `/documents/` (trailing slash)
- `/foo//bar` (empty segment)
- `/foo/../bar` (path traversal)
- `/.hidden` (dot prefix)
- `/foo@bar` (invalid character)
- `/CON` (Windows reserved name)
---
### `GET /clients/{clientId}/folders`
Lists all folders for a specific client, including the folder hierarchy.
**Security**: Requires JWT authentication
**Path Parameters**:
- `clientId`: Client external ID (string, max 36 chars)
**Response** (`200 OK`):
```json
{
"folders": [
{
"id": "019580df-ef65-7676-8de9-94435a93337a",
"path": "/",
"parentId": null,
"clientId": "AAA",
"createdAt": "2025-10-16T14:27:15Z",
"createdBy": "system"
},
{
"id": "019580df-ef65-7676-8de9-94435a93337b",
"path": "/documents",
"parentId": "019580df-ef65-7676-8de9-94435a93337a",
"clientId": "AAA",
"createdAt": "2025-10-16T14:30:00Z",
"createdBy": "user@example.com"
},
{
"id": "019580df-ef65-7676-8de9-94435a93337c",
"path": "/documents/2024",
"parentId": "019580df-ef65-7676-8de9-94435a93337b",
"clientId": "AAA",
"createdAt": "2025-10-16T14:35:00Z",
"createdBy": "user@example.com"
}
]
}
```
**Notes**:
- Returns all folders for the client, including nested folders
- Folders are returned in a flat list; use `parentId` to reconstruct the hierarchy
- The root folder (path="/") is included if it exists
### `POST /folders`
Creates a new folder for organizing documents.
@@ -638,6 +720,36 @@ Retrieves all versions of field extractions for a document, ordered by most rece
}
```
### `GET /field-extractions/version`
Retrieves a specific version of a field extraction for a document by version number.
**Security**: Requires JWT authentication
**Query Parameters**:
- `documentId` (required): Document UUID
- `version` (required): Version number (integer, minimum 1)
**Response** (`200 OK`):
```json
{
"id": "019580df-ef65-7676-8de9-94435a93337a",
"documentId": "019580df-b3f8-7348-9ab1-1e55b3f18ed7",
"version": 1,
"singleFields": {
"fileName": "contract_2024.pdf",
"contractTitle": "Service Agreement",
"clientName": "ACME Corp"
},
"arrayFields": [],
"createdAt": "2025-10-16T14:27:15Z",
"createdBy": "user@example.com"
}
```
**Error Responses**:
- `400`: Missing required query parameters
- `404`: Version not found for the specified document
---
## Query Management (QueryService)
@@ -1061,6 +1173,174 @@ All API responses include:
---
## Authorization Reference (Permit.io Integration)
The API uses Permit.io for fine-grained authorization. This section documents what data is sent to Permit.io for each endpoint, enabling system administrators to configure the correct resources, actions, and roles in Permit.io.
### Authorization Flow
1. **Authentication**: User authenticates via AWS Cognito, receiving a JWT token
2. **User Identification**: The `sub` (subject) claim from the JWT is extracted as the user identifier
3. **Resource Mapping**: The first path segment of the URL becomes the resource (e.g., `/client/{id}/document` → `client`)
4. **Action Mapping**: The HTTP method is converted to lowercase to become the action (e.g., `GET` → `get`)
5. **Permission Check**: Permit.io checks if the user has permission to perform the action on the resource
### Permit.io Data Mapping Logic
**Resource Extraction** (from `internal/cognitoauth/mapping.go`):
- The resource is derived from the **first segment** of the URL path
- Leading slashes are removed
- Example: `/client/{id}/document/batch/{batch_id}` → resource: `client`
**Action Extraction**:
- HTTP method is converted to lowercase
- `GET` → `get`, `POST` → `post`, `PATCH` → `patch`, `DELETE` → `delete`, `HEAD` → `head`
### Endpoints Exempt from Authorization
The following endpoints skip authentication and Permit.io authorization entirely:
| Endpoint | Purpose |
|----------|---------|
| `GET /home` | Public home/menu page |
| `GET /logout` | Session termination |
| `GET /health` | Health check endpoint |
| `GET /swagger/*` | Swagger UI documentation |
| `GET /metrics` | Prometheus metrics |
### Special Authentication Endpoints
These endpoints handle authentication flows and bypass normal authorization:
| Endpoint | Purpose |
|----------|---------|
| `GET /login` | Initiates OAuth2/PKCE login flow with Cognito |
| `GET /login-callback` | Handles OAuth2 callback from Cognito |
### Authorization Reference Table
The following table shows what data is passed to Permit.io for each protected endpoint:
| HTTP Method | Endpoint | Resource | Action | Notes |
|-------------|----------|----------|--------|-------|
| **Client Management** |
| `POST` | `/client` | `client` | `post` | Create new client |
| `GET` | `/clients` | `clients` | `get` | List all clients |
| `GET` | `/client/{id}` | `client` | `get` | Get client details |
| `PATCH` | `/client/{id}` | `client` | `patch` | Update client |
| `GET` | `/client/{id}/status` | `client` | `get` | Get client sync status |
| **Document Operations** |
| `POST` | `/client/{id}/document` | `client` | `post` | Upload document |
| `GET` | `/client/{id}/document` | `client` | `get` | List client documents |
| `GET` | `/document/{id}` | `document` | `get` | Get document details |
| **Batch Document Operations** |
| `POST` | `/client/{id}/document/batch` | `client` | `post` | Upload batch (ZIP) |
| `GET` | `/client/{id}/document/batch` | `client` | `get` | List batch uploads |
| `GET` | `/client/{id}/document/batch/{batch_id}` | `client` | `get` | Get batch status |
| `DELETE` | `/client/{id}/document/batch/{batch_id}` | `client` | `delete` | Cancel batch upload |
| **Folder Operations** |
| `GET` | `/client/{id}/folders` | `client` | `get` | List client folders |
| `POST` | `/folders` | `folders` | `post` | Create folder |
| `PATCH` | `/folders/{folderId}` | `folders` | `patch` | Rename folder |
| `GET` | `/folders/{folderId}/documents` | `folders` | `get` | List folder documents |
| `GET` | `/folders/{folderId}/metrics` | `folders` | `get` | Get folder metrics |
| **Label Operations** |
| `POST` | `/documents/{documentId}/labels` | `documents` | `post` | Apply label to document |
| `GET` | `/documents/{documentId}/labels` | `documents` | `get` | Get document labels |
| `GET` | `/labels/{labelName}/documents` | `labels` | `get` | Get documents by label |
| **Field Extraction Operations** |
| `POST` | `/field-extractions` | `field-extractions` | `post` | Create field extraction |
| `GET` | `/field-extractions` | `field-extractions` | `get` | Get latest extraction |
| `GET` | `/field-extractions/version` | `field-extractions` | `get` | Get specific version |
| `GET` | `/field-extractions/history` | `field-extractions` | `get` | Get extraction history |
| **Query Management** |
| `GET` | `/query` | `query` | `get` | List queries |
| `POST` | `/query` | `query` | `post` | Create query |
| `GET` | `/query/{id}` | `query` | `get` | Get query details |
| `PATCH` | `/query/{id}` | `query` | `patch` | Update query |
| `POST` | `/query/{id}/test` | `query` | `post` | Test query execution |
| **Collector Configuration** |
| `GET` | `/client/{id}/collector` | `client` | `get` | Get collector config |
| `PATCH` | `/client/{id}/collector` | `client` | `patch` | Update collector config |
| **Export Operations** |
| `POST` | `/client/{id}/export` | `client` | `post` | Trigger export |
| `GET` | `/export/{id}` | `export` | `get` | Get export status |
| **User Administration** |
| `POST` | `/admin/users` | `admin` | `post` | Create user |
| `GET` | `/admin/users` | `admin` | `get` | List users |
| `GET` | `/admin/users/{email}` | `admin` | `get` | Get user details |
| `HEAD` | `/admin/users/{email}` | `admin` | `head` | Check user existence |
| `PATCH` | `/admin/users/{email}` | `admin` | `patch` | Update user |
| `DELETE` | `/admin/users/{email}` | `admin` | `delete` | Delete user |
| `POST` | `/admin/users/{email}/disable` | `admin` | `post` | Disable user |
| `POST` | `/admin/users/{email}/enable` | `admin` | `post` | Enable user |
### Permit.io Configuration Requirements
To configure Permit.io to work with this API, administrators must create:
#### Resources
| Resource Key | Description |
|--------------|-------------|
| `client` | Client management and client-scoped operations |
| `clients` | Client listing (plural form) |
| `document` | Individual document operations |
| `documents` | Document collection and label operations |
| `folders` | Folder management |
| `labels` | Label-based document queries |
| `field-extractions` | Field extraction operations |
| `query` | Query definition management |
| `export` | Export operations |
| `admin` | User administration |
#### Actions
| Action Key | HTTP Method | Description |
|------------|-------------|-------------|
| `get` | GET | Read/retrieve resources |
| `post` | POST | Create resources |
| `patch` | PATCH | Update resources |
| `delete` | DELETE | Delete resources |
| `head` | HEAD | Check resource existence |
#### Example Role Configuration
Typical role assignments for common personas:
| Role | Resources | Actions | Use Case |
|------|-----------|---------|----------|
| `viewer` | `client`, `clients`, `document`, `documents`, `folders`, `query`, `export` | `get` | Read-only access |
| `editor` | `client`, `document`, `documents`, `folders`, `field-extractions`, `query` | `get`, `post`, `patch` | Content management |
| `admin` | All resources | All actions | Full administrative access |
| `user_admin` | `admin` | All actions | User management only |
### Authorization Error Responses
When authorization fails, the API returns:
**HTTP 401 Unauthorized** - No valid JWT token:
```json
{
"error": "Authorization required",
"message": "This endpoint requires authentication. Please obtain a valid JWT token.",
"login_url": "/login"
}
```
**HTTP 403 Forbidden** - Insufficient permissions:
```json
{
"error": "Insufficient permissions",
"message": "Access denied by authorization service",
"user": "<cognito-subject-id>",
"resource": "<resource-name>",
"action": "<action-name>"
}
```
---
## Code Generation
### OpenAPI Integration
+50 -13
View File
@@ -22,13 +22,20 @@ func New(cfg serviceconfig.ConfigProvider) *Service {
}
}
// CreateFolder creates a new folder with validation
// Validates that parent folder exists if parentId is provided
// CreateFolder creates a new folder with validation.
// Validates folder path constraints and that parent folder exists if parentId is provided.
//
// Parameters:
// - ctx: request context
// - path: folder path (must pass all path constraints)
// - parentID: optional parent folder UUID
// - clientID: client identifier
// - createdBy: email of user creating the folder
//
// Returns:
// - *repository.Folder: the created folder
// - error: validation error (PathValidationError) or database error
func (s *Service) CreateFolder(ctx context.Context, path string, parentID *uuid.UUID, clientID string, createdBy string) (*repository.Folder, error) {
if path == "" {
return nil, fmt.Errorf("folder path cannot be empty")
}
if clientID == "" {
return nil, fmt.Errorf("clientID cannot be empty")
}
@@ -37,6 +44,23 @@ func (s *Service) CreateFolder(ctx context.Context, path string, parentID *uuid.
return nil, fmt.Errorf("createdBy cannot be empty")
}
// Check if root folder already exists for this client
rootExists := false
if path == "/" {
_, err := s.cfg.GetDBQueries().GetFolderByPath(ctx, &repository.GetFolderByPathParams{
Clientid: clientID,
Path: "/",
})
if err == nil {
rootExists = true
}
}
// Validate folder path constraints
if err := ValidateFolderPathForCreate(path, rootExists); err != nil {
return nil, err
}
// Validate parent folder exists if parentID is provided
if parentID != nil {
parent, err := s.cfg.GetDBQueries().GetFolderByID(ctx, *parentID)
@@ -86,18 +110,31 @@ func (s *Service) GetFolderByPath(ctx context.Context, clientID string, path str
return folder, nil
}
// RenameFolder renames a folder
// RenameFolder renames a folder.
// Validates folder path constraints and prevents renaming the root folder.
//
// Parameters:
// - ctx: request context
// - folderID: UUID of the folder to rename
// - newPath: new folder path (must pass all path constraints)
//
// Returns:
// - error: validation error (PathValidationError) or database error
func (s *Service) RenameFolder(ctx context.Context, folderID uuid.UUID, newPath string) error {
if newPath == "" {
return fmt.Errorf("new path cannot be empty")
}
// Verify folder exists
_, err := s.cfg.GetDBQueries().GetFolderByID(ctx, folderID)
// Verify folder exists and get current path
folder, err := s.cfg.GetDBQueries().GetFolderByID(ctx, folderID)
if err != nil {
return fmt.Errorf("folder not found: %w", err)
}
// Check if this is the root folder
isRootFolder := folder.Path == "/"
// Validate folder path constraints for rename
if err := ValidateFolderPathForRename(newPath, isRootFolder); err != nil {
return err
}
err = s.cfg.GetDBQueries().RenameFolder(ctx, &repository.RenameFolderParams{
ID: folderID,
Path: newPath,
+26 -2
View File
@@ -64,7 +64,7 @@ func TestCreateFolder(t *testing.T) {
t.Run("reject empty path", func(t *testing.T) {
_, err := svc.CreateFolder(ctx, "", nil, clientID, "user123")
require.Error(t, err)
assert.Contains(t, err.Error(), "path cannot be empty")
assert.Contains(t, err.Error(), "must start with /")
})
t.Run("reject empty clientID", func(t *testing.T) {
@@ -182,7 +182,31 @@ func TestRenameFolder(t *testing.T) {
t.Run("reject empty path", func(t *testing.T) {
err := svc.RenameFolder(ctx, uuid.New(), "")
require.Error(t, err)
assert.Contains(t, err.Error(), "path cannot be empty")
// The folder won't be found, so we can't test path validation
assert.Contains(t, err.Error(), "folder not found")
})
t.Run("reject invalid path characters", func(t *testing.T) {
path := "/test-rename-invalid"
created, err := svc.CreateFolder(ctx, path, nil, clientID, "user123")
require.NoError(t, err)
err = svc.RenameFolder(ctx, created.ID, "/invalid@path")
require.Error(t, err)
assert.True(t, folder.IsPathValidationError(err))
})
t.Run("reject root folder rename", func(t *testing.T) {
// Create root folder
rootPath := "/"
rootFolder, err := svc.CreateFolder(ctx, rootPath, nil, clientID, "user123")
require.NoError(t, err)
// Try to rename root folder
err = svc.RenameFolder(ctx, rootFolder.ID, "/renamed-root")
require.Error(t, err)
assert.True(t, folder.IsPathValidationError(err))
assert.Contains(t, err.Error(), "root folder cannot be renamed")
})
}
+319
View File
@@ -0,0 +1,319 @@
package folder
import (
"errors"
"fmt"
"regexp"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// PathValidationError represents a folder path constraint violation.
// It wraps the underlying constraint error with context about which constraint was violated.
type PathValidationError struct {
Constraint string
Message string
}
func (e *PathValidationError) Error() string {
return e.Message
}
// IsPathValidationError checks if the error is a PathValidationError
func IsPathValidationError(err error) bool {
var pve *PathValidationError
return errors.As(err, &pve)
}
// Constants for validation limits
const (
MaxSegmentLength = 255
MaxPathDepth = 20
MaxPathLength = 1000
)
// validPathCharRegex matches valid characters: alphanumeric, hyphen, underscore, period, space
// This is applied per-segment after splitting by /
var validSegmentCharRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\- ]*$`)
// windowsReservedNames are device names reserved on Windows (case-insensitive)
var windowsReservedNames = map[string]bool{
"con": true, "prn": true, "aux": true, "nul": true,
"com1": true, "com2": true, "com3": true, "com4": true, "com5": true,
"com6": true, "com7": true, "com8": true, "com9": true,
"lpt1": true, "lpt2": true, "lpt3": true, "lpt4": true, "lpt5": true,
"lpt6": true, "lpt7": true, "lpt8": true, "lpt9": true,
}
// ValidateFolderPath validates a folder path against all constraints.
// This function should be called for both folder creation and rename operations.
//
// Constraints validated:
// - Path must start with /
// - Path cannot exceed 1000 characters
// - No empty segments (double slashes)
// - No path traversal sequences (.., ./)
// - No control characters
// - Valid characters only (alphanumeric, hyphen, underscore, period, space)
// - No leading/trailing whitespace in segments
// - No dot-prefix segments (hidden files)
// - No Windows reserved names
// - Segment length <= 255 characters
// - Path depth <= 20 levels
// - Unicode NFC normalization applied
//
// Parameters:
// - path: the folder path to validate
//
// Returns:
// - error: nil if valid, PathValidationError if constraint violated
func ValidateFolderPath(path string) error {
// Normalize Unicode to NFC form
path = norm.NFC.String(path)
// Check for control characters first (before any other validation)
if err := validateNoControlCharacters(path); err != nil {
return err
}
// Must start with /
if !strings.HasPrefix(path, "/") {
return &PathValidationError{
Constraint: "start_with_slash",
Message: "folder path must start with /",
}
}
// Check max path length
if len(path) > MaxPathLength {
return &PathValidationError{
Constraint: "max_length",
Message: fmt.Sprintf("folder path exceeds maximum length of %d characters", MaxPathLength),
}
}
// Special case: root path "/" is always valid
if path == "/" {
return nil
}
// Check for path traversal sequences
if err := validateNoPathTraversal(path); err != nil {
return err
}
// Check for empty segments (double slashes or trailing slash)
if err := validateNoEmptySegments(path); err != nil {
return err
}
// Split into segments and validate each
segments := strings.Split(path[1:], "/") // Remove leading / before split
// Check path depth
if len(segments) > MaxPathDepth {
return &PathValidationError{
Constraint: "max_depth",
Message: fmt.Sprintf("folder path exceeds maximum depth of %d levels", MaxPathDepth),
}
}
for _, segment := range segments {
if err := validateSegment(segment); err != nil {
return err
}
}
return nil
}
// validateNoControlCharacters checks that the path contains no ASCII control characters (0x00-0x1F, 0x7F)
func validateNoControlCharacters(path string) error {
for _, r := range path {
if r < 0x20 || r == 0x7F {
return &PathValidationError{
Constraint: "control_characters",
Message: "folder path cannot contain control characters",
}
}
}
return nil
}
// validateNoPathTraversal checks for .. and ./ sequences
func validateNoPathTraversal(path string) error {
// Check for .. anywhere in path
if strings.Contains(path, "..") {
return &PathValidationError{
Constraint: "path_traversal",
Message: "folder path cannot contain '..' sequences",
}
}
// Check for ./ sequence
if strings.Contains(path, "./") {
return &PathValidationError{
Constraint: "path_traversal",
Message: "folder path cannot contain './' sequences",
}
}
// Check for /. at end of path
if strings.HasSuffix(path, "/.") {
return &PathValidationError{
Constraint: "path_traversal",
Message: "folder path cannot end with '/.'",
}
}
return nil
}
// validateNoEmptySegments checks for empty path segments
func validateNoEmptySegments(path string) error {
// Check for double slashes
if strings.Contains(path, "//") {
return &PathValidationError{
Constraint: "empty_segment",
Message: "folder path cannot contain empty segments (double slashes)",
}
}
// Check for trailing slash (creates empty segment)
if strings.HasSuffix(path, "/") {
return &PathValidationError{
Constraint: "empty_segment",
Message: "folder path cannot end with a trailing slash",
}
}
return nil
}
// validateSegment validates a single path segment
func validateSegment(segment string) error {
// Check segment length
if len(segment) > MaxSegmentLength {
return &PathValidationError{
Constraint: "segment_length",
Message: fmt.Sprintf("folder path segment '%s...' exceeds maximum length of %d characters", segment[:50], MaxSegmentLength),
}
}
// Check for empty segment (shouldn't happen if validateNoEmptySegments passed, but defensive)
if segment == "" {
return &PathValidationError{
Constraint: "empty_segment",
Message: "folder path cannot contain empty segments",
}
}
// Check for leading/trailing whitespace
if segment != strings.TrimSpace(segment) {
return &PathValidationError{
Constraint: "whitespace",
Message: "folder path segments cannot have leading or trailing spaces",
}
}
// Check for dot prefix (hidden files)
if strings.HasPrefix(segment, ".") {
return &PathValidationError{
Constraint: "hidden_folder",
Message: "folder path segments cannot start with a period",
}
}
// Check for valid characters
if !validSegmentCharRegex.MatchString(segment) {
// Provide more helpful error message by identifying invalid character
invalidChar := findInvalidCharacter(segment)
return &PathValidationError{
Constraint: "invalid_characters",
Message: fmt.Sprintf("folder path contains invalid character '%s'; only alphanumeric, hyphen, underscore, period, and space are allowed", invalidChar),
}
}
// Check for Windows reserved names
segmentLower := strings.ToLower(segment)
if windowsReservedNames[segmentLower] {
return &PathValidationError{
Constraint: "reserved_name",
Message: fmt.Sprintf("folder path cannot use reserved name: %s", segment),
}
}
return nil
}
// findInvalidCharacter returns the first invalid character in a segment for error reporting
func findInvalidCharacter(segment string) string {
for i, r := range segment {
// First character must be alphanumeric
if i == 0 {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
return string(r)
}
continue
}
// Subsequent characters can be alphanumeric, hyphen, underscore, period, or space
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' && r != '.' && r != ' ' {
return string(r)
}
}
return "?"
}
// ValidateFolderPathForRename validates a folder path for rename operations.
// This includes all standard path validation plus additional rename-specific checks.
//
// Parameters:
// - newPath: the new folder path
// - isRootFolder: true if the folder being renamed is the root folder
//
// Returns:
// - error: nil if valid, PathValidationError if constraint violated
func ValidateFolderPathForRename(newPath string, isRootFolder bool) error {
// Root folder cannot be renamed
if isRootFolder {
return &PathValidationError{
Constraint: "root_rename",
Message: "root folder cannot be renamed",
}
}
// Cannot rename to root
if newPath == "/" {
return &PathValidationError{
Constraint: "rename_to_root",
Message: "cannot rename folder to root path",
}
}
// Apply standard path validation
return ValidateFolderPath(newPath)
}
// ValidateFolderPathForCreate validates a folder path for create operations.
// This includes all standard path validation plus creation-specific checks.
//
// Parameters:
// - path: the folder path to create
// - rootExists: true if a root folder already exists for the client
//
// Returns:
// - error: nil if valid, PathValidationError if constraint violated
func ValidateFolderPathForCreate(path string, rootExists bool) error {
// Check if trying to create root when one already exists
if path == "/" && rootExists {
return &PathValidationError{
Constraint: "root_exists",
Message: "root folder already exists for this client",
}
}
// Apply standard path validation
return ValidateFolderPath(path)
}
+368
View File
@@ -0,0 +1,368 @@
package folder
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateFolderPath_ValidPaths(t *testing.T) {
validPaths := []string{
"/",
"/foo",
"/foo/bar",
"/foo/bar/baz",
"/my-folder",
"/my_folder",
"/my folder",
"/My Folder",
"/folder.name",
"/2024",
"/folder123",
"/a",
"/A",
"/foo bar/baz qux",
"/documents/2024/Q1",
"/client-data/reports_2024/monthly.reports",
}
for _, path := range validPaths {
t.Run(path, func(t *testing.T) {
err := ValidateFolderPath(path)
assert.NoError(t, err, "path should be valid: %s", path)
})
}
}
func TestValidateFolderPath_MustStartWithSlash(t *testing.T) {
invalidPaths := []string{
"foo",
"foo/bar",
"./foo",
"../foo",
" /foo",
}
for _, path := range invalidPaths {
t.Run(path, func(t *testing.T) {
err := ValidateFolderPath(path)
require.Error(t, err)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "start_with_slash", pve.Constraint)
})
}
}
func TestValidateFolderPath_PathTraversal(t *testing.T) {
tests := []struct {
path string
wantErr bool
}{
{"/foo/bar", false},
{"/foo/../bar", true},
{"/../etc/passwd", true},
{"/foo/./bar", true},
{"/foo/..", true},
{"/foo/bar/..", true},
{"/foo/.", true},
{"/..foo", true}, // starts with dot, blocked by hidden folder rule
{"/foo..bar", true}, // contains ..
{"/foo...bar", true}, // contains ..
{"/foo/bar..baz", true}, // contains ..
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
err := ValidateFolderPath(tt.path)
if tt.wantErr {
require.Error(t, err, "path should be invalid: %s", tt.path)
} else {
assert.NoError(t, err, "path should be valid: %s", tt.path)
}
})
}
}
func TestValidateFolderPath_EmptySegments(t *testing.T) {
invalidPaths := []string{
"//foo",
"/foo//bar",
"/foo/bar//",
"/foo/",
"/foo/bar/",
"///",
}
for _, path := range invalidPaths {
t.Run(path, func(t *testing.T) {
err := ValidateFolderPath(path)
require.Error(t, err, "path should be invalid: %s", path)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "empty_segment", pve.Constraint)
})
}
}
func TestValidateFolderPath_LeadingTrailingWhitespace(t *testing.T) {
invalidPaths := []string{
"/ foo",
"/foo ",
"/ foo ",
"/foo/ bar",
"/foo /bar",
"/ foo",
"/foo ",
}
for _, path := range invalidPaths {
t.Run(path, func(t *testing.T) {
err := ValidateFolderPath(path)
require.Error(t, err, "path should be invalid: %s", path)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "whitespace", pve.Constraint)
})
}
}
func TestValidateFolderPath_HiddenFolders(t *testing.T) {
invalidPaths := []string{
"/.hidden",
"/foo/.secret",
"/.git",
"/foo/.config/bar",
}
for _, path := range invalidPaths {
t.Run(path, func(t *testing.T) {
err := ValidateFolderPath(path)
require.Error(t, err, "path should be invalid: %s", path)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
// Could be hidden_folder or path_traversal depending on the case
assert.Contains(t, []string{"hidden_folder", "path_traversal"}, pve.Constraint)
})
}
}
func TestValidateFolderPath_InvalidCharacters(t *testing.T) {
invalidPaths := []string{
"/foo@bar",
"/foo#bar",
"/foo\\bar",
"/foo<bar>",
"/foo\"bar",
"/foo'bar",
"/foo:bar",
"/foo*bar",
"/foo?bar",
"/foo|bar",
"/foo`bar",
"/foo~bar",
"/foo!bar",
"/foo$bar",
"/foo%bar",
"/foo^bar",
"/foo&bar",
"/foo+bar",
"/foo=bar",
"/foo[bar]",
"/foo{bar}",
"/foo;bar",
"/foo,bar",
}
for _, path := range invalidPaths {
t.Run(path, func(t *testing.T) {
err := ValidateFolderPath(path)
require.Error(t, err, "path should be invalid: %s", path)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "invalid_characters", pve.Constraint)
})
}
}
func TestValidateFolderPath_WindowsReservedNames(t *testing.T) {
reservedNames := []string{
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
"con", "prn", "aux", "nul", // lowercase
"Con", "Prn", "Aux", "Nul", // mixed case
}
for _, name := range reservedNames {
t.Run(name, func(t *testing.T) {
// Test as root segment
err := ValidateFolderPath("/" + name)
require.Error(t, err, "reserved name should be invalid: /%s", name)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "reserved_name", pve.Constraint)
// Test as nested segment
err = ValidateFolderPath("/foo/" + name + "/bar")
require.Error(t, err, "reserved name in path should be invalid: /foo/%s/bar", name)
require.ErrorAs(t, err, &pve)
assert.Equal(t, "reserved_name", pve.Constraint)
})
}
}
func TestValidateFolderPath_SegmentLength(t *testing.T) {
// Valid: exactly 255 characters
validSegment := strings.Repeat("a", 255)
err := ValidateFolderPath("/" + validSegment)
assert.NoError(t, err, "255 character segment should be valid")
// Invalid: 256 characters
invalidSegment := strings.Repeat("a", 256)
err = ValidateFolderPath("/" + invalidSegment)
require.Error(t, err)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "segment_length", pve.Constraint)
}
func TestValidateFolderPath_PathDepth(t *testing.T) {
// Valid: exactly 20 levels
segments := make([]string, 20)
for i := range segments {
segments[i] = "a"
}
validPath := "/" + strings.Join(segments, "/")
err := ValidateFolderPath(validPath)
assert.NoError(t, err, "20 level path should be valid")
// Invalid: 21 levels
segments = make([]string, 21)
for i := range segments {
segments[i] = "a"
}
invalidPath := "/" + strings.Join(segments, "/")
err = ValidateFolderPath(invalidPath)
require.Error(t, err)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "max_depth", pve.Constraint)
}
func TestValidateFolderPath_MaxLength(t *testing.T) {
// Valid: exactly 1000 characters using multiple segments (each <=255 chars)
// 4 segments of 249 chars each = /249/249/249/249 = 1 + 249 + 1 + 249 + 1 + 249 + 1 + 249 = 1000
seg249 := strings.Repeat("a", 249)
validPath := "/" + seg249 + "/" + seg249 + "/" + seg249 + "/" + seg249
assert.Equal(t, 1000, len(validPath), "sanity check: path should be 1000 chars")
err := ValidateFolderPath(validPath)
assert.NoError(t, err, "1000 character path should be valid")
// Invalid: 1001 characters
invalidPath := validPath + "a"
err = ValidateFolderPath(invalidPath)
require.Error(t, err)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "max_length", pve.Constraint)
}
func TestValidateFolderPath_ControlCharacters(t *testing.T) {
controlChars := []rune{
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F,
0x7F,
}
for _, char := range controlChars {
t.Run(string(char), func(t *testing.T) {
path := "/foo" + string(char) + "bar"
err := ValidateFolderPath(path)
require.Error(t, err, "control character 0x%02X should be invalid", char)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "control_characters", pve.Constraint)
})
}
}
func TestValidateFolderPath_SegmentMustStartWithAlphanumeric(t *testing.T) {
invalidPaths := []string{
"/-foo",
"/_foo",
"/ foo", // leading space
"/foo/-bar",
"/foo/_bar",
}
for _, path := range invalidPaths {
t.Run(path, func(t *testing.T) {
err := ValidateFolderPath(path)
require.Error(t, err, "path should be invalid: %s", path)
})
}
}
func TestValidateFolderPathForRename(t *testing.T) {
t.Run("root folder cannot be renamed", func(t *testing.T) {
err := ValidateFolderPathForRename("/new-name", true)
require.Error(t, err)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "root_rename", pve.Constraint)
})
t.Run("cannot rename to root", func(t *testing.T) {
err := ValidateFolderPathForRename("/", false)
require.Error(t, err)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "rename_to_root", pve.Constraint)
})
t.Run("valid rename", func(t *testing.T) {
err := ValidateFolderPathForRename("/new-folder", false)
assert.NoError(t, err)
})
}
func TestValidateFolderPathForCreate(t *testing.T) {
t.Run("cannot create root when exists", func(t *testing.T) {
err := ValidateFolderPathForCreate("/", true)
require.Error(t, err)
var pve *PathValidationError
require.ErrorAs(t, err, &pve)
assert.Equal(t, "root_exists", pve.Constraint)
})
t.Run("can create root when not exists", func(t *testing.T) {
err := ValidateFolderPathForCreate("/", false)
assert.NoError(t, err)
})
t.Run("valid create", func(t *testing.T) {
err := ValidateFolderPathForCreate("/new-folder", true)
assert.NoError(t, err)
})
}
func TestIsPathValidationError(t *testing.T) {
t.Run("PathValidationError returns true", func(t *testing.T) {
err := &PathValidationError{Constraint: "test", Message: "test message"}
assert.True(t, IsPathValidationError(err))
})
t.Run("other error returns false", func(t *testing.T) {
err := assert.AnError
assert.False(t, IsPathValidationError(err))
})
t.Run("nil returns false", func(t *testing.T) {
assert.False(t, IsPathValidationError(nil))
})
}
+17 -16
View File
@@ -2555,14 +2555,14 @@ func (_c *MockClientInterface_ListAdminUsers_Call) RunAndReturn(run func(context
return _c
}
// ListClientFolders provides a mock function with given fields: ctx, id, reqEditors
func (_m *MockClientInterface) ListClientFolders(ctx context.Context, id queryapi.ClientID, reqEditors ...queryapi.RequestEditorFn) (*http.Response, error) {
// ListClientFolders provides a mock function with given fields: ctx, id, params, reqEditors
func (_m *MockClientInterface) ListClientFolders(ctx context.Context, id queryapi.ClientID, params *queryapi.ListClientFoldersParams, reqEditors ...queryapi.RequestEditorFn) (*http.Response, error) {
_va := make([]interface{}, len(reqEditors))
for _i := range reqEditors {
_va[_i] = reqEditors[_i]
}
var _ca []interface{}
_ca = append(_ca, ctx, id)
_ca = append(_ca, ctx, id, params)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
@@ -2572,19 +2572,19 @@ func (_m *MockClientInterface) ListClientFolders(ctx context.Context, id queryap
var r0 *http.Response
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, queryapi.ClientID, ...queryapi.RequestEditorFn) (*http.Response, error)); ok {
return rf(ctx, id, reqEditors...)
if rf, ok := ret.Get(0).(func(context.Context, queryapi.ClientID, *queryapi.ListClientFoldersParams, ...queryapi.RequestEditorFn) (*http.Response, error)); ok {
return rf(ctx, id, params, reqEditors...)
}
if rf, ok := ret.Get(0).(func(context.Context, queryapi.ClientID, ...queryapi.RequestEditorFn) *http.Response); ok {
r0 = rf(ctx, id, reqEditors...)
if rf, ok := ret.Get(0).(func(context.Context, queryapi.ClientID, *queryapi.ListClientFoldersParams, ...queryapi.RequestEditorFn) *http.Response); ok {
r0 = rf(ctx, id, params, reqEditors...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*http.Response)
}
}
if rf, ok := ret.Get(1).(func(context.Context, queryapi.ClientID, ...queryapi.RequestEditorFn) error); ok {
r1 = rf(ctx, id, reqEditors...)
if rf, ok := ret.Get(1).(func(context.Context, queryapi.ClientID, *queryapi.ListClientFoldersParams, ...queryapi.RequestEditorFn) error); ok {
r1 = rf(ctx, id, params, reqEditors...)
} else {
r1 = ret.Error(1)
}
@@ -2600,21 +2600,22 @@ type MockClientInterface_ListClientFolders_Call struct {
// ListClientFolders is a helper method to define mock.On call
// - ctx context.Context
// - id queryapi.ClientID
// - params *queryapi.ListClientFoldersParams
// - reqEditors ...queryapi.RequestEditorFn
func (_e *MockClientInterface_Expecter) ListClientFolders(ctx interface{}, id interface{}, reqEditors ...interface{}) *MockClientInterface_ListClientFolders_Call {
func (_e *MockClientInterface_Expecter) ListClientFolders(ctx interface{}, id interface{}, params interface{}, reqEditors ...interface{}) *MockClientInterface_ListClientFolders_Call {
return &MockClientInterface_ListClientFolders_Call{Call: _e.mock.On("ListClientFolders",
append([]interface{}{ctx, id}, reqEditors...)...)}
append([]interface{}{ctx, id, params}, reqEditors...)...)}
}
func (_c *MockClientInterface_ListClientFolders_Call) Run(run func(ctx context.Context, id queryapi.ClientID, reqEditors ...queryapi.RequestEditorFn)) *MockClientInterface_ListClientFolders_Call {
func (_c *MockClientInterface_ListClientFolders_Call) Run(run func(ctx context.Context, id queryapi.ClientID, params *queryapi.ListClientFoldersParams, reqEditors ...queryapi.RequestEditorFn)) *MockClientInterface_ListClientFolders_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]queryapi.RequestEditorFn, len(args)-2)
for i, a := range args[2:] {
variadicArgs := make([]queryapi.RequestEditorFn, len(args)-3)
for i, a := range args[3:] {
if a != nil {
variadicArgs[i] = a.(queryapi.RequestEditorFn)
}
}
run(args[0].(context.Context), args[1].(queryapi.ClientID), variadicArgs...)
run(args[0].(context.Context), args[1].(queryapi.ClientID), args[2].(*queryapi.ListClientFoldersParams), variadicArgs...)
})
return _c
}
@@ -2624,7 +2625,7 @@ func (_c *MockClientInterface_ListClientFolders_Call) Return(_a0 *http.Response,
return _c
}
func (_c *MockClientInterface_ListClientFolders_Call) RunAndReturn(run func(context.Context, queryapi.ClientID, ...queryapi.RequestEditorFn) (*http.Response, error)) *MockClientInterface_ListClientFolders_Call {
func (_c *MockClientInterface_ListClientFolders_Call) RunAndReturn(run func(context.Context, queryapi.ClientID, *queryapi.ListClientFoldersParams, ...queryapi.RequestEditorFn) (*http.Response, error)) *MockClientInterface_ListClientFolders_Call {
_c.Call.Return(run)
return _c
}
@@ -2553,14 +2553,14 @@ func (_c *MockClientWithResponsesInterface_ListAdminUsersWithResponse_Call) RunA
return _c
}
// ListClientFoldersWithResponse provides a mock function with given fields: ctx, id, reqEditors
func (_m *MockClientWithResponsesInterface) ListClientFoldersWithResponse(ctx context.Context, id queryapi.ClientID, reqEditors ...queryapi.RequestEditorFn) (*queryapi.ListClientFoldersResponse, error) {
// ListClientFoldersWithResponse provides a mock function with given fields: ctx, id, params, reqEditors
func (_m *MockClientWithResponsesInterface) ListClientFoldersWithResponse(ctx context.Context, id queryapi.ClientID, params *queryapi.ListClientFoldersParams, reqEditors ...queryapi.RequestEditorFn) (*queryapi.ListClientFoldersResponse, error) {
_va := make([]interface{}, len(reqEditors))
for _i := range reqEditors {
_va[_i] = reqEditors[_i]
}
var _ca []interface{}
_ca = append(_ca, ctx, id)
_ca = append(_ca, ctx, id, params)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
@@ -2570,19 +2570,19 @@ func (_m *MockClientWithResponsesInterface) ListClientFoldersWithResponse(ctx co
var r0 *queryapi.ListClientFoldersResponse
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, queryapi.ClientID, ...queryapi.RequestEditorFn) (*queryapi.ListClientFoldersResponse, error)); ok {
return rf(ctx, id, reqEditors...)
if rf, ok := ret.Get(0).(func(context.Context, queryapi.ClientID, *queryapi.ListClientFoldersParams, ...queryapi.RequestEditorFn) (*queryapi.ListClientFoldersResponse, error)); ok {
return rf(ctx, id, params, reqEditors...)
}
if rf, ok := ret.Get(0).(func(context.Context, queryapi.ClientID, ...queryapi.RequestEditorFn) *queryapi.ListClientFoldersResponse); ok {
r0 = rf(ctx, id, reqEditors...)
if rf, ok := ret.Get(0).(func(context.Context, queryapi.ClientID, *queryapi.ListClientFoldersParams, ...queryapi.RequestEditorFn) *queryapi.ListClientFoldersResponse); ok {
r0 = rf(ctx, id, params, reqEditors...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*queryapi.ListClientFoldersResponse)
}
}
if rf, ok := ret.Get(1).(func(context.Context, queryapi.ClientID, ...queryapi.RequestEditorFn) error); ok {
r1 = rf(ctx, id, reqEditors...)
if rf, ok := ret.Get(1).(func(context.Context, queryapi.ClientID, *queryapi.ListClientFoldersParams, ...queryapi.RequestEditorFn) error); ok {
r1 = rf(ctx, id, params, reqEditors...)
} else {
r1 = ret.Error(1)
}
@@ -2598,21 +2598,22 @@ type MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call struct
// ListClientFoldersWithResponse is a helper method to define mock.On call
// - ctx context.Context
// - id queryapi.ClientID
// - params *queryapi.ListClientFoldersParams
// - reqEditors ...queryapi.RequestEditorFn
func (_e *MockClientWithResponsesInterface_Expecter) ListClientFoldersWithResponse(ctx interface{}, id interface{}, reqEditors ...interface{}) *MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call {
func (_e *MockClientWithResponsesInterface_Expecter) ListClientFoldersWithResponse(ctx interface{}, id interface{}, params interface{}, reqEditors ...interface{}) *MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call {
return &MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call{Call: _e.mock.On("ListClientFoldersWithResponse",
append([]interface{}{ctx, id}, reqEditors...)...)}
append([]interface{}{ctx, id, params}, reqEditors...)...)}
}
func (_c *MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call) Run(run func(ctx context.Context, id queryapi.ClientID, reqEditors ...queryapi.RequestEditorFn)) *MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call {
func (_c *MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call) Run(run func(ctx context.Context, id queryapi.ClientID, params *queryapi.ListClientFoldersParams, reqEditors ...queryapi.RequestEditorFn)) *MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]queryapi.RequestEditorFn, len(args)-2)
for i, a := range args[2:] {
variadicArgs := make([]queryapi.RequestEditorFn, len(args)-3)
for i, a := range args[3:] {
if a != nil {
variadicArgs[i] = a.(queryapi.RequestEditorFn)
}
}
run(args[0].(context.Context), args[1].(queryapi.ClientID), variadicArgs...)
run(args[0].(context.Context), args[1].(queryapi.ClientID), args[2].(*queryapi.ListClientFoldersParams), variadicArgs...)
})
return _c
}
@@ -2622,7 +2623,7 @@ func (_c *MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call) R
return _c
}
func (_c *MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call) RunAndReturn(run func(context.Context, queryapi.ClientID, ...queryapi.RequestEditorFn) (*queryapi.ListClientFoldersResponse, error)) *MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call {
func (_c *MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call) RunAndReturn(run func(context.Context, queryapi.ClientID, *queryapi.ListClientFoldersParams, ...queryapi.RequestEditorFn) (*queryapi.ListClientFoldersResponse, error)) *MockClientWithResponsesInterface_ListClientFoldersWithResponse_Call {
_c.Call.Return(run)
return _c
}
+49 -7
View File
@@ -726,6 +726,10 @@ type Folder struct {
// Id Folder ID
Id openapi_types.UUID `json:"id"`
// Metrics Processing metrics for this folder. Only populated when metrics=true
// is specified in the request. Null otherwise.
Metrics nullable.Nullable[FolderMetricsInline] `json:"metrics,omitempty"`
// ParentId Parent folder ID
ParentId nullable.Nullable[openapi_types.UUID] `json:"parentId,omitempty"`
@@ -768,6 +772,15 @@ type FolderMetrics struct {
TotalDocuments int32 `json:"totalDocuments"`
}
// FolderMetricsInline Inline processing metrics for a folder (excludes folderId since it's on parent)
type FolderMetricsInline struct {
// ByLabel Document counts by label
ByLabel map[string]int32 `json:"byLabel"`
// TotalDocuments Total number of documents in folder
TotalDocuments int32 `json:"totalDocuments"`
}
// FolderRename Request to rename an existing folder
type FolderRename struct {
// Path New folder path (must start with /). Use "/" for root folder.
@@ -1038,6 +1051,13 @@ type UploadDocumentBatchMultipartBody struct {
Archive openapi_types.File `json:"archive"`
}
// ListClientFoldersParams defines parameters for ListClientFolders.
type ListClientFoldersParams struct {
// Metrics When true, include processing metrics for each folder (document counts by label).
// Defaults to false for performance reasons.
Metrics *bool `form:"metrics,omitempty" json:"metrics,omitempty"`
}
// GetCurrentFieldExtractionParams defines parameters for GetCurrentFieldExtraction.
type GetCurrentFieldExtractionParams struct {
// DocumentId The document ID
@@ -1265,7 +1285,7 @@ type ClientInterface interface {
TriggerExport(ctx context.Context, id ClientID, body TriggerExportJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// ListClientFolders request
ListClientFolders(ctx context.Context, id ClientID, reqEditors ...RequestEditorFn) (*http.Response, error)
ListClientFolders(ctx context.Context, id ClientID, params *ListClientFoldersParams, reqEditors ...RequestEditorFn) (*http.Response, error)
// GetStatusByClientId request
GetStatusByClientId(ctx context.Context, id ClientID, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -1666,8 +1686,8 @@ func (c *Client) TriggerExport(ctx context.Context, id ClientID, body TriggerExp
return c.Client.Do(req)
}
func (c *Client) ListClientFolders(ctx context.Context, id ClientID, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewListClientFoldersRequest(c.Server, id)
func (c *Client) ListClientFolders(ctx context.Context, id ClientID, params *ListClientFoldersParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewListClientFoldersRequest(c.Server, id, params)
if err != nil {
return nil, err
}
@@ -2964,7 +2984,7 @@ func NewTriggerExportRequestWithBody(server string, id ClientID, contentType str
}
// NewListClientFoldersRequest generates requests for ListClientFolders
func NewListClientFoldersRequest(server string, id ClientID) (*http.Request, error) {
func NewListClientFoldersRequest(server string, id ClientID, params *ListClientFoldersParams) (*http.Request, error) {
var err error
var pathParam0 string
@@ -2989,6 +3009,28 @@ func NewListClientFoldersRequest(server string, id ClientID) (*http.Request, err
return nil, err
}
if params != nil {
queryValues := queryURL.Query()
if params.Metrics != nil {
if queryFrag, err := runtime.StyleParamWithLocation("form", true, "metrics", runtime.ParamLocationQuery, *params.Metrics); err != nil {
return nil, err
} else if parsed, err := url.ParseQuery(queryFrag); err != nil {
return nil, err
} else {
for k, v := range parsed {
for _, v2 := range v {
queryValues.Add(k, v2)
}
}
}
}
queryURL.RawQuery = queryValues.Encode()
}
req, err := http.NewRequest("GET", queryURL.String(), nil)
if err != nil {
return nil, err
@@ -4050,7 +4092,7 @@ type ClientWithResponsesInterface interface {
TriggerExportWithResponse(ctx context.Context, id ClientID, body TriggerExportJSONRequestBody, reqEditors ...RequestEditorFn) (*TriggerExportResponse, error)
// ListClientFoldersWithResponse request
ListClientFoldersWithResponse(ctx context.Context, id ClientID, reqEditors ...RequestEditorFn) (*ListClientFoldersResponse, error)
ListClientFoldersWithResponse(ctx context.Context, id ClientID, params *ListClientFoldersParams, reqEditors ...RequestEditorFn) (*ListClientFoldersResponse, error)
// GetStatusByClientIdWithResponse request
GetStatusByClientIdWithResponse(ctx context.Context, id ClientID, reqEditors ...RequestEditorFn) (*GetStatusByClientIdResponse, error)
@@ -5565,8 +5607,8 @@ func (c *ClientWithResponses) TriggerExportWithResponse(ctx context.Context, id
}
// ListClientFoldersWithResponse request returning *ListClientFoldersResponse
func (c *ClientWithResponses) ListClientFoldersWithResponse(ctx context.Context, id ClientID, reqEditors ...RequestEditorFn) (*ListClientFoldersResponse, error) {
rsp, err := c.ListClientFolders(ctx, id, reqEditors...)
func (c *ClientWithResponses) ListClientFoldersWithResponse(ctx context.Context, id ClientID, params *ListClientFoldersParams, reqEditors ...RequestEditorFn) (*ListClientFoldersResponse, error) {
rsp, err := c.ListClientFolders(ctx, id, params, reqEditors...)
if err != nil {
return nil, err
}
+1
View File
@@ -108,6 +108,7 @@ tasks:
fi
GOMAXPROCS={{.THREAD_PARALLEL}} go test \
-timeout 15m \
-p {{.PKG_PARALLEL}} -parallel {{.TEST_PARALLEL}} \
-coverpkg={{.INTERNAL}},{{.API}},{{.PKG}} \
-coverprofile=$COVERAGE_TARGET $TEST_TARGETS
+49
View File
@@ -418,8 +418,22 @@ paths:
Returns all folders belonging to a client. The response includes each folder's
ID, path, and parent folder ID, allowing clients to reconstruct the folder hierarchy.
Root-level folders will have a null parentId.
When metrics=true is specified, each folder will include processing metrics
(document counts and label breakdown). This adds overhead so should only be
used when metrics are needed.
security:
- jwtAuth: []
parameters:
- name: metrics
in: query
required: false
description: |
When true, include processing metrics for each folder (document counts by label).
Defaults to false for performance reasons.
schema:
type: boolean
default: false
responses:
"200":
description: List of folders for the client.
@@ -3022,6 +3036,13 @@ components:
maxLength: 320
description: Email of the user who created this folder
example: user@example.com
metrics:
allOf:
- $ref: "#/components/schemas/FolderMetricsInline"
nullable: true
description: |
Processing metrics for this folder. Only populated when metrics=true
is specified in the request. Null otherwise.
FolderList:
description: List of folders for a client
@@ -3074,6 +3095,34 @@ components:
OCR_Processed: 120
Dashboard_Ready: 100
FolderMetricsInline:
description: Inline processing metrics for a folder (excludes folderId since it's on parent)
type: object
required:
- totalDocuments
- byLabel
properties:
totalDocuments:
type: integer
format: int32
minimum: 0
maximum: 1000000
description: Total number of documents in folder
example: 150
byLabel:
type: object
maxProperties: 100
additionalProperties:
type: integer
format: int32
minimum: 0
maximum: 1000000
description: Document counts by label
example:
Ingested: 150
OCR_Processed: 120
Dashboard_Ready: 100
# Label schemas
LabelApplication:
description: Request to apply a workflow label to a document
+503 -2
View File
@@ -251,7 +251,7 @@ func verifyFolderMetrics(t *testing.T, net test.Network, clientID string) {
t.Helper()
// List all folders for the client using the new endpoint
foldersRes, err := net.Client.ListClientFoldersWithResponse(t.Context(), queryapi.ClientID(clientID))
foldersRes, err := net.Client.ListClientFoldersWithResponse(t.Context(), queryapi.ClientID(clientID), nil)
require.NoError(t, err, "Failed to list client folders")
if foldersRes.JSON200 == nil {
t.Logf("Response status: %d, body: %s", foldersRes.StatusCode(), string(foldersRes.Body))
@@ -873,7 +873,7 @@ func TestFolderAPI(t *testing.T) {
// Get the root folder and verify its metrics separately
fmt.Println("\n=== Testing root folder metrics ===")
foldersRes, err := net.Client.ListClientFoldersWithResponse(t.Context(), queryapi.ClientID(clientID))
foldersRes, err := net.Client.ListClientFoldersWithResponse(t.Context(), queryapi.ClientID(clientID), nil)
require.NoError(t, err, "Failed to list client folders")
require.NotNil(t, foldersRes.JSON200, "Expected 200 for folder list")
@@ -912,5 +912,506 @@ func ptr(s string) *string {
return &s
}
// ptrFloat64 returns a pointer to a float64 value.
func ptrFloat64(f float64) *float64 {
return &f
}
// ptrBool returns a pointer to a bool value.
func ptrBool(b bool) *bool {
return &b
}
// TestDeepFolderHierarchy tests folder creation with 10+ levels of nesting
// (Phase 10 - Scenario 4: Folder hierarchy with recursive queries).
func TestDeepFolderHierarchy(t *testing.T) {
cfg := &TextExtractionConfig{}
// Create full network
net, cleanup := test.CreateFullNetwork(t, t.Context(), cfg)
defer cleanup()
// Create test client
clientName := fmt.Sprintf("DeepFolderTestClient_%d", time.Now().UnixNano())
clientExternalID := fmt.Sprintf("deep-fld-%d", time.Now().Unix())
createRes, err := net.Client.CreateClientWithResponse(t.Context(), queryapi.ClientCreate{
Name: clientName,
Id: clientExternalID,
})
require.NoError(t, err)
require.NotNil(t, createRes.JSON201, "Expected 201 response for client creation, got status %d", createRes.StatusCode())
clientID := createRes.JSON201.Id
fmt.Printf("Created client: %s\n", clientID)
// Enable sync
canSync := true
_, err = net.Client.UpdateClientWithResponse(t.Context(), clientID, queryapi.ClientUpdate{
CanSync: &canSync,
})
require.NoError(t, err)
// Create 12 levels of nested folders
fmt.Println("\n=== Creating 12 levels of nested folders ===")
const folderDepth = 12
var parentID *openapi_types.UUID
var folderIDs []openapi_types.UUID
var currentPath string
for i := 1; i <= folderDepth; i++ {
currentPath = fmt.Sprintf("%s/level%d", currentPath, i)
createFolderReq := queryapi.CreateFolderJSONRequestBody{
Path: currentPath,
ClientId: clientID,
CreatedBy: "test@example.com",
}
if parentID != nil {
createFolderReq.ParentId = parentID
}
folderRes, err := net.Client.CreateFolderWithResponse(t.Context(), createFolderReq)
require.NoError(t, err, "Failed to create folder at level %d", i)
require.NotNil(t, folderRes.JSON201, "Expected 201 for folder at level %d, got %d with body: %s",
i, folderRes.StatusCode(), string(folderRes.Body))
folderID := folderRes.JSON201.Id
folderIDs = append(folderIDs, folderID)
parentID = &folderID
fmt.Printf(" Level %d: ID=%s, Path=%s\n", i, folderID, currentPath)
}
assert.Len(t, folderIDs, folderDepth, "Should have created %d folders", folderDepth)
// List all folders and verify count
fmt.Println("\n=== Listing all folders for client ===")
foldersRes, err := net.Client.ListClientFoldersWithResponse(t.Context(), queryapi.ClientID(clientID), nil)
require.NoError(t, err, "Failed to list client folders")
require.NotNil(t, foldersRes.JSON200, "Expected 200 response for folder list")
// Should have at least 12 folders (plus root if created)
fmt.Printf(" Total folders found: %d\n", len(foldersRes.JSON200.Folders))
assert.GreaterOrEqual(t, len(foldersRes.JSON200.Folders), folderDepth,
"Expected at least %d folders", folderDepth)
// Verify we can get metrics for the deepest folder
fmt.Println("\n=== Testing metrics for deepest folder (level 12) ===")
deepestFolderID := folderIDs[len(folderIDs)-1]
metricsRes, err := net.Client.GetFolderMetricsWithResponse(t.Context(), deepestFolderID)
require.NoError(t, err, "Failed to get metrics for deepest folder")
require.NotNil(t, metricsRes.JSON200, "Expected 200 for deepest folder metrics")
fmt.Printf(" Deepest folder metrics: folderId=%s, totalDocuments=%d\n",
metricsRes.JSON200.FolderId, metricsRes.JSON200.TotalDocuments)
assert.Equal(t, deepestFolderID, metricsRes.JSON200.FolderId,
"Metrics should be for the requested folder")
// Verify we can get documents for the deepest folder (should be empty)
fmt.Println("\n=== Testing documents for deepest folder ===")
docsRes, err := net.Client.GetFolderDocumentsWithResponse(t.Context(), deepestFolderID)
require.NoError(t, err, "Failed to get documents for deepest folder")
require.NotNil(t, docsRes.JSON200, "Expected 200 for folder documents")
docCount := 0
if docsRes.JSON200.Documents != nil {
docCount = len(*docsRes.JSON200.Documents)
}
fmt.Printf(" Documents in deepest folder: %d\n", docCount)
fmt.Println("\n=== Deep folder hierarchy test complete ===")
}
// TestLargeArrayFieldExtraction tests field extraction creation with large array sizes (N=100)
// (Phase 10 - Performance test: Large array sizes).
func TestLargeArrayFieldExtraction(t *testing.T) {
cfg := &TextExtractionConfig{}
// Create full network
net, cleanup := test.CreateFullNetwork(t, t.Context(), cfg)
defer cleanup()
// Create test client
clientName := fmt.Sprintf("LargeArrayTestClient_%d", time.Now().UnixNano())
clientExternalID := fmt.Sprintf("lg-array-%d", time.Now().Unix())
createRes, err := net.Client.CreateClientWithResponse(t.Context(), queryapi.ClientCreate{
Name: clientName,
Id: clientExternalID,
})
require.NoError(t, err)
require.NotNil(t, createRes.JSON201, "Expected 201 response for client creation")
clientID := createRes.JSON201.Id
fmt.Printf("Created client: %s\n", clientID)
// Enable sync
canSync := true
_, err = net.Client.UpdateClientWithResponse(t.Context(), clientID, queryapi.ClientUpdate{
CanSync: &canSync,
})
require.NoError(t, err)
// Upload a single document
fmt.Println("\n=== Uploading document ===")
uploadSingleDocument(t, net.Client, clientID, "large_array_test.pdf")
// Wait for document to appear
var documents []queryapi.DocumentSummary
waitForDocumentCount(t, net.Client, clientID, 1, &documents)
require.Len(t, documents, 1, "Expected 1 document")
docID := documents[0].Id
fmt.Printf(" Document uploaded: ID=%s\n", docID)
// Create field extraction with 100 array field items
fmt.Println("\n=== Creating field extraction with 100 array items ===")
const arraySize = 100
arrayFields := make([]queryapi.ArrayFieldItem, arraySize)
for i := 0; i < arraySize; i++ {
arrayFields[i] = queryapi.ArrayFieldItem{
ExhibitTitle: ptr(fmt.Sprintf("Exhibit %d", i+1)),
ExhibitPage: ptr(fmt.Sprintf("%d", i+1)),
ReimbProvTin: ptr(fmt.Sprintf("%09d", i+100000000)),
ReimbProvNpi: ptr(fmt.Sprintf("%010d", i+1000000000)),
ReimbProvName: ptr(fmt.Sprintf("Provider %d", i+1)),
AareteDerivedClaimTypeCd: ptr("INPATIENT"),
ReimbPctRate: ptrFloat64(float64(80+i%20) + float64(i%100)/100.0),
ReimbFeeRate: ptrFloat64(float64(100 + i*10)),
CarveoutInd: ptrBool(i%2 == 0),
DefaultInd: ptrBool(i == 0),
}
}
startTime := time.Now()
createFieldExtRes, err := net.Client.CreateFieldExtractionWithResponse(t.Context(), queryapi.CreateFieldExtractionJSONRequestBody{
DocumentId: docID,
SingleFields: queryapi.SingleFields{
FileName: ptr("large_array_test.pdf"),
ContractTitle: ptr("Large Array Performance Test Contract"),
ClientName: ptr("Performance Test Client"),
},
ArrayFields: arrayFields,
CreatedBy: openapi_types.Email("test@example.com"),
})
createDuration := time.Since(startTime)
require.NoError(t, err, "Failed to create field extraction")
if createFieldExtRes.JSON400 != nil {
t.Fatalf("Got 400 error creating field extraction: %s", createFieldExtRes.JSON400.Message)
}
require.NotNil(t, createFieldExtRes.JSON201, "Expected 201 for field extraction, got %d with body: %s",
createFieldExtRes.StatusCode(), string(createFieldExtRes.Body))
fmt.Printf(" Field extraction created in %v\n", createDuration)
fmt.Printf(" Version: %d\n", createFieldExtRes.JSON201.Version)
// Verify the current field extraction
fmt.Println("\n=== Verifying current field extraction ===")
getFieldExtRes, err := net.Client.GetCurrentFieldExtractionWithResponse(t.Context(), &queryapi.GetCurrentFieldExtractionParams{
DocumentId: docID,
})
require.NoError(t, err, "Failed to get current field extraction")
require.NotNil(t, getFieldExtRes.JSON200, "Expected 200 for get field extraction")
// Verify array field count
actualArraySize := len(getFieldExtRes.JSON200.ArrayFields)
fmt.Printf(" Array fields returned: %d\n", actualArraySize)
assert.Equal(t, arraySize, actualArraySize, "Expected %d array fields", arraySize)
// Verify single fields
assert.NotNil(t, getFieldExtRes.JSON200.SingleFields.ContractTitle)
if getFieldExtRes.JSON200.SingleFields.ContractTitle != nil {
assert.Equal(t, "Large Array Performance Test Contract", *getFieldExtRes.JSON200.SingleFields.ContractTitle)
}
// Performance assertion: should complete reasonably quickly (under 30 seconds)
assert.Less(t, createDuration, 30*time.Second,
"Field extraction with 100 array items should complete in under 30 seconds")
fmt.Println("\n=== Large array field extraction test complete ===")
}
// TestLabelReapplicationHistory tests applying the same label multiple times to track history
// (Phase 10 - Performance test: Many labels per document history).
func TestLabelReapplicationHistory(t *testing.T) {
cfg := &TextExtractionConfig{}
// Create full network
net, cleanup := test.CreateFullNetwork(t, t.Context(), cfg)
defer cleanup()
// Create test client
clientName := fmt.Sprintf("LabelHistoryTestClient_%d", time.Now().UnixNano())
clientExternalID := fmt.Sprintf("lbl-hist-%d", time.Now().Unix())
createRes, err := net.Client.CreateClientWithResponse(t.Context(), queryapi.ClientCreate{
Name: clientName,
Id: clientExternalID,
})
require.NoError(t, err)
require.NotNil(t, createRes.JSON201, "Expected 201 response for client creation")
clientID := createRes.JSON201.Id
fmt.Printf("Created client: %s\n", clientID)
// Enable sync
canSync := true
_, err = net.Client.UpdateClientWithResponse(t.Context(), clientID, queryapi.ClientUpdate{
CanSync: &canSync,
})
require.NoError(t, err)
// Upload a single document
fmt.Println("\n=== Uploading document ===")
uploadSingleDocument(t, net.Client, clientID, "label_history_test.pdf")
// Wait for document to appear
var documents []queryapi.DocumentSummary
waitForDocumentCount(t, net.Client, clientID, 1, &documents)
require.Len(t, documents, 1, "Expected 1 document")
docID := documents[0].Id
fmt.Printf(" Document uploaded: ID=%s\n", docID)
// Apply each of the 5 available labels multiple times to build history
// Note: The system has 5 predefined labels: Ingested, OCR_Processed, GenAI_Processed, Doczy_AI_Completed, Dashboard_Ready
labels := []string{"Ingested", "OCR_Processed", "GenAI_Processed", "Doczy_AI_Completed", "Dashboard_Ready"}
const applicationsPerLabel = 4
fmt.Printf("\n=== Applying each label %d times (total %d label applications) ===\n",
applicationsPerLabel, len(labels)*applicationsPerLabel)
totalApplications := 0
for round := 1; round <= applicationsPerLabel; round++ {
for _, label := range labels {
applyLabelRes, err := net.Client.ApplyLabelWithResponse(t.Context(), docID, queryapi.ApplyLabelJSONRequestBody{
Label: label,
AppliedBy: openapi_types.Email(fmt.Sprintf("user%d@example.com", round)),
})
require.NoError(t, err, "Failed to apply label %s in round %d", label, round)
if applyLabelRes.JSON201 != nil {
totalApplications++
if round == 1 || round == applicationsPerLabel {
fmt.Printf(" Round %d: Applied '%s'\n", round, label)
}
} else if applyLabelRes.JSON400 != nil {
// This might happen if re-application isn't allowed - that's informative
t.Logf(" Round %d: Label '%s' returned 400: %s", round, label, applyLabelRes.JSON400.Message)
}
}
if round == 1 {
fmt.Println(" ...")
}
}
fmt.Printf(" Total successful label applications: %d\n", totalApplications)
// Get all labels for the document and verify history
fmt.Println("\n=== Verifying label history ===")
getLabelsRes, err := net.Client.GetDocumentLabelsWithResponse(t.Context(), docID)
require.NoError(t, err, "Failed to get document labels")
require.NotNil(t, getLabelsRes.JSON200, "Expected 200 for get labels")
labelCount := 0
if getLabelsRes.JSON200.Labels != nil {
labelCount = len(*getLabelsRes.JSON200.Labels)
}
fmt.Printf(" Total label entries in history: %d\n", labelCount)
// If re-application is allowed, we should have multiple entries
// If not, we should have exactly 5 (one per unique label)
if labelCount == len(labels) {
fmt.Println(" Note: System appears to keep only most recent application per label")
} else if labelCount == totalApplications {
fmt.Println(" Note: System preserves full history of all label applications")
}
// The key test: verify we have at least one entry per label
assert.GreaterOrEqual(t, labelCount, len(labels),
"Should have at least one entry per label type")
// Verify we can query documents by each label
fmt.Println("\n=== Verifying query by label ===")
for _, label := range labels {
getDocsByLabelRes, err := net.Client.GetDocumentsByLabelWithResponse(t.Context(), label, &queryapi.GetDocumentsByLabelParams{
ClientId: queryapi.ClientID(clientID),
})
require.NoError(t, err, "Failed to get documents by label %s", label)
require.NotNil(t, getDocsByLabelRes.JSON200, "Expected 200 for get documents by label %s", label)
docCount := 0
if getDocsByLabelRes.JSON200.Documents != nil {
docCount = len(*getDocsByLabelRes.JSON200.Documents)
}
assert.Equal(t, 1, docCount, "Expected 1 document with label '%s'", label)
}
fmt.Println(" All labels return the document correctly")
fmt.Println("\n=== Label reapplication history test complete ===")
}
// TestFieldExtractionByFolderAndLabel tests Scenario 2: Query field extractions by folder and label
// (Phase 10 - Scenario 2: Cross-feature integration).
// This test uses a simpler approach - single document upload to test cross-feature integration.
func TestFieldExtractionByFolderAndLabel(t *testing.T) {
cfg := &TextExtractionConfig{}
// Create full network
net, cleanup := test.CreateFullNetwork(t, t.Context(), cfg)
defer cleanup()
// Create test client
clientName := fmt.Sprintf("FolderLabelFETestClient_%d", time.Now().UnixNano())
clientExternalID := fmt.Sprintf("fld-lbl-fe-%d", time.Now().Unix())
createRes, err := net.Client.CreateClientWithResponse(t.Context(), queryapi.ClientCreate{
Name: clientName,
Id: clientExternalID,
})
require.NoError(t, err)
require.NotNil(t, createRes.JSON201, "Expected 201 response for client creation, got status: %d, body: %s",
createRes.StatusCode(), string(createRes.Body))
clientID := createRes.JSON201.Id
fmt.Printf("Created client: %s\n", clientID)
// Enable sync
canSync := true
_, err = net.Client.UpdateClientWithResponse(t.Context(), clientID, queryapi.ClientUpdate{
CanSync: &canSync,
})
require.NoError(t, err)
// Create two folders
fmt.Println("\n=== Creating folders ===")
folder1Res, err := net.Client.CreateFolderWithResponse(t.Context(), queryapi.CreateFolderJSONRequestBody{
Path: "/contracts",
ClientId: clientID,
CreatedBy: "test@example.com",
})
require.NoError(t, err, "Failed to create folder1")
require.NotNil(t, folder1Res.JSON201, "Expected 201 for folder1")
folder1ID := folder1Res.JSON201.Id
fmt.Printf(" Created /contracts folder: ID=%s\n", folder1ID)
folder2Res, err := net.Client.CreateFolderWithResponse(t.Context(), queryapi.CreateFolderJSONRequestBody{
Path: "/invoices",
ClientId: clientID,
CreatedBy: "test@example.com",
})
require.NoError(t, err, "Failed to create folder2")
require.NotNil(t, folder2Res.JSON201, "Expected 201 for folder2")
folder2ID := folder2Res.JSON201.Id
fmt.Printf(" Created /invoices folder: ID=%s\n", folder2ID)
// Upload single document and wait for it
fmt.Println("\n=== Uploading document ===")
uploadSingleDocument(t, net.Client, clientID, "contract_doc.pdf")
var documents []queryapi.DocumentSummary
waitForDocumentCount(t, net.Client, clientID, 1, &documents)
require.Len(t, documents, 1, "Expected 1 document")
docID := documents[0].Id
fmt.Printf(" Uploaded document: ID=%s\n", docID)
// Apply labels to document
fmt.Println("\n=== Applying labels ===")
for _, label := range []string{"Ingested", "OCR_Processed", "Dashboard_Ready"} {
_, err := net.Client.ApplyLabelWithResponse(t.Context(), docID, queryapi.ApplyLabelJSONRequestBody{
Label: label,
AppliedBy: openapi_types.Email("test@example.com"),
})
require.NoError(t, err, "Failed to apply label %s", label)
}
fmt.Printf(" Applied Ingested, OCR_Processed, Dashboard_Ready\n")
// Create field extraction
fmt.Println("\n=== Creating field extraction ===")
feRes, err := net.Client.CreateFieldExtractionWithResponse(t.Context(), queryapi.CreateFieldExtractionJSONRequestBody{
DocumentId: docID,
SingleFields: queryapi.SingleFields{
FileName: ptr("contract_doc.pdf"),
ContractTitle: ptr("Service Agreement"),
ClientName: ptr("Test Client"),
},
ArrayFields: []queryapi.ArrayFieldItem{
{
ExhibitTitle: ptr("Exhibit A"),
ReimbPctRate: ptrFloat64(85.0),
ReimbFeeRate: ptrFloat64(150.0),
},
},
CreatedBy: openapi_types.Email("test@example.com"),
})
require.NoError(t, err, "Failed to create field extraction")
require.NotNil(t, feRes.JSON201, "Expected 201 for field extraction")
fmt.Printf(" Field extraction created: version=%d\n", feRes.JSON201.Version)
// Query by label and verify field extraction exists
fmt.Println("\n=== Querying documents by label and verifying field extractions ===")
// Get documents with Dashboard_Ready label
dashboardReadyDocsRes, err := net.Client.GetDocumentsByLabelWithResponse(t.Context(), "Dashboard_Ready", &queryapi.GetDocumentsByLabelParams{
ClientId: queryapi.ClientID(clientID),
})
require.NoError(t, err, "Failed to get documents by Dashboard_Ready label")
require.NotNil(t, dashboardReadyDocsRes.JSON200, "Expected 200 for get documents by label")
dashboardReadyDocs := dashboardReadyDocsRes.JSON200.Documents
require.NotNil(t, dashboardReadyDocs, "Expected documents list")
assert.Len(t, *dashboardReadyDocs, 1, "Expected 1 document with Dashboard_Ready label")
fmt.Printf(" Documents with Dashboard_Ready: %d\n", len(*dashboardReadyDocs))
// Verify that document has a field extraction
for _, doc := range *dashboardReadyDocs {
feGetRes, err := net.Client.GetCurrentFieldExtractionWithResponse(t.Context(), &queryapi.GetCurrentFieldExtractionParams{
DocumentId: doc.Id,
})
require.NoError(t, err, "Failed to get field extraction for document %s", doc.Id)
require.NotNil(t, feGetRes.JSON200, "Expected field extraction for Dashboard_Ready document")
assert.NotNil(t, feGetRes.JSON200.SingleFields.ContractTitle, "Expected contract title")
fmt.Printf(" Document %s has field extraction with title: %s\n",
doc.Id, *feGetRes.JSON200.SingleFields.ContractTitle)
}
// Get documents with OCR_Processed label
ocrDocsRes, err := net.Client.GetDocumentsByLabelWithResponse(t.Context(), "OCR_Processed", &queryapi.GetDocumentsByLabelParams{
ClientId: queryapi.ClientID(clientID),
})
require.NoError(t, err, "Failed to get documents by OCR_Processed label")
require.NotNil(t, ocrDocsRes.JSON200, "Expected 200 for get documents by label")
ocrDocs := ocrDocsRes.JSON200.Documents
require.NotNil(t, ocrDocs, "Expected documents list")
assert.Len(t, *ocrDocs, 1, "Expected 1 document with OCR_Processed label")
fmt.Printf(" Documents with OCR_Processed: %d\n", len(*ocrDocs))
// Verify folder metrics
fmt.Println("\n=== Verifying folder metrics ===")
metrics1Res, err := net.Client.GetFolderMetricsWithResponse(t.Context(), folder1ID)
require.NoError(t, err, "Failed to get folder1 metrics")
require.NotNil(t, metrics1Res.JSON200, "Expected 200 for folder1 metrics")
fmt.Printf(" /contracts folder metrics: totalDocs=%d\n", metrics1Res.JSON200.TotalDocuments)
metrics2Res, err := net.Client.GetFolderMetricsWithResponse(t.Context(), folder2ID)
require.NoError(t, err, "Failed to get folder2 metrics")
require.NotNil(t, metrics2Res.JSON200, "Expected 200 for folder2 metrics")
fmt.Printf(" /invoices folder metrics: totalDocs=%d\n", metrics2Res.JSON200.TotalDocuments)
// Test: Get all documents with Ingested label
fmt.Println("\n=== Verifying Ingested documents ===")
allIngestedRes, err := net.Client.GetDocumentsByLabelWithResponse(t.Context(), "Ingested", &queryapi.GetDocumentsByLabelParams{
ClientId: queryapi.ClientID(clientID),
})
require.NoError(t, err, "Failed to get documents by Ingested label")
require.NotNil(t, allIngestedRes.JSON200, "Expected 200 for get documents by label")
ingestedDocs := allIngestedRes.JSON200.Documents
require.NotNil(t, ingestedDocs, "Expected documents list")
assert.Len(t, *ingestedDocs, 1, "Expected 1 document with Ingested label")
fmt.Printf(" Documents with Ingested label: %d\n", len(*ingestedDocs))
// Verify field extraction on the ingested document
fmt.Println("\n=== Checking field extraction on Ingested document ===")
for _, doc := range *ingestedDocs {
feGetRes, err := net.Client.GetCurrentFieldExtractionWithResponse(t.Context(), &queryapi.GetCurrentFieldExtractionParams{
DocumentId: doc.Id,
})
require.NoError(t, err, "Failed to query field extraction for document %s", doc.Id)
require.NotNil(t, feGetRes.JSON200, "Expected field extraction for Ingested document")
fmt.Printf(" Document %s has field extraction\n", doc.Id)
}
fmt.Println("\n=== Field extraction by folder and label test complete ===")
}
// Ensure io import is used
var _ = io.EOF