Files
encapi/migrations/migrations_test.go
T
unkin-agent 42307f285f
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Adopt golib/pg for migrations and pool construction
encapi's schema was a cumulative IF NOT EXISTS blob re-executed inline on every
start: it grows forever, records nothing, and cannot express a change that is
not a fresh CREATE. golib owns that mechanism now, so encapi keeps the SQL and
drops the runner.

- Move the DDL verbatim into migrations/0001_init.sql, embedded via
  migrations.FS. It stays IF NOT EXISTS-guarded, so the first start against the
  live database re-runs it as a no-op and only lands the schema_migrations row.
- Build the pool with pg.NewMigrated under the advisory lock named
  encapi-migrations, and delete the inline migrate(). database.New now takes a
  context and a logger; main.go hands it the signal context so a start blocked
  on the migration lock still dies on SIGTERM.
- Render the DSN with pg.DSN. The env var contract is untouched — the fields are
  still resolved by internal/config, because encapi defaults DBUSER and DBNAME
  to "encapi" where pg.DSNFromEnv treats both as required.
- Guard the set: embedded files must match migrations/, every CREATE must be
  idempotent, the derived lock key is pinned, and a container test proves the
  adoption path over a database that predates schema_migrations.
- Plumb GOPRIVATE=git.unkin.net for the first cross-repo Go dependency:
  exported by the Makefile, set in the Dockerfile and the woodpecker Go steps,
  documented in the README.
2026-09-02 00:17:04 +10:00

100 lines
2.7 KiB
Go

package migrations
import (
"io/fs"
"os"
"strings"
"testing"
"git.unkin.net/unkin/golib/pg"
)
// readDir returns the .sql files on disk, keyed by name.
func readDir(t *testing.T) map[string]string {
t.Helper()
entries, err := os.ReadDir(".")
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(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 .sql files in migrations/")
}
return out
}
// The embedded set is the schema the binary ships, so it must match the
// directory exactly — a file on disk but outside the embed pattern would never
// run in production while still passing a local review.
func TestEmbeddedMatchesDirectory(t *testing.T) {
onDisk := readDir(t)
entries, err := fs.ReadDir(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 := 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, want := range onDisk {
got, ok := embedded[name]
if !ok {
t.Errorf("migrations/%s is not embedded", name)
continue
}
if got != want {
t.Errorf("%s: embedded body differs from migrations/%s", name, name)
}
}
}
// Migrations are re-run against a database whose schema predates
// schema_migrations, so every CREATE must tolerate the objects already
// existing.
func TestMigrationsAreIdempotent(t *testing.T) {
for name, body := range readDir(t) {
for _, line := range strings.Split(body, "\n") {
upper := strings.ToUpper(strings.TrimSpace(line))
if !strings.HasPrefix(upper, "CREATE ") {
continue
}
if !strings.Contains(upper, "IF NOT EXISTS") && !strings.Contains(upper, "CREATE OR REPLACE") {
t.Errorf("%s: %q is not guarded with IF NOT EXISTS", name, strings.TrimSpace(line))
}
}
}
}
// golib derives the advisory lock key from LockName. Replicas only serialize
// against each other while they agree on the key, so renaming the lock during a
// rolling deploy would let two versions migrate at once.
func TestLockKeyIsStable(t *testing.T) {
// FNV-1a/64 of "encapi-migrations", reinterpreted as int64.
const deployedKey int64 = -7124093699699113719
if got := pg.LockKey(LockName); got != deployedKey {
t.Fatalf("LockKey(%q) = %d, want %d", LockName, got, deployedKey)
}
}