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