194 lines
7.9 KiB
C#
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(_ => 1).Select(g =>
|
|
/// 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);
|
|
}
|
|
}
|
|
}
|