From e099d4f6cfda4f1d7dcdfb9bde4890a990ec17e7 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 11:53:28 +0200 Subject: [PATCH] test(data): assert migrated schema matches EF model (schema-drift guard) Sibling migrations branched off the same parent have silently dropped each other's columns during a SQLite table rebuild before, and every other Data.Tests fixture uses EnsureCreated, which skips the migration chain entirely and would never catch that. --- .../MigrationSchemaDriftTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/ClaudeDo.Data.Tests/MigrationSchemaDriftTests.cs diff --git a/tests/ClaudeDo.Data.Tests/MigrationSchemaDriftTests.cs b/tests/ClaudeDo.Data.Tests/MigrationSchemaDriftTests.cs new file mode 100644 index 00000000..65139540 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/MigrationSchemaDriftTests.cs @@ -0,0 +1,96 @@ +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; + } +}