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())!;
}
}