using System.Text.RegularExpressions; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; using Xunit; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// /// Gitea #62: the database-side config watermark behind the pending-updates short-circuit, /// against a real migrated SQL Server database (the AddConfigGraphRowVersions /// migration included). The "peer node" in these tests is a separate raw /// that never touches this process's DbContext — the exact shape /// of a write made by the other central node, which a per-process watermark never sees /// (arch-review 2026-09-09 MG-3). /// public class ConfigGraphWatermarkReaderTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; /// Initializes the suite with the shared MS SQL migration fixture. /// The shared MS SQL migration fixture. public ConfigGraphWatermarkReaderTests(MsSqlMigrationFixture fixture) => _fixture = fixture; private ScadaBridgeDbContext CreateContext(SqlReaderCommandRecorder? recorder = null) { var builder = new DbContextOptionsBuilder().UseSqlServer(_fixture.ConnectionString); if (recorder is not null) builder.AddInterceptors(recorder); return new ScadaBridgeDbContext(builder.Options); } private async Task ReadAsync() { await using var context = CreateContext(); var token = await new ConfigGraphWatermarkReader(context).ReadAsync(); Assert.NotNull(token); return token!; } private void PeerExecute(string sql, params SqlParameter[] parameters) { using var connection = _fixture.OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = sql; command.Parameters.AddRange(parameters); command.ExecuteNonQuery(); } /// /// Verifies that the token is stable with no writes, and moves for a peer-node insert, update /// and delete made on a connection this process's context never saw. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task Token_IsStableWithoutWrites_AndMovesForPeerInsertUpdateAndDelete() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var ident = "wm-" + Guid.NewGuid().ToString("N")[..8]; var t0 = await ReadAsync(); Assert.Equal(t0, await ReadAsync()); // no write → identical token (the short-circuit case) // Peer INSERT. PeerExecute("INSERT INTO [Sites] ([Name], [SiteIdentifier]) VALUES (@n, @i);", new SqlParameter("@n", ident), new SqlParameter("@i", ident)); var t1 = await ReadAsync(); Assert.NotEqual(t0, t1); Assert.Equal(t1, await ReadAsync()); // Peer UPDATE of a column no application write path ever "bumps" anything for. PeerExecute("UPDATE [Sites] SET [Description] = N'edited by the peer' WHERE [SiteIdentifier] = @i;", new SqlParameter("@i", ident)); var t2 = await ReadAsync(); Assert.NotEqual(t1, t2); // Peer DELETE of a row that is NOT the table's newest — so MAX(RowVersion) stays put and // only the row count can catch it. A second, newer row is inserted first to guarantee that. var newer = ident + "-b"; PeerExecute("INSERT INTO [Sites] ([Name], [SiteIdentifier]) VALUES (@n, @i);", new SqlParameter("@n", newer), new SqlParameter("@i", newer)); var t3 = await ReadAsync(); PeerExecute("DELETE FROM [Sites] WHERE [SiteIdentifier] = @i;", new SqlParameter("@i", ident)); var t4 = await ReadAsync(); Assert.NotEqual(t3, t4); } /// Verifies that an EF write through this process's context also moves the token. /// A task that represents the asynchronous operation. [SkippableFact] public async Task Token_MovesForALocalEfWrite_AndTheRowVersionIsNotAConcurrencyToken() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var ident = "wm-" + Guid.NewGuid().ToString("N")[..8]; var before = await ReadAsync(); int id; await using (var context = CreateContext()) { var site = new Site(ident, ident); context.Sites.Add(site); await context.SaveChangesAsync(); id = site.Id; } var afterInsert = await ReadAsync(); Assert.NotEqual(before, afterInsert); // A detached Update of a stale copy must still save: the column is change detection only, // never an optimistic-concurrency check that would fail a save the old schema accepted. await using (var context = CreateContext()) { context.Sites.Update(new Site(ident, ident) { Id = id, Description = "detached update" }); await context.SaveChangesAsync(); } Assert.NotEqual(afterInsert, await ReadAsync()); } /// /// Guard for the covered set: every table the pending-updates evaluator's repository reads /// touch must carry the watermark column, so a new input table cannot silently escape it. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task EveryTableThePendingUpdatesReadsTouch_IsCoveredByTheWatermark() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Seed one site → template → instance so the split-query child reads actually execute // (EF skips a split query's child statements when the root row is absent). var ident = "wm-" + Guid.NewGuid().ToString("N")[..8]; int siteId, templateId, instanceId; await using (var seed = CreateContext()) { var site = new Site(ident, ident); var template = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template(ident); seed.Sites.Add(site); seed.Templates.Add(template); await seed.SaveChangesAsync(); var instance = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance(ident) { SiteId = site.Id, TemplateId = template.Id, }; seed.Instances.Add(instance); await seed.SaveChangesAsync(); (siteId, templateId, instanceId) = (site.Id, template.Id, instance.Id); } var recorder = new SqlReaderCommandRecorder(); await using var context = CreateContext(recorder); var watermark = new TemplateGraphWatermark(); var sites = new SiteRepository(context, watermark); var templates = new TemplateEngineRepository(context, watermark); var deployments = new DeploymentManagerRepository(context); var external = new ExternalSystemRepository(context); // PendingUpdatesEvaluator → ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync await sites.GetAllSitesAsync(); await templates.GetAllSharedScriptsAsync(); await external.GetAllExternalSystemsAsync(); await external.GetAllDatabaseConnectionsAsync(); await deployments.GetAllExternalSystemMethodsAsync(); await sites.GetDataConnectionsBySiteIdAsync(siteId); // → DeploymentService.GetInstanceStalenessAsync / FlatteningPipeline.FlattenCoreAsync await deployments.GetDeployedInstanceSummariesAsync(); await deployments.GetDeployedRevisionHashesAsync(); await deployments.GetDeployedSnapshotsByInstanceIdsAsync([instanceId]); await templates.GetInstanceByIdAsync(instanceId); await templates.GetTemplateWithChildrenAsync(templateId); var covered = ConfigGraphRowVersions.EntityTypes .Select(t => context.Model.FindEntityType(t)!.GetTableName()!) .ToHashSet(StringComparer.OrdinalIgnoreCase); var read = recorder.Commands .SelectMany(c => Regex.Matches(c, @"(?:FROM|JOIN)\s+(?:\[\w+\]\.)?\[(\w+)\]").Select(m => m.Groups[1].Value)) .ToHashSet(StringComparer.OrdinalIgnoreCase); Assert.NotEmpty(read); Assert.Empty(read.Except(covered)); // Positive control: the regex really does see the tables (a vacuous empty set would pass above). Assert.Contains("Templates", read); Assert.Contains("InstanceAttributeOverrides", read); Assert.Contains("DeployedConfigSnapshots", read); } /// Verifies that a non-SQL-Server provider yields no token, so callers never short-circuit on it. /// A task that represents the asynchronous operation. [Fact] public async Task NonSqlServerProvider_ReturnsNull() { await using var context = SqliteTestHelper.CreateInMemoryContext(); Assert.Null(await new ConfigGraphWatermarkReader(context).ReadAsync()); } }