diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 74a01dd..33e1fcd 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -2,7 +2,10 @@ package store import ( "context" + "database/sql" + "io/fs" "path/filepath" + "sort" "strings" "testing" ) @@ -291,6 +294,167 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) { } } +// TestRebuildMigrationsPreserveForeignKeyReferences proves the specific +// property none of the other migration tests cover: every other test opens +// a fresh DB via store.Open, which runs migrations 0001-0025 in one +// uninterrupted pass over an empty database, so migrations 0023/0024/0025's +// table-rebuild (create-new/copy/drop-old/rename-into-place) never has any +// real pre-existing rows to carry across. A real single-tenant deployment +// upgrading to this schema version has months of synced activities and +// kind_assignments rows referencing real workout_kinds rows by foreign key +// (per this repo's CLAUDE.md: one profile, real Garmin history, a review +// queue with manual overrides already recorded). This test manually applies +// migrations up through 0022, inserts rows simulating that pre-existing +// install, then applies 0023/0024/0025 and verifies the FK-referencing row +// still resolves correctly -- and that FK enforcement is genuinely back on +// afterward -- rather than just checking that migrations apply to an empty +// DB without erroring. +func TestRebuildMigrationsPreserveForeignKeyReferences(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "geniusrun_rebuild_test.db") + + // Deliberately not store.Open: that always applies every migration in + // one uninterrupted pass with no way to stop partway through. The sqlite + // driver itself is already registered via db.go's blank import. + sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer sqlDB.Close() + + if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); err != nil { + t.Fatalf("create schema_migrations table: %v", err) + } + + rebuildMigrations := map[string]bool{ + "0023_profile_user_scoped.sql": true, + "0024_workout_kinds_user_scoped.sql": true, + "0025_sync_state_user_scoped.sql": true, + } + + // Mirrors db.go's migrate() method: FK toggle in autocommit mode around + // the transaction, only for the three table-rebuild migrations. + applyMigration := func(name string) { + t.Helper() + content, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + t.Fatalf("read migration %s: %v", name, err) + } + + needsFKToggle := rebuildMigrations[name] + if needsFKToggle { + if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil { + t.Fatalf("disable foreign keys before migration %s: %v", name, err) + } + } + + tx, err := sqlDB.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("begin migration tx for %s: %v", name, err) + } + if _, err := tx.ExecContext(ctx, string(content)); err != nil { + tx.Rollback() + t.Fatalf("apply migration %s: %v", name, err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil { + tx.Rollback() + t.Fatalf("record migration %s: %v", name, err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit migration %s: %v", name, err) + } + + if needsFKToggle { + if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil { + t.Fatalf("enable foreign keys after migration %s: %v", name, err) + } + } + } + + entries, err := fs.Glob(migrationsFS, "migrations/*.sql") + if err != nil { + t.Fatalf("glob migrations: %v", err) + } + sort.Strings(entries) + + // Apply every migration up to (but not including) the three rebuilds. + for _, entry := range entries { + name := entry[len("migrations/"):] + if rebuildMigrations[name] { + continue + } + applyMigration(name) + } + + // Simulate a real pre-existing single-tenant install at this point in + // schema history: a workout kind, a synced activity, and a + // kind_assignment referencing both by foreign key. + res, err := sqlDB.ExecContext(ctx, `INSERT INTO workout_kinds (name, rule_json) VALUES ('Pre-Existing Custom Kind', '{}')`) + if err != nil { + t.Fatalf("insert pre-existing workout_kinds row: %v", err) + } + kindID, err := res.LastInsertId() + if err != nil { + t.Fatalf("workout_kinds LastInsertId: %v", err) + } + + res, err = sqlDB.ExecContext(ctx, ` + INSERT INTO activities (garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json) + VALUES (123456789, '2026-01-01 06:00:00', 1800, 5000, '{}')`) + if err != nil { + t.Fatalf("insert pre-existing activities row: %v", err) + } + activityID, err := res.LastInsertId() + if err != nil { + t.Fatalf("activities LastInsertId: %v", err) + } + + res, err = sqlDB.ExecContext(ctx, ` + INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status) + VALUES (?, ?, 'rule_engine', 'assigned')`, activityID, kindID) + if err != nil { + t.Fatalf("insert pre-existing kind_assignments row: %v", err) + } + assignmentID, err := res.LastInsertId() + if err != nil { + t.Fatalf("kind_assignments LastInsertId: %v", err) + } + + // Now apply the three rebuild migrations that drop/recreate profile, + // workout_kinds, and sync_state. + for _, name := range []string{ + "0023_profile_user_scoped.sql", + "0024_workout_kinds_user_scoped.sql", + "0025_sync_state_user_scoped.sql", + } { + applyMigration(name) + } + + // The pre-existing kind_assignment row must still resolve to the same + // workout_kind, by the same name, across the drop/recreate/rename. + var resolvedName string + if err := sqlDB.QueryRowContext(ctx, ` + SELECT wk.name FROM kind_assignments ka + JOIN workout_kinds wk ON wk.id = ka.workout_kind_id + WHERE ka.id = ?`, assignmentID).Scan(&resolvedName); err != nil { + t.Fatalf("join kind_assignments -> workout_kinds after rebuild: %v", err) + } + if resolvedName != "Pre-Existing Custom Kind" { + t.Fatalf("expected pre-existing kind_assignment to still resolve to 'Pre-Existing Custom Kind' after rebuild, got %q", resolvedName) + } + + // FK enforcement must be genuinely active again post-migration: a bogus + // workout_kind_id must be rejected, not silently accepted. + if _, err := sqlDB.ExecContext(ctx, ` + INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status) + VALUES (?, 999999, 'rule_engine', 'assigned')`, activityID); err == nil { + t.Fatal("expected FK constraint violation for nonexistent workout_kind_id after rebuild, but insert succeeded") + } +} + func contains(s string, substrs ...string) bool { lower := strings.ToLower(s) for _, substr := range substrs {