// Package queryapi_test — Milestone 5 client-scoped read-only document // endpoints (plan §10 M5). // // Routes covered here: // - GET /client/{clientId}/documents/{documentId}/schema // - GET /client/{clientId}/documents/{documentId}/all-metadata // // These endpoints expose the M1-shipped client-scoped repo queries // (GetDocumentEnrichedForClient, GetDocumentLabelsForClient, // GetDocumentCustomSchemaIdForClient, GetCurrentDocumentCustomMetadataForClient) // behind two HTTP routes plan §3 calls "user routes". Plan §3 mandates // the singular `/client/...` form; the OpenAPI spec must register only // the singular path so Permit.io's existing `client_user` policy applies. // // Compile contract: this file references queryapi.GetDocumentSchema, // queryapi.GetDocumentAllMetadata controller methods plus // queryapi.DocumentSchemaResponse and queryapi.DocumentAllMetadataResponse // types. Backend-eng adds the OpenAPI spec entries, regenerates, and // the file compiles. Until then it fails to compile — the failing // state plan §10 M5 endorses. // // All tests use: // - real Postgres via internal/test.CreateDB // - the M2 helpers (newBotContext, newBotContextAs, resetClientForBotTests) // for actor-injection and per-test cleanup // - testActorSubject as the default Cognito sub // // No mocks. No t.Skip beyond testing.Short. No commented-out tests. package queryapi_test import ( "encoding/json" "fmt" "net/http" "testing" queryapi "queryorchestration/api/queryAPI" "queryorchestration/internal/database/repository" "queryorchestration/internal/test" "github.com/google/uuid" "github.com/labstack/echo/v4" openapi_types "github.com/oapi-codegen/runtime/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // trivialDocSchemaJSON is a minimal JSON Schema that passes the // schema-validator but carries enough fields for the M5 schema endpoint // to demonstrate it serializes the schema body verbatim. Not the literal // from sample.response.json — that is the real Aaria payload; here we // just need a recognizable test fixture. const trivialDocSchemaJSON = `{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "contract_title": {"type": "string"}, "effective_date": {"type": "string"} }, "additionalProperties": false }` // botDocumentsContext bundles the per-test fixture: ControllerConfig, // Controllers, and a clientID. Each test owns its own client so the // shared testcontainer Postgres reuse is safe. type botDocumentsContext struct { cfg *ControllerConfig cons *queryapi.Controllers clientID string } // newBotDocumentsContext is the standard fixture for M5 tests. It // stands up a real Postgres, runs initializeTestConfig (which sets the // CHATBOT_* env placeholders), builds the full controller stack, and // resets the test client. Mirrors newBotTurnsContext from M4 but // without the FastAPI httptest server because M5 endpoints do not // touch FastAPI. func newBotDocumentsContext(t *testing.T, clientID string) *botDocumentsContext { t.Helper() cfg := &ControllerConfig{} test.CreateDB(t, cfg) initializeTestConfig(t, cfg) svc := createControllerServices(cfg) cons := queryapi.NewControllers(svc, cfg) resetClientForBotTests(t, cfg, clientID) test.CreateTestClient(t, cfg, clientID, "bot-m5-"+clientID) return &botDocumentsContext{ cfg: cfg, cons: cons, clientID: clientID, } } // seedDocumentForClient creates a documents row owned by clientID and // returns its uuid. Tests that need a cross-client document call this // helper with the other clientID. func seedDocumentForClient(t *testing.T, ctx *botDocumentsContext, clientID, hash string) uuid.UUID { t.Helper() docID, err := ctx.cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{ Clientid: clientID, Hash: hash, }) require.NoError(t, err) return docID } // seedSchemaForClient inserts an active client_metadata_schemas row // and returns its id. The schema body is trivialDocSchemaJSON; tests // only need the row to exist for the binding/rejection cases. func seedSchemaForClient(t *testing.T, ctx *botDocumentsContext, clientID, name string) uuid.UUID { t.Helper() desc := "m5 test schema" row, err := ctx.cfg.GetDBQueries().CreateClientMetadataSchema(t.Context(), &repository.CreateClientMetadataSchemaParams{ ClientID: clientID, Name: name, Description: &desc, SchemaDef: []byte(trivialDocSchemaJSON), Version: 1, Status: repository.SchemaStatusTypeActive, CreatedBy: "tester", }) require.NoError(t, err) return row.ID } // bindSchemaToDocument writes documents.custom_schema_id via the sqlc // generated query. Same-client constraint comes from the composite FK // (migration 128); cross-client binding is rejected at DB level. func bindSchemaToDocument(t *testing.T, ctx *botDocumentsContext, docID, schemaID uuid.UUID) { t.Helper() require.NoError(t, ctx.cfg.GetDBQueries().SetDocumentCustomSchemaId(t.Context(), &repository.SetDocumentCustomSchemaIdParams{ CustomSchemaID: &schemaID, ID: docID, })) } // applyLabel writes a documentLabels row. The label string must exist // in the labels seed (migration 110 seeds: Ingested, OCR_Processed, // GenAI_Processed, Doczy_AI_Completed, Dashboard_Ready). func applyLabel(t *testing.T, ctx *botDocumentsContext, docID uuid.UUID, label string) { t.Helper() _, err := ctx.cfg.GetDBQueries().ApplyLabel(t.Context(), &repository.ApplyLabelParams{ Documentid: docID, Label: label, Appliedby: "tester", }) require.NoError(t, err) } // applyCustomMetadata writes one document_custom_metadata row at the // supplied version. Caller is responsible for monotonic version values // because the table enforces (document_id, version) uniqueness. func applyCustomMetadata(t *testing.T, ctx *botDocumentsContext, docID uuid.UUID, version int32, metadata string) { t.Helper() _, err := ctx.cfg.GetDBQueries().CreateDocumentCustomMetadata(t.Context(), &repository.CreateDocumentCustomMetadataParams{ DocumentID: docID, Metadata: []byte(metadata), Version: version, CreatedBy: "tester", }) require.NoError(t, err) } // assertHTTPErrorM5 asserts the returned err is *echo.HTTPError with // the expected code. Distinct from the M4 assertHTTPError so the M5 // file does not depend on M4 helper symbols. func assertHTTPErrorM5(t *testing.T, err error, wantCode int) { t.Helper() require.Error(t, err) httpErr, ok := err.(*echo.HTTPError) require.Truef(t, ok, "expected *echo.HTTPError, got %T: %v", err, err) assert.Equal(t, wantCode, httpErr.Code) } // ---------- GET /client/{clientId}/documents/{documentId}/schema ---------- // TestGetDocumentSchema_OwnerSuccess: client_user authenticated for // clientA fetches a document owned by clientA whose schema is bound to // schemaS; response is 200 with the schema body serialized verbatim. func TestGetDocumentSchema_OwnerSuccess(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "BOT_M5_SCHEMA_OK" bdc := newBotDocumentsContext(t, clientID) docID := seedDocumentForClient(t, bdc, clientID, "m5-schema-ok-doc") schemaID := seedSchemaForClient(t, bdc, clientID, "m5-schema-ok") bindSchemaToDocument(t, bdc, docID, schemaID) path := fmt.Sprintf("/client/%s/documents/%s/schema", clientID, docID) ctx, rec := newBotContext(t, http.MethodGet, path, nil) require.NoError(t, bdc.cons.GetDocumentSchema(ctx, clientID, openapi_types.UUID(docID))) require.Equal(t, http.StatusOK, rec.Code) var resp queryapi.DocumentSchemaResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.NotNil(t, resp.Schema, "bound document must return a non-nil schema body") // The schema body round-trips through the response. Asserting on a // known property name keeps the test resilient to whichever exact // field name backend-eng picks (Schema, schemaDef, etc.); the // JSON-tag-driven serialization will surface "properties" verbatim. props, ok := (*resp.Schema)["properties"].(map[string]interface{}) require.Truef(t, ok, "schema body must include 'properties' map; got %v", *resp.Schema) require.Contains(t, props, "contract_title") } // TestGetDocumentSchema_CrossClient_404: client_user authenticated for // clientA requests a document owned by clientB. Plan §9: cross-client // reads must miss with 404. func TestGetDocumentSchema_CrossClient_404(t *testing.T) { if testing.Short() { t.SkipNow() } const ( clientA = "BOT_M5_SCHEMA_X_A" clientB = "BOT_M5_SCHEMA_X_B" ) bdc := newBotDocumentsContext(t, clientA) resetClientForBotTests(t, bdc.cfg, clientB) test.CreateTestClient(t, bdc.cfg, clientB, "bot-m5-x-b") // docB belongs to clientB. docB := seedDocumentForClient(t, bdc, clientB, "m5-schema-x-doc-b") schemaB := seedSchemaForClient(t, bdc, clientB, "m5-schema-x-b") bindSchemaToDocument(t, bdc, docB, schemaB) // Caller authenticates for clientA. path := fmt.Sprintf("/client/%s/documents/%s/schema", clientA, docB) ctx, _ := newBotContext(t, http.MethodGet, path, nil) err := bdc.cons.GetDocumentSchema(ctx, clientA, openapi_types.UUID(docB)) assertHTTPErrorM5(t, err, http.StatusNotFound) } // TestGetDocumentSchema_UnboundDocument_NullSchema: document exists but // has NULL custom_schema_id. Per the dispatch authoritative answer, the // endpoint returns 200 with schema=null in the body (the document // exists; only the optional binding is absent). func TestGetDocumentSchema_UnboundDocument_NullSchema(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "BOT_M5_SCHEMA_UB" bdc := newBotDocumentsContext(t, clientID) // Document with no schema bound. docID := seedDocumentForClient(t, bdc, clientID, "m5-schema-unbound-doc") path := fmt.Sprintf("/client/%s/documents/%s/schema", clientID, docID) ctx, rec := newBotContext(t, http.MethodGet, path, nil) require.NoError(t, bdc.cons.GetDocumentSchema(ctx, clientID, openapi_types.UUID(docID))) require.Equal(t, http.StatusOK, rec.Code, "plan §10 M5: unbound document returns 200 with schema=null, not 404") var resp queryapi.DocumentSchemaResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.Nil(t, resp.Schema, "unbound document must return schema=null (the document exists; only the binding is absent)") } // TestGetDocumentSchema_NoSubject_401: handler returns 401 when no // Cognito subject is present. func TestGetDocumentSchema_NoSubject_401(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "BOT_M5_SCHEMA_401" bdc := newBotDocumentsContext(t, clientID) docID := seedDocumentForClient(t, bdc, clientID, "m5-schema-401-doc") path := fmt.Sprintf("/client/%s/documents/%s/schema", clientID, docID) ctx, _ := newBotContextAs(t, http.MethodGet, path, nil, "") err := bdc.cons.GetDocumentSchema(ctx, clientID, openapi_types.UUID(docID)) assertHTTPErrorM5(t, err, http.StatusUnauthorized) } // TestGetDocumentSchema_DocumentNotFound_404: the supplied document id // does not exist in the documents table. func TestGetDocumentSchema_DocumentNotFound_404(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "BOT_M5_SCHEMA_NF" bdc := newBotDocumentsContext(t, clientID) missingID := uuid.New() path := fmt.Sprintf("/client/%s/documents/%s/schema", clientID, missingID) ctx, _ := newBotContext(t, http.MethodGet, path, nil) err := bdc.cons.GetDocumentSchema(ctx, clientID, openapi_types.UUID(missingID)) assertHTTPErrorM5(t, err, http.StatusNotFound) } // ---------- GET /client/{clientId}/documents/{documentId}/all-metadata ---------- // TestGetDocumentAllMetadata_OwnerSuccess: docA belongs to clientA, // has 2 labels and 3 custom-metadata key/value pairs. GET returns 200 // with document, labels, and customMetadata populated. func TestGetDocumentAllMetadata_OwnerSuccess(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "BOT_M5_ALL_OK" bdc := newBotDocumentsContext(t, clientID) docID := seedDocumentForClient(t, bdc, clientID, "m5-all-ok-doc") schemaID := seedSchemaForClient(t, bdc, clientID, "m5-all-ok-schema") bindSchemaToDocument(t, bdc, docID, schemaID) // Two labels. applyLabel(t, bdc, docID, "Ingested") applyLabel(t, bdc, docID, "OCR_Processed") // One metadata version with three keys. applyCustomMetadata(t, bdc, docID, 1, `{"contract_title":"AAA","effective_date":"2026-01-01","payer_name":"X Health"}`) path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientID, docID) ctx, rec := newBotContext(t, http.MethodGet, path, nil) require.NoError(t, bdc.cons.GetDocumentAllMetadata(ctx, clientID, openapi_types.UUID(docID))) require.Equal(t, http.StatusOK, rec.Code) var resp queryapi.DocumentAllMetadataResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) // Document fields. assert.Equal(t, openapi_types.UUID(docID), resp.Document.Id) assert.Equal(t, clientID, string(resp.Document.ClientId)) // Labels: must contain both seeded labels (order is implementation- // defined; the labels SQL ORDERs by appliedAt DESC). require.Len(t, resp.Labels, 2, "both seeded labels must appear in the response") labelStrings := []string{string(resp.Labels[0].Label), string(resp.Labels[1].Label)} assert.Contains(t, labelStrings, "Ingested") assert.Contains(t, labelStrings, "OCR_Processed") // Custom metadata: three keys round-trip with their values. require.NotNil(t, resp.CustomMetadata) assert.Equal(t, "AAA", (*resp.CustomMetadata)["contract_title"]) assert.Equal(t, "2026-01-01", (*resp.CustomMetadata)["effective_date"]) assert.Equal(t, "X Health", (*resp.CustomMetadata)["payer_name"]) } // TestGetDocumentAllMetadata_CrossClient_404: clientA's document, // requested as clientB. Plan §9: cross-client reads miss with 404. func TestGetDocumentAllMetadata_CrossClient_404(t *testing.T) { if testing.Short() { t.SkipNow() } const ( clientA = "BOT_M5_ALL_X_A" clientB = "BOT_M5_ALL_X_B" ) bdc := newBotDocumentsContext(t, clientA) resetClientForBotTests(t, bdc.cfg, clientB) test.CreateTestClient(t, bdc.cfg, clientB, "bot-m5-all-x-b") docB := seedDocumentForClient(t, bdc, clientB, "m5-all-x-doc-b") path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientA, docB) ctx, _ := newBotContext(t, http.MethodGet, path, nil) err := bdc.cons.GetDocumentAllMetadata(ctx, clientA, openapi_types.UUID(docB)) assertHTTPErrorM5(t, err, http.StatusNotFound) } // TestAllMetadata_UnboundSchemaEmptyMetadata: document has labels but no // custom-metadata schema bound (no metadata rows possible without a // schema). Response shows labels populated and customMetadata as an // empty map / null. // // Renamed from TestGetDocumentAllMetadata_UnboundCustomSchema_EmptyMetadata // because the original 60-char Go name produced a 66-char Postgres // alias when prefixed with `postgrestest`, exceeding NAMEDATALEN=63. // Same trap M1 hit on TestGetCurrentDocumentCustomMetadataForClient. func TestAllMetadata_UnboundSchemaEmptyMetadata(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "BOT_M5_ALL_UB" bdc := newBotDocumentsContext(t, clientID) docID := seedDocumentForClient(t, bdc, clientID, "m5-all-unbound-doc") applyLabel(t, bdc, docID, "Ingested") path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientID, docID) ctx, rec := newBotContext(t, http.MethodGet, path, nil) require.NoError(t, bdc.cons.GetDocumentAllMetadata(ctx, clientID, openapi_types.UUID(docID))) require.Equal(t, http.StatusOK, rec.Code) var resp queryapi.DocumentAllMetadataResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.Len(t, resp.Labels, 1, "the one applied label must appear") assert.Equal(t, "Ingested", string(resp.Labels[0].Label)) // CustomMetadata is either nil or an empty map. Both shapes match the // "no schema bound -> no metadata" semantics; assert that nothing // surfaces a non-trivial entry. if resp.CustomMetadata != nil { assert.Empty(t, *resp.CustomMetadata, "unbound document must have empty/null customMetadata") } } // TestGetDocumentAllMetadata_NoLabels_EmptyArray: document has no // labels and no custom metadata. Response is well-formed with empty // arrays/maps. func TestGetDocumentAllMetadata_NoLabels_EmptyArray(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "BOT_M5_ALL_EMPTY" bdc := newBotDocumentsContext(t, clientID) docID := seedDocumentForClient(t, bdc, clientID, "m5-all-empty-doc") path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientID, docID) ctx, rec := newBotContext(t, http.MethodGet, path, nil) require.NoError(t, bdc.cons.GetDocumentAllMetadata(ctx, clientID, openapi_types.UUID(docID))) require.Equal(t, http.StatusOK, rec.Code) var resp queryapi.DocumentAllMetadataResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.NotNil(t, resp.Labels, "labels must be a non-nil slice so json.Marshal emits []") assert.Empty(t, resp.Labels) if resp.CustomMetadata != nil { assert.Empty(t, *resp.CustomMetadata) } } // TestGetDocumentAllMetadata_NoSubject_401: handler returns 401 when no // Cognito subject is present. func TestGetDocumentAllMetadata_NoSubject_401(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "BOT_M5_ALL_401" bdc := newBotDocumentsContext(t, clientID) docID := seedDocumentForClient(t, bdc, clientID, "m5-all-401-doc") path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientID, docID) ctx, _ := newBotContextAs(t, http.MethodGet, path, nil, "") err := bdc.cons.GetDocumentAllMetadata(ctx, clientID, openapi_types.UUID(docID)) assertHTTPErrorM5(t, err, http.StatusUnauthorized) } // TestGetDocumentAllMetadata_DocumentNotFound_404: the supplied // document id does not exist. func TestGetDocumentAllMetadata_DocumentNotFound_404(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "BOT_M5_ALL_NF" bdc := newBotDocumentsContext(t, clientID) missingID := uuid.New() path := fmt.Sprintf("/client/%s/documents/%s/all-metadata", clientID, missingID) ctx, _ := newBotContext(t, http.MethodGet, path, nil) err := bdc.cons.GetDocumentAllMetadata(ctx, clientID, openapi_types.UUID(missingID)) assertHTTPErrorM5(t, err, http.StatusNotFound) } // ---------- Authorization smoke ---------- // TestGetDocumentEndpoints_PluralRouteAbsent: assert via OpenAPI grep // that /clients/{clientId}/documents/.../{schema,all-metadata} (plural) // is NOT registered. Plan §3 mandates the singular form so Permit.io's // existing client_user policy applies. func TestGetDocumentEndpoints_PluralRouteAbsent(t *testing.T) { if testing.Short() { t.SkipNow() } swagger, err := queryapi.GetSwagger() require.NoError(t, err) require.NotNil(t, swagger.Paths) for _, p := range []string{ "/clients/{clientId}/documents/{documentId}/schema", "/clients/{clientId}/documents/{documentId}/all-metadata", } { assert.Nil(t, swagger.Paths.Find(p), "plural route %s must not be registered (plan §3 mandates singular `client`)", p) } // Positive control: the singular routes must be declared. for _, p := range []string{ "/client/{clientId}/documents/{documentId}/schema", "/client/{clientId}/documents/{documentId}/all-metadata", } { assert.NotNilf(t, swagger.Paths.Find(p), "singular route %s must be registered", p) } }