diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbConfigConvergenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbConfigConvergenceTests.cs new file mode 100644 index 00000000..418afe6b --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbConfigConvergenceTests.cs @@ -0,0 +1,177 @@ +using ZB.MOM.WW.LocalDb; + +namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; + +/// +/// Phase 2 convergence: deployed configuration across a real site pair, plus the resync +/// behaviour that the bespoke replicator needed a directional guard for. +/// +/// +/// Companion to : same +/// specifications-before-deletion role, for the intents held by +/// SiteReplicationActorTests and SfBufferResyncPredicateTests rather than by +/// the store-and-forward ReplicationService. +/// +[Collection("LocalDbSitePairConvergence")] +public sealed class LocalDbConfigConvergenceTests : LocalDbSitePairHarness +{ + // ---- data helpers ----------------------------------------------------------------- + + private static Task DeployConfigAsync(ILocalDb db, string instance, string configJson, string deploymentId) + => db.ExecuteAsync( + """ + INSERT INTO deployed_configurations ( + instance_unique_name, config_json, deployment_id, revision_hash, + is_enabled, deployed_at) + VALUES (@instance, @configJson, @deploymentId, @deploymentId, 1, @now) + ON CONFLICT(instance_unique_name) DO UPDATE SET + config_json = excluded.config_json, + deployment_id = excluded.deployment_id, + revision_hash = excluded.revision_hash, + deployed_at = excluded.deployed_at; + """, + new { instance, configJson, deploymentId, now = DateTime.UtcNow.ToString("o") }); + + private static async Task ReadConfigAsync(ILocalDb db, string instance) + { + var rows = await db.QueryAsync( + "SELECT config_json FROM deployed_configurations WHERE instance_unique_name = @instance", + static r => r.GetString(0), new { instance }); + return rows.Count == 0 ? null : rows[0]; + } + + private static Task EnqueueAsync(ILocalDb db, string id) + => db.ExecuteAsync( + """ + INSERT INTO sf_messages ( + id, category, target, payload_json, retry_count, max_retries, + retry_interval_ms, created_at, status) + VALUES (@id, 0, 'central', '{}', 0, 50, 30000, @now, 0); + """, + new { id, now = DateTime.UtcNow.ToString("o") }); + + private static async Task MessageExistsAsync(ILocalDb db, string id) + { + var rows = await db.QueryAsync( + "SELECT 1 FROM sf_messages WHERE id = @id", static r => r.GetInt32(0), new { id }); + return rows.Count > 0; + } + + // ---- scenarios -------------------------------------------------------------------- + + [Fact] + public async Task ANodeWithNewerLocalRows_KeepsThem_WhenAPeerSnapshotArrives() + { + // N1 Critical (was: SfBufferResyncPredicateTests). That test existed because the + // bespoke replicator's ReplaceAllAsync was a destructive DELETE-then-INSERT: a resync + // running in the wrong direction WIPED a live buffer, so the code needed a + // same-oldest-Up predicate to decide who had authority. The divergence case was a + // rolling restart leaving the delivering node oldest but not leader. + // + // Under LocalDb that failure mode is structurally impossible rather than guarded + // against: snapshot resync merges per row under LWW and never deletes. So this is + // deliberately NOT a directional-authority test — there is no active/standby + // asymmetry left to enforce. It asserts the property the guard was protecting: no + // node loses rows to a peer's snapshot. + // + // The setup is the wipe scenario made concrete. Each node holds a row the other has + // never seen, and they disagree about a third; then they resync. + await DeployConfigAsync(A, "shared-instance", """{"v":1}""", "dep-1"); + await WaitUntilAsync( + async () => await ReadConfigAsync(B, "shared-instance") is not null, + "the shared row to exist on both nodes before they diverge"); + + await StopPassiveAsync(); + + // A keeps working while B is down: a brand-new buffered message and a newer version + // of the shared config. + await EnqueueAsync(A, "live-row-on-a"); + await DeployConfigAsync(A, "shared-instance", """{"v":2}""", "dep-2"); + + // B is offline but its database is still writable — it also has local state the + // snapshot exchange must not destroy. + await EnqueueAsync(B, "live-row-on-b"); + + await RestartPairAsync(); + + await WaitUntilAsync( + async () => + await MessageExistsAsync(A, "live-row-on-a") && + await MessageExistsAsync(A, "live-row-on-b") && + await MessageExistsAsync(B, "live-row-on-a") && + await MessageExistsAsync(B, "live-row-on-b"), + "both nodes to hold the UNION of the rows each wrote while partitioned"); + + // And the contended row converges to the newer write rather than either node's + // snapshot flattening the other. + await WaitUntilAsync( + async () => await ReadConfigAsync(A, "shared-instance") == """{"v":2}""" + && await ReadConfigAsync(B, "shared-instance") == """{"v":2}""", + "both nodes to converge on the newer config for the contended instance"); + } + + [Fact] + public async Task ConfigDeployedOnA_ConvergesToB_WithoutAnyFetch() + { + // Was: SiteReplicationActorTests #1-#5 (notify-and-fetch — the standby is told a + // deploy happened, then HTTP-fetches the config itself, with retries and a + // superseded-404 path). Under CDC the config row simply replicates, so that whole + // exchange is deleted at Task 15. + // + // The plan called for asserting a fetcher test double records zero invocations. That + // would be THEATRE here and is deliberately not done: this harness has no actor + // system, no central, and no IDeploymentConfigFetcher in the graph at all, so a + // double would record zero calls whether or not the fetch path still existed. An + // assertion that cannot fail is worse than none. + // + // What this test honestly proves is the positive half: the config reaches node B + // through replication ALONE, in a process where fetching is not merely unused but + // absent. The negative half — that no fetch path survives — is proved by Task 15 + // deleting the code and the build still passing, which is a stronger check than any + // runtime counter. + // + // Scope note (D1): SiteReconciliationActor survives Phase 2 and legitimately fetches + // over HTTP at node startup when central reports gaps. "The standby never fetches, + // ever" would be a FALSE claim; the claim is about the deploy path only. + await DeployConfigAsync(A, "inst-deploy", """{"setpoint":42}""", "dep-100"); + + await WaitUntilAsync( + async () => await ReadConfigAsync(B, "inst-deploy") == """{"setpoint":42}""", + "the deployed config to reach node B over replication alone"); + } + + [Fact] + public async Task RedeployingAnInstance_ConvergesToTheNewRevision_OnBothNodes() + { + // The steady-state deploy loop, and the reason the old path needed a deployed_at + // guard: a standby that applied a STALE fetch would pin an instance to superseded + // config until the next deploy. Under LWW the newer write wins on the primary key, + // so the guard is not what protects this — but the outcome still has to hold. + await DeployConfigAsync(A, "inst-redeploy", """{"rev":"a"}""", "dep-a"); + await WaitUntilAsync( + async () => await ReadConfigAsync(B, "inst-redeploy") == """{"rev":"a"}""", + "the first revision to reach node B"); + + await DeployConfigAsync(A, "inst-redeploy", """{"rev":"b"}""", "dep-b"); + + await WaitUntilAsync( + async () => await ReadConfigAsync(A, "inst-redeploy") == """{"rev":"b"}""" + && await ReadConfigAsync(B, "inst-redeploy") == """{"rev":"b"}""", + "both nodes to converge on the redeployed revision"); + } + + [Fact] + public async Task ConfigDeployedOnB_ConvergesToA() + { + // Deliberately the reverse direction. The bespoke replicator was asymmetric by + // design — the active node pushed, the standby fetched — so "which node deployed it" + // was a meaningful question. Under CDC it is not, and this test is what says so. + // After a failover the surviving node must be able to deploy without waiting to be + // promoted to some authority role. + await DeployConfigAsync(B, "inst-from-b", """{"origin":"b"}""", "dep-b1"); + + await WaitUntilAsync( + async () => await ReadConfigAsync(A, "inst-from-b") == """{"origin":"b"}""", + "a config deployed on node B to reach node A"); + } +}