310 lines
9.8 KiB
Go
310 lines
9.8 KiB
Go
// 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))
|
|
}
|