first commit
This commit is contained in:
309
go-api/internal/testutil/db.go
Normal file
309
go-api/internal/testutil/db.go
Normal file
@@ -0,0 +1,309 @@
|
||||
// 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))
|
||||
}
|
||||
Reference in New Issue
Block a user