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_OmitsThatGapItem() { 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); // Both missing locally, but C already has an in-flight pending row. StageReturns(true); _deploymentRepo.StagePendingIfAbsentAsync( 3, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(false); 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()); } }