using Microsoft.EntityFrameworkCore; using Microsoft.Data.Sqlite; namespace ClaudeDo.Data.Tests; // Sibling migrations branched off the same parent have silently dropped each other's columns // during a SQLite table rebuild before (see docs/explore-notes). Every other Data.Tests fixture // uses EnsureCreated, which skips the migration chain entirely, so nothing else in the suite // would catch that. This compares the schema the migration chain actually produces against the // schema EnsureCreated builds from today's model. public sealed class MigrationSchemaDriftTests : IDisposable { private readonly string _migratedDbPath; private readonly string _modelDbPath; public MigrationSchemaDriftTests() { _migratedDbPath = Path.Combine(Path.GetTempPath(), $"claudedo_drift_migrated_{Guid.NewGuid():N}.db"); _modelDbPath = Path.Combine(Path.GetTempPath(), $"claudedo_drift_model_{Guid.NewGuid():N}.db"); } public void Dispose() { SqliteConnection.ClearAllPools(); foreach (var path in new[] { _migratedDbPath, _modelDbPath }) foreach (var suffix in new[] { "", "-wal", "-shm" }) try { File.Delete(path + suffix); } catch { } } [Fact] public void Migrated_schema_has_the_same_columns_as_the_current_model() { using (var migrated = new ClaudeDoDbContext(OptionsFor(_migratedDbPath))) ClaudeDoDbContext.MigrateAndConfigure(migrated); using (var model = new ClaudeDoDbContext(OptionsFor(_modelDbPath))) model.Database.EnsureCreated(); var migratedColumns = ReadColumnsByTable(_migratedDbPath); var modelColumns = ReadColumnsByTable(_modelDbPath); var mismatches = new List(); foreach (var (table, expectedColumns) in modelColumns) { if (!migratedColumns.TryGetValue(table, out var actualColumns)) { mismatches.Add($"{table}: missing from migrated schema entirely"); continue; } var missingInMigrated = expectedColumns.Except(actualColumns).ToList(); var extraInMigrated = actualColumns.Except(expectedColumns).ToList(); if (missingInMigrated.Count > 0 || extraInMigrated.Count > 0) { mismatches.Add( $"{table}: missing-in-migrated=[{string.Join(", ", missingInMigrated)}] " + $"extra-in-migrated=[{string.Join(", ", extraInMigrated)}]"); } } Assert.True(mismatches.Count == 0, "Schema drift between migrated DB and current model:\n" + string.Join("\n", mismatches)); } private static DbContextOptions OptionsFor(string dbPath) => new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath}") .Options; private static Dictionary> ReadColumnsByTable(string dbPath) { using var conn = new SqliteConnection($"Data Source={dbPath}"); conn.Open(); var tables = new List(); using (var cmd = conn.CreateCommand()) { cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name != '__EFMigrationsHistory' AND name NOT LIKE 'sqlite_%'"; using var reader = cmd.ExecuteReader(); while (reader.Read()) tables.Add(reader.GetString(0)); } var result = new Dictionary>(); foreach (var table in tables) { var columns = new HashSet(); using var cmd = conn.CreateCommand(); cmd.CommandText = $"PRAGMA table_info(\"{table}\")"; using var reader = cmd.ExecuteReader(); while (reader.Read()) columns.Add(reader.GetString(reader.GetOrdinal("name"))); result[table] = columns; } return result; } }