Files
query-orchestration/internal/customschema/service_test.go
T

926 lines
35 KiB
Go
Raw Normal View History

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