Files
query-orchestration/internal/database/repository/customschemas_queries_test.go
T
Jay Brown 17fc813823 Merged in feature/mutable-metadata1 (pull request #221)
M1, M2 and M3 complete

* M1, M2 and M3 complete

* review changes

* docs

* docs
2026-04-16 23:11:26 +00:00

602 lines
23 KiB
Go

package repository_test
import (
"context"
"errors"
"testing"
"queryorchestration/internal/database/repository"
"queryorchestration/internal/serviceconfig"
"queryorchestration/internal/test"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
)
// Mutable metadata feature, milestone 1.4: integration tests for the eight
// sqlc-generated schema CRUD queries against a real Postgres testcontainer.
//
// The tests exercise every generated method at least once:
// - CreateClientMetadataSchema
// - GetClientMetadataSchema
// - GetLatestSchemaByName
// - GetMaxSchemaVersion
// - SetSchemaStatus
// - GetSchemaDocumentCount
// - LockSchemaVersionsForName
// - ListClientMetadataSchemas (covered by TestListClientMetadataSchemas_Filters)
//
// No mocks. All queries run against the shared testcontainer that
// test.CreateDB stands up. The Postgres container is reused across test
// runs so each top-level test re-seeds its client rows with a
// DELETE-then-CreateClient idempotency dance (see the §1.1/§1.3 tests in
// client_metadata_schemas_migration_test.go for the same pattern).
// trivialSchemaJSON is a minimal but valid schema_def. Used throughout
// these tests when the exact content does not matter.
const trivialSchemaJSON = `{"type":"object","additionalProperties":false}`
// resetClient makes a client row idempotent across reruns of the same test.
// The Postgres container and per-test database are reused, so leftover rows
// from a previous run must be cleaned out before CreateClient. The FK on
// documents.clientid is NOT ON DELETE CASCADE, so we have to delete
// documents explicitly before the client delete can succeed. Deleting
// documents also implicitly clears documents.custom_schema_id references,
// which lets the subsequent cascade through clients -> client_metadata_schemas
// proceed cleanly.
func resetClient(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, name string) {
t.Helper()
pool := cfg.GetDBPool()
// Order matters: documents first (no cascade from clients), then
// clients (cascade wipes client_metadata_schemas via the FK from §1.1).
_, 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 = cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
Name: name,
Clientid: clientID,
})
require.NoError(t, err, "CreateClient for clientID=%q must succeed", clientID)
}
// seedSchema inserts a schema row via the generated CreateClientMetadataSchema
// query and returns the resulting row. Keeps the test bodies uncluttered.
func seedSchema(
t *testing.T,
ctx context.Context,
queries *repository.Queries,
clientID, name, description string,
version int32,
status repository.SchemaStatusType,
) *repository.ClientMetadataSchema {
t.Helper()
desc := description
row, err := queries.CreateClientMetadataSchema(ctx, &repository.CreateClientMetadataSchemaParams{
ClientID: clientID,
Name: name,
Description: &desc,
SchemaDef: []byte(trivialSchemaJSON),
Version: version,
Status: status,
CreatedBy: "tester",
})
require.NoError(t, err, "seed CreateClientMetadataSchema(%s,%s,v%d) must succeed", clientID, name, version)
require.NotNil(t, row)
return row
}
// TestClientMetadataSchemas_CRUD covers the seven non-list queries with one
// subtest per query. Each subtest is responsible for its own fixture data
// (no shared schema rows across subtests) so that reordering or running a
// single subtest in isolation still works.
func TestClientMetadataSchemas_CRUD(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
// Two distinct clients so cross-client isolation can be exercised if
// needed. Each test body picks whichever it needs.
const (
clientA = "SQLC_Q_A"
clientB = "SQLC_Q_B"
)
resetClient(t, ctx, cfg, clientA, "sqlc-q-a")
resetClient(t, ctx, cfg, clientB, "sqlc-q-b")
t.Run("create_and_get", func(t *testing.T) {
// Use a name unique to this subtest so parallel / rerun interactions
// can't collide with other subtests that seed the same client.
const schemaName = "create_and_get"
desc := "unit-test schema"
params := &repository.CreateClientMetadataSchemaParams{
ClientID: clientA,
Name: schemaName,
Description: &desc,
SchemaDef: []byte(trivialSchemaJSON),
Version: 1,
Status: repository.SchemaStatusTypeActive,
CreatedBy: "tester",
}
created, err := queries.CreateClientMetadataSchema(ctx, params)
require.NoError(t, err)
require.NotNil(t, created)
require.NotEqual(t, uuid.Nil, created.ID, "id must be populated by DEFAULT")
require.Equal(t, clientA, created.ClientID)
require.Equal(t, schemaName, created.Name)
require.NotNil(t, created.Description)
require.Equal(t, desc, *created.Description)
require.JSONEq(t, trivialSchemaJSON, string(created.SchemaDef))
require.Equal(t, int32(1), created.Version)
require.Equal(t, repository.SchemaStatusTypeActive, created.Status)
require.True(t, created.CreatedAt.Valid, "created_at must be set by DEFAULT now()")
require.False(t, created.CreatedAt.Time.IsZero())
require.Equal(t, "tester", created.CreatedBy)
// GetClientMetadataSchema must round-trip the row exactly.
fetched, err := queries.GetClientMetadataSchema(ctx, created.ID)
require.NoError(t, err)
require.NotNil(t, fetched)
require.Equal(t, created.ID, fetched.ID)
require.Equal(t, created.ClientID, fetched.ClientID)
require.Equal(t, created.Name, fetched.Name)
require.NotNil(t, fetched.Description)
require.Equal(t, *created.Description, *fetched.Description)
require.JSONEq(t, string(created.SchemaDef), string(fetched.SchemaDef))
require.Equal(t, created.Version, fetched.Version)
require.Equal(t, created.Status, fetched.Status)
require.Equal(t, created.CreatedAt.Time.UnixMicro(), fetched.CreatedAt.Time.UnixMicro())
require.Equal(t, created.CreatedBy, fetched.CreatedBy)
})
t.Run("get_not_found_returns_err_no_rows", func(t *testing.T) {
// A random UUID will not collide with any seeded row.
randomID := uuid.New()
_, err := queries.GetClientMetadataSchema(ctx, randomID)
require.Error(t, err)
require.True(t, errors.Is(err, pgx.ErrNoRows),
"expected pgx.ErrNoRows, got %T: %v", err, err)
})
t.Run("get_latest_by_name", func(t *testing.T) {
const schemaName = "get_latest_by_name"
seedSchema(t, ctx, queries, clientA, schemaName, "v1", 1, repository.SchemaStatusTypeSuperseded)
seedSchema(t, ctx, queries, clientA, schemaName, "v2", 2, repository.SchemaStatusTypeSuperseded)
v3 := seedSchema(t, ctx, queries, clientA, schemaName, "v3", 3, repository.SchemaStatusTypeActive)
latest, err := queries.GetLatestSchemaByName(ctx, &repository.GetLatestSchemaByNameParams{
ClientID: clientA,
Name: schemaName,
})
require.NoError(t, err)
require.NotNil(t, latest)
require.Equal(t, v3.ID, latest.ID, "GetLatestSchemaByName must return the highest-version row")
require.Equal(t, int32(3), latest.Version)
})
t.Run("get_max_schema_version", func(t *testing.T) {
const schemaName = "get_max_schema_version"
// Invariant the service layer relies on: brand-new (client, name)
// pair returns 0, not an error. This is what lets the service safely
// assign v1 to a new lineage on the first write.
maxVer, err := queries.GetMaxSchemaVersion(ctx, &repository.GetMaxSchemaVersionParams{
ClientID: clientA,
Name: schemaName,
})
require.NoError(t, err, "GetMaxSchemaVersion on empty lineage must NOT error (COALESCE)")
require.NotNil(t, maxVer)
require.Equal(t, int32(0), *maxVer, "empty lineage must return 0")
// Non-contiguous versions on purpose — the query must return MAX,
// not COUNT or the last inserted version.
seedSchema(t, ctx, queries, clientA, schemaName, "v1", 1, repository.SchemaStatusTypeSuperseded)
seedSchema(t, ctx, queries, clientA, schemaName, "v2", 2, repository.SchemaStatusTypeSuperseded)
seedSchema(t, ctx, queries, clientA, schemaName, "v5", 5, repository.SchemaStatusTypeActive)
maxVer, err = queries.GetMaxSchemaVersion(ctx, &repository.GetMaxSchemaVersionParams{
ClientID: clientA,
Name: schemaName,
})
require.NoError(t, err)
require.NotNil(t, maxVer)
require.Equal(t, int32(5), *maxVer, "GetMaxSchemaVersion must return the largest existing version")
})
t.Run("set_schema_status", func(t *testing.T) {
const schemaName = "set_schema_status"
row := seedSchema(t, ctx, queries, clientA, schemaName, "v1", 1, repository.SchemaStatusTypeActive)
// active -> superseded
err := queries.SetSchemaStatus(ctx, &repository.SetSchemaStatusParams{
ID: row.ID,
Status: repository.SchemaStatusTypeSuperseded,
})
require.NoError(t, err)
reloaded, err := queries.GetClientMetadataSchema(ctx, row.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeSuperseded, reloaded.Status)
// superseded -> retired
err = queries.SetSchemaStatus(ctx, &repository.SetSchemaStatusParams{
ID: row.ID,
Status: repository.SchemaStatusTypeRetired,
})
require.NoError(t, err)
reloaded, err = queries.GetClientMetadataSchema(ctx, row.ID)
require.NoError(t, err)
require.Equal(t, repository.SchemaStatusTypeRetired, reloaded.Status)
})
t.Run("get_schema_document_count", func(t *testing.T) {
const schemaName = "get_schema_document_count"
schema := seedSchema(t, ctx, queries, clientA, schemaName, "v1", 1, repository.SchemaStatusTypeActive)
// No documents bound yet.
count, err := queries.GetSchemaDocumentCount(ctx, &schema.ID)
require.NoError(t, err)
require.Equal(t, int64(0), count, "unbound schema must report 0 documents")
// Insert a first document owned by the SAME client as the schema
// and bind it. Same-client is required by the composite FK on
// documents.custom_schema_id (see migration 128 / §1.3 test).
doc1, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientA,
Hash: "sqlc_q_doc_hash_1",
})
require.NoError(t, err)
_, err = pool.Exec(ctx,
`UPDATE documents SET custom_schema_id = $1 WHERE id = $2`,
schema.ID, doc1)
require.NoError(t, err)
count, err = queries.GetSchemaDocumentCount(ctx, &schema.ID)
require.NoError(t, err)
require.Equal(t, int64(1), count, "one bound document must report 1")
// Bind a second document.
doc2, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientA,
Hash: "sqlc_q_doc_hash_2",
})
require.NoError(t, err)
_, err = pool.Exec(ctx,
`UPDATE documents SET custom_schema_id = $1 WHERE id = $2`,
schema.ID, doc2)
require.NoError(t, err)
count, err = queries.GetSchemaDocumentCount(ctx, &schema.ID)
require.NoError(t, err)
require.Equal(t, int64(2), count, "two bound documents must report 2")
})
t.Run("lock_schema_versions_for_name", func(t *testing.T) {
// This test only proves that LockSchemaVersionsForName returns the
// expected rows when run inside a transaction. It does NOT exercise
// the FOR UPDATE serialization semantics — concurrent version
// creation is proven by the Milestone 1.6 service-layer tests. The
// value of LockSchemaVersionsForName at this layer is just "give me
// back every version row of this (client, name) pair, under a
// transactional lock"; that is what we assert.
const schemaName = "lock_schema_versions_for_name"
v1 := seedSchema(t, ctx, queries, clientA, schemaName, "v1", 1, repository.SchemaStatusTypeSuperseded)
v2 := seedSchema(t, ctx, queries, clientA, schemaName, "v2", 2, repository.SchemaStatusTypeActive)
tx, err := pool.Begin(ctx)
require.NoError(t, err, "pool.Begin must succeed")
// Always roll back — the seeded rows already exist outside the tx
// and nothing inside the tx needs to persist.
defer func() { _ = tx.Rollback(ctx) }()
txQueries := queries.WithTx(tx)
locked, err := txQueries.LockSchemaVersionsForName(ctx, &repository.LockSchemaVersionsForNameParams{
ClientID: clientA,
Name: schemaName,
})
require.NoError(t, err)
require.Len(t, locked, 2, "must lock every version row for the (client, name) pair")
gotIDs := map[uuid.UUID]bool{}
for _, row := range locked {
gotIDs[row.ID] = true
}
require.True(t, gotIDs[v1.ID], "locked set must include v1 row")
require.True(t, gotIDs[v2.ID], "locked set must include v2 row")
})
}
// TestListClientMetadataSchemas_Filters exercises the filter matrix of
// ListClientMetadataSchemas. Uses a client pair distinct from the CRUD test
// (SQLC_LIST_A / SQLC_LIST_B) so the two top-level tests can run in parallel
// without stepping on each other.
//
// Filter semantics asserted:
// - include_all_versions=false returns only the MAX(version) row per
// (client_id, name).
// - include_all_versions=true returns every row.
// - name_filter uses EXACT match (the generated SQL is
// `cms.name = $2::varchar`, not ILIKE). See the name_filter_narrows
// subtest for the assertion.
// - include_all_statuses=true bypasses the status filter; otherwise
// status_filter selects exactly one status.
// - document_count is a correlated subquery on documents.custom_schema_id.
// - client_id scoping is strict (cross-client isolation).
// - LIMIT/OFFSET paginate across an ORDER BY (name, version DESC) window.
func TestListClientMetadataSchemas_Filters(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
ctx := t.Context()
cfg := &serviceconfig.BaseConfig{}
test.CreateDB(t, cfg)
pool := cfg.GetDBPool()
require.NotNil(t, pool)
queries := cfg.GetDBQueries()
require.NotNil(t, queries)
const (
clientA = "SQLC_LIST_A"
clientB = "SQLC_LIST_B"
)
resetClient(t, ctx, cfg, clientA, "sqlc-list-a")
resetClient(t, ctx, cfg, clientB, "sqlc-list-b")
// Shared fixture on clientA: two alpha versions (v1 superseded, v2 active)
// and one beta v1 active. Every later subtest that seeds additional rows
// does so on a DIFFERENT name so it doesn't disturb these.
alphaV1 := seedSchema(t, ctx, queries, clientA, "alpha", "", 1, repository.SchemaStatusTypeSuperseded)
alphaV2 := seedSchema(t, ctx, queries, clientA, "alpha", "", 2, repository.SchemaStatusTypeActive)
betaV1 := seedSchema(t, ctx, queries, clientA, "beta", "", 1, repository.SchemaStatusTypeActive)
t.Run("default_filters_returns_latest_active", func(t *testing.T) {
// include_all_versions=false collapses to MAX(version) per name, so
// the superseded alpha v1 drops out. Combined with status_filter=active,
// the result is exactly {alpha v2, beta v1}.
rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{
ClientID: clientA,
NameFilter: nil,
IncludeAllStatuses: false,
StatusFilter: repository.SchemaStatusTypeActive,
IncludeAllVersions: false,
OffsetVal: 0,
LimitVal: 100,
})
require.NoError(t, err)
// Build a set of (name, version) tuples for assertion.
type tuple struct {
name string
version int32
}
got := map[tuple]bool{}
for _, r := range rows {
got[tuple{r.Name, r.Version}] = true
}
require.Truef(t, got[tuple{"alpha", 2}], "result must contain (alpha, v2); got=%v", got)
require.Truef(t, got[tuple{"beta", 1}], "result must contain (beta, v1); got=%v", got)
require.Falsef(t, got[tuple{"alpha", 1}], "result must NOT contain (alpha, v1) when include_all_versions=false; got=%v", got)
})
t.Run("include_all_versions_returns_every_row", func(t *testing.T) {
rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{
ClientID: clientA,
NameFilter: nil,
IncludeAllStatuses: true, // don't let status filter confuse this assertion
StatusFilter: repository.SchemaStatusTypeActive,
IncludeAllVersions: true,
OffsetVal: 0,
LimitVal: 100,
})
require.NoError(t, err)
// Shared fixture yields 3 rows (alpha v1, alpha v2, beta v1).
// Other subtests may have added MORE rows to clientA by the time
// this subtest runs (t.Run children run sequentially inside the
// top-level test so ordering is deterministic — this subtest runs
// second, before any seed-adding subtest). Assert the three
// fixture rows are present rather than an exact length.
ids := map[uuid.UUID]bool{}
for _, r := range rows {
ids[r.ID] = true
}
require.True(t, ids[alphaV1.ID], "include_all_versions must include alpha v1")
require.True(t, ids[alphaV2.ID], "include_all_versions must include alpha v2")
require.True(t, ids[betaV1.ID], "include_all_versions must include beta v1")
})
t.Run("name_filter_narrows", func(t *testing.T) {
// The generated SQL uses EXACT match (`cms.name = $2::varchar`),
// NOT ILIKE. Passing "alpha" must return only alpha rows.
nameFilter := "alpha"
rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{
ClientID: clientA,
NameFilter: &nameFilter,
IncludeAllStatuses: true,
StatusFilter: repository.SchemaStatusTypeActive,
IncludeAllVersions: true,
OffsetVal: 0,
LimitVal: 100,
})
require.NoError(t, err)
require.NotEmpty(t, rows, "name_filter=alpha must return at least one row")
for _, r := range rows {
require.Equal(t, "alpha", r.Name,
"name_filter is exact match; every row must have name=alpha, got %q", r.Name)
}
})
t.Run("status_filter_superseded", func(t *testing.T) {
// Seed one additional row with status=superseded on a name that no
// other subtest touches, so we can assert status filtering cleanly.
gammaV1 := seedSchema(t, ctx, queries, clientA, "gamma", "", 1, repository.SchemaStatusTypeSuperseded)
rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{
ClientID: clientA,
NameFilter: nil,
IncludeAllStatuses: false,
StatusFilter: repository.SchemaStatusTypeSuperseded,
IncludeAllVersions: true, // look at every row, filter by status only
OffsetVal: 0,
LimitVal: 100,
})
require.NoError(t, err)
sawGamma := false
for _, r := range rows {
require.Equal(t, repository.SchemaStatusTypeSuperseded, r.Status,
"status_filter=superseded must not return %s rows", r.Status)
if r.ID == gammaV1.ID {
sawGamma = true
}
}
require.True(t, sawGamma, "result must include the seeded gamma superseded row")
})
t.Run("include_all_statuses_returns_all", func(t *testing.T) {
rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{
ClientID: clientA,
NameFilter: nil,
IncludeAllStatuses: true,
StatusFilter: repository.SchemaStatusTypeActive, // ignored
IncludeAllVersions: true,
OffsetVal: 0,
LimitVal: 100,
})
require.NoError(t, err)
// The shared-fixture rows plus the gamma row from the previous
// subtest must all come back regardless of status.
seenStatuses := map[repository.SchemaStatusType]bool{}
for _, r := range rows {
seenStatuses[r.Status] = true
}
require.True(t, seenStatuses[repository.SchemaStatusTypeActive], "result must include active rows")
require.True(t, seenStatuses[repository.SchemaStatusTypeSuperseded], "result must include superseded rows")
})
t.Run("document_count_is_populated", func(t *testing.T) {
// Fresh schema on clientB with two bound documents. The correlated
// subquery must report DocumentCount=2.
schemaB := seedSchema(t, ctx, queries, clientB, "docbound", "", 1, repository.SchemaStatusTypeActive)
doc1, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientB,
Hash: "sqlc_list_doc_hash_1",
})
require.NoError(t, err)
_, err = pool.Exec(ctx,
`UPDATE documents SET custom_schema_id = $1 WHERE id = $2`,
schemaB.ID, doc1)
require.NoError(t, err)
doc2, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
Clientid: clientB,
Hash: "sqlc_list_doc_hash_2",
})
require.NoError(t, err)
_, err = pool.Exec(ctx,
`UPDATE documents SET custom_schema_id = $1 WHERE id = $2`,
schemaB.ID, doc2)
require.NoError(t, err)
rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{
ClientID: clientB,
NameFilter: nil,
IncludeAllStatuses: true,
StatusFilter: repository.SchemaStatusTypeActive,
IncludeAllVersions: true,
OffsetVal: 0,
LimitVal: 100,
})
require.NoError(t, err)
require.Len(t, rows, 1, "clientB fixture has exactly one schema")
require.Equal(t, schemaB.ID, rows[0].ID)
require.Equal(t, int64(2), rows[0].DocumentCount,
"DocumentCount must reflect bound documents via correlated subquery")
})
t.Run("cross_client_isolation", func(t *testing.T) {
// Listing for clientA must not leak any clientB rows — and we know
// clientB now has a schema from the previous subtest.
rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{
ClientID: clientA,
NameFilter: nil,
IncludeAllStatuses: true,
StatusFilter: repository.SchemaStatusTypeActive,
IncludeAllVersions: true,
OffsetVal: 0,
LimitVal: 100,
})
require.NoError(t, err)
require.NotEmpty(t, rows)
for _, r := range rows {
require.Equal(t, clientA, r.ClientID,
"cross-client isolation: clientA query must not leak %s rows", r.ClientID)
}
})
t.Run("pagination_limit_offset", func(t *testing.T) {
// Seed 5 schemas with distinct names on a NEW client so the
// pagination assertions don't have to account for shared-fixture
// rows accumulated by the earlier subtests. Uses a third client id
// to stay isolated.
const clientC = "SQLC_LIST_C"
resetClient(t, ctx, cfg, clientC, "sqlc-list-c")
names := []string{"p1_apple", "p2_banana", "p3_cherry", "p4_date", "p5_elderberry"}
for _, n := range names {
seedSchema(t, ctx, queries, clientC, n, "", 1, repository.SchemaStatusTypeActive)
}
fetch := func(offset, limit int64) []*repository.ListClientMetadataSchemasRow {
rows, err := queries.ListClientMetadataSchemas(ctx, &repository.ListClientMetadataSchemasParams{
ClientID: clientC,
NameFilter: nil,
IncludeAllStatuses: true,
StatusFilter: repository.SchemaStatusTypeActive,
IncludeAllVersions: true,
OffsetVal: offset,
LimitVal: limit,
})
require.NoError(t, err)
return rows
}
page1 := fetch(0, 2)
require.Len(t, page1, 2)
page2 := fetch(2, 2)
require.Len(t, page2, 2)
page3 := fetch(4, 2)
require.Len(t, page3, 1)
// Combined across the three calls we must see every seeded name
// exactly once — no duplicates, no gaps.
seen := map[string]int{}
for _, r := range page1 {
seen[r.Name]++
}
for _, r := range page2 {
seen[r.Name]++
}
for _, r := range page3 {
seen[r.Name]++
}
require.Len(t, seen, 5, "pagination must cover all 5 distinct schemas")
for _, n := range names {
require.Equal(t, 1, seen[n], "name %q must appear exactly once across pages", n)
}
})
}