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);
}
}