Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairConvergenceTests.cs
T
Joseph Doherty 2bbe66311d test(localdb): port store-and-forward replication intents as CDC convergence specs
Task 10. Specifications first, deletion second: the bespoke ReplicationService
(explicit Add/Remove/Park/Requeue over Akka) dies at Task 14, so its behaviour
is restated here as outcomes the CDC replacement must still deliver. Written
in terms of ROWS, not operations — under CDC there is no Add or Park message to
observe, only a row that must end up right on both nodes.

Not ported: ReplicationOperations_AreDispatchedInIssueOrder. It asserts the
mechanism (inline fire-and-forget dispatch), and CDC capture is asynchronous
and batched by construction. Its portable content is the ordering OUTCOME —
add-then-remove must never converge to present — which is a test here, with
that reasoning recorded in the file so it does not read as an accidental drop.

DEVIATION: extracted the Phase 1 fixture into LocalDbSitePairHarness rather
than duplicating ~150 lines. Phase 1's tests now derive from it and still pass
unchanged. The harness registers the Phase 2 tables itself, since production
OnReady does not until Task 14; that method is marked for deletion at the
cutover, and the 8-table list is written literally so a cutover registering the
wrong set fails these tests instead of agreeing with itself.

Non-vacuity verified by unregistering sf_messages: 6 of 7 failed. The 7th —
the ordering test — PASSED, because an absent row is also what a pair that
replicates nothing looks like. Fixed with a control row that must converge in
the same window, so the absence is evidence rather than silence.

Also corrected two comments from Task 9 that claimed Task 14 makes
notification_lists/smtp_configurations replicated. It explicitly does not
register them, for the same reason the migrator skips them.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-20 03:50:48 -04:00

153 lines
6.8 KiB
C#

