using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; /// /// R03:3.2 on the real provider: the migrated config database allows SNAPSHOT isolation (migration /// EnableSnapshotIsolation) and reads under /// it. The InMemory tests cannot see either — the provider has no transactions — so this is the one /// place the deploy path's "one consistent read" is proven against SQL Server. /// [Trait("Category", "SchemaCompliance")] [Collection(nameof(SchemaComplianceCollection))] public sealed class DraftSnapshotFactorySqlServerTests { private readonly SchemaComplianceFixture _fixture; /// Initializes a new instance of the class. /// The migrated test database. public DraftSnapshotFactorySqlServerTests(SchemaComplianceFixture fixture) => _fixture = fixture; /// The migration leaves the database with snapshot isolation allowed. [Fact] public void Migrated_database_allows_snapshot_isolation() { using var conn = _fixture.OpenConnection(); using var cmd = new SqlCommand( "SELECT snapshot_isolation_state_desc FROM sys.databases WHERE name = DB_NAME();", conn); cmd.ExecuteScalar().ShouldBe("ON"); } /// /// A row committed by another connection while the snapshot read is in progress is NOT observed by /// the tables read after it — every table reflects the instant the transaction started. Positive /// control: a read started after the commit does see the row. /// [Fact] public async Task FromConfigDb_reads_every_table_as_of_one_instant() { var clusterId = $"SNAP-{Guid.NewGuid():N}"[..24]; var scriptId = $"late-{Guid.NewGuid():N}"; await using (var seed = NewContext()) { seed.ServerClusters.Add(new ServerCluster { ClusterId = clusterId, Name = "snapshot-read", Enterprise = "zb", Site = "test", NodeCount = 1, RedundancyMode = RedundancyMode.None, CreatedBy = "test", }); await seed.SaveChangesAsync(); } try { var options = new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString) .AddInterceptors(new LateEditInterceptor(_fixture.ConnectionString, clusterId, scriptId)) .Options; await using (var db = new OtOpcUaConfigDbContext(options)) { var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(db); // The interceptor committed a Script while ServerClusters (the FIRST table read) was being // materialised. Scripts is read later in the same transaction and must not see it. snapshot.ServerClusters.ShouldContain(c => c.ClusterId == clusterId); snapshot.Scripts.ShouldNotContain(s => s.ScriptId == scriptId); } await using (var verify = NewContext()) { // Positive control: the edit really landed, and a read started afterwards sees it. (await verify.Scripts.AnyAsync(s => s.ScriptId == scriptId)).ShouldBeTrue(); var later = await DraftSnapshotFactory.FromConfigDbAsync(verify); later.Scripts.ShouldContain(s => s.ScriptId == scriptId); } } finally { await using var cleanup = NewContext(); await cleanup.Scripts.Where(s => s.ScriptId == scriptId).ExecuteDeleteAsync(); await cleanup.ServerClusters.Where(c => c.ClusterId == clusterId).ExecuteDeleteAsync(); } } private OtOpcUaConfigDbContext NewContext() => new( new DbContextOptionsBuilder().UseSqlServer(_fixture.ConnectionString).Options); /// /// Commits a Script on a SEPARATE connection the first time the snapshot read materialises the /// seeded ServerCluster row — i.e. mid-read, after the first table and before Scripts. /// private sealed class LateEditInterceptor(string connectionString, string clusterId, string scriptId) : IMaterializationInterceptor { private int _fired; public object InitializedInstance(MaterializationInterceptionData materializationData, object entity) { if (entity is ServerCluster c && c.ClusterId == clusterId && Interlocked.Exchange(ref _fired, 1) == 0) { using var other = new OtOpcUaConfigDbContext( new DbContextOptionsBuilder().UseSqlServer(connectionString).Options); other.Scripts.Add(new Script { ScriptId = scriptId, Name = "late edit", SourceCode = "return 1;", SourceHash = "late-hash", }); other.SaveChanges(); } return entity; } } }