Files
krow_backend/go-api/internal/domain/definitions_schema_test.go
2026-08-28 12:21:44 +05:30

781 lines
27 KiB
Go

package domain_test
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/krow/krow-backend/go-api/internal/testutil"
)
// Phase 4C — the shape of the authored-definition tables.
//
// These test the MIGRATION, not any Go code: there is no agent or skill package
// yet, and there deliberately is not one until Phase 4D. What is under test is
// whether the database refuses the things it is supposed to refuse.
//
// An external test package (`domain_test`) rather than `package domain`,
// because testutil imports seeder which imports domain — reachable from an
// external test binary, an import cycle from an internal one.
const (
agents = "agent_definitions"
skills = "skill_definitions"
)
// fixture is a migrated sandbox with one organization and two users.
type fixture struct {
pool *pgxpool.Pool
ctx context.Context
orgID string
alice string
bob string
}
func newFixture(t *testing.T, label string) *fixture {
t.Helper()
ctx := context.Background()
pool := testutil.Sandbox(t, label)
testutil.ApplyAllMigrations(ctx, t, pool)
f := &fixture{pool: pool, ctx: ctx}
if err := pool.QueryRow(ctx,
`INSERT INTO organizations (name, slug) VALUES ('Defs Org','defs-org') RETURNING id::text`).
Scan(&f.orgID); err != nil {
t.Fatalf("create organization: %v", err)
}
f.alice = f.newUser(t, "alice@example.test")
f.bob = f.newUser(t, "bob@example.test")
return f
}
func (f *fixture) newUser(t *testing.T, email string) string {
t.Helper()
var id string
if err := f.pool.QueryRow(f.ctx,
`INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid, $2::citext, $3) RETURNING id::text`,
f.orgID, email, email).Scan(&id); err != nil {
t.Fatalf("create user %s: %v", email, err)
}
return id
}
// row is one candidate definition. Any field may be made deliberately wrong.
type row struct {
table string
defID string
orgID string
visibility string
owner *string
createdBy *string
markdown string
status string
version *int
}
func (f *fixture) insert(r row) (string, error) {
cols := []string{"definition_id", "org_id", "visibility", "owner_user_id", "created_by", "markdown"}
vals := []string{"$1::text", "$2::uuid", "$3::text", "$4::uuid", "$5::uuid", "$6::text"}
args := []any{r.defID, r.orgID, r.visibility, r.owner, r.createdBy, r.markdown}
if r.status != "" {
cols, vals = append(cols, "status"), append(vals, "$7::text")
args = append(args, r.status)
}
if r.version != nil {
cols = append(cols, "version")
vals = append(vals, "$"+itoa(len(args)+1)+"::integer")
args = append(args, *r.version)
}
var id string
err := f.pool.QueryRow(f.ctx,
"INSERT INTO "+r.table+" ("+strings.Join(cols, ", ")+") VALUES ("+
strings.Join(vals, ", ")+") RETURNING id::text", args...).Scan(&id)
return id, err
}
func itoa(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return string(rune('0'+n/10)) + string(rune('0'+n%10))
}
// personal and organization build a valid row of each tier, so a test can
// change exactly one thing and see whether the database notices.
func (f *fixture) personal(table, defID, owner string) row {
return row{table: table, defID: defID, orgID: f.orgID, visibility: "personal",
owner: &owner, createdBy: &owner, markdown: "---\nid: " + defID + "\n---\n"}
}
func (f *fixture) organization(table, defID, author string) row {
return row{table: table, defID: defID, orgID: f.orgID, visibility: "organization",
owner: nil, createdBy: &author, markdown: "---\nid: " + defID + "\n---\n"}
}
func mustInsert(t *testing.T, f *fixture, r row) string {
t.Helper()
id, err := f.insert(r)
if err != nil {
t.Fatalf("a valid %s row was refused: %v", r.table, err)
}
return id
}
func refused(t *testing.T, f *fixture, r row, wantConstraint, why string) {
t.Helper()
_, err := f.insert(r)
if err == nil {
t.Fatalf("%s: the row was ACCEPTED — %s", r.table, why)
}
if wantConstraint != "" && !strings.Contains(err.Error(), wantConstraint) {
t.Errorf("%s: refused by %v, want the %s constraint", r.table, err, wantConstraint)
}
}
/* ── 1, 2. The ownership invariant ──────────────────────────────────────── */
func TestVisibilityRequiresMatchingOwnership(t *testing.T) {
f := newFixture(t, "defs_ownership")
for _, table := range []string{agents, skills} {
t.Run(table, func(t *testing.T) {
// The two valid shapes.
mustInsert(t, f, f.personal(table, "valid-personal", f.alice))
mustInsert(t, f, f.organization(table, "valid-org", f.alice))
// 1. A personal definition with no owner belongs to nobody.
bad := f.personal(table, "no-owner", f.alice)
bad.owner = nil
refused(t, f, bad, "visibility_owner",
"a personal definition must have an owner")
// 2. An organization definition with an owner is two answers to
// "whose is this", which is one too many.
bad = f.organization(table, "with-owner", f.alice)
bad.owner = &f.alice
refused(t, f, bad, "visibility_owner",
"an organization definition must not have an owner")
// And an unrecognised tier is not a tier.
bad = f.personal(table, "bad-tier", f.alice)
bad.visibility = "public"
refused(t, f, bad, "visibility_check", "`public` is not a visibility")
})
}
}
/* ── 3. definition_id format ────────────────────────────────────────────── */
// The same rule the frontend validator enforces, restated in the database so a
// caller that bypasses the application cannot store an id the registry could
// never address.
func TestDefinitionIDFormat(t *testing.T) {
f := newFixture(t, "defs_idformat")
valid := []string{"a", "board", "krow-workforce-agent", "x1", "a-1-b", "0abc"}
invalid := map[string]string{
"leading dash": "-board",
"upper case": "Board",
"underscore": "my_skill",
"space": "my skill",
"trailing dot": "board.",
"empty": "",
"slash": "custom/board",
"unicode": "bòard",
"sql-ish": "a'; DROP TABLE users; --",
"newline": "board\nx",
}
for _, table := range []string{agents, skills} {
t.Run(table, func(t *testing.T) {
for _, id := range valid {
if _, err := f.insert(f.personal(table, id, f.alice)); err != nil {
t.Errorf("valid id %q was refused: %v", id, err)
}
}
for name, id := range invalid {
bad := f.personal(table, id, f.bob)
refused(t, f, bad, "definition_id_format", "id "+name+" ("+id+") is not a valid id")
}
})
}
}
/* ── 4, 5, 6, 7. Status vocabularies and version ────────────────────────── */
func TestAgentStatusAndVersion(t *testing.T) {
f := newFixture(t, "defs_agentstatus")
// 4. The three agent statuses, and nothing else.
for _, status := range []string{"draft", "published", "archived"} {
r := f.personal(agents, "s-"+status, f.alice)
r.status = status
mustInsert(t, f, r)
}
for _, status := range []string{"active", "inactive", "live", "DRAFT", ""} {
r := f.personal(agents, "bad-status", f.bob)
r.status = status
if status == "" {
continue // an omitted status takes the default; tested below
}
refused(t, f, r, "status_check", "`"+status+"` is not an agent status")
}
// The default is draft: creating an agent must never publish it.
id := mustInsert(t, f, f.personal(agents, "defaulted", f.bob))
var status string
var version int
if err := f.pool.QueryRow(f.ctx,
`SELECT status, version FROM agent_definitions WHERE id = $1::uuid`, id).
Scan(&status, &version); err != nil {
t.Fatalf("read back: %v", err)
}
if status != "draft" {
t.Errorf("default status = %q, want draft", status)
}
if version != 1 {
t.Errorf("default version = %d, want 1", version)
}
// 6. A version is a whole number of 1 or more.
for _, v := range []int{0, -1, -100} {
r := f.personal(agents, "bad-version", f.bob)
r.version = &v
refused(t, f, r, "version_check", "version must be at least 1")
}
for _, v := range []int{1, 2, 9999} {
r := f.personal(agents, "v-ok", f.alice)
r.version = &v
r.defID = "v-ok-" + itoa(v%100)
if _, err := f.insert(r); err != nil {
t.Errorf("version %d was refused: %v", v, err)
}
}
}
func TestSkillStatusAndNoVersion(t *testing.T) {
f := newFixture(t, "defs_skillstatus")
// 5. The two skill statuses, and nothing else.
for _, status := range []string{"active", "inactive"} {
r := f.personal(skills, "s-"+status, f.alice)
r.status = status
mustInsert(t, f, r)
}
for _, status := range []string{"draft", "published", "archived", "ACTIVE"} {
r := f.personal(skills, "bad-status", f.bob)
r.status = status
refused(t, f, r, "status_check", "`"+status+"` is not a skill status")
}
// The default is active — a skill is on unless somebody turns it off.
id := mustInsert(t, f, f.personal(skills, "defaulted", f.bob))
var status string
if err := f.pool.QueryRow(f.ctx,
`SELECT status FROM skill_definitions WHERE id = $1::uuid`, id).Scan(&status); err != nil {
t.Fatalf("read back: %v", err)
}
if status != "active" {
t.Errorf("default status = %q, want active", status)
}
// 7. Skills have NO version. The frontend has no notion of one, so the
// column must not exist — inventing it "for symmetry" would create a field
// nothing can set and nothing can mean.
var exists int
if err := f.pool.QueryRow(f.ctx,
`SELECT count(*)::int FROM information_schema.columns
WHERE table_schema='public' AND table_name='skill_definitions' AND column_name='version'`).
Scan(&exists); err != nil {
t.Fatalf("look for a version column: %v", err)
}
if exists != 0 {
t.Error("skill_definitions has a version column; skills have no version concept")
}
}
/* ── 8. Markdown bound ──────────────────────────────────────────────────── */
func TestMarkdownSizeBound(t *testing.T) {
f := newFixture(t, "defs_markdown")
for _, table := range []string{agents, skills} {
t.Run(table, func(t *testing.T) {
// The largest definition shipped with the product is 3,156 bytes,
// so anything realistic is far inside the bound.
ok := f.personal(table, "big-but-fine", f.alice)
ok.markdown = strings.Repeat("x", 65536)
mustInsert(t, f, ok)
over := f.personal(table, "too-big", f.bob)
over.markdown = strings.Repeat("x", 65537)
refused(t, f, over, "markdown_size", "a definition over the size bound")
empty := f.personal(table, "empty-md", f.bob)
empty.markdown = ""
refused(t, f, empty, "markdown_size", "an empty definition cannot parse")
})
}
}
// The Markdown is stored byte-for-byte. A definition has to survive a round
// trip to a .md file on disk, so anything that rewrote it here — trimming,
// newline normalisation, unicode folding — would break that.
func TestMarkdownIsStoredVerbatim(t *testing.T) {
f := newFixture(t, "defs_verbatim")
// A BOM, CRLF endings, trailing spaces and a tab — exactly the four things
// normalizeDefinition exists to tolerate. The database must not "help" by
// removing any of them: normalising is the parser's job, on read.
source := "\ufeff---\r\nid: verbatim\r\nname: Verbatim\r\n---\r\n\r\n# Verbatim \r\n\ttabbed\n"
r := f.personal(agents, "verbatim", f.alice)
r.markdown = source
id := mustInsert(t, f, r)
var stored string
if err := f.pool.QueryRow(f.ctx,
`SELECT markdown FROM agent_definitions WHERE id = $1::uuid`, id).Scan(&stored); err != nil {
t.Fatalf("read back: %v", err)
}
if stored != source {
t.Errorf("the stored Markdown differs from what was written:\n in %q\n out %q", source, stored)
}
}
/* ── 9, 10, 11, 12. Foreign keys and deletion ───────────────────────────── */
func TestForeignKeysAndDeleteBehaviour(t *testing.T) {
f := newFixture(t, "defs_fk")
missing := "00000000-0000-0000-0000-000000000000"
for _, table := range []string{agents, skills} {
t.Run(table+"/rejects unknown references", func(t *testing.T) {
// 9. An organization that does not exist.
bad := f.personal(table, "bad-org", f.alice)
bad.orgID = missing
refused(t, f, bad, "org_id_fkey", "org_id must reference a real organization")
// 10. An owner that does not exist.
bad = f.personal(table, "bad-owner", f.alice)
bad.owner = &missing
refused(t, f, bad, "owner_user_id_fkey", "owner_user_id must reference a real user")
// 11. An author that does not exist.
bad = f.organization(table, "bad-author", f.alice)
bad.createdBy = &missing
refused(t, f, bad, "created_by_fkey", "created_by must reference a real user")
})
}
// 12. Deletion, three behaviours, each different and each deliberate.
t.Run("deleting the owner destroys their personal definitions", func(t *testing.T) {
carol := f.newUser(t, "carol@example.test")
mustInsert(t, f, f.personal(agents, "carols-agent", carol))
mustInsert(t, f, f.personal(skills, "carols-skill", carol))
if _, err := f.pool.Exec(f.ctx, `DELETE FROM users WHERE id = $1::uuid`, carol); err != nil {
t.Fatalf("delete the user: %v", err)
}
for _, table := range []string{agents, skills} {
var n int
if err := f.pool.QueryRow(f.ctx,
"SELECT count(*)::int FROM "+table+" WHERE definition_id LIKE 'carols-%'").Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Errorf("%s: %d personal definitions survive their deleted owner, want 0", table, n)
}
}
})
t.Run("deleting the author keeps the organization's definition", func(t *testing.T) {
dave := f.newUser(t, "dave@example.test")
id := mustInsert(t, f, f.organization(agents, "daves-shared-agent", dave))
if _, err := f.pool.Exec(f.ctx, `DELETE FROM users WHERE id = $1::uuid`, dave); err != nil {
t.Fatalf("delete the user: %v", err)
}
var author *string
if err := f.pool.QueryRow(f.ctx,
`SELECT created_by::text FROM agent_definitions WHERE id = $1::uuid`, id).Scan(&author); err != nil {
t.Fatalf("the shared definition did not survive its author: %v", err)
}
if author != nil {
t.Errorf("created_by = %v, want NULL after the author was deleted", *author)
}
})
t.Run("deleting the organization destroys both tiers", func(t *testing.T) {
g := newFixture(t, "defs_orgcascade")
mustInsert(t, g, g.personal(agents, "doomed-personal", g.alice))
mustInsert(t, g, g.organization(skills, "doomed-shared", g.alice))
if _, err := g.pool.Exec(g.ctx, `DELETE FROM organizations WHERE id = $1::uuid`, g.orgID); err != nil {
t.Fatalf("delete the organization: %v", err)
}
for _, table := range []string{agents, skills} {
var n int
if err := g.pool.QueryRow(g.ctx, "SELECT count(*)::int FROM "+table).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Errorf("%s: %d rows survive their deleted organization, want 0", table, n)
}
}
})
}
/* ── 13, 14. Uniqueness, per tier ───────────────────────────────────────── */
func TestUniquenessPerTier(t *testing.T) {
f := newFixture(t, "defs_unique")
for _, table := range []string{agents, skills} {
t.Run(table, func(t *testing.T) {
// 13. One personal definition per id per owner.
mustInsert(t, f, f.personal(table, "board", f.alice))
refused(t, f, f.personal(table, "board", f.alice), "personal_key",
"one user cannot hold two personal definitions of the same id")
// A different user may hold their own, which is the whole point of
// personal definitions.
mustInsert(t, f, f.personal(table, "board", f.bob))
// 14. One organization definition per id per organization.
mustInsert(t, f, f.organization(table, "board", f.alice))
refused(t, f, f.organization(table, "board", f.bob), "org_key",
"one organization cannot hold two shared definitions of the same id")
// Personal and organization definitions of the SAME id coexist:
// that is shadow-by-id, and it is the reason definition_id is not
// globally unique.
var personal, shared int
if err := f.pool.QueryRow(f.ctx,
"SELECT count(*) FILTER (WHERE visibility='personal'), "+
"count(*) FILTER (WHERE visibility='organization') "+
"FROM "+table+" WHERE definition_id = 'board'").Scan(&personal, &shared); err != nil {
t.Fatalf("count: %v", err)
}
if personal != 2 || shared != 1 {
t.Errorf("board: %d personal + %d shared, want 2 + 1", personal, shared)
}
})
}
// A second organization may hold its own definition of the same id.
t.Run("across organizations", func(t *testing.T) {
var otherOrg string
if err := f.pool.QueryRow(f.ctx,
`INSERT INTO organizations (name, slug) VALUES ('Other','other-defs') RETURNING id::text`).
Scan(&otherOrg); err != nil {
t.Fatalf("create the second organization: %v", err)
}
var erin string
if err := f.pool.QueryRow(f.ctx,
`INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid,'erin@example.test','Erin')
RETURNING id::text`, otherOrg).Scan(&erin); err != nil {
t.Fatalf("create a user in the second organization: %v", err)
}
r := f.organization(agents, "board", erin)
r.orgID = otherOrg
mustInsert(t, f, r)
})
}
/* ── Schema shape ───────────────────────────────────────────────────────── */
func TestDefinitionTablesShape(t *testing.T) {
f := newFixture(t, "defs_shape")
shared := map[string]string{
"id": "uuid",
"definition_id": "text",
"org_id": "uuid",
"visibility": "text",
"owner_user_id": "uuid",
"created_by": "text-or-uuid", // placeholder, replaced below
"markdown": "text",
"status": "text",
"name": "text",
"description": "text",
"pages": "ARRAY",
"created_date": "timestamp with time zone",
"updated_date": "timestamp with time zone",
}
shared["created_by"] = "uuid"
nullable := map[string]bool{"owner_user_id": true, "created_by": true}
for _, table := range []string{agents, skills} {
want := map[string]string{}
for k, v := range shared {
want[k] = v
}
if table == agents {
want["version"] = "integer"
}
t.Run(table, func(t *testing.T) {
rows, err := f.pool.Query(f.ctx,
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema='public' AND table_name=$1`, table)
if err != nil {
t.Fatalf("read columns: %v", err)
}
got := map[string]string{}
for rows.Next() {
var name, kind, isNullable string
if err := rows.Scan(&name, &kind, &isNullable); err != nil {
t.Fatalf("scan: %v", err)
}
got[name] = kind
if (isNullable == "YES") != nullable[name] {
t.Errorf("%s.%s is_nullable=%s, want nullable=%v", table, name, isNullable, nullable[name])
}
}
rows.Close()
if err := rows.Err(); err != nil {
t.Fatalf("read columns: %v", err)
}
for name, kind := range want {
if got[name] == "" {
t.Errorf("%s.%s is missing", table, name)
} else if got[name] != kind {
t.Errorf("%s.%s is %s, want %s", table, name, got[name], kind)
}
}
for name := range got {
if _, expected := want[name]; !expected {
t.Errorf("%s has an unexpected column %q", table, name)
}
}
})
}
}
func TestDefinitionIndexes(t *testing.T) {
f := newFixture(t, "defs_indexes")
want := map[string][]string{
agents: {
"agent_definitions_pkey",
"agent_definitions_personal_key",
"agent_definitions_org_key",
"agent_definitions_org_visibility_idx",
"agent_definitions_owner_idx",
"agent_definitions_published_idx",
},
skills: {
"skill_definitions_pkey",
"skill_definitions_personal_key",
"skill_definitions_org_key",
"skill_definitions_org_visibility_idx",
"skill_definitions_owner_idx",
"skill_definitions_active_idx",
},
}
for table, names := range want {
rows, err := f.pool.Query(f.ctx,
`SELECT indexname, indexdef FROM pg_indexes WHERE schemaname='public' AND tablename=$1`, table)
if err != nil {
t.Fatalf("list indexes: %v", err)
}
got := map[string]string{}
for rows.Next() {
var name, def string
if err := rows.Scan(&name, &def); err != nil {
t.Fatalf("scan: %v", err)
}
got[name] = def
}
rows.Close()
for _, name := range names {
if got[name] == "" {
t.Errorf("%s: index %s is missing", table, name)
}
}
// The two uniqueness indexes must be partial and unique, or they mean
// something other than what they are named.
for _, name := range []string{table + "_personal_key", table + "_org_key"} {
def := got[name]
if !strings.Contains(def, "UNIQUE") {
t.Errorf("%s is not UNIQUE: %s", name, def)
}
if !strings.Contains(def, "WHERE") {
t.Errorf("%s is not partial: %s", name, def)
}
}
}
// created_by is deliberately unindexed: attribution only, no listing is
// keyed by it, and its SET NULL scan happens only when a user is deleted.
for _, table := range []string{agents, skills} {
var n int
if err := f.pool.QueryRow(f.ctx,
`SELECT count(*)::int FROM pg_indexes
WHERE schemaname='public' AND tablename=$1 AND indexdef LIKE '%(created_by)%'`,
table).Scan(&n); err != nil {
t.Fatalf("look for a created_by index: %v", err)
}
if n != 0 {
t.Errorf("%s has an index on created_by; it was deliberately omitted", table)
}
}
}
/* ── Reversibility ──────────────────────────────────────────────────────── */
func TestMigration000005IsReversible(t *testing.T) {
ctx := context.Background()
pool := testutil.Sandbox(t, "defs_reversible")
testutil.ApplyAllMigrations(ctx, t, pool)
const up = "000005_agent_skill_definitions.up.sql"
const down = "000005_agent_skill_definitions.down.sql"
exists := func(name string) bool {
var reg *string
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1)::text`, name).Scan(&reg); err != nil {
t.Fatalf("to_regclass(%s): %v", name, err)
}
return reg != nil
}
for _, table := range []string{agents, skills} {
if !exists(table) {
t.Fatalf("%s does not exist before the rollback", table)
}
}
if err := testutil.ApplyMigration(ctx, t, pool, down); err != nil {
t.Fatalf("apply %s: %v", down, err)
}
for _, table := range []string{agents, skills} {
if exists(table) {
t.Errorf("%s survived the rollback", table)
}
}
// The rollback must reach nothing that predates it.
for _, table := range []string{"users", "organizations", "sessions", "user_preferences", "job_postings"} {
if !exists(table) {
t.Fatalf("the rollback dropped %s, which 000005 did not create", table)
}
}
// And no enum type was created, so none can be left behind.
var leftover int
if err := pool.QueryRow(ctx,
`SELECT count(*)::int FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname='public' AND t.typtype='e'
AND t.typname IN ('definition_visibility','agent_status','skill_status')`).Scan(&leftover); err != nil {
t.Fatalf("look for leftover types: %v", err)
}
if leftover != 0 {
t.Errorf("%d enum types left behind by the rollback", leftover)
}
// Re-applying restores exactly what was removed.
if err := testutil.ApplyMigration(ctx, t, pool, up); err != nil {
t.Fatalf("re-apply %s: %v", up, err)
}
for _, table := range []string{agents, skills} {
if !exists(table) {
t.Errorf("%s did not come back", table)
}
}
}
// Every migration has a matching down file, and the set is what we think it is.
//
// The list is written out rather than counted. A migration is the one kind of
// change that cannot be undone by editing a file, so adding one should require
// naming it here — a bare count would let a stray file slip in by incrementing
// a number, which is exactly the review nobody performs.
func TestMigrationPairsAreComplete(t *testing.T) {
ups := testutil.MigrationFiles(t, ".up.sql")
downs := testutil.MigrationFiles(t, ".down.sql")
if len(ups) != len(downs) {
t.Fatalf("%d up and %d down migrations", len(ups), len(downs))
}
for i, up := range ups {
want := strings.TrimSuffix(up, ".up.sql") + ".down.sql"
if downs[i] != want {
t.Errorf("%s has no matching down migration (found %s)", up, downs[i])
}
}
want := []string{
"000001_initial_schema.up.sql",
"000002_application_interview_id.up.sql",
"000003_drop_screened_consistent_check.up.sql",
"000004_auth_sessions.up.sql",
"000005_agent_skill_definitions.up.sql",
"000006_agent_runs.up.sql",
"000007_agent_confirmations.up.sql",
"000008_knowledge.up.sql",
"000009_confirmation_replay.up.sql",
"000010_definition_versions.up.sql",
}
if len(ups) != len(want) {
t.Fatalf("%d migrations, want %d — update this list deliberately", len(ups), len(want))
}
for i, name := range want {
if ups[i] != name {
t.Errorf("migration %d is %s, want %s", i+1, ups[i], name)
}
}
}
// The tables that exist, counted, plus the ones that deliberately do not.
//
// The Phase 4B decision was explicit about which tables must NOT appear, and
// that half of this test is the durable half — the forbidden list below is a
// design decision, not a snapshot. The count is the snapshot, and it is here so
// that a table arriving without a decision behind it fails somewhere.
func TestMigrationsAddOnlyTheTablesWeDecidedOn(t *testing.T) {
f := newFixture(t, "defs_tablecount")
var n int
if err := f.pool.QueryRow(f.ctx,
`SELECT count(*)::int FROM information_schema.tables
WHERE table_schema='public' AND table_type='BASE TABLE'`).Scan(&n); err != nil {
t.Fatalf("count tables: %v", err)
}
// 17 from 000001, + auth_sessions (000004), + agent_definitions and
// skill_definitions (000005), + agent_runs (000006), + agent_confirmations
// (000007), + knowledge_documents and knowledge_chunks (000008),
// + definition_versions (000010). schema_migrations is golang-migrate's and
// is absent when the files are applied directly.
if n != 25 {
t.Errorf("%d base tables after every migration, want 25", n)
}
// `definition_versions` was on this list, deferred by the Phase 4B decision.
// It is built now — §3's "specs are immutable once published" needs it, and
// a run recording an agent_version that resolves to nothing is a record
// nobody can explain. Removed from the list deliberately rather than
// silently, which is the whole reason the list is written out.
for _, forbidden := range []string{
"definition_permissions", "agent_skills",
"agent_subagents", "agent_knowledge", "conversations",
"conversation_messages", "conversation_feedback",
} {
var reg *string
if err := f.pool.QueryRow(f.ctx,
`SELECT to_regclass('public.' || $1)::text`, forbidden).Scan(&reg); err != nil {
t.Fatalf("to_regclass: %v", err)
}
if reg != nil {
t.Errorf("table %s exists; Phase 4B deferred or rejected it", forbidden)
}
}
}