using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Messages.Audit; using ScadaLink.Commons.Messages.RemoteQuery; using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Audit; using ScadaLink.Communication; namespace ScadaLink.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"); } /// /// 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); } }