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