package core import ( "context" "database/sql" "io" "io/fs" "log/slog" "path/filepath" "strings" "testing" "github.com/mithral/vellum/migrations" ) func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // highestMigrationNumber reads the embedded migrations so the version // assertion does not need editing every time a migration is added. func highestMigrationNumber(t *testing.T) int { t.Helper() names, err := fs.Glob(migrations.FS, "*.sql") if err != nil { t.Fatal(err) } highest := 0 for _, name := range names { n, err := migrationNumber(name) if err != nil { t.Fatal(err) } if n > highest { highest = n } } return highest } func openTempDB(t *testing.T) *sql.DB { t.Helper() db, err := OpenDB(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("OpenDB: %v", err) } t.Cleanup(func() { _ = db.Close() }) return db } func TestMigrateAppliesAndIsIdempotent(t *testing.T) { ctx := context.Background() db := openTempDB(t) if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil { t.Fatalf("first Migrate: %v", err) } // Running again must be a no-op, not an error. if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil { t.Fatalf("second Migrate (should be no-op): %v", err) } var version int if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { t.Fatalf("read user_version: %v", err) } want := highestMigrationNumber(t) if version != want { t.Errorf("user_version = %d, want %d", version, want) } var tables int if err := db.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); err != nil { t.Fatalf("count tables: %v", err) } if tables < 16 { t.Errorf("table count = %d, want >= 16", tables) } // WAL must be active: progress writes during a scan depend on it. var mode string if err := db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil { t.Fatalf("read journal_mode: %v", err) } if !strings.EqualFold(mode, "wal") { t.Errorf("journal_mode = %q, want wal", mode) } } func TestForeignKeyEnforcementIsOn(t *testing.T) { ctx := context.Background() db := openTempDB(t) if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil { t.Fatalf("Migrate: %v", err) } // library_grants references users and libraries; neither 999 exists. _, err := db.ExecContext(ctx, "INSERT INTO library_grants(user_id, library_id) VALUES (999, 999)") if err == nil { t.Fatal("expected foreign key violation, got nil") } } func TestMigrationNumber(t *testing.T) { cases := map[string]int{ "0001_core.sql": 1, "0042_audiobooks.sql": 42, "7_shorthand.sql": 7, "0010_no_ext": 10, } for name, want := range cases { got, err := migrationNumber(name) if err != nil { t.Errorf("migrationNumber(%q): %v", name, err) continue } if got != want { t.Errorf("migrationNumber(%q) = %d, want %d", name, got, want) } } if _, err := migrationNumber("core.sql"); err == nil { t.Error("expected error for filename without a numeric prefix") } } func TestSplitStatementsIgnoresComments(t *testing.T) { in := ` -- a comment with a semicolon; which must not split CREATE TABLE a (id INTEGER); -- trailing comment; /* block; comment; here */ CREATE TABLE b (id INTEGER); ` got := splitStatements(in) if len(got) != 2 { t.Fatalf("got %d statements, want 2: %#v", len(got), got) } if !strings.Contains(got[0], "CREATE TABLE a") { t.Errorf("statement 0 = %q", got[0]) } if !strings.Contains(got[1], "CREATE TABLE b") { t.Errorf("statement 1 = %q", got[1]) } } func TestSplitStatementsKeepsTriggerBodiesWhole(t *testing.T) { in := ` CREATE TABLE t (id INTEGER, name TEXT); CREATE TRIGGER t_ai AFTER INSERT ON t BEGIN INSERT INTO t(name) VALUES (new.name); UPDATE t SET name = 'x' WHERE id = new.id; END; CREATE TABLE after_trigger (id INTEGER); ` got := splitStatements(in) if len(got) != 3 { t.Fatalf("got %d statements, want 3:\n%#v", len(got), got) } if !strings.Contains(got[1], "CREATE TRIGGER") || !strings.Contains(got[1], "END") { t.Errorf("trigger was split apart: %q", got[1]) } if n := strings.Count(got[1], ";"); n != 2 { t.Errorf("trigger body has %d semicolons, want 2 (the internal ones)", n) } if !strings.Contains(got[2], "after_trigger") { t.Errorf("statement after the trigger = %q", got[2]) } }