// Package queryapi_test — Milestone 2 §2.8 end-to-end integration tests. // // These tests compose the Milestone 2 HTTP handlers, the customschema // service, the fieldextraction service, and the real Postgres testcontainer // produced by internal/test.CreateDB to prove the mutual-exclusivity // invariant, the full happy-path round-trip, and the edge cases the plan // calls out in §2.8. // // No mocks. Every request goes through a real handler receiver. The // helpers below reuse the Milestone 1 testutils (newCustomSchemaContext, // seedCustomSchema, resetClientForSchemaTests) so the two files share the // same fixture shape. package queryapi_test import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" queryapi "queryorchestration/api/queryAPI" "queryorchestration/internal/cognitoauth" "queryorchestration/internal/database/repository" "queryorchestration/internal/fieldextraction" "queryorchestration/internal/test" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/oapi-codegen/nullable" openapi_types "github.com/oapi-codegen/runtime/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // newCustomMetadataContext is the Milestone 2 sibling of // newCustomSchemaContext — it exists only so the tests in this file can // read clearly at a glance; the implementation is identical. func newCustomMetadataContext(t testing.TB, method string, body interface{}) (echo.Context, *httptest.ResponseRecorder) { t.Helper() e := echo.New() var reqBody []byte if body != nil { var err error reqBody, err = json.Marshal(body) require.NoError(t, err) } req := httptest.NewRequest(method, "/", bytes.NewReader(reqBody)) if reqBody != nil { req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) } rec := httptest.NewRecorder() ctx := e.NewContext(req, rec) ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject}) ctx.Set("user_info", cognitoauth.UserInfo{Username: "admin"}) return ctx, rec } // setupM2Fixture boots a fresh DB, wipes any rows belonging to clientID, // creates the client and returns the controllers. Mirrors the Milestone 1 // pattern but in one helper so the §2.8 tests stay short. func setupM2Fixture(t *testing.T, clientID string) (*queryapi.Controllers, *ControllerConfig) { t.Helper() cfg := &ControllerConfig{} test.CreateDB(t, cfg) initializeTestConfig(t, cfg) svc := createControllerServices(cfg) cons := queryapi.NewControllers(svc, cfg) resetClientForSchemaTests(t, cfg, clientID) test.CreateTestClient(t, cfg, clientID, "M2 integration "+clientID) return cons, cfg } // seedDoc creates a document for clientID with a unique hash. func seedDoc(t *testing.T, cfg *ControllerConfig, clientID, hash string) uuid.UUID { t.Helper() id, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{ Clientid: clientID, Hash: hash, }) require.NoError(t, err) return id } // assignSchemaForTest wraps AssignDocumentSchema against a freshly built // echo context so the §2.8 tests can bind a document in one line. func assignSchemaForTest(t *testing.T, cons *queryapi.Controllers, docID, schemaID uuid.UUID) { t.Helper() body := queryapi.DocumentSchemaAssignRequest{ CustomSchemaId: nullable.NewNullableWithValue(openapi_types.UUID(schemaID)), } ctx, rec := newCustomMetadataContext(t, http.MethodPatch, body) require.NoError(t, cons.AssignDocumentSchema(ctx, openapi_types.UUID(docID))) require.Equal(t, http.StatusOK, rec.Code, "assign must return 200") } // ---------- end-to-end happy round-trip ---------- // TestM2_HappyRoundTrip covers the §2.8 full round-trip: // create schema → assign to document → POST valid metadata → GET returns // it with populated schemaId / schemaName / schemaVersion / createdBy. func TestM2_HappyRoundTrip(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M2_HAPPY" cons, cfg := setupM2Fixture(t, clientID) schema := seedCustomSchema(t, cons, clientID, "m2-happy") docID := seedDoc(t, cfg, clientID, "m2-happy-doc") assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) // POST valid metadata. postBody := queryapi.CustomMetadataRequest{ DocumentId: openapi_types.UUID(docID), Metadata: map[string]interface{}{"name": "contract-42"}, } ctxPost, recPost := newCustomMetadataContext(t, http.MethodPost, postBody) require.NoError(t, cons.SetCustomMetadata(ctxPost)) require.Equal(t, http.StatusCreated, recPost.Code) var posted queryapi.CustomMetadataResponse require.NoError(t, json.Unmarshal(recPost.Body.Bytes(), &posted)) assert.Equal(t, int32(1), posted.Version) assert.Equal(t, testActorSubject, posted.CreatedBy) // GET /custom-metadata?documentId=... ctxGet, recGet := newCustomMetadataContext(t, http.MethodGet, nil) err := cons.GetCurrentCustomMetadata(ctxGet, queryapi.GetCurrentCustomMetadataParams{ DocumentId: openapi_types.UUID(docID), }) require.NoError(t, err) require.Equal(t, http.StatusOK, recGet.Code) var got queryapi.CustomMetadataResponse require.NoError(t, json.Unmarshal(recGet.Body.Bytes(), &got)) assert.Equal(t, posted.Version, got.Version) assert.Equal(t, posted.Id, got.Id) require.True(t, got.SchemaId.IsSpecified() && !got.SchemaId.IsNull()) sid, _ := got.SchemaId.Get() assert.Equal(t, schema.Id, sid) schemaName, _ := got.SchemaName.Get() assert.Equal(t, "m2-happy", schemaName) schemaVersion, _ := got.SchemaVersion.Get() assert.Equal(t, int32(1), schemaVersion) } // TestM2_PostWithoutSchemaReturns400 proves POST /custom-metadata refuses // writes to documents that have no schema binding. func TestM2_PostWithoutSchemaReturns400(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M2_NOSCH" cons, cfg := setupM2Fixture(t, clientID) docID := seedDoc(t, cfg, clientID, "m2-nosch-doc") postBody := queryapi.CustomMetadataRequest{ DocumentId: openapi_types.UUID(docID), Metadata: map[string]interface{}{"name": "nope"}, } ctxPost, recPost := newCustomMetadataContext(t, http.MethodPost, postBody) err := cons.SetCustomMetadata(ctxPost) // The handler propagates echo.HTTPError for mapped sentinels; the // real HTTP server flushes this as a response. For the handler unit // test we inspect the error directly. requireHTTPStatus(t, err, recPost, http.StatusBadRequest) } // TestM2_LegacyWriteOnCustomDoc covers the §2.8 mutual-exclusivity end: // schema bound → legacy field extraction write → 409. func TestM2_LegacyWriteOnCustomDoc(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M2_LEGACY" cons, cfg := setupM2Fixture(t, clientID) schema := seedCustomSchema(t, cons, clientID, "m2-legacy") docID := seedDoc(t, cfg, clientID, "m2-legacy-doc") assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) // Attempt legacy write via the fieldextraction service directly // (the REST handler would do the same mapping — we test service + // handler mapping separately in the fieldextraction package). svc := createControllerServices(cfg) _, err := svc.FieldExtraction.CreateFieldExtraction(t.Context(), &fieldextraction.CreateFieldExtractionInput{ DocumentID: docID, SingleFields: &repository.AddFieldExtractionParams{ Documentid: docID, Createdby: "tester", }, ArrayFields: nil, }) require.Error(t, err) assert.ErrorIs(t, err, fieldextraction.ErrMutualExclusivityViolation) } // TestM2_GetByVersion covers §2.8 GET /custom-metadata/version happy // path: post 3 versions, retrieve v2 exactly. func TestM2_GetByVersion(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M2_VERSION" cons, cfg := setupM2Fixture(t, clientID) schema := seedCustomSchema(t, cons, clientID, "m2-version") docID := seedDoc(t, cfg, clientID, "m2-version-doc") assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) for _, payload := range []string{`{"name":"v1"}`, `{"name":"v2"}`, `{"name":"v3"}`} { var m map[string]interface{} require.NoError(t, json.Unmarshal([]byte(payload), &m)) body := queryapi.CustomMetadataRequest{ DocumentId: openapi_types.UUID(docID), Metadata: m, } ctx, rec := newCustomMetadataContext(t, http.MethodPost, body) require.NoError(t, cons.SetCustomMetadata(ctx)) require.Equal(t, http.StatusCreated, rec.Code) } ctxVer, recVer := newCustomMetadataContext(t, http.MethodGet, nil) err := cons.GetCustomMetadataByVersion(ctxVer, queryapi.GetCustomMetadataByVersionParams{ DocumentId: openapi_types.UUID(docID), Version: 2, }) require.NoError(t, err) require.Equal(t, http.StatusOK, recVer.Code) var got queryapi.CustomMetadataResponse require.NoError(t, json.Unmarshal(recVer.Body.Bytes(), &got)) assert.Equal(t, int32(2), got.Version) // metadata must be the v2 payload, not v1 or v3 v, ok := got.Metadata["name"] require.True(t, ok) assert.Equal(t, "v2", v) } // TestM2_History covers the §2.8 history GET happy path with pagination. func TestM2_History(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M2_HIST" cons, cfg := setupM2Fixture(t, clientID) schema := seedCustomSchema(t, cons, clientID, "m2-hist") docID := seedDoc(t, cfg, clientID, "m2-hist-doc") assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) for i := 0; i < 5; i++ { body := queryapi.CustomMetadataRequest{ DocumentId: openapi_types.UUID(docID), Metadata: map[string]interface{}{"name": "x"}, } ctx, rec := newCustomMetadataContext(t, http.MethodPost, body) require.NoError(t, cons.SetCustomMetadata(ctx)) require.Equal(t, http.StatusCreated, rec.Code) } limit := int32(2) offset := int32(0) ctx1, rec1 := newCustomMetadataContext(t, http.MethodGet, nil) require.NoError(t, cons.GetCustomMetadataHistory(ctx1, queryapi.GetCustomMetadataHistoryParams{ DocumentId: openapi_types.UUID(docID), Limit: &limit, Offset: &offset, })) require.Equal(t, http.StatusOK, rec1.Code) var page1 queryapi.CustomMetadataHistoryResponse require.NoError(t, json.Unmarshal(rec1.Body.Bytes(), &page1)) require.Len(t, page1.Versions, 2) assert.Equal(t, int32(5), page1.Versions[0].Version) assert.Equal(t, int32(4), page1.Versions[1].Version) offset2 := int32(2) ctx2, rec2 := newCustomMetadataContext(t, http.MethodGet, nil) require.NoError(t, cons.GetCustomMetadataHistory(ctx2, queryapi.GetCustomMetadataHistoryParams{ DocumentId: openapi_types.UUID(docID), Limit: &limit, Offset: &offset2, })) var page2 queryapi.CustomMetadataHistoryResponse require.NoError(t, json.Unmarshal(rec2.Body.Bytes(), &page2)) require.Len(t, page2.Versions, 2) assert.Equal(t, int32(3), page2.Versions[0].Version) assert.Equal(t, int32(2), page2.Versions[1].Version) } // TestM2_AssignToDocWithLegacyExtractionsReturns409 covers the reverse // direction of the mutual-exclusivity invariant: a doc with legacy // extractions cannot be bound to a schema. func TestM2_AssignToDocWithLegacyExtractionsReturns409(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M2_LEGACYASSIGN" cons, cfg := setupM2Fixture(t, clientID) // Create a document and seed a legacy extraction via the service. svc := createControllerServices(cfg) docID := seedDoc(t, cfg, clientID, "m2-legacy-assign-doc") _, err := svc.FieldExtraction.CreateFieldExtraction(t.Context(), &fieldextraction.CreateFieldExtractionInput{ DocumentID: docID, SingleFields: &repository.AddFieldExtractionParams{ Documentid: docID, Createdby: "tester", }, ArrayFields: nil, }) require.NoError(t, err) schema := seedCustomSchema(t, cons, clientID, "m2-legacy-assign") // Attempt to bind — must 409. body := queryapi.DocumentSchemaAssignRequest{ CustomSchemaId: nullable.NewNullableWithValue(schema.Id), } ctx, rec := newCustomMetadataContext(t, http.MethodPatch, body) err = cons.AssignDocumentSchema(ctx, openapi_types.UUID(docID)) requireHTTPStatus(t, err, rec, http.StatusConflict) } // TestM2_FKCascadeDeletesMetadata proves the FK cascade from documents // removes document_custom_metadata rows on document delete, covering the // §2.8 "FK-cascade delete" row. func TestM2_FKCascadeDeletesMetadata(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M2_FKCASCADE" cons, cfg := setupM2Fixture(t, clientID) schema := seedCustomSchema(t, cons, clientID, "m2-fkcascade") docID := seedDoc(t, cfg, clientID, "m2-fkcascade-doc") assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) // Write two metadata versions via the handler. for i := 0; i < 2; i++ { body := queryapi.CustomMetadataRequest{ DocumentId: openapi_types.UUID(docID), Metadata: map[string]interface{}{"name": "x"}, } ctx, rec := newCustomMetadataContext(t, http.MethodPost, body) require.NoError(t, cons.SetCustomMetadata(ctx)) require.Equal(t, http.StatusCreated, rec.Code) } var before int require.NoError(t, cfg.GetDBPool().QueryRow(t.Context(), `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, docID).Scan(&before)) require.Equal(t, 2, before) // Delete the document row directly (the feature relies on the FK // cascade, not on an application-level delete path). _, err := cfg.GetDBPool().Exec(t.Context(), `DELETE FROM documents WHERE id = $1`, docID) require.NoError(t, err) var after int require.NoError(t, cfg.GetDBPool().QueryRow(t.Context(), `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, docID).Scan(&after)) assert.Equal(t, 0, after, "FK cascade must remove every metadata row") } // TestM2_GetByVersionNotFound exercises the ErrMetadataVersionNotFound (404) // branch in mapCustomMetadataError by requesting a version that does not exist. func TestM2_GetByVersionNotFound(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M2_VERNOTFOUND" cons, cfg := setupM2Fixture(t, clientID) schema := seedCustomSchema(t, cons, clientID, "m2-ver-notfound") docID := seedDoc(t, cfg, clientID, "m2-ver-notfound-doc") assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) // POST one version so the document exists and has metadata. body := queryapi.CustomMetadataRequest{ DocumentId: openapi_types.UUID(docID), Metadata: map[string]interface{}{"name": "x"}, } ctx, rec := newCustomMetadataContext(t, http.MethodPost, body) require.NoError(t, cons.SetCustomMetadata(ctx)) require.Equal(t, http.StatusCreated, rec.Code) // Request a version that does not exist → 404. ctxVer, recVer := newCustomMetadataContext(t, http.MethodGet, nil) err := cons.GetCustomMetadataByVersion(ctxVer, queryapi.GetCustomMetadataByVersionParams{ DocumentId: openapi_types.UUID(docID), Version: 99, }) requireHTTPStatus(t, err, recVer, http.StatusNotFound) } // TestM2_CreateFieldExtractionOnCustomDocReturns409 exercises the // ErrMutualExclusivityViolation branch in the CreateFieldExtraction handler. // A document bound to a custom schema must reject legacy field extraction writes // with 409. func TestM2_CreateFieldExtractionOnCustomDocReturns409(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M2_LEGFX409" cons, cfg := setupM2Fixture(t, clientID) schema := seedCustomSchema(t, cons, clientID, "m2-legfx-409") docID := seedDoc(t, cfg, clientID, "m2-legfx-409-doc") assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) // Attempt a legacy field extraction write — must 409. reqBody := queryapi.FieldExtractionRequest{ DocumentId: queryapi.DocumentID(docID), CreatedBy: "test@example.com", SingleFields: queryapi.SingleFields{ FileName: func() *string { s := "test.pdf"; return &s }(), }, } b, err := json.Marshal(reqBody) require.NoError(t, err) e := echo.New() req := httptest.NewRequest(http.MethodPost, "/field-extractions", bytes.NewReader(b)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() ctx := e.NewContext(req, rec) ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject}) fxErr := cons.CreateFieldExtraction(ctx) requireHTTPStatus(t, fxErr, rec, http.StatusConflict) } // TestM2_CreateFieldExtractionDocNotFound exercises the ErrDocumentNotFound (404) // branch in the CreateFieldExtraction handler when the document does not exist. func TestM2_CreateFieldExtractionDocNotFound(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M2_FXDOCNOTFOUND" cons, _ := setupM2Fixture(t, clientID) // Use a UUID that will not match any document. nonExistentDocID := uuid.New() reqBody := queryapi.FieldExtractionRequest{ DocumentId: queryapi.DocumentID(nonExistentDocID), CreatedBy: "test@example.com", SingleFields: queryapi.SingleFields{ FileName: func() *string { s := "test.pdf"; return &s }(), }, } b, err := json.Marshal(reqBody) require.NoError(t, err) e := echo.New() req := httptest.NewRequest(http.MethodPost, "/field-extractions", bytes.NewReader(b)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() ctx := e.NewContext(req, rec) ctx.Set("user_claims", map[string]interface{}{"sub": testActorSubject}) fxErr := cons.CreateFieldExtraction(ctx) requireHTTPStatus(t, fxErr, rec, http.StatusNotFound) } // requireHTTPStatus asserts the handler produced the expected HTTP code, // handling both the "echo.HTTPError returned" and "recorder has code // already" shapes the handlers can emit. Milestone 2 handlers always // return an echo.HTTPError for mapped sentinels rather than writing to // the recorder, so the error branch is the common path. func requireHTTPStatus(t *testing.T, err error, rec *httptest.ResponseRecorder, want int) { t.Helper() if err != nil { var httpErr *echo.HTTPError if errors.As(err, &httpErr) { assert.Equalf(t, want, httpErr.Code, "expected status %d, got %d: %v", want, httpErr.Code, httpErr.Message) return } t.Fatalf("expected echo.HTTPError with status %d, got %T: %v", want, err, err) } assert.Equal(t, want, rec.Code) }