using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// Phase 1 convergence: operation tracking and site events across a real site pair.
/// </summary>
/// <remarks>
/// This is the test that answers the question Phase 1 exists to answer: does a site node
/// pair actually stop losing operation-tracking and site-event state? Everything upstream
/// of it — schema helpers, DI wiring, the interceptor — can be individually green while the
/// pair still fails to converge.
/// <para>
/// The fixture lives in <see cref="LocalDbSitePairHarness"/>, shared with the Phase 2
/// convergence suites.
/// </para>
/// </remarks>
[Collection("LocalDbSitePairConvergence")]
public sealed class LocalDbSitePairConvergenceTests : LocalDbSitePairHarness
{
// ---- data helpers -----------------------------------------------------------------
private static Task WriteTrackingAsync(ILocalDb db, string id, string status, string target)
=> db.ExecuteAsync(
"""
INSERT INTO OperationTracking (
TrackedOperationId, Kind, TargetSummary, Status, RetryCount,
CreatedAtUtc, UpdatedAtUtc)
VALUES (@id, 'ApiCallCached', @target, @status, 0, @now, @now)
ON CONFLICT(TrackedOperationId) DO UPDATE SET
Status = excluded.Status,
TargetSummary = excluded.TargetSummary,
UpdatedAtUtc = excluded.UpdatedAtUtc;
""",
new { id, status, target, now = DateTime.UtcNow.ToString("o") });
private static Task WriteEventAsync(ILocalDb db, string id, string message)
=> db.ExecuteAsync(
"""
INSERT INTO site_events (id, timestamp, event_type, severity, source, message)
VALUES (@id, @ts, 'script', 'Info', 'convergence-test', @message);
""",
new { id, ts = DateTimeOffset.UtcNow.ToString("o"), message });
private static async Task<string?> ReadTrackingStatusAsync(ILocalDb db, string id)
{
var rows = await db.QueryAsync(
"SELECT Status FROM OperationTracking WHERE TrackedOperationId = @id",
static r => r.GetString(0), new { id });
return rows.Count == 0 ? null : rows[0];
}
private static Task<IReadOnlyList<string>> ReadEventIdsAsync(ILocalDb db)
=> db.QueryAsync("SELECT id FROM site_events ORDER BY id", static r => r.GetString(0));
// ---- scenarios --------------------------------------------------------------------
[Fact]
public async Task TrackingRow_WrittenOnA_BecomesReadableOnB()
{
await WriteTrackingAsync(A, "op-a-1", "Submitted", "ERP.GetOrder");
await WaitUntilAsync(
async () => await ReadTrackingStatusAsync(B, "op-a-1") == "Submitted",
"the tracking row written on node A to appear on node B");
}
[Fact]
public async Task TrackingRow_WrittenOnB_BecomesReadableOnA()
{
// Replication is bidirectional even though only A dials: proving the passive node's
// writes flow back is what makes a failover in EITHER direction safe.
await WriteTrackingAsync(B, "op-b-1", "Submitted", "ERP.GetOrder");
await WaitUntilAsync(
async () => await ReadTrackingStatusAsync(A, "op-b-1") == "Submitted",
"the tracking row written on node B to appear on node A");
}
[Fact]
public async Task SameOperation_UpdatedOnBothNodes_ConvergesToOneWinner()
{
// Last-writer-wins on the primary key. The specific winner is not asserted — that is
// the HLC's business — but the two nodes MUST agree, and must agree on a value one of
// them actually wrote rather than a merge of both.
await WriteTrackingAsync(A, "op-conflict", "Submitted", "ERP.GetOrder");
await WaitUntilAsync(
async () => await ReadTrackingStatusAsync(B, "op-conflict") is not null,
"the conflicting row to exist on both nodes before it is updated");
await WriteTrackingAsync(A, "op-conflict", "Delivered", "ERP.GetOrder");
await WriteTrackingAsync(B, "op-conflict", "Parked", "ERP.GetOrder");
await WaitUntilAsync(
async () =>
{
var a = await ReadTrackingStatusAsync(A, "op-conflict");
var b = await ReadTrackingStatusAsync(B, "op-conflict");
return a is not null && a == b;
},
"both nodes to converge on one status for the contended operation");
var winner = await ReadTrackingStatusAsync(A, "op-conflict");
Assert.Contains(winner, new[] { "Delivered", "Parked" });
}
[Fact]
public async Task EventsLoggedOnBothNodes_ConvergeToTheUnion_WithNoIdCollisions()
{
// The reason site_events moved to GUID ids. Under the old autoincrement scheme both
// nodes would independently mint id=1, id=2, ... and last-writer-wins on the primary
// key would silently DESTROY one node's events instead of merging them. The union
// count is the assertion that catches that.
var idsFromA = Enumerable.Range(0, 5).Select(_ => Guid.NewGuid().ToString("N")).ToList();
var idsFromB = Enumerable.Range(0, 5).Select(_ => Guid.NewGuid().ToString("N")).ToList();
foreach (var id in idsFromA) await WriteEventAsync(A, id, "from A");
foreach (var id in idsFromB) await WriteEventAsync(B, id, "from B");
var expected = idsFromA.Concat(idsFromB).OrderBy(x => x, StringComparer.Ordinal).ToList();
await WaitUntilAsync(
async () => (await ReadEventIdsAsync(A)).SequenceEqual(expected)
&& (await ReadEventIdsAsync(B)).SequenceEqual(expected),
"both nodes to hold the union of all 10 events");
}
[Fact]
public async Task PeerOffline_ThenRejoins_CatchesUpOnEverythingItMissed()
{
// The failover case that motivates Phase 1: one node is down while the other keeps
// working, and nothing written during the outage may be lost.
await StopPassiveAsync();
var idsDuringOutage = Enumerable.Range(0, 5).Select(_ => Guid.NewGuid().ToString("N")).ToList();
foreach (var id in idsDuringOutage) await WriteEventAsync(A, id, "written while B was down");
await WriteTrackingAsync(A, "op-during-outage", "Delivered", "ERP.GetOrder");
// Node B's database survived the host teardown (pre-constructed instance), so this is
// a genuine rejoin rather than a fresh node.
await RestartPairAsync();
await WaitUntilAsync(
async () =>
{
var events = await ReadEventIdsAsync(B);
return idsDuringOutage.All(events.Contains)
&& await ReadTrackingStatusAsync(B, "op-during-outage") == "Delivered";
},
"node B to catch up on everything written while it was offline");
}
}