Files
OwnCord/Server/db/migrate_test.go
T
J3vbandClaude Opus 5 ea0430c5b0 fix: batch of correctness fixes across server and client (#1375)
* fix(client): 1 defect(s) (OC-0201)

* fix(service): 1 defect(s) (OC-0202)

HandleTyping built the per-user-per-channel rate-limit key before resolving the channel or checking read permission, so forged channel ids could pin unbounded dead entries in the shared process-wide RateLimiter.

* fix(client): 2 defect(s) (OC-0203, OC-0224)

* fix(server): 1 defect(s) (OC-0204)

* fix(ws): 2 defect(s) (OC-0205, OC-0211)

* fix(admin): 2 defect(s) (OC-0209, OC-0212)

* fix(client): 1 defect(s) (OC-0210)

* fix(db): 1 defect(s) (OC-0213)

* fix(ws): 1 defect(s) (OC-0214)

Route handler-driven PresenceEvent through BroadcastToAll instead of BroadcastToAllLow so every source of a user's presence shares one ordered per-client FIFO.

* fix(admin): 1 defect(s) (OC-0215)

PATCH /users/{id} combining banned + role_id committed and broadcast the ban before authorizing the role change, so a refused role change returned an error while leaving the target banned. Authorize the role change up front via the new ModerationService.AuthorizeRoleChange.

* fix(db): 1 defect(s) (OC-0216)

LinkAttachmentsToMessage no longer claims an attachment that is a user's live avatar (users.avatar points at it). Once message_id is set, handleServeFile's avatar branch (gated on ChannelID == nil) is unreachable and the file falls under the message's channel ACL / soft-delete state, permanently disagreeing with users.avatar about who may read it.

* fix(emoji): 1 defect(s) (OC-0217)

* fix(client): 1 defect(s) (OC-0218)

The data-copy phase of an HTTP proxy tunnel was unbounded. Steps 1-2 of
handle_connection (header read, TCP connect, TLS handshake) each run under
a 10s guard, but step 3 called io::copy_bidirectional with no deadline. A
remote that completes the TLS handshake and then neither responds nor
closes parks the spawned connection task, the loopback socket and the
remote TLS session indefinitely: copy_bidirectional only resolves once
BOTH directions finish, so closing the local side alone does not free it.

Wrap the copy in copy_with_deadline, a generic helper bounded by
DATA_PHASE_TIMEOUT (600s). The bound is deliberately far looser than the
10s setup guards because this phase carries the REST body, including
attachment and avatar uploads, so it must reclaim only genuinely stuck
connections rather than merely slow ones. The helper is generic over the
stream types so it can be exercised without a live TLS connection.

Regression test drives two in-memory duplex pairs whose far ends stay
alive, so neither half ever observes EOF and raw copy_bidirectional would
block forever; the test asserts the call resolves on its own deadline with
ErrorKind::TimedOut.

Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* fix(ws): 1 defect(s) (OC-0219)

* fix(client): 1 defect(s) (OC-0221)

UpdateNotifier scheduled its deferred update check with a setTimeout whose
handle was never retained, so destroy() could not cancel it. A component torn
down inside the 3s window (page swap / logout) still fired performCheck() and
issued a network update check against the old server URL. Retain the timer
handle and clear it in destroy().

* fix(dm): 1 defect(s) (OC-0222)

* fix(client): 1 defect(s) (OC-0223)

* fix(voice): 1 defect(s) (OC-0225)

The Grant-Microphone retry's .finally hardcoded grantMicBtn.disabled = false, undoing updateFrozen()'s socket-down freeze when the WS socket dropped while the mic permission request was in flight. Delegate the state back to render().

* fix(admin): 1 defect(s) (OC-0226)

handleApplyUpdate broadcasts a 'restarting in 5s' notice before the on-disk
swap. Every failure path in the swap returned silently, leaving clients
counting down to a restart that never happened. Extract the swap into
applyStagedUpdate and send a corrective 'update_aborted' broadcast from a
deferred guard on every path that does not reach the respawn.

* fix(admin): 1 defect(s) (OC-0227)

PATCH /channels/{id} accepted a blank or whitespace-only name, leaving the
channel unidentifiable in clients. updateChannelRequest.validate() now
rejects it the way handleCreateChannel already did.

* fix(identity): 1 defect(s) (OC-0228)

* fix(admin): run deferred cleanup before the update restart exits

The fix batch left three golangci-lint findings and two prettier findings
that CI gates on.

applyStagedUpdate called os.Exit(0) in the same function that defers both
staged.Close() and the corrective "update_aborted" broadcast, so neither
ran (gocritic exitAfterDefer). Return a bool instead and let the caller
exit once those defers have run — on Windows, releasing the staged binary's
file handle is the reason the restart exists at all, so this is a real fix
rather than a lint appeasement. The exported test hook calls the function as
a statement, so the added result does not affect it.

Also modernize a bulk-insert loop to range-over-int, compare backup bytes
with bytes.Equal, and reflow two test files to prettier's output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* test(ws): pin the live presence path against the invisible custom-status leak

OC-0207 and OC-0211 are the same defect at two emitters: hub_broadcast.go's
BroadcastPresence (connect/reconnect) and event.go's presenceEvents (live
presence_update). The fix for OC-0211 closed both sites in one change, but
only the hub_broadcast side got a regression test.

This pins the event.go sibling: an invisible user's real custom status must
be blanked on the PresenceOthersEvent frame while the owner's own
PresenceSelfEvent still carries it. Without it, a later change could reopen
the live path while the committed test kept passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* fix(ws): 1 defect(s) (OC-0206)

* test(ws): silence a contextcheck false positive in the reconnect race test

RefreshChannelVisibility takes no context by design — it is reached through
the admin HubBroadcaster interface, which carries none, so it builds its own
internally. contextcheck flags the call only because the test closure around
it holds a ctx for its override write, so there is nothing to propagate.
Suppress at the call site rather than widen a production interface (and its
mocks) to satisfy a lint in a test.

golangci-lint v2.11.3 (the version ci.yml pins) now reports 0 issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 16:30:05 +02:00

836 lines
28 KiB
Go

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