using ClaudeDo.Data; using ClaudeDo.Data.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; namespace ClaudeDo.Data.Tests; // EnsureCreated (used by every other Data.Tests fixture) builds the schema straight from the // current model and skips migrations entirely, so it can never exercise the AddTaskNumbers // backfill. This test runs a real Migrate() against a database seeded with pre-migration rows. public sealed class TaskNumberMigrationTests : IDisposable { private const string MigrationBeforeTaskNumbers = "20260810115437_AddFailureReason"; private readonly string _dbPath; private readonly DbContextOptions _options; public TaskNumberMigrationTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_tasknum_migration_{Guid.NewGuid():N}.db"); _options = new DbContextOptionsBuilder() .UseSqlite($"Data Source={_dbPath}") .Options; } public void Dispose() { foreach (var suffix in new[] { "", "-wal", "-shm" }) try { File.Delete(_dbPath + suffix); } catch { } } [Fact] public async Task Migrate_backfills_task_numbers_in_creation_order_with_id_tiebreak() { await using (var ctx = new ClaudeDoDbContext(_options)) { var migrator = ctx.Database.GetInfrastructure().GetRequiredService(); migrator.Migrate(MigrationBeforeTaskNumbers); var conn = ctx.Database.GetDbConnection(); await conn.OpenAsync(); await ExecAsync(conn, """ INSERT INTO lists (id, name, created_at) VALUES ('l1', 'List', '2026-01-01 00:00:00.0000000'); """); // Inserted out of creation order, and 'd'/'c' share a timestamp to exercise the id tiebreak. await ExecAsync(conn, InsertTaskSql("d", "2026-01-03 00:00:00.0000000")); await ExecAsync(conn, InsertTaskSql("a", "2026-01-01 00:00:00.0000000")); await ExecAsync(conn, InsertTaskSql("c", "2026-01-03 00:00:00.0000000")); await ExecAsync(conn, InsertTaskSql("b", "2026-01-02 00:00:00.0000000")); migrator.Migrate(); } await using var verify = new ClaudeDoDbContext(_options); var byId = await verify.Tasks.AsNoTracking().ToDictionaryAsync(t => t.Id, t => t.Number); Assert.Equal(1, byId["a"]); Assert.Equal(2, byId["b"]); Assert.Equal(3, byId["c"]); Assert.Equal(4, byId["d"]); var numbers = byId.Values.OrderBy(n => n).ToList(); Assert.Equal(new[] { 1, 2, 3, 4 }, numbers); var settings = await verify.AppSettings.AsNoTracking().FirstAsync(s => s.Id == AppSettingsEntity.SingletonId); Assert.Equal(5, settings.NextTaskNumber); } private static string InsertTaskSql(string id, string createdAt) => $""" INSERT INTO tasks (id, list_id, title, status, created_at) VALUES ('{id}', 'l1', 'Task {id}', 'idle', '{createdAt}'); """; private static async Task ExecAsync(System.Data.Common.DbConnection conn, string sql) { await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; await cmd.ExecuteNonQueryAsync(); } }