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 126–130). 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 139–140). 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 141–144). 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 145–147). 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") }) }