using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// /// Unit tests for the central-side startup-reconciliation handler /// (). A site node reports its local inventory; the /// service diffs it against central's expected deployed set, stages fresh fetch /// tokens for the gap (missing/stale), and reports orphans. /// public class ReconcileServiceTests { private const string BaseUrl = "https://central.example:9000"; private const string SiteIdentifier = "site1"; private const int SiteId = 7; private readonly IDeploymentManagerRepository _deploymentRepo = Substitute.For(); private readonly ISiteRepository _siteRepo = Substitute.For(); private ReconcileService CreateService(TimeSpan? ttl = null) { var options = Options.Create(new CommunicationOptions { CentralFetchBaseUrl = BaseUrl, PendingDeploymentTtl = ttl ?? TimeSpan.FromMinutes(5), }); return new ReconcileService( _deploymentRepo, _siteRepo, options, NullLogger.Instance); } private void SiteResolves() => _siteRepo.GetSiteByIdentifierAsync(SiteIdentifier, Arg.Any()) .Returns(new Site("Site One", SiteIdentifier) { Id = SiteId }); private static ExpectedDeployment Expected(int id, string name, string rev, string dep, bool enabled = true) => new(id, name, rev, dep, enabled); private void ExpectedSet(params ExpectedDeployment[] expected) => _deploymentRepo.GetExpectedDeploymentsForSiteAsync(SiteId, Arg.Any()) .Returns(expected.ToList()); private void SnapshotFor(ExpectedDeployment exp) => _deploymentRepo.GetDeployedSnapshotByInstanceIdAsync(exp.InstanceId, Arg.Any()) .Returns(new DeployedConfigSnapshot(exp.DeploymentId, exp.RevisionHash, $"{{\"cfg\":\"{exp.InstanceUniqueName}\"}}")); private void StageReturns(bool result) => _deploymentRepo.StagePendingIfAbsentAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(result); private static ReconcileSiteRequest Request(params (string Name, string Rev)[] local) => new(SiteIdentifier, "node-a", local.ToDictionary(x => x.Name, x => x.Rev)); [Fact] public async Task Reconcile_GapIsMissingAndStale_CurrentOmitted_WithFreshTokensAndSnapshotDeploymentIds() { SiteResolves(); var a = Expected(1, "inst-A", "rev1", "dep-A"); var b = Expected(2, "inst-B", "rev2", "dep-B"); var c = Expected(3, "inst-C", "rev3", "dep-C", enabled: false); ExpectedSet(a, b, c); SnapshotFor(b); SnapshotFor(c); StageReturns(true); // Node has A current, B stale (revOLD), C missing entirely. var response = await CreateService().ReconcileAsync( Request(("inst-A", "rev1"), ("inst-B", "revOLD"))); Assert.Equal(BaseUrl, response.CentralFetchBaseUrl); Assert.Equal(2, response.Gap.Count); Assert.DoesNotContain(response.Gap, g => g.InstanceUniqueName == "inst-A"); var gapB = Assert.Single(response.Gap, g => g.InstanceUniqueName == "inst-B"); Assert.Equal("dep-B", gapB.DeploymentId); Assert.Equal("rev2", gapB.RevisionHash); Assert.True(gapB.IsEnabled); Assert.False(string.IsNullOrWhiteSpace(gapB.FetchToken)); var gapC = Assert.Single(response.Gap, g => g.InstanceUniqueName == "inst-C"); Assert.Equal("dep-C", gapC.DeploymentId); Assert.Equal("rev3", gapC.RevisionHash); Assert.False(gapC.IsEnabled); Assert.False(string.IsNullOrWhiteSpace(gapC.FetchToken)); // Fresh, distinct tokens per gap item. Assert.NotEqual(gapB.FetchToken, gapC.FetchToken); Assert.Empty(response.OrphanNames); } [Fact] public async Task Reconcile_LocalNameNotInExpected_IsReportedAsOrphan() { SiteResolves(); var a = Expected(1, "inst-A", "rev1", "dep-A"); ExpectedSet(a); StageReturns(true); // inst-A is current; inst-Z is not deployed centrally → orphan. var response = await CreateService().ReconcileAsync( Request(("inst-A", "rev1"), ("inst-Z", "revX"))); Assert.Empty(response.Gap); var orphan = Assert.Single(response.OrphanNames); Assert.Equal("inst-Z", orphan); } [Fact] public async Task Reconcile_StagePendingReturnsFalse_ExistingRowPresent_ReturnsExistingTokenInGap() { // Live-found bug: two site nodes concurrently missing the SAME instance both reconcile at // startup. The first stages a pending row; the second's StagePendingIfAbsent returns false. // The second node must STILL heal — the handler reads the existing pending row and returns // ITS deploymentId/revHash/token (the fetch token is multi-use within its TTL). SiteResolves(); var b = Expected(2, "inst-B", "rev2", "dep-B"); var c = Expected(3, "inst-C", "rev3", "dep-C"); ExpectedSet(b, c); SnapshotFor(b); SnapshotFor(c); // B stages fresh (true). C already has a pending row (a concurrent reconcile from the // other node, or an in-flight deploy, staged it first) → stage returns false. StageReturns(true); _deploymentRepo.StagePendingIfAbsentAsync( 3, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(false); // The existing pending row for inst-C carries the FIRST node's deploymentId + token. var nowUtc = DateTimeOffset.UtcNow; _deploymentRepo.GetPendingDeploymentByInstanceIdAsync(3, Arg.Any()) .Returns(new PendingDeployment( "dep-C-existing", 3, "rev3-existing", "{\"cfg\":\"inst-C\"}", "tok-C-existing", nowUtc, nowUtc.AddMinutes(5))); var response = await CreateService().ReconcileAsync(Request(/* empty local inventory */)); Assert.Equal(2, response.Gap.Count); var gapB = Assert.Single(response.Gap, g => g.InstanceUniqueName == "inst-B"); Assert.Equal("dep-B", gapB.DeploymentId); // inst-C IS included, carrying the EXISTING row's deploymentId/revHash/token — NOT the // snapshot's (snapshot.DeploymentId would be "dep-C"). var gapC = Assert.Single(response.Gap, g => g.InstanceUniqueName == "inst-C"); Assert.Equal("dep-C-existing", gapC.DeploymentId); Assert.Equal("rev3-existing", gapC.RevisionHash); Assert.Equal("tok-C-existing", gapC.FetchToken); Assert.True(gapC.IsEnabled); } [Fact] public async Task Reconcile_StagePendingReturnsFalse_NoExistingRow_OmitsThatGapItem() { // Fallback path: stage returns false, but the pending row raced away (was purged between // the stage attempt and the read). Omit the item — the node retries on the next reconcile. SiteResolves(); var b = Expected(2, "inst-B", "rev2", "dep-B"); var c = Expected(3, "inst-C", "rev3", "dep-C"); ExpectedSet(b, c); SnapshotFor(b); SnapshotFor(c); StageReturns(true); _deploymentRepo.StagePendingIfAbsentAsync( 3, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(false); // The pending row was purged between the stage attempt and the read. _deploymentRepo.GetPendingDeploymentByInstanceIdAsync(3, Arg.Any()) .Returns((PendingDeployment?)null); var response = await CreateService().ReconcileAsync(Request(/* empty local inventory */)); var gap = Assert.Single(response.Gap); Assert.Equal("inst-B", gap.InstanceUniqueName); Assert.DoesNotContain(response.Gap, g => g.InstanceUniqueName == "inst-C"); } [Fact] public async Task Reconcile_SnapshotMissing_SkipsGapItem() { SiteResolves(); var b = Expected(2, "inst-B", "rev2", "dep-B"); ExpectedSet(b); // No snapshot configured for inst-B → repo returns null (deleted race). StageReturns(true); var response = await CreateService().ReconcileAsync(Request()); Assert.Empty(response.Gap); Assert.Empty(response.OrphanNames); } [Fact] public async Task Reconcile_UnknownSite_ReturnsEmptyResponse_NoThrow() { _siteRepo.GetSiteByIdentifierAsync(SiteIdentifier, Arg.Any()) .Returns((Site?)null); var response = await CreateService().ReconcileAsync( Request(("inst-A", "rev1"))); Assert.Empty(response.Gap); Assert.Empty(response.OrphanNames); Assert.Equal(BaseUrl, response.CentralFetchBaseUrl); await _deploymentRepo.DidNotReceive() .GetExpectedDeploymentsForSiteAsync(Arg.Any(), Arg.Any()); } }