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

926 lines
35 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package customschema_test
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"queryorchestration/internal/customschema"
"queryorchestration/internal/database/repository"
"queryorchestration/internal/serviceconfig"
"queryorchestration/internal/test"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// Mutable metadata feature, milestone 1.6 (Part A): integration tests for
// customschema.Service CRUD methods. Red phase — these tests are written
// before the method bodies exist. Every test here is expected to fail on
// the "not implemented" sentinel in service.go until the golang engineer
// fills in the bodies in the next dispatch.
//
// No mocks. The capturingAuditSink below is a real implementation of
// customschema.AuditSink that buffers records in memory. The DB is the
// real Postgres testcontainer that test.CreateDB stands up.
// trivialValidSchemaJSON is the minimal schema_def that passes every
// rule in customschema.SchemaValidator: type=object, additionalProperties
// explicitly present, well under the 64KB cap.
const trivialValidSchemaJSON = `{"type":"object","additionalProperties":false}`
// capturingAuditSink buffers every AuditRecord in memory so tests can
// assert on what the service emitted. It is a real AuditSink, not a mock.
type capturingAuditSink struct {
mu sync.Mutex
records []customschema.AuditRecord
}
// Record appends the record under the mutex. Never returns an error so
// the service's happy path is never disturbed by the sink.
func (c *capturingAuditSink) Record(ctx context.Context, rec customschema.AuditRecord) error {
c.mu.Lock()
defer c.mu.Unlock()
c.records = append(c.records, rec)
return nil
}
// all returns a defensive copy of the captured records so callers can
// iterate without holding the sink's mutex.
func (c *capturingAuditSink) all() []customschema.AuditRecord {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]customschema.AuditRecord, len(c.records))
copy(out, c.records)
return out
}
// testFixture bundles everything a subtest needs from newTestService so
// tests can inspect DB rows directly and call the service simultaneously.
type testFixture struct {
service *customschema.Service
sink *capturingAuditSink
cfg *serviceconfig.BaseConfig
queries *repository.Queries
}
// testActor is the canonical actor string every test passes to write
// methods. Writing to AuditRecord.Actor must use THIS value (not anything
// read off the input struct) because the v4 rule is "actor is a separate
// argument, never a body field".
const testActor = "test-actor-00000000-0000-4000-8000-000000000001"
// newTestService boots a fresh DB via test.CreateDB, wipes leftover rows
// for the test's clients, re-creates the clients, and returns a ready
// Service wired to a capturing sink. Tests pass the set of client IDs
// they need so unrelated runs don't clobber each other's fixtures.
func newTestService(t *testing.T, clientIDs ...string) *testFixture {
t.Helper()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
ctx := t.Context()
for _, clientID := range clientIDs {
// Same idempotency dance as resetClient() in the repository tests:
// documents first (no cascade from clients), then clients (cascade
// wipes client_metadata_schemas via the FK from migration 127).
_, err := pool.Exec(ctx, `DELETE FROM documents WHERE clientId = $1`, clientID)
require.NoError(t, err, "pre-test DELETE documents for clientID=%q must succeed", clientID)
_, err = pool.Exec(ctx, `DELETE FROM clients WHERE clientId = $1`, clientID)
require.NoError(t, err, "pre-test DELETE clients for clientID=%q must succeed", clientID)
err = queries.CreateClient(ctx, &repository.CreateClientParams{
Name: strings.ToLower(clientID),
Clientid: clientID,
})
require.NoError(t, err, "CreateClient for clientID=%q must succeed", clientID)
}
sink := &capturingAuditSink{}
svc := customschema.New(cfg, &customschema.SchemaValidator{}, sink)
return &testFixture{
service: svc,
sink: sink,
cfg: cfg,
queries: queries,
}
}
// TestService_CreateSchema covers the happy path and every documented
// rejection rule for CreateSchema (tracking lines 126130).
func TestService_CreateSchema(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_CREATE_A"
fx := newTestService(t, clientID)
t.Run("happy_path_valid_schema", func(t *testing.T) {
input := customschema.CreateSchemaInput{
ClientID: clientID,
Name: "happy_path",
Description: "unit test schema",
SchemaDef: json.RawMessage(trivialValidSchemaJSON),
}
got, err := fx.service.CreateSchema(ctx, input, testActor)
require.NoError(t, err)
require.NotNil(t, got)
require.NotEqual(t, uuid.Nil, got.ID, "ID must be assigned")
require.Equal(t, clientID, got.ClientID)
require.Equal(t, "happy_path", got.Name)
require.Equal(t, 1, got.Version)
require.Equal(t, "active", got.Status)
require.False(t, got.CreatedAt.IsZero(), "CreatedAt must be populated")
require.Equal(t, testActor, got.CreatedBy,
"CreatedBy must be the actor arg, never a body field")
// DB round-trip: the row must exist under this id.
row, err := fx.queries.GetClientMetadataSchema(ctx, got.ID)
require.NoError(t, err)
require.Equal(t, clientID, row.ClientID)
require.Equal(t, "happy_path", row.Name)
})
t.Run("writes_schema_create_audit_entry", func(t *testing.T) {
// Seed a second schema (distinct name so the happy-path row above
// does not collide with this one) and verify exactly one audit
// entry was emitted for this subtest's write.
before := len(fx.sink.all())
input := customschema.CreateSchemaInput{
ClientID: clientID,
Name: "audit_entry_probe",
Description: "",
SchemaDef: json.RawMessage(trivialValidSchemaJSON),
}
got, err := fx.service.CreateSchema(ctx, input, testActor)
require.NoError(t, err)
require.NotNil(t, got)
records := fx.sink.all()
require.Equal(t, before+1, len(records),
"exactly one audit entry must be emitted by CreateSchema")
last := records[len(records)-1]
require.Equal(t, customschema.AuditActionSchemaCreate, last.Action)
require.Equal(t, testActor, last.Actor)
require.Equal(t, got.ID, last.ResourceID)
// AuditRecord.ClientID is uuid.UUID, but client_metadata_schemas.client_id
// is a varchar(255) Cognito-style identifier. This is a type mismatch
// the team lead needs to resolve. For the red-phase test we simply
// assert the schema's string ClientID is threaded into Details under
// a well-known key so the engineer has freedom to pick either
// Details.client_id_legacy or uuid.Nil in the ClientID column.
if last.Details != nil {
// If the engineer chose the Details route, assert the string
// shows up there. If they chose uuid.Nil in ClientID, Details
// may be nil — that's still acceptable for this red phase and
// the assertion simply short-circuits.
if v, ok := last.Details["client_id"]; ok {
require.Equal(t, clientID, v,
"Details.client_id must mirror the legacy varchar client id")
}
}
})
t.Run("rejects_invalid_json_schema", func(t *testing.T) {
before := len(fx.sink.all())
input := customschema.CreateSchemaInput{
ClientID: clientID,
Name: "invalid_json",
SchemaDef: json.RawMessage("{this is not json"),
}
_, err := fx.service.CreateSchema(ctx, input, testActor)
require.Error(t, err, "malformed JSON must be rejected")
// No DB row written for this name.
_, getErr := fx.queries.GetLatestSchemaByName(ctx, &repository.GetLatestSchemaByNameParams{
ClientID: clientID,
Name: "invalid_json",
})
require.Error(t, getErr, "no row must exist for the rejected name")
// No audit entry emitted.
require.Equal(t, before, len(fx.sink.all()),
"no audit entry must be emitted on validation failure")
})
t.Run("rejects_oversize_schema_def", func(t *testing.T) {
// Build a JSON document whose length is strictly greater than
// MaxSchemaDefinitionBytes. The validator rejects on strict >.
var b strings.Builder
b.WriteString(`{"type":"object","additionalProperties":false,"description":"`)
padding := customschema.MaxSchemaDefinitionBytes + 1 - b.Len() - 2
for i := 0; i < padding; i++ {
b.WriteByte('x')
}
b.WriteString(`"}`)
oversize := b.String()
require.Greater(t, len(oversize), customschema.MaxSchemaDefinitionBytes,
"test fixture must actually exceed the schema-definition ceiling")
before := len(fx.sink.all())
input := customschema.CreateSchemaInput{
ClientID: clientID,
Name: "oversize_probe",
SchemaDef: json.RawMessage(oversize),
}
_, err := fx.service.CreateSchema(ctx, input, testActor)
require.Error(t, err, "oversize schema_def must be rejected")
_, getErr := fx.queries.GetLatestSchemaByName(ctx, &repository.GetLatestSchemaByNameParams{
ClientID: clientID,
Name: "oversize_probe",
})
require.Error(t, getErr, "no row must exist for the rejected oversize schema")
require.Equal(t, before, len(fx.sink.all()),
"no audit entry must be emitted on oversize rejection")
})
t.Run("rejects_duplicate_name_at_version_1", func(t *testing.T) {
const schemaName = "duplicate_name_probe"
input := customschema.CreateSchemaInput{
ClientID: clientID,
Name: schemaName,
SchemaDef: json.RawMessage(trivialValidSchemaJSON),
}
_, err := fx.service.CreateSchema(ctx, input, testActor)
require.NoError(t, err, "first insert must succeed")
before := len(fx.sink.all())
_, err = fx.service.CreateSchema(ctx, input, testActor)
require.Error(t, err,
"second insert with same (client, name) must fail at the service layer")
// Count rows for this (client, name). Should still be exactly one.
var count int
err = fx.cfg.GetDBPool().QueryRow(ctx,
`SELECT COUNT(*) FROM client_metadata_schemas WHERE client_id = $1 AND name = $2`,
clientID, schemaName,
).Scan(&count)
require.NoError(t, err)
require.Equal(t, 1, count,
"duplicate rejection must not write a second row")
// No second audit entry (the first audit entry from the happy
// call in this subtest is still there; the rejected call must
// have emitted nothing).
require.Equal(t, before, len(fx.sink.all()),
"rejected duplicate must emit no audit entry")
})
}
// TestService_GetSchema covers the GetSchema happy path and the 404
// sentinel (tracking lines 139140).
func TestService_GetSchema(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_GET_A"
fx := newTestService(t, clientID)
t.Run("happy_path", func(t *testing.T) {
// Seed directly via the repository (no need to exercise CreateSchema here).
desc := "get-happy"
row, err := fx.queries.CreateClientMetadataSchema(ctx, &repository.CreateClientMetadataSchemaParams{
ClientID: clientID,
Name: "get_happy",
Description: &desc,
SchemaDef: []byte(trivialValidSchemaJSON),
Version: 1,
Status: repository.SchemaStatusTypeActive,
CreatedBy: "seed-user",
})
require.NoError(t, err)
require.NotNil(t, row)
got, err := fx.service.GetSchema(ctx, row.ID)
require.NoError(t, err)
require.Equal(t, row.ID, got.Schema.ID)
require.Equal(t, clientID, got.Schema.ClientID)
require.Equal(t, "get_happy", got.Schema.Name)
require.Equal(t, 1, got.Schema.Version)
require.Equal(t, "active", got.Schema.Status)
require.Equal(t, "seed-user", got.Schema.CreatedBy)
// Freshly created schema has no bound documents.
require.Equal(t, int64(0), got.DocumentCount)
require.True(t, got.CanDelete)
})
t.Run("returns_not_found_sentinel", func(t *testing.T) {
missing := uuid.New()
_, err := fx.service.GetSchema(ctx, missing)
require.Error(t, err)
require.True(t, errors.Is(err, customschema.ErrSchemaNotFound),
"GetSchema must wrap ErrSchemaNotFound on missing row, got: %v", err)
})
}
// TestService_ListSchemas covers the filter matrix plus DocumentCount /
// CanDelete decoration (tracking lines 141144).
func TestService_ListSchemas(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_LIST_A"
fx := newTestService(t, clientID)
// Shared fixture seeded once per top-level test run. Subtests that
// mutate DocumentCount use their own distinct schema name.
seedDirect := func(name string, version int32, status repository.SchemaStatusType) *repository.ClientMetadataSchema {
desc := ""
row, err := fx.queries.CreateClientMetadataSchema(ctx, &repository.CreateClientMetadataSchemaParams{
ClientID: clientID,
Name: name,
Description: &desc,
SchemaDef: []byte(trivialValidSchemaJSON),
Version: version,
Status: status,
CreatedBy: "seed-user",
})
require.NoError(t, err)
return row
}
seedDirect("alpha", 1, repository.SchemaStatusTypeActive)
alphaV2 := seedDirect("alpha", 2, repository.SchemaStatusTypeActive)
betaV1 := seedDirect("beta", 1, repository.SchemaStatusTypeActive)
seedDirect("gamma", 1, repository.SchemaStatusTypeSuperseded)
t.Run("default_filters_returns_active_latest", func(t *testing.T) {
got, err := fx.service.ListSchemas(ctx, clientID, customschema.ListFilters{
Limit: 100,
Offset: 0,
})
require.NoError(t, err)
// Build a (name, version) set for assertion.
type tup struct {
name string
version int
}
seen := map[tup]customschema.SchemaSummary{}
for _, s := range got {
seen[tup{s.Schema.Name, s.Schema.Version}] = s
}
require.Contains(t, seen, tup{"alpha", 2},
"default filters must include alpha v2")
require.Contains(t, seen, tup{"beta", 1},
"default filters must include beta v1")
require.NotContains(t, seen, tup{"alpha", 1},
"default filters must collapse to MAX(version)")
require.NotContains(t, seen, tup{"gamma", 1},
"default filters must drop superseded rows")
// Each summary from the fixture is unbound, so canDelete is true.
require.Equal(t, int64(0), seen[tup{"alpha", 2}].DocumentCount)
require.True(t, seen[tup{"alpha", 2}].CanDelete)
require.Equal(t, int64(0), seen[tup{"beta", 1}].DocumentCount)
require.True(t, seen[tup{"beta", 1}].CanDelete)
_ = alphaV2
_ = betaV1
})
t.Run("status_any_returns_all", func(t *testing.T) {
got, err := fx.service.ListSchemas(ctx, clientID, customschema.ListFilters{
StatusAny: true,
IncludeAllVersions: true,
Limit: 100,
})
require.NoError(t, err)
foundGamma := false
for _, s := range got {
if s.Schema.Name == "gamma" && s.Schema.Version == 1 {
foundGamma = true
require.Equal(t, "superseded", s.Schema.Status)
}
}
require.True(t, foundGamma,
"StatusAny+IncludeAllVersions must surface the superseded row")
})
t.Run("include_all_versions_returns_every_version", func(t *testing.T) {
got, err := fx.service.ListSchemas(ctx, clientID, customschema.ListFilters{
IncludeAllVersions: true,
Limit: 100,
})
require.NoError(t, err)
versions := map[int]bool{}
for _, s := range got {
if s.Schema.Name == "alpha" {
versions[s.Schema.Version] = true
}
}
require.True(t, versions[1], "alpha v1 must be returned when IncludeAllVersions=true")
require.True(t, versions[2], "alpha v2 must be returned when IncludeAllVersions=true")
})
t.Run("document_count_drives_can_delete", func(t *testing.T) {
// Dedicated schema + two bound documents so the fixture above is
// not disturbed. Use the SAME client so the FK is satisfied.
bound := seedDirect("bound_schema", 1, repository.SchemaStatusTypeActive)
pool := fx.cfg.GetDBPool()
doc1, err := fx.queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientID,
Hash: "svc_list_bound_doc_1",
})
require.NoError(t, err)
_, err = pool.Exec(ctx,
`UPDATE documents SET custom_schema_id = $1 WHERE id = $2`,
bound.ID, doc1)
require.NoError(t, err)
doc2, err := fx.queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientID,
Hash: "svc_list_bound_doc_2",
})
require.NoError(t, err)
_, err = pool.Exec(ctx,
`UPDATE documents SET custom_schema_id = $1 WHERE id = $2`,
bound.ID, doc2)
require.NoError(t, err)
got, err := fx.service.ListSchemas(ctx, clientID, customschema.ListFilters{
Name: "bound_schema",
Limit: 100,
Offset: 0,
})
require.NoError(t, err)
require.Len(t, got, 1, "name filter must narrow to exactly one row")
require.Equal(t, int64(2), got[0].DocumentCount)
require.False(t, got[0].CanDelete, "CanDelete must be false when documents are bound")
})
t.Run("name_filter_narrows_results", func(t *testing.T) {
// Name filter is EXACT match (the underlying query uses `name = $`,
// not ILIKE). Asserted by the repository tests too; re-asserted here
// at the service layer so handlers know they cannot pass a prefix.
got, err := fx.service.ListSchemas(ctx, clientID, customschema.ListFilters{
Name: "alpha",
Limit: 100,
})
require.NoError(t, err)
for _, s := range got {
require.Equal(t, "alpha", s.Schema.Name,
"name filter must return only rows whose name matches exactly")
}
})
}
// TestService_DeleteSchema covers the retire-on-delete happy path, the
// audit entry, the in-use rejection, and the not-found sentinel
// (tracking lines 145147).
func TestService_DeleteSchema(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_DELETE_A"
fx := newTestService(t, clientID)
seed := func(name string) *repository.ClientMetadataSchema {
desc := ""
row, err := fx.queries.CreateClientMetadataSchema(ctx, &repository.CreateClientMetadataSchemaParams{
ClientID: clientID,
Name: name,
Description: &desc,
SchemaDef: []byte(trivialValidSchemaJSON),
Version: 1,
Status: repository.SchemaStatusTypeActive,
CreatedBy: "seed-user",
})
require.NoError(t, err)
return row
}
t.Run("happy_path_retires", func(t *testing.T) {
schema := seed("delete_happy")
err := fx.service.DeleteSchema(ctx, schema.ID, testActor)
require.NoError(t, err)
// Row must still exist with status=retired.
reloaded, err := fx.queries.GetClientMetadataSchema(ctx, schema.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeRetired, reloaded.Status,
"DeleteSchema must retire the row, not physically delete it")
})
t.Run("writes_schema_delete_audit_entry", func(t *testing.T) {
schema := seed("delete_audit")
before := len(fx.sink.all())
err := fx.service.DeleteSchema(ctx, schema.ID, testActor)
require.NoError(t, err)
records := fx.sink.all()
require.Equal(t, before+1, len(records),
"exactly one audit entry must be emitted by DeleteSchema")
last := records[len(records)-1]
require.Equal(t, customschema.AuditActionSchemaDelete, last.Action)
require.Equal(t, testActor, last.Actor)
require.Equal(t, schema.ID, last.ResourceID)
})
t.Run("rejects_when_document_bound_returns_conflict", func(t *testing.T) {
schema := seed("delete_in_use")
doc, err := fx.queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientID,
Hash: "svc_delete_bound_doc",
})
require.NoError(t, err)
_, err = fx.cfg.GetDBPool().Exec(ctx,
`UPDATE documents SET custom_schema_id = $1 WHERE id = $2`,
schema.ID, doc)
require.NoError(t, err)
before := len(fx.sink.all())
err = fx.service.DeleteSchema(ctx, schema.ID, testActor)
require.Error(t, err)
require.True(t, errors.Is(err, customschema.ErrSchemaInUse),
"DeleteSchema must wrap ErrSchemaInUse when a document is bound, got: %v", err)
// Status must still be active — no state change on rejection.
reloaded, err := fx.queries.GetClientMetadataSchema(ctx, schema.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeActive, reloaded.Status,
"rejected delete must not mutate status")
require.Equal(t, before, len(fx.sink.all()),
"rejected delete must emit no audit entry")
})
t.Run("returns_not_found_on_missing_id", func(t *testing.T) {
before := len(fx.sink.all())
missing := uuid.New()
err := fx.service.DeleteSchema(ctx, missing, testActor)
require.Error(t, err)
require.True(t, errors.Is(err, customschema.ErrSchemaNotFound),
"DeleteSchema must wrap ErrSchemaNotFound on missing id, got: %v", err)
require.Equal(t, before, len(fx.sink.all()),
"missing-id delete must emit no audit entry")
})
}
// TestService_CreateSchemaVersion is Part B of mutable-metadata milestone
// 1.6: integration tests for CreateSchemaVersion. Red phase — the stub in
// service.go returns errNotImplemented so every subtest below fails until
// the golang engineer fills in the body in the next dispatch.
//
// Tracking §1.6 lines 131-138 enumerate the rules this test codifies:
//
// 131 happy path produces v2
// 132 concurrency produces distinct versions
// 133 rejects when parent does not exist
// 134 non-active parent rejection (caller passed v1 while v2 is active)
// 135 retired parent rejection
// 136 active-parent invariant after every write
// 137 rejects invalid schema definition
// 138 writes schema.update audit entry
//
// Sentinel mapping decision (made by the test engineer for this dispatch,
// the golang engineer must match):
//
// - ErrSchemaParentNotActive -> parent row's status is not 'active' and
// not 'retired' (typical: 'superseded').
// - ErrSchemaParentRetired -> parent row's status is 'retired'. More
// specific than ErrSchemaParentNotActive so the handler layer can
// surface a more actionable reason.
func TestService_CreateSchemaVersion(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
const clientID = "TEST_SVC_V"
fx := newTestService(t, clientID)
// seedV1 creates a fresh (clientID, name) lineage at version 1 via the
// real CreateSchema code path so the test exercises the same insert
// route the handler layer will take. Returns the new schema's id.
seedV1 := func(t *testing.T, name string) *customschema.Schema {
t.Helper()
// Idempotency: wipe any leftover lineage rows under this name.
_, err := fx.cfg.GetDBPool().Exec(ctx,
`DELETE FROM client_metadata_schemas WHERE client_id = $1 AND name = $2`,
clientID, name)
require.NoError(t, err)
got, err := fx.service.CreateSchema(ctx, customschema.CreateSchemaInput{
ClientID: clientID,
Name: name,
Description: "seed v1",
SchemaDef: json.RawMessage(trivialValidSchemaJSON),
}, testActor)
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, 1, got.Version)
require.Equal(t, "active", got.Status)
return got
}
// countRows returns the number of rows in client_metadata_schemas for
// a (client, name) pair. Used by the "no row inserted" assertions.
countRows := func(t *testing.T, name string) int {
t.Helper()
var n int
err := fx.cfg.GetDBPool().QueryRow(ctx,
`SELECT COUNT(*) FROM client_metadata_schemas WHERE client_id = $1 AND name = $2`,
clientID, name).Scan(&n)
require.NoError(t, err)
return n
}
// countActiveRows returns the number of 'active' rows for a (client,
// name) pair. Used by the active-parent invariant assertions.
countActiveRows := func(t *testing.T, name string) int {
t.Helper()
var n int
err := fx.cfg.GetDBPool().QueryRow(ctx,
`SELECT COUNT(*) FROM client_metadata_schemas WHERE client_id = $1 AND name = $2 AND status = 'active'`,
clientID, name).Scan(&n)
require.NoError(t, err)
return n
}
freshInput := func(desc string) customschema.CreateSchemaVersionInput {
return customschema.CreateSchemaVersionInput{
Description: desc,
SchemaDef: json.RawMessage(trivialValidSchemaJSON),
}
}
t.Run("happy_path_produces_v2", func(t *testing.T) {
v1 := seedV1(t, "invoice")
got, err := fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("v2"), testActor)
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, 2, got.Version, "new row must land at version 2")
require.Equal(t, "active", got.Status, "new row must be active")
require.Equal(t, testActor, got.CreatedBy,
"CreatedBy must be the actor arg, never a body field")
require.NotEqual(t, v1.ID, got.ID, "v2 must have a distinct id from v1")
// Parent should now be superseded.
parent, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeSuperseded, parent.Status,
"parent v1 must transition to superseded inside the same tx")
// New row should be active.
child, err := fx.queries.GetClientMetadataSchema(ctx, got.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeActive, child.Status,
"new v2 row must be active")
require.Equal(t, int32(2), child.Version)
})
t.Run("rejects_parent_not_found", func(t *testing.T) {
// Use a schema name that has no rows so the count assertion is
// unambiguous. Count BEFORE the call and after to prove nothing
// was inserted.
const probeName = "ghost_parent"
require.Equal(t, 0, countRows(t, probeName),
"fixture precondition: no rows for probe lineage")
missing := uuid.New()
_, err := fx.service.CreateSchemaVersion(ctx, missing, freshInput("ghost"), testActor)
require.Error(t, err)
require.True(t, errors.Is(err, customschema.ErrSchemaNotFound),
"missing parent must surface ErrSchemaNotFound, got: %v", err)
// No row was inserted under the probe name — obvious because no
// lineage row ever existed for it.
require.Equal(t, 0, countRows(t, probeName),
"missing-parent rejection must not insert any row")
})
t.Run("rejects_non_active_parent", func(t *testing.T) {
v1 := seedV1(t, "alpha")
// Supersede v1 by creating v2 from v1.
v2, err := fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("alpha v2"), testActor)
require.NoError(t, err)
require.NotNil(t, v2)
require.Equal(t, 2, v2.Version)
// Now v1 is superseded. Calling CreateSchemaVersion with v1.ID
// must be rejected with ErrSchemaParentNotActive.
_, err = fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("should fail"), testActor)
require.Error(t, err)
require.True(t, errors.Is(err, customschema.ErrSchemaParentNotActive),
"non-active parent must surface ErrSchemaParentNotActive, got: %v", err)
// v2 is still active, v1 is still superseded, total rows still 2.
v2reload, err := fx.queries.GetClientMetadataSchema(ctx, v2.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeActive, v2reload.Status,
"v2 must remain active after rejection")
v1reload, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeSuperseded, v1reload.Status,
"v1 must remain superseded after rejection")
require.Equal(t, 2, countRows(t, "alpha"),
"rejected call must not insert a third row")
})
t.Run("rejects_retired_parent", func(t *testing.T) {
// The test engineer chose ErrSchemaParentRetired as the expected
// sentinel here (more specific than ErrSchemaParentNotActive so
// the handler layer can surface a more actionable reason). The
// golang engineer must map retired parents to this sentinel.
v1 := seedV1(t, "beta")
// Retire v1 via the real DeleteSchema path.
err := fx.service.DeleteSchema(ctx, v1.ID, testActor)
require.NoError(t, err)
_, err = fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("should fail"), testActor)
require.Error(t, err)
require.True(t, errors.Is(err, customschema.ErrSchemaParentRetired),
"retired parent must surface ErrSchemaParentRetired, got: %v", err)
// v1 is still retired, no new row inserted.
v1reload, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeRetired, v1reload.Status,
"retired v1 must remain retired after rejection")
require.Equal(t, 1, countRows(t, "beta"),
"rejected call against retired parent must not insert a row")
})
t.Run("invalid_schema_definition_rejected", func(t *testing.T) {
v1 := seedV1(t, "gamma")
before := len(fx.sink.all())
_, err := fx.service.CreateSchemaVersion(ctx, v1.ID, customschema.CreateSchemaVersionInput{
Description: "malformed",
SchemaDef: json.RawMessage("{this is not json"),
}, testActor)
require.Error(t, err, "malformed schema_def must be rejected")
// Parent is untouched, no new row inserted, no audit entry.
v1reload, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeActive, v1reload.Status,
"parent must remain active after validation failure")
require.Equal(t, 1, countRows(t, "gamma"),
"validation failure must not insert a new row")
require.Equal(t, before, len(fx.sink.all()),
"validation failure must not emit an audit entry")
})
t.Run("writes_schema_update_audit_entry", func(t *testing.T) {
// ResourceID choice: the new version's id. Justification: the
// audit event is reporting "a new version landed", so the row
// it names is the new version's id, not the (now superseded)
// parent's id. The golang engineer must match this.
v1 := seedV1(t, "delta")
before := len(fx.sink.all())
got, err := fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("delta v2"), testActor)
require.NoError(t, err)
require.NotNil(t, got)
records := fx.sink.all()
require.Equal(t, before+1, len(records),
"exactly one audit entry must be emitted by CreateSchemaVersion")
last := records[len(records)-1]
require.Equal(t, customschema.AuditActionSchemaUpdate, last.Action,
"audit action must be schema.update")
require.Equal(t, testActor, last.Actor,
"audit actor must be the actor arg, never a body field")
require.Equal(t, got.ID, last.ResourceID,
"audit ResourceID must name the new version's id, not the parent's id")
require.Equal(t, clientID, last.ClientID,
"audit ClientID must be the schema's varchar client id")
})
t.Run("active_parent_invariant_after_each_write", func(t *testing.T) {
v1 := seedV1(t, "epsilon")
// Three sequential calls produce v2 -> v3 -> v4. After each call
// there must be exactly one active row for (clientID, epsilon).
currentParentID := v1.ID
for i := 0; i < 3; i++ {
next, err := fx.service.CreateSchemaVersion(ctx, currentParentID, freshInput("epsilon step"), testActor)
require.NoError(t, err, "sequential call %d must succeed", i+1)
require.NotNil(t, next)
require.Equal(t, i+2, next.Version, "sequential call %d must land at version %d", i+1, i+2)
require.Equal(t, 1, countActiveRows(t, "epsilon"),
"active-parent invariant must hold after sequential call %d", i+1)
currentParentID = next.ID
}
// Total of 4 rows: v1/v2/v3 superseded, v4 active.
require.Equal(t, 4, countRows(t, "epsilon"),
"three sequential version creations plus v1 must produce 4 rows")
// Verify the per-status breakdown via a direct DB count so the
// test doesn't depend on ListSchemas filter semantics.
var superseded int
err := fx.cfg.GetDBPool().QueryRow(ctx,
`SELECT COUNT(*) FROM client_metadata_schemas
WHERE client_id = $1 AND name = 'epsilon' AND status = 'superseded'`,
clientID).Scan(&superseded)
require.NoError(t, err)
require.Equal(t, 3, superseded,
"exactly three rows must be superseded after the loop")
})
t.Run("active_parent_invariant_holds_after_rejection", func(t *testing.T) {
v1 := seedV1(t, "zeta")
v2, err := fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("zeta v2"), testActor)
require.NoError(t, err)
require.NotNil(t, v2)
// Branching from v1 again must be rejected.
_, err = fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput("should fail"), testActor)
require.Error(t, err)
require.True(t, errors.Is(err, customschema.ErrSchemaParentNotActive),
"branching from superseded v1 must surface ErrSchemaParentNotActive, got: %v", err)
require.Equal(t, 1, countActiveRows(t, "zeta"),
"active-parent invariant must still hold after rejection (exactly one active row)")
v1reload, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeSuperseded, v1reload.Status,
"v1 must remain superseded after rejection")
v2reload, err := fx.queries.GetClientMetadataSchema(ctx, v2.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeActive, v2reload.Status,
"v2 must remain active after rejection")
require.Equal(t, 2, countRows(t, "zeta"),
"rejected call must not insert a third row")
})
t.Run("concurrency_produces_distinct_versions", func(t *testing.T) {
// Race design: two goroutines fire CreateSchemaVersion against
// the same (clientID, "eta") v1 parent. Tracking line 132 says
// "concurrent calls produce distinct version numbers". The v4
// implementation achieves this by the first committer winning
// with v2, and the second committer seeing the (now superseded)
// parent and failing cleanly with ErrSchemaParentNotActive (per
// tracking line 134). The expected end state is exactly two
// rows: v1 superseded + v2 active. No v3, no duplicate v2.
//
// NOTE ON REGIME: if a future version of the service implements
// a retry path where the loser re-reads the lineage and promotes
// itself to v3, this subtest must be relaxed to "both succeed
// with versions (2, 3)". The current v4 plan explicitly does
// NOT implement that retry path — winner/loser is the contract.
// Do not assert a specific winner; either goroutine may win.
v1 := seedV1(t, "eta")
var (
wg sync.WaitGroup
mu sync.Mutex
oks []*customschema.Schema
fails []error
)
wg.Add(2)
for _, desc := range []string{"v2-racer-A", "v2-racer-B"} {
desc := desc
go func() {
defer wg.Done()
got, err := fx.service.CreateSchemaVersion(ctx, v1.ID, freshInput(desc), testActor)
mu.Lock()
defer mu.Unlock()
if err != nil {
fails = append(fails, err)
return
}
oks = append(oks, got)
}()
}
wg.Wait()
require.Len(t, oks, 1,
"exactly one goroutine must succeed (winner-takes-all); got %d winners", len(oks))
require.Len(t, fails, 1,
"exactly one goroutine must fail; got %d failures", len(fails))
require.True(t, errors.Is(fails[0], customschema.ErrSchemaParentNotActive),
"loser must fail with ErrSchemaParentNotActive, got: %v", fails[0])
require.Equal(t, 2, oks[0].Version,
"winner must land at version 2")
require.Equal(t, "active", oks[0].Status,
"winner row must be active")
// End state: exactly two rows for eta — v1 superseded + v2 active.
require.Equal(t, 2, countRows(t, "eta"),
"concurrency must leave exactly two rows (v1 + v2); no v3 and no duplicate v2")
require.Equal(t, 1, countActiveRows(t, "eta"),
"active-parent invariant must hold after the race")
v1reload, err := fx.queries.GetClientMetadataSchema(ctx, v1.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeSuperseded, v1reload.Status,
"v1 must be superseded after the race")
})
}