Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/DeploymentDriftProbeSqlServerTests.cs
T

194 lines
7.9 KiB
C#

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;
/// <summary>
/// The SQL-Server-backed half of <see cref="DeploymentDriftProbeTests"/>: pins that
/// <c>DeploymentDriftProbe</c>'s watermark aggregate — <c>ConfigEdits.GroupBy(_ =&gt; 1).Select(g =&gt;
/// new { g.Max(...), g.Count() })</c> — actually <b>translates</b>, and translates to the right
/// values, on the real provider.
/// </summary>
/// <remarks>
/// <para>
/// 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:
/// </para>
/// <list type="bullet">
/// <item>a later-timestamped edit moves <c>Max</c> only (its <c>Count</c> also moves, so this
/// one alone is not decisive) —</item>
/// <item>an <b>earlier</b>-timestamped edit leaves <c>Max</c> where it was and moves
/// <c>Count</c> alone, which is the only thing that can invalidate the cache; a translation
/// that dropped the <c>Count</c> projection would serve the stale verdict and fail here.</item>
/// </list>
/// <para>
/// 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.
/// </para>
/// <para>
/// The table is cleared at the start of every test: the collection shares one database and the
/// watermark is a whole-table aggregate.
/// </para>
/// </remarks>
[Collection(nameof(AdminUiSqlServerCollection))]
public sealed class DeploymentDriftProbeSqlServerTests(AdminUiSqlServerFixture sql)
{
private const string SealedHash = "sealed-hash-aaaa";
/// <summary>A fixed instant so the "earlier edit" case is unambiguously earlier, clock skew aside.</summary>
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();
}
/// <summary>Stand-in for the <c>ConfigComposer</c> seam that counts how often it was invoked.</summary>
private sealed class CountingCompose(string result)
{
public string Result { get; set; } = result;
public int Count { get; private set; }
public Task<string> ComposeAsync(OtOpcUaConfigDbContext db)
{
Count++;
return Task.FromResult(Result);
}
}
}