17fc813823
M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
545 lines
21 KiB
Go
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)
|
|
}
|