using ClaudeDo.Data.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; namespace ClaudeDo.Data.Tests; // The 40 incremental migrations were squashed into one InitialCreate, so every existing database // carries a history EF no longer recognises. MigrateAndConfigure re-stamps those instead of // re-creating tables — this is the only test covering that path (every other Data.Tests fixture // uses EnsureCreated, which skips migrations entirely). // Replaces TaskNumberMigrationTests, whose subject (the AddTaskNumbers backfill) was squashed away. public sealed class MigrationBaselineTests : IDisposable { private const string LastPreSquashMigrationId = "20260825063230_AddPrimeActionKind"; private const string SquashedMigrationId = "20260826094154_InitialCreate"; private readonly string _dbPath; private readonly DbContextOptions _options; public MigrationBaselineTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_baseline_{Guid.NewGuid():N}.db"); _options = new DbContextOptionsBuilder() .UseSqlite($"Data Source={_dbPath}") .Options; } public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); foreach (var suffix in new[] { "", "-wal", "-shm" }) try { File.Delete(_dbPath + suffix); } catch { } } [Fact] public void Fresh_database_migrates_and_seeds() { using var ctx = new ClaudeDoDbContext(_options); ClaudeDoDbContext.MigrateAndConfigure(ctx); AssertFullyMigrated(ctx); Assert.NotEmpty(ctx.Lists); } [Fact] public void Fully_migrated_pre_squash_database_is_restamped_and_keeps_its_rows() { SeedSquashSchemaWithHistory(LastPreSquashMigrationId, taskTitle: "survivor"); using var ctx = new ClaudeDoDbContext(_options); ClaudeDoDbContext.MigrateAndConfigure(ctx); AssertFullyMigrated(ctx); Assert.Equal("survivor", ctx.Tasks.Single().Title); } [Fact] public void Restamping_is_idempotent() { SeedSquashSchemaWithHistory(LastPreSquashMigrationId, taskTitle: "survivor"); using (var first = new ClaudeDoDbContext(_options)) ClaudeDoDbContext.MigrateAndConfigure(first); using var second = new ClaudeDoDbContext(_options); ClaudeDoDbContext.MigrateAndConfigure(second); AssertFullyMigrated(second); Assert.Equal("survivor", second.Tasks.Single().Title); } [Fact] public void Database_stuck_mid_chain_fails_loudly_instead_of_being_stamped() { SeedSquashSchemaWithHistory("20260810115437_AddFailureReason", taskTitle: "stale"); using var ctx = new ClaudeDoDbContext(_options); var ex = Assert.Throws(() => ClaudeDoDbContext.MigrateAndConfigure(ctx)); Assert.Contains("Reinstall ClaudeDo", ex.Message); } [Fact] public void Pre_ef_database_without_history_is_baselined() { SeedSquashSchemaWithHistory(historyId: null, taskTitle: "legacy"); using var ctx = new ClaudeDoDbContext(_options); ClaudeDoDbContext.MigrateAndConfigure(ctx); AssertFullyMigrated(ctx); Assert.Equal("legacy", ctx.Tasks.Single().Title); } // Builds the schema as of the squash point by migrating *to* InitialCreate, then rewrites the // history into the legacy shape: current tables, old (or missing) history — what a database // fully migrated by the old chain looks like. // NOT EnsureCreated: that builds *today's* schema, so every migration added after the squash // would then re-add a column that already exists ("duplicate column name"). private void SeedSquashSchemaWithHistory(string? historyId, string taskTitle) { using var seed = new ClaudeDoDbContext(_options); seed.Database.GetService().Migrate(SquashedMigrationId); // Raw SQL, not EF: the current model knows columns this old schema doesn't have yet. var conn = seed.Database.GetDbConnection(); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $""" INSERT INTO lists (id, name, created_at) VALUES ('l1', 'List', '2026-01-01 00:00:00'); INSERT INTO tasks (id, number, list_id, title, status, created_at) VALUES ('t1', 1, 'l1', '{taskTitle}', 'idle', '2026-01-01 00:00:00'); {(historyId is null ? """DROP TABLE "__EFMigrationsHistory";""" : $""" DELETE FROM "__EFMigrationsHistory"; INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ('{historyId}', '8.0.11'); """)} """; cmd.ExecuteNonQuery(); conn.Close(); } /// The restamp only sets the baseline — migrations added after the squash must still apply on /// top, so assert "nothing pending", not "InitialCreate is the only row". private static void AssertFullyMigrated(ClaudeDoDbContext ctx) { Assert.Contains(SquashedMigrationId, ctx.Database.GetAppliedMigrations()); Assert.Empty(ctx.Database.GetPendingMigrations()); } }