package main import ( "context" "crypto/rand" "database/sql" "encoding/hex" "net/url" "os" "path/filepath" "strings" "testing" "git.sr.ht/~bitfehler/brant/cli" "github.com/alexflint/go-arg" "github.com/vaughan0/go-ini" ) // testParse parses argv the way main() does, except that the environment is // ignored: BRANT_DSN / BRANT_MIGRATION_DIR / BRANT_DIALECT in a developer's // shell would otherwise silently change the defaults under test. func testParse(t *testing.T, argv ...string) (*arg.Parser, *Args) { t.Helper() p, a := newParser(arg.Config{IgnoreEnv: true, Out: os.Stderr, Exit: func(int) {}}) if err := p.Parse(argv); err != nil { t.Fatalf("parse %v: %v", argv, err) } return p, a } func TestParseSubcommands(t *testing.T) { t.Run("up", func(t *testing.T) { p, a := testParse(t, "up") if p.Subcommand() == nil { t.Fatal("up: no subcommand selected") } if a.Up == nil { t.Fatal("up: Up subcommand not set") } if a.Init != nil { t.Error("up: Init unexpectedly set") } if a.Directory != defaultDirectory { t.Errorf("up: Directory = %q, want %q", a.Directory, defaultDirectory) } if a.DataSourceName != "" { t.Errorf("up: DataSourceName = %q, want empty", a.DataSourceName) } if a.Auto { t.Error("up: Auto set without -a") } }) t.Run("current", func(t *testing.T) { _, a := testParse(t, "current") if a.Current == nil { t.Fatal("current: Current subcommand not set") } }) t.Run("init", func(t *testing.T) { p, a := testParse(t, "init") if p.Subcommand() == nil { t.Fatal("init: no subcommand selected") } if a.Init == nil { t.Fatal("init: Init subcommand not set") } if a.Init.Schema != defaultSchema { t.Errorf("init: Schema = %q, want %q", a.Init.Schema, defaultSchema) } }) t.Run("init --schema", func(t *testing.T) { _, a := testParse(t, "init", "--schema", "/opt/spec.sr.ht.sql") if a.Init == nil { t.Fatal("init --schema: Init subcommand not set") } if a.Init.Schema != "/opt/spec.sr.ht.sql" { t.Errorf("init --schema: Schema = %q", a.Init.Schema) } }) t.Run("-a up", func(t *testing.T) { _, a := testParse(t, "-a", "up") if !a.Auto { t.Error("-a up: Auto not set") } if a.Up == nil { t.Error("-a up: Up subcommand not set") } }) t.Run("--dsn and --dir", func(t *testing.T) { _, a := testParse(t, "--dsn", "postgres://u@h/spec.sr.ht", "--dir", "/srv/m", "up") if a.DataSourceName != "postgres://u@h/spec.sr.ht" { t.Errorf("DataSourceName = %q", a.DataSourceName) } if a.Directory != "/srv/m" { t.Errorf("Directory = %q", a.Directory) } }) // The brant subcommands we do not advertise in the README must still parse: // this binary is a wrapper, not a reduced surface. for _, name := range []string{"down", "list", "validate", "ping"} { t.Run(name, func(t *testing.T) { p, _ := testParse(t, name) if p.Subcommand() == nil { t.Fatalf("%s: no subcommand selected", name) } }) } } func TestParseNoSubcommandIsUsageError(t *testing.T) { p, _ := testParse(t) if p.Subcommand() != nil { t.Fatal("bare invocation selected a subcommand") } } func TestResolveDSN(t *testing.T) { confWith := func(dsn string) ini.File { f := ini.File{serviceName: ini.Section{}} if dsn != "" { f[serviceName]["connection-string"] = dsn } return f } t.Run("flag wins over config", func(t *testing.T) { got, err := resolveDSN(confWith("postgres://from-config/x"), "postgres://from-flag/x") if err != nil { t.Fatalf("resolveDSN: %v", err) } if got != "postgres://from-flag/x" { t.Errorf("got %q, want the --dsn value", got) } }) t.Run("config used when flag empty", func(t *testing.T) { got, err := resolveDSN(confWith("postgres://from-config/x"), "") if err != nil { t.Fatalf("resolveDSN: %v", err) } if got != "postgres://from-config/x" { t.Errorf("got %q, want the configured value", got) } }) t.Run("missing key is an error", func(t *testing.T) { if _, err := resolveDSN(ini.File{}, ""); err == nil { t.Fatal("expected an error with no config and no --dsn") } }) t.Run("empty configured value is an error", func(t *testing.T) { if _, err := resolveDSN(confWith(""), ""); err == nil { t.Fatal("expected an error for an empty connection-string") } }) } func TestResolvePaths(t *testing.T) { t.Run("checkout ./migrations wins", func(t *testing.T) { dir := t.TempDir() if err := os.Mkdir(filepath.Join(dir, "migrations"), 0o755); err != nil { t.Fatal(err) } t.Chdir(dir) a := &Args{Init: &InitArgs{Schema: defaultSchema}} a.Directory = defaultDirectory if err := resolvePaths(ini.File{}, a); err != nil { t.Fatalf("resolvePaths: %v", err) } if a.Directory != defaultDirectory { t.Errorf("Directory = %q, want the checkout dir %q", a.Directory, defaultDirectory) } if a.Init.Schema != defaultSchema { t.Errorf("Schema = %q, want the checkout file %q", a.Init.Schema, defaultSchema) } }) t.Run("installed assets path when no ./migrations", func(t *testing.T) { t.Chdir(t.TempDir()) a := &Args{Init: &InitArgs{Schema: defaultSchema}} a.Directory = defaultDirectory if err := resolvePaths(ini.File{}, a); err != nil { t.Fatalf("resolvePaths: %v", err) } wantDir := filepath.Join(defaultAssets, "migrations", serviceName) if a.Directory != wantDir { t.Errorf("Directory = %q, want %q", a.Directory, wantDir) } wantSchema := filepath.Join(defaultAssets, serviceName+".sql") if a.Init.Schema != wantSchema { t.Errorf("Schema = %q, want %q", a.Init.Schema, wantSchema) } }) t.Run("[sr.ht]assets overrides the installed path", func(t *testing.T) { t.Chdir(t.TempDir()) conf := ini.File{"sr.ht": ini.Section{"assets": "/opt/sourcehut"}} a := &Args{Init: &InitArgs{Schema: defaultSchema}} a.Directory = defaultDirectory if err := resolvePaths(conf, a); err != nil { t.Fatalf("resolvePaths: %v", err) } if want := "/opt/sourcehut/migrations/" + serviceName; a.Directory != want { t.Errorf("Directory = %q, want %q", a.Directory, want) } if want := "/opt/sourcehut/" + serviceName + ".sql"; a.Init.Schema != want { t.Errorf("Schema = %q, want %q", a.Init.Schema, want) } }) t.Run("--dir override is respected verbatim", func(t *testing.T) { dir := t.TempDir() if err := os.Mkdir(filepath.Join(dir, "migrations"), 0o755); err != nil { t.Fatal(err) } t.Chdir(dir) a := &Args{Init: &InitArgs{Schema: defaultSchema}} a.Directory = "/srv/other-migrations" if err := resolvePaths(ini.File{}, a); err != nil { t.Fatalf("resolvePaths: %v", err) } if a.Directory != "/srv/other-migrations" { t.Errorf("Directory = %q, want the --dir value", a.Directory) } if a.Init.Schema != defaultSchema { t.Errorf("Schema = %q, want it untouched when --dir is given", a.Init.Schema) } }) t.Run("--schema override survives the assets fallback", func(t *testing.T) { t.Chdir(t.TempDir()) a := &Args{Init: &InitArgs{Schema: "/tmp/custom.sql"}} a.Directory = defaultDirectory if err := resolvePaths(ini.File{}, a); err != nil { t.Fatalf("resolvePaths: %v", err) } if a.Init.Schema != "/tmp/custom.sql" { t.Errorf("Schema = %q, want the --schema value", a.Init.Schema) } }) t.Run("a ./migrations file is not a directory", func(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "migrations"), []byte("not a dir"), 0o644); err != nil { t.Fatal(err) } t.Chdir(dir) a := &Args{} a.Directory = defaultDirectory if err := resolvePaths(ini.File{}, a); err != nil { t.Fatalf("resolvePaths: %v", err) } if want := filepath.Join(defaultAssets, "migrations", serviceName); a.Directory != want { t.Errorf("Directory = %q, want the assets path %q", a.Directory, want) } }) } // testEnv names the DSN env var that gates the Postgres-backed tests, matching // the convention used by the db package. When unset those tests skip; the flag, // DSN and path-resolution tests above run on a machine with no database at all. const testEnv = "SPECSRHT_TEST_PG" // scratchDSN creates an isolated schema on the Postgres pointed at by // SPECSRHT_TEST_PG and returns a DSN pinned to it, plus a cleanup that drops it. // A per-test schema avoids needing CREATE DATABASE privileges. func scratchDSN(t *testing.T) (string, func()) { t.Helper() base := os.Getenv(testEnv) if base == "" { t.Skipf("%s not set; skipping Postgres-backed test (set it to a DSN to run)", testEnv) } admin, err := sql.Open("postgres", base) if err != nil { t.Fatalf("open admin pool: %v", err) } if err := admin.Ping(); err != nil { admin.Close() t.Fatalf("ping %s: %v", testEnv, err) } buf := make([]byte, 8) if _, err := rand.Read(buf); err != nil { admin.Close() t.Fatalf("rand: %v", err) } schema := "specsrht_migrate_test_" + hex.EncodeToString(buf) if _, err := admin.Exec(`CREATE SCHEMA "` + schema + `"`); err != nil { admin.Close() t.Fatalf("create schema %s: %v", schema, err) } scoped, err := withSearchPath(base, schema) if err != nil { admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`) admin.Close() t.Fatalf("build scoped dsn: %v", err) } return scoped, func() { if _, err := admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`); err != nil { t.Errorf("drop schema %s: %v", schema, err) } admin.Close() } } // withSearchPath returns base with a connection option that pins search_path to // schema, handling both the URL and keyword DSN forms. func withSearchPath(base, schema string) (string, error) { opt := "-c search_path=" + schema if strings.Contains(base, "://") { u, err := url.Parse(base) if err != nil { return "", err } q := u.Query() q.Set("options", opt) u.RawQuery = q.Encode() return u.String(), nil } return base + " options='" + opt + "'", nil } // repoFile resolves a path relative to the repository root (this package lives // two levels below it). func repoFile(name string) string { return filepath.Join("..", "..", name) } func newArgs(dsn, dir string) *Args { drv := driverName return &Args{Args: cli.Args{ Directory: dir, DataSourceName: dsn, Driver: &drv, }} } // TestInitStampsToHead exercises the `init` path against a real database: the // full schema.sql applies and the version table lands on the head migration, so // a subsequent `up` has nothing to do. func TestInitStampsToHead(t *testing.T) { dsn, cleanup := scratchDSN(t) defer cleanup() a := newArgs(dsn, repoFile("migrations")) a.Init = &InitArgs{Schema: repoFile("schema.sql")} if err := initDatabase(a); err != nil { t.Fatalf("initDatabase: %v", err) } p, err := cli.ProviderFromArgs(&a.Args) if err != nil { t.Fatalf("provider: %v", err) } defer p.Close() ctx := context.Background() cur, err := p.Current(ctx) if err != nil { t.Fatalf("current: %v", err) } sources := p.ListSources() if len(sources) == 0 { t.Fatal("no migration sources found") } if head := sources[len(sources)-1].Version; cur != head { t.Errorf("current = %d after init, want head %d", cur, head) } applied, err := p.Up(ctx) if err != nil { t.Fatalf("up after init: %v", err) } if len(applied) != 0 { t.Errorf("up after init applied %d migrations, want 0", len(applied)) } } // TestUpFromEmpty exercises the upgrade path: replaying migrations/*.sql on an // empty schema reaches the same head version init stamps to. func TestUpFromEmpty(t *testing.T) { dsn, cleanup := scratchDSN(t) defer cleanup() a := newArgs(dsn, repoFile("migrations")) p, err := cli.ProviderFromArgs(&a.Args) if err != nil { t.Fatalf("provider: %v", err) } defer p.Close() ctx := context.Background() applied, err := p.Up(ctx) if err != nil { t.Fatalf("up: %v", err) } if len(applied) == 0 { t.Fatal("up applied no migrations on an empty schema") } cur, err := p.Current(ctx) if err != nil { t.Fatalf("current: %v", err) } sources := p.ListSources() if head := sources[len(sources)-1].Version; cur != head { t.Errorf("current = %d after up, want head %d", cur, head) } // The migrations must produce the tables the design's schema declares. db, err := p.DB() if err != nil { t.Fatalf("db: %v", err) } for _, table := range []string{"space", "document_id", "proposal", "agent_token", "index_stamp", "digest_mark"} { var n int if err := db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil { t.Errorf("table %s missing after up: %v", table, err) } } }