package cli import ( "bytes" "database/sql" "os" "path/filepath" "strings" "testing" "banger/internal/store" "github.com/spf13/cobra" _ "modernc.org/sqlite" ) func TestNewBangerdCommandSubcommands(t *testing.T) { cmd := NewBangerdCommand() if cmd.Use != "bangerd" { t.Errorf("Use = %q, want bangerd", cmd.Use) } for _, flag := range []string{"system", "root-helper", "check-migrations"} { if cmd.Flag(flag) == nil { t.Errorf("flag %q missing", flag) } } } func TestLastID(t *testing.T) { tests := []struct { name string in []int want int }{ {"nil", nil, 0}, {"empty", []int{}, 0}, {"single", []int{7}, 7}, {"sorted ascending", []int{1, 2, 3}, 3}, {"unsorted, max in middle", []int{1, 99, 5}, 99}, {"duplicates", []int{4, 4, 2, 4}, 4}, {"negative ignored", []int{-3, -1, 0}, 0}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := lastID(tc.in); got != tc.want { t.Fatalf("lastID(%v) = %d, want %d", tc.in, got, tc.want) } }) } } // stubExit replaces bangerdExit for the test and returns a pointer to // the captured exit code (-1 = not called) and a restore func. func stubExit(t *testing.T) *int { t.Helper() called := -1 prev := bangerdExit bangerdExit = func(code int) { called = code } t.Cleanup(func() { bangerdExit = prev }) return &called } // pointHomeAtTempDB sets XDG_STATE_HOME (and HOME, which Resolve falls // back to) so that paths.Resolve().DBPath lands at /banger/state.db. // Returns the DB path. func pointHomeAtTempDB(t *testing.T) string { t.Helper() tmp := t.TempDir() t.Setenv("HOME", tmp) t.Setenv("XDG_STATE_HOME", tmp) t.Setenv("XDG_CONFIG_HOME", tmp) t.Setenv("XDG_CACHE_HOME", tmp) t.Setenv("XDG_RUNTIME_DIR", tmp) dir := filepath.Join(tmp, "banger") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatalf("mkdir state dir: %v", err) } return filepath.Join(dir, "state.db") } func TestRunCheckMigrationsCompatible(t *testing.T) { dbPath := pointHomeAtTempDB(t) s, err := store.Open(dbPath) if err != nil { t.Fatalf("store.Open: %v", err) } _ = s.Close() exit := stubExit(t) cmd := &cobra.Command{} var out bytes.Buffer cmd.SetOut(&out) if err := runCheckMigrations(cmd, false); err != nil { t.Fatalf("runCheckMigrations: %v", err) } if *exit != -1 { t.Errorf("bangerdExit called with %d, want no call", *exit) } if !strings.HasPrefix(out.String(), "compatible:") { t.Errorf("stdout = %q, want prefix \"compatible:\"", out.String()) } } func TestRunCheckMigrationsMigrationsNeeded(t *testing.T) { dbPath := pointHomeAtTempDB(t) // Hand-craft a DB that has schema_migrations with only the baseline // row — InspectSchemaState classifies this as "migrations needed". dsn := "file:" + dbPath + "?_pragma=foreign_keys(1)" db, err := sql.Open("sqlite", dsn) if err != nil { t.Fatalf("sql.Open: %v", err) } if _, err := db.Exec(`CREATE TABLE schema_migrations (id INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil { t.Fatalf("create table: %v", err) } if _, err := db.Exec(`INSERT INTO schema_migrations VALUES (1, 'baseline', '2026-01-01T00:00:00Z')`); err != nil { t.Fatalf("insert baseline: %v", err) } _ = db.Close() exit := stubExit(t) cmd := &cobra.Command{} var out bytes.Buffer cmd.SetOut(&out) if err := runCheckMigrations(cmd, false); err != nil { t.Fatalf("runCheckMigrations: %v", err) } if *exit != 1 { t.Errorf("bangerdExit called with %d, want 1", *exit) } if !strings.HasPrefix(out.String(), "migrations needed:") { t.Errorf("stdout = %q, want prefix \"migrations needed:\"", out.String()) } } func TestRunCheckMigrationsIncompatible(t *testing.T) { dbPath := pointHomeAtTempDB(t) s, err := store.Open(dbPath) if err != nil { t.Fatalf("store.Open: %v", err) } _ = s.Close() // Inject an unknown migration id directly so the binary's known set // is a strict subset — InspectSchemaState classifies as incompatible. dsn := "file:" + dbPath db, err := sql.Open("sqlite", dsn) if err != nil { t.Fatalf("sql.Open: %v", err) } if _, err := db.Exec(`INSERT INTO schema_migrations VALUES (9999, 'from_the_future', '2030-01-01T00:00:00Z')`); err != nil { t.Fatalf("insert future row: %v", err) } _ = db.Close() exit := stubExit(t) cmd := &cobra.Command{} var out bytes.Buffer cmd.SetOut(&out) if err := runCheckMigrations(cmd, false); err != nil { t.Fatalf("runCheckMigrations: %v", err) } if *exit != 2 { t.Errorf("bangerdExit called with %d, want 2", *exit) } if !strings.HasPrefix(out.String(), "incompatible:") { t.Errorf("stdout = %q, want prefix \"incompatible:\"", out.String()) } } func TestRunCheckMigrationsInspectError(t *testing.T) { // Point at a state dir with a non-DB file at state.db so Inspect // fails to open it. The function should wrap the error with the path. dbPath := pointHomeAtTempDB(t) if err := os.WriteFile(dbPath, []byte("not a sqlite file"), 0o600); err != nil { t.Fatalf("write garbage: %v", err) } stubExit(t) cmd := &cobra.Command{} var out bytes.Buffer cmd.SetOut(&out) err := runCheckMigrations(cmd, false) if err == nil { t.Fatal("runCheckMigrations: nil error, want wrapped inspect error") } if !strings.Contains(err.Error(), dbPath) { t.Errorf("error %q does not mention DB path %q", err.Error(), dbPath) } }