using System.Text.RegularExpressions; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; /// /// SQL Server tests for the deploy artifact itself: the dotnet ef migrations script /// --idempotent output that upgrades a central configuration database (ScadaBridge#126). /// /// /// EF puts each migration of an idempotent script in ONE batch behind per-statement /// IF NOT EXISTS (… __EFMigrationsHistory …) guards, and SQL Server compiles the whole /// batch before any guard runs. A raw-SQL statement that references a column added earlier in /// the same migration therefore fails with Msg 207 even though the guard would have let the /// ADD COLUMN run first — database update / Migrate() never see it because they /// send one command at a time. The tests in this class migrate the fixture database down and /// back up, so they own their fixture and leave the database fully migrated. /// public sealed class IdempotentMigrationScriptTests : IClassFixture { // The last migration deployed everywhere before the 2026-11 central-pair migration window // (migration 57). The idempotent script from here to head is what DEV/QA/prod run. private const string BaselineMigration = "20260914173147_AddSiteArtifactRevision"; private static readonly Regex BatchSeparator = new(@"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase); private readonly MsSqlMigrationFixture _fixture; /// Creates the test class with its own migrated SQL Server database. /// The per-class migration fixture. public IdempotentMigrationScriptTests(MsSqlMigrationFixture fixture) => _fixture = fixture; /// /// From the migration-57 baseline the generated idempotent script runs batch by batch /// without an error, applies every pending migration, and a second run is a no-op. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task IdempotentScript_FromBaseline_RunsClean_AndReRunsAsANoOp() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var ctx = CreateContext(); var migrator = ctx.GetService(); var all = ctx.Database.GetMigrations().ToList(); var script = migrator.GenerateScript( fromMigration: BaselineMigration, options: MigrationsSqlGenerationOptions.Idempotent); try { await migrator.MigrateAsync(BaselineMigration); Assert.Equal(all.IndexOf(BaselineMigration) + 1, (await ReadHistoryAsync()).Count); await RunScriptAsync(script); Assert.Equal(all, await ReadHistoryAsync()); Assert.Equal(2, await CountChainIndexesAsync()); // Second run against the fully-migrated database: every guard is false. await RunScriptAsync(script); Assert.Equal(all, await ReadHistoryAsync()); } finally { // Leave the fixture database fully migrated whatever happened above. await migrator.MigrateAsync(); } } private ScadaBridgeDbContext CreateContext() => new(new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString, sql => sql.CommandTimeout(600)).Options); private async Task RunScriptAsync(string script) { await using var conn = new SqlConnection(_fixture.ConnectionString); await conn.OpenAsync(); foreach (var batch in BatchSeparator.Split(script)) { if (string.IsNullOrWhiteSpace(batch)) { continue; } await using var cmd = conn.CreateCommand(); cmd.CommandTimeout = 600; cmd.CommandText = batch; await cmd.ExecuteNonQueryAsync(); } } private async Task> ReadHistoryAsync() { await using var conn = new SqlConnection(_fixture.ConnectionString); await conn.OpenAsync(); await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId"; var ids = new List(); await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { ids.Add(reader.GetString(0)); } return ids; } private async Task CountChainIndexesAsync() { await using var conn = new SqlConnection(_fixture.ConnectionString); await conn.OpenAsync(); await using var cmd = conn.CreateCommand(); cmd.CommandText = """ SELECT COUNT(*) FROM sys.indexes WHERE object_id = OBJECT_ID('dbo.AuditLog') AND name IN ('IX_AuditLog_Unchained', 'IX_AuditLog_ChainSeq') """; return (int)(await cmd.ExecuteScalarAsync())!; } }