fix(store-and-forward): resolve StoreAndForward-004,005,010,013 — accurate handler-contract doc, conditional sweep writes, reset LastAttemptAt on parked retry, test coverage

This commit is contained in:
Joseph Doherty
2026-05-16 21:44:10 -04:00
parent a88bec9376
commit 5672502d83
7 changed files with 447 additions and 18 deletions

View File

@@ -0,0 +1,173 @@
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.Enums;
namespace ScadaLink.StoreAndForward.Tests;
/// <summary>
/// StoreAndForward-013: tests for the <see cref="ParkedMessageHandlerActor"/> actor
/// bridge — the Query/Retry/Discard request-to-response mapping and the
/// <c>ExtractMethodName</c> payload-JSON parsing (including the malformed-JSON branch).
/// </summary>
public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposable
{
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _service;
public ParkedMessageHandlerActorTests()
{
var connStr = $"Data Source=ActorTests_{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 message and parks it via the retry sweep (MaxRetries = 1).</summary>
private async Task<string> ParkMessageAsync(StoreAndForwardCategory category, string payloadJson)
{
_service.RegisterDeliveryHandler(category, _ => throw new HttpRequestException("always fails"));
var result = await _service.EnqueueAsync(category, "target", payloadJson, maxRetries: 1);
await _service.RetryPendingMessagesAsync();
return result.MessageId;
}
[Fact]
public async Task Query_ReturnsParkedEntries_WithExtractedMethodName()
{
await ParkMessageAsync(StoreAndForwardCategory.ExternalSystem,
"""{"MethodName":"StartPump","Args":{}}""");
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
actor.Tell(new ParkedMessageQueryRequest("corr-1", "site-1", 1, 50, DateTimeOffset.UtcNow));
var response = ExpectMsg<ParkedMessageQueryResponse>();
Assert.True(response.Success);
Assert.Equal("corr-1", response.CorrelationId);
Assert.Equal("site-1", response.SiteId);
Assert.Single(response.Messages);
Assert.Equal("StartPump", response.Messages[0].MethodName);
Assert.Equal(StoreAndForwardCategory.ExternalSystem, response.Messages[0].Category);
}
[Fact]
public async Task Query_NotificationPayload_UsesSubjectAsMethodName()
{
await ParkMessageAsync(StoreAndForwardCategory.Notification,
"""{"Subject":"Tank overflow","Body":"..."}""");
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
actor.Tell(new ParkedMessageQueryRequest("corr-2", "site-1", 1, 50, DateTimeOffset.UtcNow));
var response = ExpectMsg<ParkedMessageQueryResponse>();
Assert.True(response.Success);
Assert.Equal("Tank overflow", response.Messages[0].MethodName);
}
[Fact]
public async Task Query_MalformedJsonPayload_FallsBackToCategoryName()
{
await ParkMessageAsync(StoreAndForwardCategory.CachedDbWrite, "not-valid-json{");
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
actor.Tell(new ParkedMessageQueryRequest("corr-3", "site-1", 1, 50, DateTimeOffset.UtcNow));
var response = ExpectMsg<ParkedMessageQueryResponse>();
Assert.True(response.Success);
// Malformed JSON must not throw — ExtractMethodName falls back to the category.
Assert.Equal(StoreAndForwardCategory.CachedDbWrite.ToString(), response.Messages[0].MethodName);
}
[Fact]
public async Task Query_PayloadWithoutMethodNameOrSubject_FallsBackToCategoryName()
{
await ParkMessageAsync(StoreAndForwardCategory.ExternalSystem, """{"Unrelated":"value"}""");
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
actor.Tell(new ParkedMessageQueryRequest("corr-4", "site-1", 1, 50, DateTimeOffset.UtcNow));
var response = ExpectMsg<ParkedMessageQueryResponse>();
Assert.Equal(StoreAndForwardCategory.ExternalSystem.ToString(), response.Messages[0].MethodName);
}
[Fact]
public async Task Retry_ParkedMessage_ReturnsSuccess()
{
var messageId = await ParkMessageAsync(StoreAndForwardCategory.ExternalSystem, """{}""");
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
actor.Tell(new ParkedMessageRetryRequest("corr-5", "site-1", messageId, DateTimeOffset.UtcNow));
var response = ExpectMsg<ParkedMessageRetryResponse>();
Assert.True(response.Success);
Assert.Equal("corr-5", response.CorrelationId);
Assert.Null(response.ErrorMessage);
var msg = await _storage.GetMessageByIdAsync(messageId);
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
}
[Fact]
public void Retry_UnknownMessage_ReturnsFailureWithMessage()
{
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
actor.Tell(new ParkedMessageRetryRequest("corr-6", "site-1", "does-not-exist", DateTimeOffset.UtcNow));
var response = ExpectMsg<ParkedMessageRetryResponse>();
Assert.False(response.Success);
Assert.Equal("corr-6", response.CorrelationId);
Assert.NotNull(response.ErrorMessage);
}
[Fact]
public async Task Discard_ParkedMessage_ReturnsSuccessAndRemovesMessage()
{
var messageId = await ParkMessageAsync(StoreAndForwardCategory.ExternalSystem, """{}""");
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
actor.Tell(new ParkedMessageDiscardRequest("corr-7", "site-1", messageId, DateTimeOffset.UtcNow));
var response = ExpectMsg<ParkedMessageDiscardResponse>();
Assert.True(response.Success);
Assert.Equal("corr-7", response.CorrelationId);
var msg = await _storage.GetMessageByIdAsync(messageId);
Assert.Null(msg);
}
[Fact]
public void Discard_UnknownMessage_ReturnsFailureWithMessage()
{
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
actor.Tell(new ParkedMessageDiscardRequest("corr-8", "site-1", "does-not-exist", DateTimeOffset.UtcNow));
var response = ExpectMsg<ParkedMessageDiscardResponse>();
Assert.False(response.Success);
Assert.NotNull(response.ErrorMessage);
}
}