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; /// /// Task 5 (#22 Retry/Discard relay): tests the site-side execution of a /// central→site / /// relay command on the . The cached /// call's S&F buffer message id is the , 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 (Applied=false), never a corruption. /// 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.Instance); var options = new StoreAndForwardOptions { DefaultRetryInterval = TimeSpan.Zero, DefaultMaxRetries = 1, RetryTimerInterval = TimeSpan.FromMinutes(10), ReplicationEnabled = false, }; _service = new StoreAndForwardService( _storage, options, NullLogger.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); } /// /// Enqueues a cached-call message whose S&F id is the supplied /// and parks it via the retry sweep. /// 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(); 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(); 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(); // 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(); // 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(); 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); } }