Files
Agent-System/internal/db/migrate_test.go
T
agent-bot ee1e53b510
CI / go-vet (pull_request) Failing after 10s
CI / backup-restore-drill (pull_request) Failing after 25s
CI / web (pull_request) Successful in 33s
fix(tests): per-package test databases + legitimate approval fixtures (issue #2 close)
Root causes of the remaining 5 CI PG failures (and the hidden flaky set):
1. All PG-integration packages shared ONE database under parallel package
   execution - migrations/seeds/truncates raced (nondeterministic failure
   sets, locally and in CI).
   Fix: RequireTestDatabaseURL/SkipIfNoTestDatabaseURL now derive a
   per-package database (<base>_<pkgHash>, advisory-lock-guarded
   CREATE); 9 raw-env straggler tests funnelled through the helpers;
   db-package tests call it unqualified (no import cycle).
2. Approval-chain fixtures were not production-shaped: freezeReviewSnapshot
   only honors head_snapshot_id, fake content hashes never froze a
   snapshot, so ApproverApprove rejected with 'approved requires frozen
   review_snapshot_id'. Errors were swallowed (_, _ =) hiding this.
   Fix (ownership-respecting, arch-enforced):
   - docrevision.SeedContentSnapshotForTest seeds the ledger tables it owns
   - docgov GovernanceEngine.FreezeHeadSnapshotForTest freezes head/review
     pointers on document_records (docgov-owned) and clears engine cache
   - all four integration fixtures use the pair; swallowed errors checked
3. Fixed-name fixtures collide once a package shares one DB (projects
   uq_projects_name_global): uniquified in projectinstance + server tests.
4. CAS/ctxmgr tests never bootstrapped migrations (relied on other packages
   having migrated the shared DB): explicit RunMigrationsOnceForTest.

Verified: full parallel short suite = 75 packages, 0 failures against an
isolated throwaway PG. CI skip list removed - short tests fully required.
2026-09-29 09:54:43 +00:00

293 lines
8.2 KiB
Go

package db
import (
"context"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/pressly/goose/v3"
)
func TestMigrationGate_ExpectedFilesMatchAppliedVersion(t *testing.T) {
migDir := ResolveMigrationsDir()
expected, err := ExpectedMigrationCount(migDir)
if err != nil {
t.Fatalf("count migrations: %v", err)
}
if expected < 1 {
t.Fatal("expected at least one migration file")
}
dsn := SkipIfNoTestDatabaseURL(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("postgres unavailable: %v", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
t.Skipf("postgres ping failed: %v", err)
}
if err := RunMigrations(ctx, pool, migDir); err != nil {
t.Fatalf("RunMigrations: %v", err)
}
version, err := CurrentVersion(ctx, pool)
if err != nil {
t.Fatalf("CurrentVersion: %v", err)
}
if int(version) < expected {
t.Fatalf("applied schema version %d behind migration files %d", version, expected)
}
}
func TestMigration032_AllowsMigrationRepairKinds(t *testing.T) {
files, err := ListMigrationFiles(ResolveMigrationsDir())
if err != nil {
t.Fatal(err)
}
var found string
for _, name := range files {
if strings.HasPrefix(name, "00032_") && strings.HasSuffix(name, ".sql") {
found = name
break
}
}
if found == "" {
t.Fatal("expected goose 00032 for migration_repair check constraints")
}
raw, err := os.ReadFile(filepath.Join(ResolveMigrationsDir(), found))
if err != nil {
t.Fatal(err)
}
text := string(raw)
if !strings.Contains(text, "-- +goose Up") || !strings.Contains(text, "-- +goose Down") {
t.Fatal("00032 must have Up and Down")
}
if !strings.Contains(text, "chk_snapshot_source_kind") || !strings.Contains(text, "chk_change_kind") {
t.Fatal("00032 must replace source/change kind checks")
}
if strings.Count(text, "'migration_repair'") < 2 {
t.Fatalf("00032 must allow migration_repair on both constraints, got %s", found)
}
}
func TestGooseU9_00038_HasUpAndDown(t *testing.T) {
files, err := ListMigrationFiles(ResolveMigrationsDir())
if err != nil {
t.Fatal(err)
}
var found string
for _, name := range files {
if strings.HasPrefix(name, "00038_") && strings.HasSuffix(name, ".sql") {
found = name
break
}
}
if found == "" {
t.Fatal("expected goose 00038 for dataset lifecycle")
}
raw, err := os.ReadFile(filepath.Join(ResolveMigrationsDir(), found))
if err != nil {
t.Fatal(err)
}
text := string(raw)
if !strings.Contains(text, "-- +goose Up") || !strings.Contains(text, "-- +goose Down") {
t.Fatal("00038 must have Up and Down")
}
for _, table := range []string{"dataset_registry", "dataset_backfill_jobs", "dataset_backfill_checkpoints", "dataset_backfill_queue", "dataset_destroy_requests"} {
if !strings.Contains(text, table) {
t.Fatalf("00038 must mention %s", table)
}
}
}
func TestGooseU3_00044_HasUpAndDown(t *testing.T) {
files, err := ListMigrationFiles(ResolveMigrationsDir())
if err != nil {
t.Fatal(err)
}
var found string
for _, name := range files {
if strings.HasPrefix(name, "00044_") && strings.HasSuffix(name, ".sql") {
found = name
break
}
}
if found == "" {
t.Fatal("expected goose 00044 for intent plans")
}
raw, err := os.ReadFile(filepath.Join(ResolveMigrationsDir(), found))
if err != nil {
t.Fatal(err)
}
text := string(raw)
if !strings.Contains(text, "-- +goose Up") || !strings.Contains(text, "-- +goose Down") {
t.Fatal("00044 must have Up and Down")
}
for _, table := range []string{"intent_plans", "intent_plan_steps", "intent_plan_decisions"} {
if !strings.Contains(text, table) {
t.Fatalf("00044 must mention %s", table)
}
}
for _, idx := range []string{"uq_intent_plans_turn", "uq_intent_plans_one_active_write", "uq_intent_plan_decisions_nonce"} {
if !strings.Contains(text, idx) {
t.Fatalf("00044 must mention %s", idx)
}
}
}
func TestGooseU4_00045_HasUpAndDown(t *testing.T) {
files, err := ListMigrationFiles(ResolveMigrationsDir())
if err != nil {
t.Fatal(err)
}
var found string
for _, name := range files {
if strings.HasPrefix(name, "00045_") && strings.HasSuffix(name, ".sql") {
found = name
break
}
}
if found == "" {
t.Fatal("expected goose 00045 for intent plan projection")
}
raw, err := os.ReadFile(filepath.Join(ResolveMigrationsDir(), found))
if err != nil {
t.Fatal(err)
}
text := string(raw)
if !strings.Contains(text, "-- +goose Up") || !strings.Contains(text, "-- +goose Down") {
t.Fatal("00045 must have Up and Down")
}
for _, table := range []string{"intent_plan_projections", "intent_plan_shadow"} {
if !strings.Contains(text, table) {
t.Fatalf("00045 must mention %s", table)
}
}
}
func TestGooseM2_00034to00036_DownThenUp(t *testing.T) {
if testing.Short() {
t.Skip("goose down is not part of -short")
}
dsn := SkipIfNoTestDatabaseURL(t)
if strings.Contains(dsn, "5433") || strings.Contains(dsn, "agent_system_dev") ||
(strings.Contains(dsn, "5434") && strings.Contains(dsn, "agent_system_test")) ||
strings.Contains(dsn, "5435") {
t.Fatal("TEST_DATABASE_URL points at a shared env port; refuse agent_system_dev/test/main")
}
migDir := ResolveMigrationsDir()
files, err := ListMigrationFiles(migDir)
if err != nil {
t.Fatal(err)
}
want := []string{
"00034_2026-09-14-workspace-lease-fencing.sql",
"00035_2026-09-14-durable-event-delivery.sql",
"00036_2026-09-14-final-artifact-vault.sql",
"00037_2026-09-15-task-run-manifests.sql",
"00038_2026-09-15-dataset-lifecycle.sql",
"00044_2026-09-15-intent-plans.sql",
"00045_2026-09-16-intent-plan-projection.sql",
}
joined := strings.Join(files, "\n")
for _, name := range want {
if !strings.Contains(joined, name) {
t.Fatalf("missing %s", name)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(admin.Close)
const isolated = "agent_rel_m2_goose"
_, _ = admin.Exec(ctx, `DROP DATABASE IF EXISTS `+isolated)
if _, err := admin.Exec(ctx, `CREATE DATABASE `+isolated); err != nil {
t.Fatalf("create isolated db: %v", err)
}
t.Cleanup(func() {
_, _ = admin.Exec(context.Background(), `DROP DATABASE IF EXISTS `+isolated)
})
m2dsn := rewriteDatabaseName(dsn, isolated)
pool, err := pgxpool.New(ctx, m2dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if err := RunMigrations(ctx, pool, migDir); err != nil {
t.Fatalf("up: %v", err)
}
ver, err := CurrentVersion(ctx, pool)
if err != nil || ver < 38 {
t.Fatalf("version after up: %d %v", ver, err)
}
sqlDB := OpenSQLDB(pool)
defer sqlDB.Close()
if err := goose.SetDialect("postgres"); err != nil {
t.Fatal(err)
}
goose.SetTableName(schemaTable)
if err := goose.DownToContext(ctx, sqlDB, migDir, 33); err != nil {
t.Fatalf("down to 33: %v", err)
}
for _, table := range []string{"event_envelopes", "event_deliveries", "event_dead_letters", "final_artifacts", "task_run_manifests", "dataset_registry"} {
var n int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_name=$1`, table).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("%s still present after down", table)
}
}
var col int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name='document_records' AND column_name='lease_fencing_token'
`).Scan(&col); err != nil {
t.Fatal(err)
}
if col != 0 {
t.Fatal("lease_fencing_token must drop with 00034 Down")
}
if err := RunMigrations(ctx, pool, migDir); err != nil {
t.Fatalf("up after down: %v", err)
}
}
func rewriteDatabaseName(dsn, name string) string {
if u, err := url.Parse(dsn); err == nil && u.Scheme != "" {
u.Path = "/" + name
return u.String()
}
return dsn
}
func TestRunMigrationsEmbeddedFallback(t *testing.T) {
dsn := SkipIfNoTestDatabaseURL(t)
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
// 不存在的磁盘目录必须触发内嵌回退并成功走完迁移链(幂等)。
if err := RunMigrations(ctx, pool, filepath.Join(t.TempDir(), "no-such-dir")); err != nil {
t.Fatalf("embedded fallback migrations: %v", err)
}
}