Files
query-orchestration/api/queryAPI/custommetadata_integration_test.go
Jay Brown 17fc813823 Merged in feature/mutable-metadata1 (pull request #221)
M1, M2 and M3 complete

* M1, M2 and M3 complete

* review changes

* docs

* docs
2026-04-16 23:11:26 +00:00

490 lines
18 KiB
Go

// 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)
}