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:
+244
-211
@@ -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(), ¶ms.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
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user