Files
OwnCord/Server/db/migrate_test.go
T

836 lines
28 KiB
Go
Raw Normal View History

package db_test
// migrate_test.go — TDD tests for the tracked migration system.
//
// RED phase: these tests are written before the implementation exists.
// They verify the contract of MigrateFS after it gains schema_versions tracking.
//
// Test matrix:
// TestMigrate_SchemaVersionsTableCreated — schema_versions exists after first run
// TestMigrate_AllMigrationsRecorded — every applied file is recorded
// TestMigrate_SkipsAlreadyApplied — second call skips files already in schema_versions
// TestMigrate_AppliesNewMigrationsOnly — only new files are applied on subsequent runs
// TestMigrate_OrderIsLexicographic — migrations execute in sorted filename order
// TestMigrate_SeedExistingDatabase — existing DB (no schema_versions) is seeded
// TestMigrate_SeedDoesNotReRunMigrations — seeded migrations are not re-executed
// TestMigrate_SchemaVersionsAppliedAtRecorded — applied_at column is populated
// TestMigrate_EmptyFSSucceeds — empty FS is fine, no error
// TestMigrate_InvalidSQLReturnsError — bad SQL still surfaces as an error
// TestMigrate_ReadFileErrorReturnsError — FS read failure surfaces as an error
// TestMigrate_PartialRunRecordsOnlyApplied — failure mid-run leaves earlier files recorded
// TestMigrate_AppliedAtIsISO8601 — applied_at timestamp format is valid
import (
"context"
"database/sql"
"fmt"
"io/fs"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/J3vb/OwnCord/Server/db"
)
// failReadDirFS is an fs.FS whose root Open succeeds but ReadDir always errors.
// This exercises the sqlFilenames ReadDir error path.
type failReadDirFS struct{}
func (failReadDirFS) Open(name string) (fs.File, error) {
if name == "." {
return &badDirFile{}, nil
}
return nil, fmt.Errorf("no files")
}
type badDirFile struct{}
func (badDirFile) Read([]byte) (int, error) { return 0, fmt.Errorf("not a file") }
func (badDirFile) Close() error { return nil }
func (badDirFile) Stat() (fs.FileInfo, error) { return fakeDirInfo{}, nil }
func (badDirFile) ReadDir(int) ([]fs.DirEntry, error) {
return nil, fmt.Errorf("readdir always fails")
}
// ---- helpers ----------------------------------------------------------------
// countVersions returns the number of rows in schema_versions.
func countVersions(t *testing.T, database *db.DB) int {
t.Helper()
var n int
err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM schema_versions").Scan(&n)
if err != nil {
t.Fatalf("counting schema_versions: %v", err)
}
return n
}
// hasVersion reports whether a specific filename is recorded in schema_versions.
func hasVersion(t *testing.T, database *db.DB, filename string) bool {
t.Helper()
var v string
err := database.QueryRowContext(context.Background(),
"SELECT version FROM schema_versions WHERE version = ?", filename,
).Scan(&v)
if err == sql.ErrNoRows {
return false
}
if err != nil {
t.Fatalf("querying schema_versions for %q: %v", filename, err)
}
return true
}
// tableExists reports whether a table (or virtual table) exists in sqlite_master.
func tableExists(t *testing.T, database *db.DB, name string) bool {
t.Helper()
var n string
err := database.QueryRowContext(context.Background(),
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", name,
).Scan(&n)
if err == sql.ErrNoRows {
return false
}
if err != nil {
t.Fatalf("checking table %q: %v", name, err)
}
return true
}
// simpleFS builds an fstest.MapFS with the provided filename→SQL pairs.
func simpleFS(pairs ...string) fstest.MapFS {
if len(pairs)%2 != 0 {
panic("simpleFS requires an even number of arguments (name, sql, ...)")
}
m := fstest.MapFS{}
for i := 0; i < len(pairs); i += 2 {
m[pairs[i]] = &fstest.MapFile{Data: []byte(pairs[i+1])}
}
return m
}
// ---- tests ------------------------------------------------------------------
// TestMigrate_SchemaVersionsTableCreated verifies that MigrateFS creates the
// schema_versions tracking table on first run.
func TestMigrate_SchemaVersionsTableCreated(t *testing.T) {
database := openMemory(t)
fsys := simpleFS(
"001_create_foo.sql", "CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY);",
)
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() error: %v", err)
}
if !tableExists(t, database, "schema_versions") {
t.Error("schema_versions table was not created by MigrateFS")
}
}
// TestMigrate_AllMigrationsRecorded verifies that every applied .sql file
// gets a row inserted into schema_versions.
func TestMigrate_AllMigrationsRecorded(t *testing.T) {
database := openMemory(t)
fsys := simpleFS(
"001_alpha.sql", "CREATE TABLE IF NOT EXISTS alpha (id INTEGER PRIMARY KEY);",
"002_beta.sql", "CREATE TABLE IF NOT EXISTS beta (id INTEGER PRIMARY KEY);",
"003_gamma.sql", "CREATE TABLE IF NOT EXISTS gamma (id INTEGER PRIMARY KEY);",
)
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() error: %v", err)
}
for _, name := range []string{"001_alpha.sql", "002_beta.sql", "003_gamma.sql"} {
if !hasVersion(t, database, name) {
t.Errorf("migration %q not recorded in schema_versions", name)
}
}
}
// TestMigrate_SkipsAlreadyApplied verifies that a second call to MigrateFS
// with the same FS does not re-execute already-applied migrations.
func TestMigrate_SkipsAlreadyApplied(t *testing.T) {
database := openMemory(t)
// This migration inserts a row; if re-run it would violate UNIQUE.
fsys := simpleFS(
"001_unique.sql", `
CREATE TABLE IF NOT EXISTS unique_check (val TEXT UNIQUE);
INSERT INTO unique_check (val) VALUES ('singleton');
`,
)
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() first run error: %v", err)
}
// Second run — must not fail even though the INSERT would conflict if re-run.
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() second run error (migration was re-executed): %v", err)
}
// Confirm the row exists exactly once.
var count int
if err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM unique_check WHERE val='singleton'").Scan(&count); err != nil {
t.Fatalf("counting unique_check: %v", err)
}
if count != 1 {
t.Errorf("unique_check has %d rows, want exactly 1 — migration was re-run", count)
}
}
// TestMigrate_AppliesNewMigrationsOnly verifies that when a new file is added
// to the FS, only that file is applied on the second call.
func TestMigrate_AppliesNewMigrationsOnly(t *testing.T) {
database := openMemory(t)
fsFirst := simpleFS(
"001_base.sql", "CREATE TABLE IF NOT EXISTS base_tbl (id INTEGER PRIMARY KEY);",
)
if err := db.MigrateFS(database, fsFirst); err != nil {
t.Fatalf("MigrateFS() first run error: %v", err)
}
versionsAfterFirst := countVersions(t, database)
// Add a second migration.
fsSecond := simpleFS(
"001_base.sql", "CREATE TABLE IF NOT EXISTS base_tbl (id INTEGER PRIMARY KEY);",
"002_extra.sql", "CREATE TABLE IF NOT EXISTS extra_tbl (id INTEGER PRIMARY KEY);",
)
if err := db.MigrateFS(database, fsSecond); err != nil {
t.Fatalf("MigrateFS() second run error: %v", err)
}
versionsAfterSecond := countVersions(t, database)
if versionsAfterSecond != versionsAfterFirst+1 {
t.Errorf(
"expected %d version rows after second run, got %d",
versionsAfterFirst+1, versionsAfterSecond,
)
}
if !hasVersion(t, database, "002_extra.sql") {
t.Error("002_extra.sql not recorded after second run")
}
if !tableExists(t, database, "extra_tbl") {
t.Error("extra_tbl not created by second run")
}
}
// TestMigrate_OrderIsLexicographic verifies that migrations are applied in
// sorted filename order, not insertion or readdir order.
func TestMigrate_OrderIsLexicographic(t *testing.T) {
database := openMemory(t)
// 002 creates the table; 001 tries to insert into it.
// If run out of order (002 before 001) the INSERT would fail with "no such table".
// With lexicographic ordering 001 runs first and creates the table,
// then 002 inserts into it — so we verify the correct order by checking
// the table was created before the insert was attempted.
fsys := simpleFS(
"002_insert.sql", "INSERT INTO order_check (label) VALUES ('second');",
"001_create.sql", "CREATE TABLE IF NOT EXISTS order_check (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT);",
)
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() error: %v", err)
}
var label string
if err := database.QueryRowContext(context.Background(), "SELECT label FROM order_check LIMIT 1").Scan(&label); err != nil {
t.Fatalf("selecting from order_check: %v", err)
}
if label != "second" {
t.Errorf("label = %q, want 'second'", label)
}
}
// TestMigrate_SeedExistingDatabase verifies that when schema_versions does not
// exist but other known tables do (simulating an existing DB from before
// tracking was added), all current migration filenames are seeded so they are
// not re-executed.
func TestMigrate_SeedExistingDatabase(t *testing.T) {
database := openMemory(t)
// Manually create a table to simulate a previously-migrated database
// that does not yet have schema_versions.
if _, err := database.ExecContext(context.Background(),
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);",
); err != nil {
t.Fatalf("setup: creating users table: %v", err)
}
// This migration would drop and recreate users; if it runs it will wipe data.
// The seeding logic must prevent it from running.
fsys := simpleFS(
"001_initial.sql", `
DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT);
`,
)
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() error: %v", err)
}
// The migration must be recorded (seeded).
if !hasVersion(t, database, "001_initial.sql") {
t.Error("001_initial.sql should be seeded into schema_versions for existing DB")
}
// The users table must still have its original schema (no 'name' column),
// proving the DROP/CREATE did not run.
_, err := database.ExecContext(context.Background(), "INSERT INTO users (id) VALUES (42)")
if err != nil {
t.Errorf("users table appears to have been recreated (DROP ran): %v", err)
}
}
// TestMigrate_SeedDoesNotReRunMigrations is a companion to the seeding test:
// after seeding, a subsequent MigrateFS call with the same FS must be a no-op.
// The seeding heuristic triggers on the presence of the "users" sentinel table,
// so we create that table to simulate a pre-tracking database.
func TestMigrate_SeedDoesNotReRunMigrations(t *testing.T) {
database := openMemory(t)
// Simulate an existing DB: create the "users" sentinel table so the seeding
// heuristic fires, plus the table that the migration would modify.
if _, err := database.ExecContext(context.Background(),
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);",
); err != nil {
t.Fatalf("setup users: %v", err)
}
if _, err := database.ExecContext(context.Background(),
"CREATE TABLE IF NOT EXISTS existing (id INTEGER PRIMARY KEY);",
); err != nil {
t.Fatalf("setup existing: %v", err)
}
// This migration would INSERT into existing; if it runs, count becomes 1.
fsys := simpleFS(
"001_existing.sql", `
CREATE TABLE IF NOT EXISTS existing (id INTEGER PRIMARY KEY);
INSERT INTO existing (id) VALUES (1);
`,
)
// First call — seeds because schema_versions is absent AND users table exists.
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() first run error: %v", err)
}
// Second call — must be a no-op (migration is already recorded).
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() second run error: %v", err)
}
// existing table should be empty — the INSERT was never executed (seeded only).
var count int
if err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM existing").Scan(&count); err != nil {
t.Fatalf("counting existing: %v", err)
}
if count != 0 {
t.Errorf("existing has %d rows, want 0 — seeded migration was re-executed", count)
}
}
// TestMigrate_InterruptedSeedDoesNotOrphanTrackingTable pins OC-0213:
// schema_versions must not be created outside the seed transaction. If it
// is, an interrupted/failed first-run seed (process killed, OOM, disk
// full — anything that keeps the seed transaction from committing) leaves
// an empty schema_versions table behind. On the next start,
// schemaVersionsExists() reports true, the seeding branch is skipped
// forever, and every migration in the set is replayed against the live,
// already-populated database — including destructive ones.
//
// This test simulates the interruption with PRAGMA max_page_count: it caps
// the database's page budget so MigrateFS's seed transaction runs out of
// room partway through recording filenames, exactly like a crash mid-seed.
// It then lifts the cap (as a real restart would have headroom again) and
// calls MigrateFS a second time, verifying that seeding — not destructive
// execution — is what happens.
func TestMigrate_InterruptedSeedDoesNotOrphanTrackingTable(t *testing.T) {
database := openMemory(t)
ctx := context.Background()
// Simulate a pre-tracking existing database: the "users" sentinel table
// triggers the seeding heuristic, and carries data that the destructive
// migration below would wipe if it were ever executed instead of seeded.
if _, err := database.ExecContext(ctx,
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)",
); err != nil {
t.Fatalf("setup users table: %v", err)
}
if _, err := database.ExecContext(ctx,
"INSERT INTO users (id, name) VALUES (1, 'admin')",
); err != nil {
t.Fatalf("setup admin row: %v", err)
}
// A large migration set: one file is destructive (drops and recreates
// users, losing the row above), and hundreds of harmless, idempotent
// files pad the seed transaction out so a tight page budget is
// guaranteed to run out partway through — not on the very first insert,
// not never.
pairs := make([]string, 0, 2*502)
pairs = append(pairs,
"000_destroy_users.sql",
"DROP TABLE IF EXISTS users; CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
)
for i := 1; i <= 500; i++ {
pairs = append(pairs,
fmt.Sprintf("%04d_noop.sql", i),
"CREATE TABLE IF NOT EXISTS placeholder (id INTEGER PRIMARY KEY);",
)
}
fsys := simpleFS(pairs...)
var basePages int
if err := database.QueryRowContext(ctx, "PRAGMA page_count").Scan(&basePages); err != nil {
t.Fatalf("PRAGMA page_count: %v", err)
}
if _, err := database.ExecContext(ctx, fmt.Sprintf("PRAGMA max_page_count = %d", basePages+3)); err != nil {
t.Fatalf("PRAGMA max_page_count: %v", err)
}
// First "startup": the seed transaction is interrupted partway through.
if err := db.MigrateFS(database, fsys); err == nil {
t.Fatal("MigrateFS() under the page-budget constraint: expected an error simulating an interrupted seed, got nil")
}
// Lift the constraint — the next real startup would run on a machine
// with headroom restored.
if _, err := database.ExecContext(ctx, "PRAGMA max_page_count = 4294967294"); err != nil {
t.Fatalf("PRAGMA max_page_count reset: %v", err)
}
// Second "startup" (the retry). If the interrupted seed above left an
// orphaned, empty schema_versions table behind, MigrateFS now believes
// tracking is already in place, skips seeding entirely, and applies
// every migration for real — including 000_destroy_users.sql.
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() second run error: %v", err)
}
var name string
err := database.QueryRowContext(ctx, "SELECT name FROM users WHERE id = 1").Scan(&name)
if err != nil {
t.Fatalf("users row id=1 is gone: 000_destroy_users.sql was executed instead of seeded — an interrupted seed orphaned an empty schema_versions table: %v", err)
}
if name != "admin" {
t.Errorf("users.name = %q, want %q — users table appears to have been recreated", name, "admin")
}
}
// TestMigrate_SchemaVersionsAppliedAtRecorded verifies that applied_at is
// populated for every recorded migration.
func TestMigrate_SchemaVersionsAppliedAtRecorded(t *testing.T) {
database := openMemory(t)
fsys := simpleFS(
"001_ts.sql", "CREATE TABLE IF NOT EXISTS ts_test (id INTEGER PRIMARY KEY);",
)
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() error: %v", err)
}
var appliedAt string
err := database.QueryRowContext(context.Background(),
"SELECT applied_at FROM schema_versions WHERE version = '001_ts.sql'",
).Scan(&appliedAt)
if err != nil {
t.Fatalf("querying applied_at: %v", err)
}
if appliedAt == "" {
t.Error("applied_at should not be empty")
}
}
// TestMigrate_AppliedAtIsISO8601 verifies applied_at is a parseable datetime.
func TestMigrate_AppliedAtIsISO8601(t *testing.T) {
database := openMemory(t)
fsys := simpleFS(
"001_dt.sql", "CREATE TABLE IF NOT EXISTS dt_test (id INTEGER PRIMARY KEY);",
)
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() error: %v", err)
}
var appliedAt string
if err := database.QueryRowContext(context.Background(),
"SELECT applied_at FROM schema_versions WHERE version = '001_dt.sql'",
).Scan(&appliedAt); err != nil {
t.Fatalf("querying applied_at: %v", err)
}
// SQLite datetime('now') produces "YYYY-MM-DD HH:MM:SS".
formats := []string{
"2006-01-02 15:04:05",
time.RFC3339,
}
var parsed bool
for _, f := range formats {
if _, err := time.Parse(f, appliedAt); err == nil {
parsed = true
break
}
}
if !parsed {
t.Errorf("applied_at %q is not a recognised datetime format", appliedAt)
}
}
// TestMigrate_EmptyFSSucceeds verifies that an empty FS returns no error and
// still creates the schema_versions table.
func TestMigrate_EmptyFSSucceeds(t *testing.T) {
database := openMemory(t)
fsys := fstest.MapFS{}
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() with empty FS error: %v", err)
}
if !tableExists(t, database, "schema_versions") {
t.Error("schema_versions should be created even for empty FS")
}
if countVersions(t, database) != 0 {
t.Error("schema_versions should be empty for empty FS")
}
}
// TestMigrate_InvalidSQLReturnsError verifies that a migration with invalid
// SQL causes MigrateFS to return a non-nil error.
func TestMigrate_InvalidSQLReturnsError(t *testing.T) {
database := openMemory(t)
fsys := simpleFS(
"001_bad.sql", "THIS IS NOT VALID SQL !!!@@@###",
)
err := db.MigrateFS(database, fsys)
if err == nil {
t.Error("MigrateFS() should return error for invalid SQL, got nil")
}
}
// TestMigrate_ReadFileErrorReturnsError verifies that an FS read failure
// surfaces as an error from MigrateFS.
func TestMigrate_ReadFileErrorReturnsError(t *testing.T) {
database := openMemory(t)
err := db.MigrateFS(database, failReadFS{})
if err == nil {
t.Error("MigrateFS() should return error when ReadFile fails")
}
}
// TestMigrate_PartialRunRecordsOnlyApplied verifies that if the second
// migration in a set fails, only the first is recorded in schema_versions.
func TestMigrate_PartialRunRecordsOnlyApplied(t *testing.T) {
database := openMemory(t)
fsys := simpleFS(
"001_good.sql", "CREATE TABLE IF NOT EXISTS partial_good (id INTEGER PRIMARY KEY);",
"002_bad.sql", "THIS IS DEFINITELY NOT SQL;",
)
_ = db.MigrateFS(database, fsys) // we expect an error; ignore it here
if !hasVersion(t, database, "001_good.sql") {
t.Error("001_good.sql should be recorded even though 002 failed")
}
if hasVersion(t, database, "002_bad.sql") {
t.Error("002_bad.sql should NOT be recorded because it failed")
}
}
// TestMigrate_NonSQLFilesSkipped verifies that files without a .sql extension
// are skipped and not recorded in schema_versions.
func TestMigrate_NonSQLFilesSkipped(t *testing.T) {
database := openMemory(t)
fsys := fstest.MapFS{
"README.md": {Data: []byte("not sql")},
"001_ok.sql": {Data: []byte("CREATE TABLE IF NOT EXISTS ns_test (id INTEGER PRIMARY KEY);")},
"002_ok.go": {Data: []byte("package migrations")},
}
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() error: %v", err)
}
if hasVersion(t, database, "README.md") {
t.Error("README.md should not be recorded in schema_versions")
}
if hasVersion(t, database, "002_ok.go") {
t.Error("002_ok.go should not be recorded in schema_versions")
}
if !hasVersion(t, database, "001_ok.sql") {
t.Error("001_ok.sql should be recorded in schema_versions")
}
}
// TestMigrate_WithRealMigrations is an integration smoke test: run the
// production migration set through the tracked MigrateFS and verify the
// schema_versions table contains exactly one row per .sql file in the FS.
func TestMigrate_WithRealMigrations(t *testing.T) {
database := openMemory(t)
if err := db.Migrate(database); err != nil {
t.Fatalf("Migrate() error: %v", err)
}
if !tableExists(t, database, "schema_versions") {
t.Fatal("schema_versions not created by Migrate()")
}
// Count .sql files in the embedded FS by running Migrate again (no-op) and
// inspecting the version count. We just verify the count is > 0.
n := countVersions(t, database)
if n == 0 {
t.Error("schema_versions is empty after running production migrations")
}
}
// TestMigrate_WithRealMigrationsIdempotent verifies the production migration
// set can be run twice without error via the tracked path.
func TestMigrate_WithRealMigrationsIdempotent(t *testing.T) {
database := openMemory(t)
if err := db.Migrate(database); err != nil {
t.Fatalf("Migrate() first run error: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("Migrate() second run error: %v", err)
}
}
// TestMigrate_SeedDetectionUsesKnownTable verifies the seeding heuristic: it
// must detect an existing DB by the presence of a known table (e.g. "users"),
// not by an arbitrary table name.
func TestMigrate_SeedDetectionUsesKnownTable(t *testing.T) {
database := openMemory(t)
// Create only an unrelated table — not one of the known sentinel tables.
if _, err := database.ExecContext(context.Background(),
"CREATE TABLE IF NOT EXISTS unrelated (id INTEGER PRIMARY KEY);",
); err != nil {
t.Fatalf("setup: %v", err)
}
fsys := simpleFS(
"001_new.sql", "CREATE TABLE IF NOT EXISTS new_table (id INTEGER PRIMARY KEY);",
)
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() error: %v", err)
}
// Since "users" table was absent, seeding must NOT have occurred and the
// migration must have actually been applied.
if !tableExists(t, database, "new_table") {
t.Error("new_table should exist — migration was not seeded, so it must have run")
}
}
// TestMigrate_ErrorMessageContainsFilename verifies that when a migration
// fails, the returned error message includes the filename for easier debugging.
func TestMigrate_ErrorMessageContainsFilename(t *testing.T) {
database := openMemory(t)
fsys := simpleFS(
"042_broken.sql", "INVALID SQL STATEMENT;",
)
err := db.MigrateFS(database, fsys)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "042_broken.sql") {
t.Errorf("error %q does not mention the failing filename", err.Error())
}
}
// TestMigrate_ReadDirErrorReturnsError verifies that when the FS returns an
// error from ReadDir, MigrateFS propagates it as a non-nil error.
func TestMigrate_ReadDirErrorReturnsError(t *testing.T) {
database := openMemory(t)
err := db.MigrateFS(database, failReadDirFS{})
if err == nil {
t.Error("MigrateFS() should return error when ReadDir fails")
}
}
// TestMigrate_SchemaVersionsHasPrimaryKey verifies the schema_versions table
// uses version as PRIMARY KEY, preventing duplicate rows for the same file.
func TestMigrate_SchemaVersionsHasPrimaryKey(t *testing.T) {
database := openMemory(t)
fsys := simpleFS(
"001_pk.sql", "CREATE TABLE IF NOT EXISTS pk_test (id INTEGER PRIMARY KEY);",
)
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() error: %v", err)
}
// Attempting a duplicate insert must fail.
_, err := database.ExecContext(context.Background(),
"INSERT INTO schema_versions (version, applied_at) VALUES ('001_pk.sql', datetime('now'))",
)
if err == nil {
t.Error("duplicate insert into schema_versions should fail — PRIMARY KEY not enforced")
}
}
// TestMigrate_SeedRecordsAllFilesFromFS verifies that seeding writes a row for
// every .sql file in the FS, including when there are multiple files.
func TestMigrate_SeedRecordsAllFilesFromFS(t *testing.T) {
database := openMemory(t)
// Create the users sentinel to trigger seeding on first call.
if _, err := database.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);"); err != nil {
t.Fatalf("setup: %v", err)
}
fsys := simpleFS(
"001_a.sql", "CREATE TABLE IF NOT EXISTS seed_a (id INTEGER PRIMARY KEY);",
"002_b.sql", "CREATE TABLE IF NOT EXISTS seed_b (id INTEGER PRIMARY KEY);",
"003_c.sql", "CREATE TABLE IF NOT EXISTS seed_c (id INTEGER PRIMARY KEY);",
)
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() error: %v", err)
}
for _, name := range []string{"001_a.sql", "002_b.sql", "003_c.sql"} {
if !hasVersion(t, database, name) {
t.Errorf("%q not seeded into schema_versions", name)
}
}
// Tables from the seeded migrations must NOT have been created
// (seeding records without executing).
for _, tbl := range []string{"seed_a", "seed_b", "seed_c"} {
if tableExists(t, database, tbl) {
t.Errorf("table %q should not exist — migration was seeded, not executed", tbl)
}
}
}
// TestMigrate_FreshDatabaseRunsAllMigrations verifies that a completely fresh
// database (no tables at all) runs every migration without seeding.
func TestMigrate_FreshDatabaseRunsAllMigrations(t *testing.T) {
database := openMemory(t)
fsys := simpleFS(
"001_fresh.sql", "CREATE TABLE IF NOT EXISTS fresh_a (id INTEGER PRIMARY KEY);",
"002_fresh.sql", "CREATE TABLE IF NOT EXISTS fresh_b (id INTEGER PRIMARY KEY);",
)
if err := db.MigrateFS(database, fsys); err != nil {
t.Fatalf("MigrateFS() error: %v", err)
}
for _, tbl := range []string{"fresh_a", "fresh_b"} {
if !tableExists(t, database, tbl) {
t.Errorf("table %q should exist on fresh database run", tbl)
}
}
if countVersions(t, database) != 2 {
t.Errorf("expected 2 version rows, got %d", countVersions(t, database))
}
}
// TestMigrate_LargeNumberOfMigrations verifies that MigrateFS can handle
// a large set of migrations without degrading or skipping any.
func TestMigrate_LargeNumberOfMigrations(t *testing.T) {
database := openMemory(t)
const n = 50
pairs := make([]string, 0, n*2)
for i := 1; i <= n; i++ {
name := fmt.Sprintf("%03d_large.sql", i)
sql := fmt.Sprintf("CREATE TABLE IF NOT EXISTS large_%03d (id INTEGER PRIMARY KEY);", i)
pairs = append(pairs, name, sql)
}
if err := db.MigrateFS(database, simpleFS(pairs...)); err != nil {
t.Fatalf("MigrateFS() error with %d migrations: %v", n, err)
}
got := countVersions(t, database)
if got != n {
t.Errorf("schema_versions has %d rows, want %d", got, n)
}
}
2026-08-01 22:06:14 +02:00
// TestMigrate_022SeedsMentionEveryone locks migration 022: the privileged
// seeded roles gain MENTION_EVERYONE (bit 21) and the plain Member role does
// not, so @everyone stays gated after an upgrade of an existing database.
func TestMigrate_022SeedsMentionEveryone(t *testing.T) {
database := openMemory(t)
if err := db.Migrate(database); err != nil {
t.Fatalf("Migrate() error: %v", err)
}
const mentionEveryone = int64(0x200000)
for _, tc := range []struct {
roleID int64
name string
want bool
}{
{1, "Owner", true},
{2, "Admin", true},
{3, "Moderator", true},
{4, "Member", false},
} {
var perms int64
if err := database.QueryRowContext(context.Background(),
`SELECT permissions FROM roles WHERE id = ?`, tc.roleID).Scan(&perms); err != nil {
t.Fatalf("read role %s: %v", tc.name, err)
}
if got := perms&mentionEveryone != 0; got != tc.want {
t.Errorf("%s MENTION_EVERYONE = %v, want %v (perms=0x%X)", tc.name, got, tc.want, perms)
}
}
}
// TestMigrate_022CreatesMentionSchema locks the storage phase 3 relies on.
func TestMigrate_022CreatesMentionSchema(t *testing.T) {
database := openMemory(t)
if err := db.Migrate(database); err != nil {
t.Fatalf("Migrate() error: %v", err)
}
if !tableExists(t, database, "message_mentions") {
t.Error("message_mentions table not created")
}
var n int
if err := database.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM pragma_table_info('messages') WHERE name = 'mentions_everyone'`,
).Scan(&n); err != nil {
t.Fatalf("pragma_table_info: %v", err)
}
if n != 1 {
t.Error("messages.mentions_everyone column not added")
}
}