package database import ( "io/fs" "os" "path/filepath" "regexp" "strings" "testing" "git.unkin.net/unkin/golib/pg" "git.unkin.net/unkin/forgebot/migrations" "git.unkin.net/unkin/forgebot/pkg/models" ) // migrationsDir is the repo's migrations/ directory, relative to this package. const migrationsDir = "../../migrations" func readMigrations(t *testing.T) map[string]string { t.Helper() entries, err := os.ReadDir(migrationsDir) if err != nil { t.Fatalf("read migrations dir: %v", err) } out := map[string]string{} for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { continue } b, err := os.ReadFile(filepath.Join(migrationsDir, e.Name())) if err != nil { t.Fatalf("read %s: %v", e.Name(), err) } out[e.Name()] = string(b) } if len(out) == 0 { t.Fatal("no migrations found") } return out } // The embedded set is the shipped schema, so it must match the migrations/ // directory exactly — a file added on disk but not embedded would never run. func TestEmbeddedMigrationsMatchDirectory(t *testing.T) { onDisk := readMigrations(t) entries, err := fs.ReadDir(migrations.FS, ".") if err != nil { t.Fatalf("read embedded migrations: %v", err) } embedded := map[string]string{} for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { continue } b, err := migrations.FS.ReadFile(e.Name()) if err != nil { t.Fatalf("read embedded %s: %v", e.Name(), err) } embedded[e.Name()] = string(b) } if len(embedded) != len(onDisk) { t.Fatalf("embedded %d files, migrations/ has %d", len(embedded), len(onDisk)) } for name, body := range embedded { want, ok := onDisk[name] if !ok { t.Errorf("%s is embedded but not in migrations/", name) continue } if body != want { t.Errorf("%s: embedded body differs from migrations/%s", name, name) } } } // The advisory lock key is derived from migrationLockName by golib. Every // forgebot-api replica must contend on the same key, so renaming the lock would // silently let two versions migrate at once during a rolling deploy. func TestMigrationLockKeyIsStable(t *testing.T) { const deployedKey int64 = -570492662391362667 if got := pg.LockKey(migrationLockName); got != deployedKey { t.Fatalf("LockKey(%q) = %d, want %d", migrationLockName, got, deployedKey) } } // Migrations run against a live database with existing rows, and a file absent // from schema_migrations is re-run even where the schema is already present, so // every statement must be guarded and re-runnable. func TestMigrations_AreAdditiveAndIdempotent(t *testing.T) { for name, body := range readMigrations(t) { upper := strings.ToUpper(body) for _, forbidden := range []string{"DROP TABLE", "DROP COLUMN", "ALTER COLUMN", "TRUNCATE", "DELETE FROM"} { if strings.Contains(upper, forbidden) { t.Errorf("%s contains destructive statement %q", name, forbidden) } } for _, stmt := range strings.Split(upper, ";") { stmt = strings.TrimSpace(stmt) switch { case strings.HasPrefix(stmt, "CREATE TABLE"), strings.HasPrefix(stmt, "CREATE INDEX"): if !strings.Contains(stmt, "IF NOT EXISTS") { t.Errorf("%s: %q is not guarded with IF NOT EXISTS", name, firstLine(stmt)) } case strings.HasPrefix(stmt, "ALTER TABLE"): if !strings.Contains(stmt, "ADD COLUMN IF NOT EXISTS") { t.Errorf("%s: %q is not an idempotent ADD COLUMN IF NOT EXISTS", name, firstLine(stmt)) } } } } } // The legacy-status rewrites are data fixups carried over from the pre-golib // runner, which re-ran them on every boot. Under versioned migrations 0001 is // replayed once against the live database, so each rewrite must read only // retired statuses and write only current ones: a rewrite whose target is also // one of its sources would cascade rows on that replay. func TestMigration0001_StatusRewritesAreIdempotent(t *testing.T) { body, ok := readMigrations(t)["0001_init.sql"] if !ok { t.Fatal("0001_init.sql missing") } current := map[string]bool{} for _, s := range []models.TaskStatus{ models.StatusTodo, models.StatusInProgress, models.StatusInReview, models.StatusDone, models.StatusWontdo, } { current[string(s)] = true } rewrite := regexp.MustCompile(`(?i)UPDATE tasks SET status = '(\w+)' WHERE status (?:=|IN) \(?([^)\n;]+)\)?`) matches := rewrite.FindAllStringSubmatch(body, -1) if len(matches) == 0 { t.Fatal("no status rewrites found in 0001_init.sql") } for _, m := range matches { target := m[1] if !current[target] { t.Errorf("rewrite targets %q, which is not a current status", target) } for _, raw := range strings.Split(m[2], ",") { source := strings.Trim(strings.TrimSpace(raw), "'") if current[source] { t.Errorf("rewrite reads current status %q as a legacy source, so a replay would cascade", source) } } } } // Every column 0001 creates must be read back by the queries in tasks.go, so a // schema change can never silently stop being scanned. func TestMigrations_ColumnsAreSelected(t *testing.T) { body := readMigrations(t)["0001_init.sql"] create := regexp.MustCompile(`(?s)CREATE TABLE IF NOT EXISTS tasks \((.*?)\n\);`) m := create.FindStringSubmatch(body) if m == nil { t.Fatal("could not parse the tasks CREATE TABLE in 0001_init.sql") } cols := map[string]bool{} for _, line := range strings.Split(m[1], "\n") { if f := strings.Fields(strings.TrimSpace(line)); len(f) > 0 { cols[f[0]] = true } } src, err := os.ReadFile("tasks.go") if err != nil { t.Fatalf("read tasks.go: %v", err) } // The task queries all select the full column list; find it and check it // covers the table. selected := map[string]bool{} for _, sel := range regexp.MustCompile(`(?s)SELECT (id, parent_task_id.*?)\n\s*FROM tasks`).FindAllStringSubmatch(string(src), -1) { for _, c := range strings.Split(sel[1], ",") { selected[strings.TrimSpace(c)] = true } } if len(selected) == 0 { t.Fatal("no task SELECT found in tasks.go") } for c := range cols { if !selected[c] { t.Errorf("column %q is never read back by the task queries", c) } } } func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s }