using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Communication; namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests; /// /// Task 5 (#22 Retry/Discard relay): tests for /// relaying operator Retry/Discard on a parked Site Call down to the owning /// site. The relay routes a / /// command via a /// to the /// (stood in by a TestProbe here) and awaits the site's /// . These tests never touch the /// SiteCalls repository — central never mutates the mirror row. /// public class SiteCallRelayTests : TestKit { /// /// A repository that fails every call — the relay path must NEVER touch the /// SiteCalls table (central is not the source of truth), so any /// invocation here is a test failure surfaced as an exception. /// private sealed class ThrowingRepository : ISiteCallAuditRepository { public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => throw new InvalidOperationException("relay must not write the SiteCalls row"); public Task GetAsync(TrackedOperationId id, CancellationToken ct = default) => throw new InvalidOperationException("relay must not read the SiteCalls row"); public Task> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => throw new InvalidOperationException("relay must not query the SiteCalls table"); public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => throw new InvalidOperationException("relay must not purge"); public Task ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute KPIs"); public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute per-site KPIs"); public Task> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute per-node KPIs"); } /// /// Builds a with a throwing repository and a /// short relay timeout, and registers as the /// central→site transport. /// private IActorRef CreateActor(IActorRef centralComm) { var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) }; var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor( new ThrowingRepository(), NullLogger.Instance, options))); actor.Tell(new RegisterCentralCommunication(centralComm)); return actor; } [Fact] public void RetrySiteCall_RoutesRetryParkedOperation_ToOwningSite() { var central = CreateTestProbe(); var actor = CreateActor(central.Ref); var id = Guid.NewGuid(); actor.Tell(new RetrySiteCallRequest("corr-1", id, "site-north")); // The relay must wrap a RetryParkedOperation in a SiteEnvelope addressed // to the owning site. var envelope = central.ExpectMsg(); Assert.Equal("site-north", envelope.SiteId); var relay = Assert.IsType(envelope.Message); Assert.Equal(id, relay.TrackedOperationId.Value); // The site applies it and acks; the relay reports Applied. central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true)); var response = ExpectMsg(); Assert.Equal("corr-1", response.CorrelationId); Assert.Equal(SiteCallRelayOutcome.Applied, response.Outcome); Assert.True(response.Success); Assert.True(response.SiteReachable); Assert.Null(response.ErrorMessage); } [Fact] public void DiscardSiteCall_RoutesDiscardParkedOperation_ToOwningSite() { var central = CreateTestProbe(); var actor = CreateActor(central.Ref); var id = Guid.NewGuid(); actor.Tell(new DiscardSiteCallRequest("corr-2", id, "site-south")); var envelope = central.ExpectMsg(); Assert.Equal("site-south", envelope.SiteId); var relay = Assert.IsType(envelope.Message); Assert.Equal(id, relay.TrackedOperationId.Value); central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true)); var response = ExpectMsg(); Assert.Equal(SiteCallRelayOutcome.Applied, response.Outcome); Assert.True(response.Success); } [Fact] public void RetrySiteCall_SiteRepliesNotApplied_ReportsNotParked() { var central = CreateTestProbe(); var actor = CreateActor(central.Ref); actor.Tell(new RetrySiteCallRequest("corr-3", Guid.NewGuid(), "site-north")); var envelope = central.ExpectMsg(); var relay = (RetryParkedOperation)envelope.Message; // The site found nothing parked — a definitive answer, not a failure. central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: false)); var response = ExpectMsg(); Assert.Equal(SiteCallRelayOutcome.NotParked, response.Outcome); Assert.False(response.Success); Assert.True(response.SiteReachable); } [Fact] public void RetrySiteCall_SiteRepliesError_ReportsOperationFailed() { var central = CreateTestProbe(); var actor = CreateActor(central.Ref); actor.Tell(new RetrySiteCallRequest("corr-4", Guid.NewGuid(), "site-north")); var envelope = central.ExpectMsg(); var relay = (RetryParkedOperation)envelope.Message; central.Reply(new ParkedOperationActionAck( relay.CorrelationId, Applied: false, "Parked message handler not available")); var response = ExpectMsg(); Assert.Equal(SiteCallRelayOutcome.OperationFailed, response.Outcome); Assert.False(response.Success); // The site WAS reached — this is an operation failure, not unreachable. Assert.True(response.SiteReachable); Assert.NotNull(response.ErrorMessage); } [Fact] public void RetrySiteCall_SiteNeverReplies_ReportsSiteUnreachable() { // A central comm probe that silently drops the relay — models an offline // site / no ClusterClient route: the Ask times out. var central = CreateTestProbe(); var actor = CreateActor(central.Ref); actor.Tell(new RetrySiteCallRequest("corr-5", Guid.NewGuid(), "site-offline")); central.ExpectMsg(); // Probe does not reply — the relay Ask times out (RelayTimeout = 500ms). var response = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.Equal(SiteCallRelayOutcome.SiteUnreachable, response.Outcome); Assert.False(response.Success); // The distinct unreachable signal the UI relies on. Assert.False(response.SiteReachable); Assert.NotNull(response.ErrorMessage); } [Fact] public void DiscardSiteCall_SiteNeverReplies_ReportsSiteUnreachable() { var central = CreateTestProbe(); var actor = CreateActor(central.Ref); actor.Tell(new DiscardSiteCallRequest("corr-6", Guid.NewGuid(), "site-offline")); central.ExpectMsg(); var response = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.Equal(SiteCallRelayOutcome.SiteUnreachable, response.Outcome); Assert.False(response.SiteReachable); } [Fact] public void RetrySiteCall_BeforeCentralCommunicationRegistered_ReportsSiteUnreachable() { // No RegisterCentralCommunication — the actor has no transport to reach // any site, so the only honest answer is "unreachable". var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) }; var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor( new ThrowingRepository(), NullLogger.Instance, options))); actor.Tell(new RetrySiteCallRequest("corr-7", Guid.NewGuid(), "site-north")); var response = ExpectMsg(); Assert.Equal(SiteCallRelayOutcome.SiteUnreachable, response.Outcome); Assert.False(response.SiteReachable); } // ── Task 14: operator-identity audit on a successful relay ── /// Captures the central direct-write audit events emitted by the relay. private sealed class RecordingCentralAuditWriter : ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter { public List Events { get; } = new(); public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent evt, CancellationToken ct = default) { lock (Events) { Events.Add(evt.AsRow()); } return Task.CompletedTask; } } /// /// A repository whose returns one fixed row (so the relay /// can read its Channel for the audit event) but still throws on every MUTATING /// call — central never writes the mirror row on the relay path. /// private sealed class RowReturningRepository : ISiteCallAuditRepository { private readonly SiteCall _row; public RowReturningRepository(SiteCall row) { _row = row; } public Task GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult(_row.TrackedOperationId == id ? _row : null); public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => throw new InvalidOperationException("relay must not write the SiteCalls row"); public Task> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => throw new InvalidOperationException("relay must not query the SiteCalls table"); public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => throw new InvalidOperationException("relay must not purge"); public Task ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute KPIs"); public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute per-site KPIs"); public Task> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute per-node KPIs"); } private static SiteCall MakeRow(Guid id, string channel = "ApiOutbound") => new() { TrackedOperationId = new TrackedOperationId(id), Channel = channel, Target = "ERP.GetOrder", SourceSite = "site-north", SourceNode = "node-a", Status = "Parked", RetryCount = 3, CreatedAtUtc = DateTime.UtcNow, UpdatedAtUtc = DateTime.UtcNow, IngestedAtUtc = DateTime.UtcNow, }; private (IActorRef Actor, RecordingCentralAuditWriter Writer) CreateActorWithAudit(IActorRef centralComm, SiteCall row) { var writer = new RecordingCentralAuditWriter(); var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) }; var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor( new RowReturningRepository(row), NullLogger.Instance, options, (ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter)writer))); actor.Tell(new RegisterCentralCommunication(centralComm)); return (actor, writer); } [Fact] public void RetrySiteCall_WhenApplied_EmitsCachedResolveSubmitted_WithOperator() { var central = CreateTestProbe(); var id = Guid.NewGuid(); var (actor, writer) = CreateActorWithAudit(central.Ref, MakeRow(id, channel: "ApiOutbound")); actor.Tell(new RetrySiteCallRequest("corr-a", id, "site-north", RequestedBy: "jdoe")); var envelope = central.ExpectMsg(); var relay = (RetryParkedOperation)envelope.Message; central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true)); ExpectMsg(r => r.Success); AwaitAssert(() => { List events; lock (writer.Events) { events = writer.Events.ToList(); } Assert.Single(events); var evt = events[0]; Assert.Equal(Commons.Types.Enums.AuditChannel.ApiOutbound, evt.Channel); Assert.Equal(Commons.Types.Enums.AuditKind.CachedResolve, evt.Kind); Assert.Equal(Commons.Types.Enums.AuditStatus.Submitted, evt.Status); Assert.Equal("jdoe", evt.Actor); Assert.Equal(id, evt.CorrelationId); }); } [Fact] public void RetrySiteCall_WhenNotApplied_EmitsNoAuditRow() { var central = CreateTestProbe(); var id = Guid.NewGuid(); var (actor, writer) = CreateActorWithAudit(central.Ref, MakeRow(id)); actor.Tell(new RetrySiteCallRequest("corr-b", id, "site-north", RequestedBy: "jdoe")); var envelope = central.ExpectMsg(); var relay = (RetryParkedOperation)envelope.Message; // Nothing was parked at the site — no state change, so no operator audit row. central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: false)); ExpectMsg(r => r.Outcome == SiteCallRelayOutcome.NotParked); // Give any erroneous emission a chance to land, then assert none did. ExpectNoMsg(TimeSpan.FromMilliseconds(300)); lock (writer.Events) { Assert.Empty(writer.Events); } } [Fact] public void DiscardSiteCall_WhenApplied_EmitsCachedResolveDiscarded_WithOperator() { var central = CreateTestProbe(); var id = Guid.NewGuid(); var (actor, writer) = CreateActorWithAudit(central.Ref, MakeRow(id, channel: "DbOutbound")); actor.Tell(new DiscardSiteCallRequest("corr-c", id, "site-north", RequestedBy: "jdoe")); var envelope = central.ExpectMsg(); var relay = (DiscardParkedOperation)envelope.Message; central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true)); ExpectMsg(r => r.Success); AwaitAssert(() => { List events; lock (writer.Events) { events = writer.Events.ToList(); } Assert.Single(events); var evt = events[0]; Assert.Equal(Commons.Types.Enums.AuditChannel.DbOutbound, evt.Channel); Assert.Equal(Commons.Types.Enums.AuditStatus.Discarded, evt.Status); Assert.Equal("jdoe", evt.Actor); }); } }