// Package queryapi_test — Milestone 3 §3.3 and §3.4 integration tests // for POST /super-admin/documents/{id}/reset-metadata. // // These tests exercise the ResetDocumentMetadata handler end-to-end against // the real Postgres testcontainer, covering all observable status codes: // 401 (missing subject), 200 (happy path with prior schema), 200 (idempotent // clean doc), 404 (no such document), 409 (legacy field extractions present). package queryapi_test import ( "encoding/json" "net/http" "net/http/httptest" "testing" queryapi "queryorchestration/api/queryAPI" "queryorchestration/internal/database/repository" "queryorchestration/internal/fieldextraction" "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" ) // setupM3Fixture boots a fresh DB, wipes rows belonging to clientID, creates // the client, and returns controllers + cfg. Mirrors setupM2Fixture from the // Milestone 2 integration tests. func setupM3Fixture(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, "M3 integration "+clientID) return cons, cfg } // callReset dispatches POST /super-admin/documents/{id}/reset-metadata via the // handler using the standard test actor subject. Returns the handler error and // the response recorder so callers can inspect both status code and body. func callReset(t *testing.T, cons *queryapi.Controllers, docID uuid.UUID) (error, *httptest.ResponseRecorder) { t.Helper() ctx, rec := newCustomSchemaContext(t, http.MethodPost, nil) err := cons.ResetDocumentMetadata(ctx, openapi_types.UUID(docID)) return err, rec } // TestResetDocumentMetadata_MissingUserSubject verifies the 401 branch: when // the echo context carries no user_claims the handler short-circuits before // calling the service. func TestResetDocumentMetadata_MissingUserSubject(t *testing.T) { if testing.Short() { t.SkipNow() } cfg := &ControllerConfig{} test.CreateDB(t, cfg) initializeTestConfig(t, cfg) svc := createControllerServices(cfg) cons := queryapi.NewControllers(svc, cfg) e := echo.New() req := httptest.NewRequest(http.MethodPost, "/", nil) rec := httptest.NewRecorder() ctx := e.NewContext(req, rec) // intentionally no ctx.Set("user_claims", ...) — simulates unauthenticated call err := cons.ResetDocumentMetadata(ctx, openapi_types.UUID(uuid.New())) requireHTTPStatus(t, err, rec, http.StatusUnauthorized) } // TestM3_ResetDocumentMetadata_DocumentNotFound exercises the 404 branch: when // the document UUID does not exist the service returns ErrDocumentNotFound which // mapResetError converts to 404. func TestM3_ResetDocumentMetadata_DocumentNotFound(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M3_RST_NOTFOUND" cons, _ := setupM3Fixture(t, clientID) err, rec := callReset(t, cons, uuid.New()) requireHTTPStatus(t, err, rec, http.StatusNotFound) } // TestM3_ResetDocumentMetadata_LegacyExtractionsConflict exercises the 409 // branch: a document carrying legacy field extractions returns // ErrMutualExclusivityViolation which mapResetError converts to 409. func TestM3_ResetDocumentMetadata_LegacyExtractionsConflict(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M3_RST_LEGACY" cons, cfg := setupM3Fixture(t, clientID) docID := seedDoc(t, cfg, clientID, "m3-reset-legacy-doc") svc := createControllerServices(cfg) _, err := svc.FieldExtraction.CreateFieldExtraction(t.Context(), &fieldextraction.CreateFieldExtractionInput{ DocumentID: docID, SingleFields: &repository.AddFieldExtractionParams{ Documentid: docID, Createdby: "tester", }, ArrayFields: nil, }) require.NoError(t, err) resetErr, rec := callReset(t, cons, docID) requireHTTPStatus(t, resetErr, rec, http.StatusConflict) } // TestM3_ResetDocumentMetadata_HappyPath exercises the 200 success path: a // document with a bound schema and two metadata versions is reset. The // response must carry the previous schema fields, metadataVersionsDeleted == 2, // and the DB must show zero metadata rows and custom_schema_id == NULL. func TestM3_ResetDocumentMetadata_HappyPath(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M3_RST_HAPPY" cons, cfg := setupM3Fixture(t, clientID) schema := seedCustomSchema(t, cons, clientID, "m3-reset-happy-schema") docID := seedDoc(t, cfg, clientID, "m3-reset-happy-doc") assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) // Write two metadata versions so the deleted count is verifiable. for range 2 { metaBody := queryapi.CustomMetadataRequest{ DocumentId: openapi_types.UUID(docID), Metadata: map[string]interface{}{"name": "v"}, } ctx, rec := newCustomMetadataContext(t, http.MethodPost, metaBody) require.NoError(t, cons.SetCustomMetadata(ctx)) require.Equal(t, http.StatusCreated, rec.Code) } // Call reset. resetErr, rec := callReset(t, cons, docID) requireHTTPStatus(t, resetErr, rec, http.StatusOK) var resp queryapi.DocumentMetadataResetResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.Equal(t, openapi_types.UUID(docID), resp.DocumentId) assert.Equal(t, int32(2), resp.MetadataVersionsDeleted) assert.Equal(t, testActorSubject, resp.ResetBy) assert.False(t, resp.ResetAt.IsZero(), "resetAt must be populated") // Previous schema fields must reflect the just-cleared binding. prevID, err := resp.PreviousSchemaId.Get() require.NoError(t, err, "previousSchemaId must be non-null after a bound reset") assert.Equal(t, schema.Id, prevID) prevName, err := resp.PreviousSchemaName.Get() require.NoError(t, err, "previousSchemaName must be non-null after a bound reset") assert.Equal(t, "m3-reset-happy-schema", prevName) _, err = resp.PreviousSchemaVersion.Get() assert.NoError(t, err, "previousSchemaVersion must be non-null after a bound reset") // DB state: all metadata rows deleted, custom_schema_id cleared. var metaCount int err = cfg.GetDBPool().QueryRow(t.Context(), `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, docID). Scan(&metaCount) require.NoError(t, err) assert.Equal(t, 0, metaCount, "reset must delete all metadata rows") var customSchemaID *uuid.UUID err = cfg.GetDBPool().QueryRow(t.Context(), `SELECT custom_schema_id FROM documents WHERE id = $1`, docID). Scan(&customSchemaID) require.NoError(t, err) assert.Nil(t, customSchemaID, "reset must set custom_schema_id to NULL") } // TestM3_ResetDocumentMetadata_IdempotentOnCleanDoc exercises the idempotent // path: resetting an already-clean document (no schema bound, no metadata) // returns 200 with metadataVersionsDeleted == 0 and null previous schema fields. func TestM3_ResetDocumentMetadata_IdempotentOnCleanDoc(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M3_RST_IDEM" cons, cfg := setupM3Fixture(t, clientID) docID := seedDoc(t, cfg, clientID, "m3-reset-idem-doc") resetErr, rec := callReset(t, cons, docID) requireHTTPStatus(t, resetErr, rec, http.StatusOK) var resp queryapi.DocumentMetadataResetResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.Equal(t, int32(0), resp.MetadataVersionsDeleted) _, err := resp.PreviousSchemaId.Get() assert.Error(t, err, "clean doc must report null previousSchemaId") _, err = resp.PreviousSchemaName.Get() assert.Error(t, err, "clean doc must report null previousSchemaName") } // TestM3_FullUpgradeFlow exercises the complete schema upgrade story: // // 1. Create schema v1 // 2. Bind document to v1 // 3. POST metadata against v1 // 4. Create schema v2 from v1 (POST .../versions with an added optional field) // 5. Reset-metadata (must return 200, metadataVersionsDeleted=1, previousSchemaId=v1.id) // 6. Assign v2 to the document // 7. POST metadata with v2 shape (include the new optional field) // 8. Assert final DB state: exactly 1 metadata row, doc's custom_schema_id = v2.id func TestM3_FullUpgradeFlow(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M3_UPGRADE" cons, cfg := setupM3Fixture(t, clientID) // Step 1: Create schema v1 via the handler. v1 := seedCustomSchema(t, cons, clientID, "m3-upgrade-lineage") // Step 2: Create a document and bind it to v1. docID := seedDoc(t, cfg, clientID, "m3-upgrade-doc") assignSchemaForTest(t, cons, docID, uuid.UUID(v1.Id)) // Step 3: POST metadata against the v1 binding. metaV1Body := queryapi.CustomMetadataRequest{ DocumentId: openapi_types.UUID(docID), Metadata: map[string]interface{}{"name": "original-contract"}, } ctxMeta1, recMeta1 := newCustomMetadataContext(t, http.MethodPost, metaV1Body) require.NoError(t, cons.SetCustomMetadata(ctxMeta1)) require.Equal(t, http.StatusCreated, recMeta1.Code) // Step 4: Create v2 from v1 — the schema definition adds an optional "notes" field. v2SchemaDef := map[string]interface{}{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": map[string]interface{}{ "name": map[string]interface{}{"type": "string"}, "notes": map[string]interface{}{"type": "string"}, }, "additionalProperties": false, } v2Body := queryapi.CustomSchemaVersionRequest{Schema: v2SchemaDef} ctxV2, recV2 := newCustomSchemaContext(t, http.MethodPost, v2Body) require.NoError(t, cons.CreateCustomSchemaVersion(ctxV2, v1.Id)) require.Equal(t, http.StatusCreated, recV2.Code) var v2 queryapi.CustomSchemaResponse require.NoError(t, json.Unmarshal(recV2.Body.Bytes(), &v2)) require.Equal(t, int32(2), v2.Version) require.Equal(t, queryapi.SchemaStatusActive, v2.Status) // Step 5: Reset-metadata — must wipe the v1 metadata and clear the schema binding. resetErr, recReset := callReset(t, cons, docID) requireHTTPStatus(t, resetErr, recReset, http.StatusOK) var resetResp queryapi.DocumentMetadataResetResponse require.NoError(t, json.Unmarshal(recReset.Body.Bytes(), &resetResp)) assert.Equal(t, int32(1), resetResp.MetadataVersionsDeleted, "reset must report exactly 1 metadata version deleted") prevID, err := resetResp.PreviousSchemaId.Get() require.NoError(t, err, "previousSchemaId must be non-null") assert.Equal(t, v1.Id, prevID, "previousSchemaId must be the v1 schema") // Step 6: Assign v2 to the document. assignSchemaForTest(t, cons, docID, uuid.UUID(v2.Id)) // Step 7: POST metadata with v2 shape (includes the new optional "notes" field). metaV2Body := queryapi.CustomMetadataRequest{ DocumentId: openapi_types.UUID(docID), Metadata: map[string]interface{}{"name": "upgraded-contract", "notes": "added in v2"}, } ctxMeta2, recMeta2 := newCustomMetadataContext(t, http.MethodPost, metaV2Body) require.NoError(t, cons.SetCustomMetadata(ctxMeta2)) require.Equal(t, http.StatusCreated, recMeta2.Code) // Step 8: Assert final DB state. // 8a: Exactly 1 metadata row for this document (the v2 write; v1 was deleted by reset). var metaCount int err = cfg.GetDBPool().QueryRow(t.Context(), `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, docID). Scan(&metaCount) require.NoError(t, err) assert.Equal(t, 1, metaCount, "after upgrade there must be exactly 1 metadata row (the v2 write)") // 8b: Document's custom_schema_id must be v2's ID. var boundSchemaID *uuid.UUID err = cfg.GetDBPool().QueryRow(t.Context(), `SELECT custom_schema_id FROM documents WHERE id = $1`, docID). Scan(&boundSchemaID) require.NoError(t, err) require.NotNil(t, boundSchemaID, "document must have a schema binding after reassignment") assert.Equal(t, uuid.UUID(v2.Id), *boundSchemaID, "document must be bound to v2") } // TestM3_AuthMatrix_Reset exercises the authorization matrix for // POST /super-admin/documents/{id}/reset-metadata: // // super_admin -> 200 // user_admin -> 403 // client_user -> 403 // auditor -> 403 // // For denied roles: assert the DB has zero metadata rows changed (nothing // was written or deleted). // For the allowed role (super_admin): need a document with a schema binding // and at least 1 metadata row, call reset, assert 200. func TestM3_AuthMatrix_Reset(t *testing.T) { if testing.Short() { t.SkipNow() } const clientID = "M3_AUTHZ_RST" cons, cfg := setupM3Fixture(t, clientID) // Seed a document with a schema + metadata so the super_admin positive // case has something to reset and the denial cases can verify the DB // is unchanged. schema := seedCustomSchema(t, cons, clientID, "m3-authz-reset-schema") docID := seedDoc(t, cfg, clientID, "m3-authz-reset-doc") assignSchemaForTest(t, cons, docID, uuid.UUID(schema.Id)) metaBody := queryapi.CustomMetadataRequest{ DocumentId: openapi_types.UUID(docID), Metadata: map[string]interface{}{"name": "authz-test"}, } ctxMeta, recMeta := newCustomMetadataContext(t, http.MethodPost, metaBody) require.NoError(t, cons.SetCustomMetadata(ctxMeta)) require.Equal(t, http.StatusCreated, recMeta.Code) resetPath := "/super-admin/documents/" + docID.String() + "/reset-metadata" rolesUnderTest := []struct { role string expectAllow bool }{ {role: "super_admin", expectAllow: true}, {role: "user_admin", expectAllow: false}, {role: "client_user", expectAllow: false}, {role: "auditor", expectAllow: false}, } countMeta := func(t *testing.T) int { t.Helper() var n int require.NoError(t, cfg.GetDBPool().QueryRow(t.Context(), `SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`, docID).Scan(&n)) return n } for _, rt := range rolesUnderTest { rt := rt t.Run(rt.role, func(t *testing.T) { beforeCount := countMeta(t) // Build the echo context for the reset handler. ctx, rec := newCustomSchemaContext(t, http.MethodPost, nil) ctx.Request().URL.Path = resetPath handlerCalled := false wrappedHandler := func(ec echo.Context) error { handlerCalled = true return cons.ResetDocumentMetadata(ec, openapi_types.UUID(docID)) } checker := &roleAwarePermitChecker{role: rt.role} err := runPermitGate(ctx, resetPath, checker, wrappedHandler) if rt.expectAllow { require.NoError(t, err, "super_admin must be allowed") assert.True(t, handlerCalled, "handler must be called for super_admin") assert.Equal(t, http.StatusOK, rec.Code, "reset must return 200 for super_admin") } else { assert.False(t, handlerCalled, "handler must NOT be called for role %s", rt.role) assert.Equal(t, http.StatusForbidden, rec.Code, "reset must return 403 for role %s", rt.role) // DB unchanged: no metadata rows were deleted. afterCount := countMeta(t) assert.Equal(t, beforeCount, afterCount, "denied request must leave metadata row count unchanged for role %s", rt.role) } }) } }