Files
Evan JarrettandClaude Opus 5 adc6394ebc db: renumber the second 0028 migration so it actually runs
Two migration files shipped as version 28: 0028_add_device_secret_lookup
(08121f3) and 0028_create_stripe_processed_events (12c55ed).

runMigrations keys applied migrations by the integer parsed from the
filename and skips any version already present in schema_migrations, so
the first file to load records 28 and the second is skipped in silence —
no error, no log. loadMigrations enumerates via fs.Glob, which sorts
lexically, so add_device_secret_lookup won and stripe_processed_events
never ran.

On an existing database that leaves stripe_processed_events missing, and
StripeEventSeen then fails closed: the error wraps into ErrWebhookProcessing,
the webhook returns 500, and Stripe redelivers into the same missing table
forever. No subscription, tier, or dispute event is ever applied — defeating
the exact idempotency 12c55ed was written to add.

Fresh installs were unaffected, which is why no test caught it: they take
the applySchema path where schema.sql already has the table and both
version-28 rows are merely recorded as applied.

Renumbered to 0029 rather than renumbering the device migration, so a
database that already ran this build (28 recorded, devices.secret_lookup
present, stripe table missing) picks the migration up on next boot instead
of staying broken. Renumbering the other file would have left that database
with the table still missing and re-run its ALTER on a column that exists.

Adds two guards: one asserting migration versions are distinct, and one
exercising the upgrade path where a duplicate manifests as a missing
schema_migrations row. Both fail on a planted duplicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:21:56 -05:00

87 lines
3.1 KiB
Go

package db
import (
"testing"
)
// TestMigrationVersionsAreUnique guards the failure mode that shipped two
// migrations numbered 0028.
//
// runMigrations keys applied migrations by the integer parsed from the
// filename and skips any version already present in schema_migrations. Two
// files sharing an ordinal therefore mean the first one applied records the
// version and the second is skipped in silence — no error, no log. Fresh
// installs hide it completely, because they take the applySchema path where
// schema.sql already contains everything and the migrations are only recorded.
// Only an upgrade of an existing database is affected, which is the one case
// with no test coverage and the most to lose.
func TestMigrationVersionsAreUnique(t *testing.T) {
migrations, err := loadMigrations()
if err != nil {
t.Fatalf("loadMigrations: %v", err)
}
if len(migrations) == 0 {
t.Fatal("loadMigrations returned no migrations")
}
seen := make(map[int]string, len(migrations))
for _, m := range migrations {
if prev, dup := seen[m.Version]; dup {
t.Errorf("duplicate migration version %04d: %q and %q — one of them will be silently skipped on an existing database; renumber the later one",
m.Version, prev, m.Name)
continue
}
seen[m.Version] = m.Name
}
}
// TestMigrationsApplyToExistingDatabase exercises the upgrade path rather than
// the fresh-install path: it forces every migration to be treated as pending
// and asserts they all apply. A migration that is skipped because it collides
// with another version fails here even though InitDB on a fresh database would
// report success.
func TestMigrationsApplyToExistingDatabase(t *testing.T) {
db, err := InitDB(":memory:", LibsqlConfig{})
if err != nil {
t.Fatalf("InitDB: %v", err)
}
defer db.Close()
if _, err := db.Exec("DELETE FROM schema_migrations"); err != nil {
t.Fatalf("clear schema_migrations: %v", err)
}
migrations, err := loadMigrations()
if err != nil {
t.Fatalf("loadMigrations: %v", err)
}
// freshDB=true records each pending migration without executing its DDL,
// which is what we want here: this database already has schema.sql applied,
// and re-running additive ALTERs would fail for reasons unrelated to the
// collision. The duplicate-version skip happens before the freshDB branch,
// so a collision still shows up as a missing row below.
if err := runMigrations(db, true); err != nil {
t.Fatalf("runMigrations: %v", err)
}
for _, m := range migrations {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.Version).Scan(&count); err != nil {
t.Fatalf("query schema_migrations for %d: %v", m.Version, err)
}
if count == 0 {
t.Errorf("migration %04d (%s) was never recorded as applied", m.Version, m.Name)
}
}
var recorded int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&recorded); err != nil {
t.Fatalf("count schema_migrations: %v", err)
}
if recorded != len(migrations) {
t.Errorf("recorded %d migrations but have %d files; a shortfall means two files share a version",
recorded, len(migrations))
}
}