// Package testutil builds a disposable, fully migrated and seeded database for // tests. // // The test database is created from scratch on every run and is named // distinctly from any real one. Nothing here ever connects to, reads or drops // the development database. package testutil import ( "context" "fmt" "os" "path/filepath" "sort" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/krow/krow-backend/go-api/internal/seeder" ) // testDBPrefix names the throwaway databases. The prefix is deliberate: a name // this specific cannot be mistaken for, or collide with, "Krow-force". // // The pid is appended because `go test ./...` runs each package in its own // process, concurrently — a single shared name means one package drops the // database another is still using. const testDBPrefix = "krow_backend_autotest" // TestDBName is this process's throwaway database. var TestDBName = fmt.Sprintf("%s_%d", testDBPrefix, os.Getpid()) // Harness is a ready database plus what was seeded into it. type Harness struct { Pool *pgxpool.Pool OrgID string Seeded *seeder.Result Now time.Time } func env(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback } func dsn(database string) string { return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", env("DATABASE_USER", "postgres"), env("DATABASE_PASSWORD", ""), env("DATABASE_HOST", "127.0.0.1"), env("DATABASE_PORT", "5432"), database) } // repoRoot walks up from the test's working directory to the repository root, // found by the migrations directory sitting beside go-api. func repoRoot(t *testing.T) string { t.Helper() dir, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } for i := 0; i < 6; i++ { if _, err := os.Stat(filepath.Join(dir, "migrations")); err == nil { return dir } dir = filepath.Dir(dir) } t.Fatalf("could not locate the repository root from the test working directory") return "" } // New builds a migrated, seeded database, or skips the test when PostgreSQL is // not reachable — so `go test ./...` still runs on a machine without a server. func New(t *testing.T) *Harness { t.Helper() ctx := context.Background() admin, err := pgxpool.New(ctx, dsn("postgres")) if err != nil { t.Skipf("PostgreSQL unavailable, skipping database tests: %v", err) } if err := admin.Ping(ctx); err != nil { admin.Close() t.Skipf("PostgreSQL unavailable, skipping database tests: %v", err) } // Terminate stragglers so DROP cannot block on a leaked connection. _, _ = admin.Exec(ctx, `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, TestDBName) if _, err := admin.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(TestDBName)); err != nil { admin.Close() t.Fatalf("drop test database: %v", err) } if _, err := admin.Exec(ctx, `CREATE DATABASE `+quoteIdent(TestDBName)); err != nil { admin.Close() t.Fatalf("create test database: %v", err) } admin.Close() pool, err := pgxpool.New(ctx, dsn(TestDBName)) if err != nil { t.Fatalf("connect to test database: %v", err) } root := repoRoot(t) applyMigrations(t, ctx, pool, filepath.Join(root, "migrations")) fixture, err := seeder.Load(filepath.Join(root, "seed", "fixtures", "seed.json")) if err != nil { t.Fatalf("load fixture: %v", err) } now := time.Now() result, err := seeder.New(pool, fixture, now).Run(ctx) if err != nil { t.Fatalf("seed: %v", err) } t.Cleanup(func() { pool.Close() dropTestDatabase() }) return &Harness{Pool: pool, OrgID: result.OrgID, Seeded: result, Now: now} } // dropTestDatabase removes this process's throwaway database. Best effort: a // leftover is harmless because the next run drops it before creating it. func dropTestDatabase() { ctx := context.Background() admin, err := pgxpool.New(ctx, dsn("postgres")) if err != nil { return } defer admin.Close() _, _ = admin.Exec(ctx, `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, TestDBName) _, _ = admin.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(TestDBName)) } // applyMigrations runs every *.up.sql in filename order. This is the same SQL // golang-migrate applies; running it directly keeps the tests independent of // the CLI being installed. func applyMigrations(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) { t.Helper() entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("read migrations: %v", err) } var files []string for _, e := range entries { if strings.HasSuffix(e.Name(), ".up.sql") { files = append(files, e.Name()) } } sort.Strings(files) if len(files) == 0 { t.Fatal("no migrations found") } for _, name := range files { sqlBytes, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { t.Fatalf("read %s: %v", name, err) } if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil { t.Fatalf("apply %s: %v", name, err) } } } // quoteIdent renders an identifier safely. The only value passed here is the // package constant above, but building DDL by concatenation without quoting is // a habit worth not having. func quoteIdent(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } // Fixture reloads the raw fixture so tests can assert the database against the // frontend's own data rather than against numbers typed into a test. func Fixture(t *testing.T) *seeder.Fixture { t.Helper() f, err := seeder.Load(filepath.Join(repoRoot(t), "seed", "fixtures", "seed.json")) if err != nil { t.Fatalf("load fixture: %v", err) } return f } /* ── Migration sandboxes ──────────────────────────────────────────────────── * * The helpers below exist for tests that drive the migration FILES themselves * — applying them, rolling them back, re-applying them — rather than using the * migrated database New() hands out. * * They need a database of their own for two reasons. New()'s database is * dropped by its own t.Cleanup, so sharing it across a test that rolls the * schema back would leave the next test's fixtures on the floor; and a down * migration must run against a database whose contents the test controls, * because 000003's down migration deliberately fails on seeded data. * * Like New(), nothing here can reach a real database: every name is built from * testDBPrefix, which cannot be confused with "Krow-force". */ // Sandbox creates an empty throwaway database and returns a pool on it. // // Nothing is migrated and nothing is seeded — that is the point. The label // distinguishes concurrent sandboxes within one package; the pid keeps // packages, which `go test ./...` runs in parallel processes, from colliding. func Sandbox(t *testing.T, label string) *pgxpool.Pool { t.Helper() ctx := context.Background() name := fmt.Sprintf("%s_%s_%d", testDBPrefix, label, os.Getpid()) admin, err := pgxpool.New(ctx, dsn("postgres")) if err != nil { t.Skipf("PostgreSQL unavailable, skipping database tests: %v", err) } if err := admin.Ping(ctx); err != nil { admin.Close() t.Skipf("PostgreSQL unavailable, skipping database tests: %v", err) } dropDatabase(ctx, admin, name) if _, err := admin.Exec(ctx, `CREATE DATABASE `+quoteIdent(name)); err != nil { admin.Close() t.Fatalf("create sandbox database %s: %v", name, err) } admin.Close() pool, err := pgxpool.New(ctx, dsn(name)) if err != nil { t.Fatalf("connect to sandbox database: %v", err) } t.Cleanup(func() { pool.Close() cleanup, err := pgxpool.New(context.Background(), dsn("postgres")) if err != nil { return } defer cleanup.Close() dropDatabase(context.Background(), cleanup, name) }) return pool } // dropDatabase terminates stragglers, then drops. Best effort on the drop // itself: a leftover is harmless because the next run drops it before creating. func dropDatabase(ctx context.Context, admin *pgxpool.Pool, name string) { _, _ = admin.Exec(ctx, `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, name) _, _ = admin.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(name)) } // RepoRoot is the repository root, located from the test's working directory. func RepoRoot(t *testing.T) string { t.Helper() return repoRoot(t) } // MigrationsDir is the directory holding the migration files. func MigrationsDir(t *testing.T) string { t.Helper() return filepath.Join(repoRoot(t), "migrations") } // MigrationFiles lists the migration files with the given suffix — ".up.sql" // or ".down.sql" — in filename order. Callers wanting to roll back should // reverse the result. func MigrationFiles(t *testing.T, suffix string) []string { t.Helper() entries, err := os.ReadDir(MigrationsDir(t)) if err != nil { t.Fatalf("read migrations: %v", err) } var files []string for _, e := range entries { if strings.HasSuffix(e.Name(), suffix) { files = append(files, e.Name()) } } sort.Strings(files) if len(files) == 0 { t.Fatalf("no %s migrations found", suffix) } return files } // ApplyMigration runs one migration file and returns its error rather than // failing the test, so a test can assert that a rollback succeeds — or, for // 000003's down migration, that it does not. func ApplyMigration(ctx context.Context, t *testing.T, pool *pgxpool.Pool, name string) error { t.Helper() sqlBytes, err := os.ReadFile(filepath.Join(MigrationsDir(t), name)) if err != nil { t.Fatalf("read %s: %v", name, err) } _, err = pool.Exec(ctx, string(sqlBytes)) return err } // ApplyAllMigrations applies every *.up.sql in order, failing the test on the // first that does not apply. This is the same SQL golang-migrate would run. func ApplyAllMigrations(ctx context.Context, t *testing.T, pool *pgxpool.Pool) { t.Helper() applyMigrations(t, ctx, pool, MigrationsDir(t)) }