using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments; /// /// The SQL-Server-backed half of : pins that /// DeploymentDriftProbe's watermark aggregate — ConfigEdits.GroupBy(_ => 1).Select(g => /// new { g.Max(...), g.Count() }) — actually translates, and translates to the right /// values, on the real provider. /// /// /// /// EF InMemory evaluates that query with LINQ-to-Objects, so the InMemory suite proves the C# /// and nothing about the SQL. Here an untranslatable shape throws and fails the test outright, /// and a mistranslation is caught by moving exactly one half of the aggregate at a time: /// /// /// a later-timestamped edit moves Max only (its Count also moves, so this /// one alone is not decisive) — /// an earlier-timestamped edit leaves Max where it was and moves /// Count alone, which is the only thing that can invalidate the cache; a translation /// that dropped the Count projection would serve the stale verdict and fail here. /// /// /// The watermark itself is private, so the assertions read it through its one observable /// consequence: whether the probe recomposes. That is deliberate — asserting a copy of the query /// would pin a copy, not the production one. /// /// /// The table is cleared at the start of every test: the collection shares one database and the /// watermark is a whole-table aggregate. /// /// [Collection(nameof(AdminUiSqlServerCollection))] public sealed class DeploymentDriftProbeSqlServerTests(AdminUiSqlServerFixture sql) { private const string SealedHash = "sealed-hash-aaaa"; /// A fixed instant so the "earlier edit" case is unambiguously earlier, clock skew aside. private static readonly DateTime Anchor = new(2026, 8, 17, 12, 0, 0, DateTimeKind.Utc); [SqlServerFact] public async Task An_empty_ConfigEdits_table_yields_a_verdict_and_caches_it() { // The no-rows shape is the one GroupBy(_ => 1) makes subtle: SQL Server produces NO group at all, // so FirstOrDefaultAsync must land on null rather than on a row of (NULL, 0). await ClearConfigEditsAsync(); var compose = new CountingCompose(SealedHash); var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); var first = await probe.GetDriftAsync(SealedHash, CancellationToken.None); var second = await probe.GetDriftAsync(SealedHash, CancellationToken.None); first.ShouldBe(false); second.ShouldBe(false); compose.Count.ShouldBe(1); } [SqlServerFact] public async Task An_unchanged_ConfigEdits_table_reuses_the_cached_verdict() { await ClearConfigEditsAsync(); await AddEditsAsync(Anchor, Anchor.AddSeconds(1)); var compose = new CountingCompose(SealedHash); var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); await probe.GetDriftAsync(SealedHash, CancellationToken.None); var second = await probe.GetDriftAsync(SealedHash, CancellationToken.None); // Two reads of the same rows must produce an EQUAL watermark. Not a tautology against SQL: // EditedAtUtc round-trips through datetime2 and an unstable projection would recompose forever. second.ShouldBe(false); compose.Count.ShouldBe(1); } [SqlServerFact] public async Task A_later_edit_moves_the_watermark_and_recomposes() { await ClearConfigEditsAsync(); await AddEditsAsync(Anchor); var compose = new CountingCompose(SealedHash); var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(false); await AddEditsAsync(Anchor.AddHours(1)); compose.Result = "a-different-hash"; (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(true); compose.Count.ShouldBe(2); } [SqlServerFact] public async Task An_earlier_edit_moves_only_the_count_and_still_recomposes() { // The decisive case for the Count half: MAX(EditedAtUtc) is unchanged, so a translation that lost // the COUNT(*) projection would report the same watermark and serve the stale (false) verdict. await ClearConfigEditsAsync(); await AddEditsAsync(Anchor); var compose = new CountingCompose(SealedHash); var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(false); await AddEditsAsync(Anchor.AddHours(-1)); compose.Result = "a-different-hash"; (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(true); compose.Count.ShouldBe(2); } [SqlServerFact] public async Task A_same_timestamp_edit_moves_only_the_count_and_still_recomposes() { // Two mutations inside one tick — the case the CLAUDE.md/probe remarks call out explicitly. await ClearConfigEditsAsync(); await AddEditsAsync(Anchor); var compose = new CountingCompose(SealedHash); var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(false); await AddEditsAsync(Anchor); compose.Result = "a-different-hash"; (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(true); compose.Count.ShouldBe(2); } [SqlServerFact] public async Task Emptying_the_table_after_a_verdict_recomposes() { // Exercises the populated-then-empty transition, i.e. the aggregate crossing back to "no group". await ClearConfigEditsAsync(); await AddEditsAsync(Anchor, Anchor.AddSeconds(1)); var compose = new CountingCompose(SealedHash); var probe = new DeploymentDriftProbe(sql.Factory, compose.ComposeAsync); (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(false); await ClearConfigEditsAsync(); compose.Result = "a-different-hash"; (await probe.GetDriftAsync(SealedHash, CancellationToken.None)).ShouldBe(true); compose.Count.ShouldBe(2); } // -------------------------------------------------------------------------------------- Helpers private async Task ClearConfigEditsAsync() { await using var db = await sql.Factory.CreateDbContextAsync(); await db.ConfigEdits.ExecuteDeleteAsync(); } private async Task AddEditsAsync(params DateTime[] editedAtUtc) { await using var db = await sql.Factory.CreateDbContextAsync(); foreach (var at in editedAtUtc) { db.ConfigEdits.Add(new ConfigEdit { EntityType = "Equipment", EntityId = Guid.NewGuid(), FieldsJson = "{}", EditedBy = "test", SourceNode = "node-1", EditedAtUtc = at, }); } await db.SaveChangesAsync(); } /// Stand-in for the ConfigComposer seam that counts how often it was invoked. private sealed class CountingCompose(string result) { public string Result { get; set; } = result; public int Count { get; private set; } public Task ComposeAsync(OtOpcUaConfigDbContext db) { Count++; return Task.FromResult(Result); } } }