feat(sitecallaudit): central→site Retry/Discard relay for parked operations
This commit is contained in:
@@ -382,6 +382,64 @@ public class CommunicationServiceTests : TestKit
|
||||
Assert.Equal("plant-a", result.Sites[0].SourceSite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrySiteCallAsync_BeforeSiteCallAuditSet_Throws()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.RetrySiteCallAsync(new RetrySiteCallRequest("corr-1", Guid.NewGuid(), "plant-a")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrySiteCallAsync_AsksSiteCallAuditProxyDirectly()
|
||||
{
|
||||
// The relay is initiated by Asking the central-local Site Call Audit
|
||||
// proxy directly (no SiteEnvelope wrapping at this layer — the actor
|
||||
// does the site routing itself).
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
var probe = CreateTestProbe();
|
||||
service.SetSiteCallAudit(probe.Ref);
|
||||
|
||||
var request = new RetrySiteCallRequest("corr-r", Guid.NewGuid(), "plant-a");
|
||||
var task = service.RetrySiteCallAsync(request);
|
||||
|
||||
var received = probe.ExpectMsg<RetrySiteCallRequest>();
|
||||
Assert.Same(request, received);
|
||||
var reply = new RetrySiteCallResponse(
|
||||
"corr-r", SiteCallRelayOutcome.Applied, true, true, null);
|
||||
probe.Reply(reply);
|
||||
|
||||
Assert.Same(reply, await task);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardSiteCallAsync_AsksSiteCallAuditProxyDirectly()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
var probe = CreateTestProbe();
|
||||
service.SetSiteCallAudit(probe.Ref);
|
||||
|
||||
var request = new DiscardSiteCallRequest("corr-d", Guid.NewGuid(), "plant-a");
|
||||
var task = service.DiscardSiteCallAsync(request);
|
||||
|
||||
var received = probe.ExpectMsg<DiscardSiteCallRequest>();
|
||||
Assert.Same(request, received);
|
||||
var reply = new DiscardSiteCallResponse(
|
||||
"corr-d", SiteCallRelayOutcome.SiteUnreachable, false, false, "unreachable");
|
||||
probe.Reply(reply);
|
||||
|
||||
var result = await task;
|
||||
Assert.Same(reply, result);
|
||||
Assert.False(result.SiteReachable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for CentralCommunicationActor: verifies the message is wrapped
|
||||
/// in a SiteEnvelope targeting the requested site and replies with a typed
|
||||
|
||||
@@ -214,4 +214,72 @@ public class SiteCommunicationActorTests : TestKit
|
||||
|
||||
ExpectMsg<EventLogQueryResponse>(msg => !msg.Success);
|
||||
}
|
||||
|
||||
// ── Task 5 (#22): central→site Retry/Discard relay for parked cached calls ──
|
||||
|
||||
[Fact]
|
||||
public void RetryParkedOperation_WithHandler_ForwardedToParkedMessageHandler()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
var handlerProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new RegisterLocalHandler(LocalHandlerType.ParkedMessages, handlerProbe.Ref));
|
||||
|
||||
var id = Commons.Types.TrackedOperationId.New();
|
||||
siteActor.Tell(new RetryParkedOperation("corr-rp", id));
|
||||
|
||||
handlerProbe.ExpectMsg<RetryParkedOperation>(msg =>
|
||||
msg.CorrelationId == "corr-rp" && msg.TrackedOperationId.Equals(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscardParkedOperation_WithHandler_ForwardedToParkedMessageHandler()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
var handlerProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new RegisterLocalHandler(LocalHandlerType.ParkedMessages, handlerProbe.Ref));
|
||||
|
||||
var id = Commons.Types.TrackedOperationId.New();
|
||||
siteActor.Tell(new DiscardParkedOperation("corr-dp", id));
|
||||
|
||||
handlerProbe.ExpectMsg<DiscardParkedOperation>(msg =>
|
||||
msg.CorrelationId == "corr-dp" && msg.TrackedOperationId.Equals(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryParkedOperation_WithoutHandler_RepliesNotAppliedAck()
|
||||
{
|
||||
// No parked-message handler registered — the relay must get a definitive
|
||||
// non-applied ack, not silence (the SiteCallAuditActor's Ask must not
|
||||
// hang and then mis-report site-unreachable when the site IS reachable).
|
||||
var dmProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new RetryParkedOperation("corr-no-handler", Commons.Types.TrackedOperationId.New()));
|
||||
|
||||
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||
Assert.Equal("corr-no-handler", ack.CorrelationId);
|
||||
Assert.False(ack.Applied);
|
||||
Assert.NotNull(ack.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscardParkedOperation_WithoutHandler_RepliesNotAppliedAck()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new DiscardParkedOperation("corr-no-handler", Commons.Types.TrackedOperationId.New()));
|
||||
|
||||
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||
Assert.False(ack.Applied);
|
||||
Assert.NotNull(ack.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
212
tests/ScadaLink.SiteCallAudit.Tests/SiteCallRelayTests.cs
Normal file
212
tests/ScadaLink.SiteCallAudit.Tests/SiteCallRelayTests.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Task 5 (#22 Retry/Discard relay): tests for <see cref="SiteCallAuditActor"/>
|
||||
/// relaying operator Retry/Discard on a parked Site Call down to the owning
|
||||
/// site. The relay routes a <see cref="RetryParkedOperation"/> /
|
||||
/// <see cref="DiscardParkedOperation"/> command via a <see cref="SiteEnvelope"/>
|
||||
/// to the <see cref="ScadaLink.Communication.Actors.CentralCommunicationActor"/>
|
||||
/// (stood in by a <c>TestProbe</c> here) and awaits the site's
|
||||
/// <see cref="ParkedOperationActionAck"/>. These tests never touch the
|
||||
/// <c>SiteCalls</c> repository — central never mutates the mirror row.
|
||||
/// </summary>
|
||||
public class SiteCallRelayTests : TestKit
|
||||
{
|
||||
/// <summary>
|
||||
/// A repository that fails every call — the relay path must NEVER touch the
|
||||
/// <c>SiteCalls</c> table (central is not the source of truth), so any
|
||||
/// invocation here is a test failure surfaced as an exception.
|
||||
/// </summary>
|
||||
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<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("relay must not read the SiteCalls row");
|
||||
|
||||
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("relay must not query the SiteCalls table");
|
||||
|
||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("relay must not purge");
|
||||
|
||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("relay must not compute KPIs");
|
||||
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("relay must not compute per-site KPIs");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="SiteCallAuditActor"/> with a throwing repository and a
|
||||
/// short relay timeout, and registers <paramref name="centralComm"/> as the
|
||||
/// central→site transport.
|
||||
/// </summary>
|
||||
private IActorRef CreateActor(IActorRef centralComm)
|
||||
{
|
||||
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
|
||||
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
||||
new ThrowingRepository(),
|
||||
NullLogger<SiteCallAuditActor>.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<SiteEnvelope>();
|
||||
Assert.Equal("site-north", envelope.SiteId);
|
||||
var relay = Assert.IsType<RetryParkedOperation>(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<RetrySiteCallResponse>();
|
||||
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<SiteEnvelope>();
|
||||
Assert.Equal("site-south", envelope.SiteId);
|
||||
var relay = Assert.IsType<DiscardParkedOperation>(envelope.Message);
|
||||
Assert.Equal(id, relay.TrackedOperationId.Value);
|
||||
|
||||
central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true));
|
||||
|
||||
var response = ExpectMsg<DiscardSiteCallResponse>();
|
||||
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<SiteEnvelope>();
|
||||
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<RetrySiteCallResponse>();
|
||||
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<SiteEnvelope>();
|
||||
var relay = (RetryParkedOperation)envelope.Message;
|
||||
central.Reply(new ParkedOperationActionAck(
|
||||
relay.CorrelationId, Applied: false, "Parked message handler not available"));
|
||||
|
||||
var response = ExpectMsg<RetrySiteCallResponse>();
|
||||
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<SiteEnvelope>();
|
||||
// Probe does not reply — the relay Ask times out (RelayTimeout = 500ms).
|
||||
|
||||
var response = ExpectMsg<RetrySiteCallResponse>(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<SiteEnvelope>();
|
||||
|
||||
var response = ExpectMsg<DiscardSiteCallResponse>(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<SiteCallAuditActor>.Instance,
|
||||
options)));
|
||||
|
||||
actor.Tell(new RetrySiteCallRequest("corr-7", Guid.NewGuid(), "site-north"));
|
||||
|
||||
var response = ExpectMsg<RetrySiteCallResponse>();
|
||||
Assert.Equal(SiteCallRelayOutcome.SiteUnreachable, response.Outcome);
|
||||
Assert.False(response.SiteReachable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Commons.Messages.RemoteQuery;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.StoreAndForward.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 5 (#22 Retry/Discard relay): tests the site-side execution of a
|
||||
/// central→site <see cref="RetryParkedOperation"/> / <see cref="DiscardParkedOperation"/>
|
||||
/// relay command on the <see cref="ParkedMessageHandlerActor"/>. The cached
|
||||
/// call's S&F buffer message id is the <see cref="TrackedOperationId"/>, so
|
||||
/// the handler resolves the parked row directly from the tracked id and reuses
|
||||
/// the existing parked-message Retry/Discard primitive. A non-parked operation
|
||||
/// must be a safe no-op (<c>Applied=false</c>), never a corruption.
|
||||
/// </summary>
|
||||
public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _keepAlive;
|
||||
private readonly StoreAndForwardStorage _storage;
|
||||
private readonly StoreAndForwardService _service;
|
||||
|
||||
public ParkedOperationRelayTests()
|
||||
{
|
||||
var connStr = $"Data Source=RelayTests_{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
_keepAlive = new SqliteConnection(connStr);
|
||||
_keepAlive.Open();
|
||||
|
||||
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
|
||||
|
||||
var options = new StoreAndForwardOptions
|
||||
{
|
||||
DefaultRetryInterval = TimeSpan.Zero,
|
||||
DefaultMaxRetries = 1,
|
||||
RetryTimerInterval = TimeSpan.FromMinutes(10),
|
||||
ReplicationEnabled = false,
|
||||
};
|
||||
|
||||
_service = new StoreAndForwardService(
|
||||
_storage, options, NullLogger<StoreAndForwardService>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync() => await _storage.InitializeAsync();
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) _keepAlive.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues a cached-call message whose S&F id is the supplied
|
||||
/// <see cref="TrackedOperationId"/> and parks it via the retry sweep.
|
||||
/// </summary>
|
||||
private async Task ParkCachedCallAsync(TrackedOperationId id)
|
||||
{
|
||||
_service.RegisterDeliveryHandler(
|
||||
StoreAndForwardCategory.ExternalSystem, _ => throw new HttpRequestException("always fails"));
|
||||
await _service.EnqueueAsync(
|
||||
StoreAndForwardCategory.ExternalSystem, "ERP.GetOrder", """{}""",
|
||||
maxRetries: 1, messageId: id.ToString());
|
||||
await _service.RetryPendingMessagesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryParkedOperation_ParkedCachedCall_ResetsToPendingAndApplied()
|
||||
{
|
||||
var id = TrackedOperationId.New();
|
||||
await ParkCachedCallAsync(id);
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
|
||||
actor.Tell(new RetryParkedOperation("corr-1", id));
|
||||
|
||||
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||
Assert.True(ack.Applied);
|
||||
Assert.Equal("corr-1", ack.CorrelationId);
|
||||
Assert.Null(ack.ErrorMessage);
|
||||
|
||||
// The parked row was reset back to Pending so the retry sweep picks it up.
|
||||
var msg = await _storage.GetMessageByIdAsync(id.ToString());
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardParkedOperation_ParkedCachedCall_RemovesRowAndApplied()
|
||||
{
|
||||
var id = TrackedOperationId.New();
|
||||
await ParkCachedCallAsync(id);
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
|
||||
actor.Tell(new DiscardParkedOperation("corr-2", id));
|
||||
|
||||
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||
Assert.True(ack.Applied);
|
||||
Assert.Equal("corr-2", ack.CorrelationId);
|
||||
|
||||
var msg = await _storage.GetMessageByIdAsync(id.ToString());
|
||||
Assert.Null(msg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryParkedOperation_UnknownOperation_IsSafeNoOp()
|
||||
{
|
||||
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
|
||||
actor.Tell(new RetryParkedOperation("corr-3", TrackedOperationId.New()));
|
||||
|
||||
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||
// No parked row matched — definitive "nothing to do", not an error.
|
||||
Assert.False(ack.Applied);
|
||||
Assert.Equal("corr-3", ack.CorrelationId);
|
||||
Assert.Null(ack.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryParkedOperation_NonParkedOperation_IsSafeNoOpAndDoesNotCorrupt()
|
||||
{
|
||||
// Enqueue a cached call but DO NOT park it — it stays Pending.
|
||||
var id = TrackedOperationId.New();
|
||||
_service.RegisterDeliveryHandler(
|
||||
StoreAndForwardCategory.ExternalSystem, _ => throw new HttpRequestException("fails"));
|
||||
await _service.EnqueueAsync(
|
||||
StoreAndForwardCategory.ExternalSystem, "ERP.GetOrder", """{}""",
|
||||
maxRetries: 5, messageId: id.ToString());
|
||||
|
||||
var before = await _storage.GetMessageByIdAsync(id.ToString());
|
||||
Assert.Equal(StoreAndForwardMessageStatus.Pending, before!.Status);
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
|
||||
actor.Tell(new RetryParkedOperation("corr-4", id));
|
||||
|
||||
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||
// The row is Pending, not Parked — Retry must be a no-op, not a mutation.
|
||||
Assert.False(ack.Applied);
|
||||
|
||||
var after = await _storage.GetMessageByIdAsync(id.ToString());
|
||||
Assert.NotNull(after);
|
||||
Assert.Equal(StoreAndForwardMessageStatus.Pending, after!.Status);
|
||||
// retry_count untouched — a Parked-only Retry must not reset a live row.
|
||||
Assert.Equal(before.RetryCount, after.RetryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardParkedOperation_NonParkedOperation_IsSafeNoOp()
|
||||
{
|
||||
var id = TrackedOperationId.New();
|
||||
_service.RegisterDeliveryHandler(
|
||||
StoreAndForwardCategory.ExternalSystem, _ => throw new HttpRequestException("fails"));
|
||||
await _service.EnqueueAsync(
|
||||
StoreAndForwardCategory.ExternalSystem, "ERP.GetOrder", """{}""",
|
||||
maxRetries: 5, messageId: id.ToString());
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
|
||||
actor.Tell(new DiscardParkedOperation("corr-5", id));
|
||||
|
||||
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||
Assert.False(ack.Applied);
|
||||
|
||||
// The Pending row must NOT have been deleted by a Parked-only Discard.
|
||||
var after = await _storage.GetMessageByIdAsync(id.ToString());
|
||||
Assert.NotNull(after);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user