Files
query-orchestration/api/queryAPI/customschemas_reset_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

398 lines
15 KiB
Go

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