Files
ClaudeDo/tests/ClaudeDo.Data.Tests/MigrationSchemaDriftTests.cs
T
Mika Kuns e099d4f6cf 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.
2026-09-02 11:53:28 +02:00

97 lines
3.9 KiB
C#

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<string>();
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<ClaudeDoDbContext> OptionsFor(string dbPath) =>
new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={dbPath}")
.Options;
private static Dictionary<string, HashSet<string>> ReadColumnsByTable(string dbPath)
{
using var conn = new SqliteConnection($"Data Source={dbPath}");
conn.Open();
var tables = new List<string>();
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<string, HashSet<string>>();
foreach (var table in tables)
{
var columns = new HashSet<string>();
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;
}
}