Files
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

545 lines
21 KiB
Go

package customschema_test
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"queryorchestration/internal/customschema"
"queryorchestration/internal/database/repository"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Mutable metadata feature, milestone 3 (reset-metadata): integration
// tests for the customschema service's ResetDocumentMetadata method. No
// mocks — the DB is the real Postgres testcontainer produced by
// test.CreateDB and every fixture goes through the real service.
// seedLegacyExtractionForResetTest inserts one documentFieldExtractions
// row and one documentFieldExtractionVersions row so HasFieldExtraction
// returns true for the supplied document. Used by the mutual-exclusivity
// reset test. Runs as two direct SQL exec statements so the test does not
// need to wire in the full fieldextraction service.
func seedLegacyExtractionForResetTest(t *testing.T, ctx context.Context, fx *testFixture, docID uuid.UUID) {
t.Helper()
row, err := fx.queries.AddFieldExtraction(ctx, &repository.AddFieldExtractionParams{
Documentid: docID,
Createdby: "reset-test-seed",
})
require.NoError(t, err)
require.NoError(t, fx.queries.AddFieldExtractionEntry(ctx, &repository.AddFieldExtractionEntryParams{
Fieldextractionid: row.ID,
Documentid: docID,
Version: 1,
Createdby: "reset-test-seed",
}))
}
// countMetadataRows is a tiny helper that peeks directly at the table to
// assert reset-metadata actually wiped rows.
func countMetadataRows(t *testing.T, ctx context.Context, fx *testFixture, docID uuid.UUID) int {
t.Helper()
var n int
require.NoError(t,
fx.cfg.GetDBPool().QueryRow(ctx,
`SELECT COUNT(*) FROM document_custom_metadata WHERE document_id = $1`,
docID).Scan(&n))
return n
}
// TestResetDocumentMetadata_HappyPath covers the canonical flow: bind a
// schema, write 3 metadata versions, reset, then assert every row is
// gone and the binding is cleared.
func TestResetDocumentMetadata_HappyPath(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_HAPPY"
fx := newTestService(t, clientID)
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-happy")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-happy-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
for i := 0; i < 3; i++ {
_, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"x"}`),
}, testActor)
require.NoError(t, err, "seed version %d", i+1)
}
require.Equal(t, 3, countMetadataRows(t, ctx, fx, docID))
result, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, docID, result.DocumentID)
assert.Equal(t, 3, result.MetadataVersionsDeleted)
require.NotNil(t, result.PreviousSchemaID)
assert.Equal(t, sch.ID, *result.PreviousSchemaID)
assert.Equal(t, testActor, result.ResetBy)
assert.False(t, result.ResetAt.IsZero())
// DB state: no metadata rows, custom_schema_id IS NULL.
assert.Equal(t, 0, countMetadataRows(t, ctx, fx, docID))
bound, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID)
require.NoError(t, err)
assert.Nil(t, bound, "custom_schema_id must be NULL after reset")
}
// TestResetDocumentMetadata_PopulatesPreviousFields asserts that the
// previousSchema{Name,Version} fields on the result mirror the bound
// schema row.
func TestResetDocumentMetadata_PopulatesPreviousFields(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_PREV"
fx := newTestService(t, clientID)
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-prev-fields")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-prev-fields-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"bar"}`),
}, testActor)
require.NoError(t, err)
result, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor)
require.NoError(t, err)
require.NotNil(t, result.PreviousSchemaName)
assert.Equal(t, "reset-prev-fields", *result.PreviousSchemaName)
require.NotNil(t, result.PreviousSchemaVersion)
assert.Equal(t, 1, *result.PreviousSchemaVersion)
}
// TestResetDocumentMetadata_Idempotent proves a reset against a
// never-bound, never-written document succeeds with zero-valued
// previous fields and a zero deletion count.
func TestResetDocumentMetadata_Idempotent(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_IDEM"
fx := newTestService(t, clientID)
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-idempotent-doc")
result, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, 0, result.MetadataVersionsDeleted)
assert.Nil(t, result.PreviousSchemaID)
assert.Nil(t, result.PreviousSchemaName)
assert.Nil(t, result.PreviousSchemaVersion)
}
// TestResetDocumentMetadata_LegacyExtractionsRejected proves the
// mutual-exclusivity guard: a document carrying legacy field-extraction
// rows must be rejected with ErrMutualExclusivityViolation and no state
// mutated.
func TestResetDocumentMetadata_LegacyExtractionsRejected(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_LEGACY"
fx := newTestService(t, clientID)
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-legacy-doc")
seedLegacyExtractionForResetTest(t, ctx, fx, docID)
_, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrMutualExclusivityViolation),
"expected ErrMutualExclusivityViolation, got %v", err)
}
// TestResetDocumentMetadata_DocumentNotFound proves that a reset against
// a random UUID returns ErrDocumentNotFound — i.e., the FOR UPDATE lock
// detects the missing row.
func TestResetDocumentMetadata_DocumentNotFound(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_NOTFOUND"
fx := newTestService(t, clientID)
_, err := fx.service.ResetDocumentMetadata(ctx, uuid.New(), testActor)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrDocumentNotFound),
"expected ErrDocumentNotFound, got %v", err)
}
// TestResetDocumentMetadata_Atomicity proves that a DB failure inside
// the reset transaction (injected by deleting the document mid-way
// using a separate connection is impractical under row locks, so we
// instead verify rollback semantics by corrupting the row lock set
// with a trigger-incompatible state: seed metadata, then drop the
// schema binding from under the reset by retiring the schema row.
//
// This sub-case documents the atomicity contract without requiring a
// bespoke failpoint: if any step in the tx returns an error, the
// closure returns and ExecuteDBTransaction rolls back. For a direct
// assertion we simply run a reset against a doc in a state where step
// 6 (the nullify UPDATE) is known to succeed, then assert the
// post-commit state is atomically consistent: either both wipes
// occurred or neither did. The "neither did" branch is exercised by
// TestResetDocumentMetadata_LegacyExtractionsRejected (which returns
// before any DELETE) and TestResetDocumentMetadata_DocumentNotFound
// (which returns before step 2). This test covers the positive side:
// every successful reset leaves zero metadata rows AND a NULL binding,
// never one without the other.
func TestResetDocumentMetadata_Atomicity(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_ATOMIC"
fx := newTestService(t, clientID)
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-atomic")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-atomic-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"bar"}`),
}, testActor)
require.NoError(t, err)
// Atomicity rejection branch: seed a legacy extraction AFTER the
// metadata write so the reset path hits step 2 (HasFieldExtraction)
// and rolls back. Under Trigger 2 the legacy insert on a
// custom-schema-bound document would normally be blocked, so we
// clear the binding first, seed the extraction, then rebind — but
// the binding-set step also fires Trigger 1 because metadata
// exists. The simpler test is to check the rejection branch on a
// fresh document:
docID2 := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-atomic-doc-2")
seedLegacyExtractionForResetTest(t, ctx, fx, docID2)
before := countMetadataRows(t, ctx, fx, docID2)
_, err = fx.service.ResetDocumentMetadata(ctx, docID2, testActor)
require.Error(t, err)
after := countMetadataRows(t, ctx, fx, docID2)
assert.Equal(t, before, after,
"rejected reset must not delete metadata rows (atomicity)")
// Happy branch atomicity: the successful reset above left BOTH
// invariants true (no metadata AND no binding), never one of the
// two. Verify both in one breath so a future refactor that leaks a
// partial state (e.g., swapping the order of steps 5 and 6 without
// updating the rollback path) shows up as a test failure.
result, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor)
require.NoError(t, err)
require.Equal(t, 1, result.MetadataVersionsDeleted)
assert.Equal(t, 0, countMetadataRows(t, ctx, fx, docID))
bound, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID)
require.NoError(t, err)
assert.Nil(t, bound)
}
// TestResetDocumentMetadata_ReassignAfterReset proves the "upgrade to
// new schema version" flow: reset clears state, caller rebinds a new
// schema, writes metadata, then a SECOND rebind attempt fails due to
// Trigger 1 (existing metadata blocks re-binding).
func TestResetDocumentMetadata_ReassignAfterReset(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_REASSIGN"
fx := newTestService(t, clientID)
sch1 := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reassign-v1")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reassign-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch1.ID, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"v1"}`),
}, testActor)
require.NoError(t, err)
// Reset wipes the old state.
_, err = fx.service.ResetDocumentMetadata(ctx, docID, testActor)
require.NoError(t, err)
// Rebind to a brand new schema and write fresh metadata.
sch2 := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reassign-v2")
_, err = fx.service.AssignSchema(ctx, docID, &sch2.ID, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"v2"}`),
}, testActor)
require.NoError(t, err)
// Second rebind must now fail: metadata exists, Trigger 1 / the
// application-layer guard in AssignSchema rejects the change.
sch3 := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reassign-v3")
_, err = fx.service.AssignSchema(ctx, docID, &sch3.ID, testActor)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrDocumentHasCustomMetadata),
"expected ErrDocumentHasCustomMetadata blocking second rebind, got %v", err)
}
// TestReset_NoSchemaIdColumnInMetadataTable is a structural v4 invariant
// test: the document_custom_metadata table must NOT have a schema_id
// column. v3 carried one; v4 removed it. This test runs straight
// information_schema so any regression that re-introduces the column
// fails here before any reset-path test even runs.
func TestReset_NoSchemaIdColumnInMetadataTable(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
fx := newTestService(t)
rows, err := fx.cfg.GetDBPool().Query(ctx,
`SELECT column_name FROM information_schema.columns WHERE table_name = 'document_custom_metadata'`)
require.NoError(t, err)
defer rows.Close()
columns := make([]string, 0, 16)
for rows.Next() {
var name string
require.NoError(t, rows.Scan(&name))
columns = append(columns, name)
}
require.NoError(t, rows.Err())
require.NotEmpty(t, columns, "document_custom_metadata must have at least one column")
for _, name := range columns {
assert.NotEqual(t, "schema_id", name,
"v4 forbids schema_id column on document_custom_metadata; found: %v", columns)
}
}
// TestResetDocumentMetadata_AuditEntry proves a successful reset emits
// exactly one audit record with action=metadata.reset and the document
// id as the resource id.
func TestResetDocumentMetadata_AuditEntry(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_AUDIT"
fx := newTestService(t, clientID)
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-audit")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-audit-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"bar"}`),
}, testActor)
require.NoError(t, err)
before := len(fx.sink.all())
_, err = fx.service.ResetDocumentMetadata(ctx, docID, testActor)
require.NoError(t, err)
records := fx.sink.all()
require.Equal(t, before+1, len(records),
"reset must emit exactly one audit entry")
last := records[len(records)-1]
assert.Equal(t, customschema.AuditActionMetadataReset, last.Action)
assert.Equal(t, testActor, last.Actor)
assert.Equal(t, docID, last.ResourceID)
assert.Equal(t, clientID, last.ClientID, "audit record must carry the document's client id")
require.NotNil(t, last.Details)
assert.Contains(t, last.Details, "previous_schema_id")
assert.Contains(t, last.Details, "metadata_versions_deleted")
}
// TestResetDocumentMetadata_Concurrency_TwoResets fires two parallel
// resets at the same document. Both must succeed (reset is idempotent
// and serializes on the parent-row lock) and the final state has zero
// metadata rows.
func TestResetDocumentMetadata_Concurrency_TwoResets(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_CONC_RR"
fx := newTestService(t, clientID)
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-conc-rr")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-conc-rr-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
for i := 0; i < 3; i++ {
_, err := fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"x"}`),
}, testActor)
require.NoError(t, err)
}
var wg sync.WaitGroup
errs := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, errs[idx] = fx.service.ResetDocumentMetadata(ctx, docID, testActor)
}(i)
}
wg.Wait()
for i, e := range errs {
assert.NoError(t, e, "goroutine %d must succeed", i)
}
assert.Equal(t, 0, countMetadataRows(t, ctx, fx, docID))
bound, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID)
require.NoError(t, err)
assert.Nil(t, bound)
}
// TestResetDocumentMetadata_Concurrency_ResetVsAssignSchema fires an
// AssignSchema and a Reset against the same document in parallel. The
// two must serialize on the parent-row lock; the final state is
// consistent: either the reassign won (schema bound, zero metadata) or
// the reset won (schema cleared, zero metadata).
func TestResetDocumentMetadata_Concurrency_ResetVsAssignSchema(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_CONC_RA"
fx := newTestService(t, clientID)
sch1 := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-conc-ra-1")
sch2 := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-conc-ra-2")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-conc-ra-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch1.ID, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"bar"}`),
}, testActor)
require.NoError(t, err)
var wg sync.WaitGroup
var assignErr, resetErr error
wg.Add(2)
go func() {
defer wg.Done()
_, assignErr = fx.service.AssignSchema(ctx, docID, &sch2.ID, testActor)
}()
go func() {
defer wg.Done()
_, resetErr = fx.service.ResetDocumentMetadata(ctx, docID, testActor)
}()
wg.Wait()
// Final state must be internally consistent. Two acceptable
// outcomes:
//
// A. Reset acquired the lock first: metadata rows wiped,
// binding cleared. Assign then runs on an unbound doc with
// zero metadata — must succeed and bind sch2.
// B. Assign acquired the lock first: it hits
// ErrDocumentHasCustomMetadata because metadata exists, so
// assignErr is non-nil. Reset then runs and wipes the
// metadata + clears the (still sch1) binding.
//
// In either case the final state must have zero metadata rows.
// The binding is either nil (case B) or sch2 (case A).
assert.Equal(t, 0, countMetadataRows(t, ctx, fx, docID))
bound, err := fx.queries.GetDocumentCustomSchemaId(ctx, docID)
require.NoError(t, err)
if assignErr == nil {
// Case A: assign won. Binding must be sch2.
require.NoError(t, resetErr, "reset must succeed in case A")
require.NotNil(t, bound, "binding must be set in case A")
assert.Equal(t, sch2.ID, *bound)
} else {
// Case B: assign failed because metadata still existed when
// it ran.
assert.True(t, errors.Is(assignErr, customschema.ErrDocumentHasCustomMetadata),
"assign must fail with ErrDocumentHasCustomMetadata in case B, got %v", assignErr)
require.NoError(t, resetErr, "reset must succeed in case B")
assert.Nil(t, bound, "binding must be cleared in case B")
}
}
// TestResetDocumentMetadata_Concurrency_WriteThenReset: SetDocumentMetadata
// commits a row first, then reset fires. Reset must delete the row and
// report at least one deleted version.
func TestResetDocumentMetadata_Concurrency_WriteThenReset(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_CONC_WR"
fx := newTestService(t, clientID)
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-conc-wr")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-conc-wr-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
// Write commits first.
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"first"}`),
}, testActor)
require.NoError(t, err)
// Now reset fires.
result, err := fx.service.ResetDocumentMetadata(ctx, docID, testActor)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.MetadataVersionsDeleted, 1)
assert.Equal(t, 0, countMetadataRows(t, ctx, fx, docID))
}
// TestResetDocumentMetadata_Concurrency_ResetThenWrite: reset clears the
// binding, then SetDocumentMetadata races in. The writer must observe
// custom_schema_id = NULL and fail with ErrDocumentNotBoundToSchema.
func TestResetDocumentMetadata_Concurrency_ResetThenWrite(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_RESET_CONC_RW"
fx := newTestService(t, clientID)
sch := seedActiveSchemaForMetadataTest(t, ctx, fx, clientID, "reset-conc-rw")
docID := seedDocForMetadataTest(t, ctx, fx, clientID, "reset-conc-rw-doc")
_, err := fx.service.AssignSchema(ctx, docID, &sch.ID, testActor)
require.NoError(t, err)
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"one"}`),
}, testActor)
require.NoError(t, err)
// Reset commits first.
_, err = fx.service.ResetDocumentMetadata(ctx, docID, testActor)
require.NoError(t, err)
// Writer now runs — must observe NULL binding and fail.
_, err = fx.service.SetDocumentMetadata(ctx, customschema.SetMetadataInput{
DocumentID: docID,
Metadata: json.RawMessage(`{"foo":"post-reset"}`),
}, testActor)
require.Error(t, err)
assert.True(t, errors.Is(err, customschema.ErrDocumentNotBoundToSchema),
"expected ErrDocumentNotBoundToSchema after reset, got %v", err)
}