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)
}
}
}