refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,455 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// Audit Log #23 — M3 Bundle E Tasks E4 + E5: the store-and-forward retry
/// loop invokes <see cref="ICachedCallLifecycleObserver"/> after every
/// cached-call attempt. The observer is given a
/// <see cref="CachedCallAttemptContext"/> derived from the underlying
/// <see cref="StoreAndForwardMessage"/>; the audit bridge then materialises
/// the right <c>CachedCallTelemetry</c> packet (Attempted on every retry,
/// CachedResolve on terminal transitions). Tests run with
/// <c>DefaultRetryInterval=Zero</c> so the timer-driven retry sweep is
/// short-circuited by directly invoking
/// <see cref="StoreAndForwardService.RetryPendingMessagesAsync"/>.
/// </summary>
public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
{
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _service;
private readonly StoreAndForwardOptions _options;
private readonly CapturingObserver _observer;
public CachedCallAttemptEmissionTests()
{
var dbName = $"E4Tests_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
_keepAlive = new SqliteConnection(connStr);
_keepAlive.Open();
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
_options = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromMinutes(10),
};
_observer = new CapturingObserver();
_service = new StoreAndForwardService(
_storage,
_options,
NullLogger<StoreAndForwardService>.Instance,
replication: null,
cachedCallObserver: _observer,
siteId: "site-77");
}
public async Task InitializeAsync() => await _storage.InitializeAsync();
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose() => _keepAlive.Dispose();
/// <summary>
/// Captures every observer notification so tests can assert on the
/// emitted lifecycle sequence.
/// </summary>
private sealed class CapturingObserver : ICachedCallLifecycleObserver
{
public List<CachedCallAttemptContext> Notifications { get; } = new();
public Exception? ThrowOnNotify { get; set; }
public Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default)
{
if (ThrowOnNotify != null)
{
return Task.FromException(ThrowOnNotify);
}
lock (Notifications)
{
Notifications.Add(context);
}
return Task.CompletedTask;
}
}
private async Task<TrackedOperationId> EnqueueBufferedAsync(
StoreAndForwardCategory category, string target, int maxRetries = 3)
{
// The TrackedOperationId is the S&F message id (Bundle E3 contract).
var trackedId = TrackedOperationId.New();
await _service.EnqueueAsync(
category,
target,
"""{"payload":"x"}""",
originInstanceName: "Plant.Pump42",
maxRetries: maxRetries,
retryInterval: TimeSpan.Zero,
attemptImmediateDelivery: false,
messageId: trackedId.ToString());
return trackedId;
}
// ── Task E4: per-attempt observer notifications ──
[Fact]
public async Task Attempt_FailWithHttp500_EmitsAttemptedTelemetry()
{
// ExternalSystem cached call buffered, retry sweep encounters a
// transient failure on the first attempt.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("HTTP 500 from ERP"));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP", maxRetries: 5);
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(trackedId, notification.TrackedOperationId);
Assert.Equal("ApiOutbound", notification.Channel);
Assert.Equal("ERP", notification.Target);
Assert.Equal("site-77", notification.SourceSite);
Assert.Equal(CachedCallAttemptOutcome.TransientFailure, notification.Outcome);
Assert.Equal(1, notification.RetryCount);
Assert.Contains("HTTP 500", notification.LastError);
Assert.Equal("Plant.Pump42", notification.SourceInstanceId);
}
[Fact]
public async Task Attempt_Success_EmitsDeliveredOutcome()
{
// ExternalSystem cached call buffered, retry sweep delivers the
// message successfully on its first attempt.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(trackedId, notification.TrackedOperationId);
Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome);
Assert.Null(notification.LastError);
}
[Fact]
public async Task Attempt_PermanentFailure_EmitsPermanentFailureOutcome()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(false));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(trackedId, notification.TrackedOperationId);
Assert.Equal(CachedCallAttemptOutcome.PermanentFailure, notification.Outcome);
}
[Fact]
public async Task Attempt_CachedDbWrite_EmitsDbOutboundChannel()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.CachedDbWrite,
_ => Task.FromResult(true));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.CachedDbWrite, "myDb");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(trackedId, notification.TrackedOperationId);
Assert.Equal("DbOutbound", notification.Channel);
Assert.Equal("myDb", notification.Target);
}
[Fact]
public async Task Attempt_NotificationCategory_NoObserverNotification()
{
// Notifications are NOT cached calls — they're forwarded to central via
// a separate forwarder. The observer must not fire for Notification
// category messages.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
_ => Task.FromResult(true));
await _service.EnqueueAsync(
StoreAndForwardCategory.Notification,
"alerts",
"""{"subject":"x"}""",
originInstanceName: "Plant.Pump42",
attemptImmediateDelivery: false);
await _service.RetryPendingMessagesAsync();
Assert.Empty(_observer.Notifications);
}
[Fact]
public async Task Attempt_MessageIdNotAGuid_NoObserverNotification()
{
// Pre-M3 cached calls (no TrackedOperationId threaded in) use a random
// GUID-N message id from S&F itself. We should still emit (M3 expects
// post-rollout these are tracked) — BUT pre-rollout messages can have
// a non-parseable id, in which case the observer is silently skipped
// to keep S&F bookkeeping intact.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem,
"ERP",
"""{}""",
originInstanceName: "Plant.Pump42",
attemptImmediateDelivery: false,
messageId: "not-a-valid-guid-id");
await _service.RetryPendingMessagesAsync();
Assert.Empty(_observer.Notifications);
}
// ── Task E5: terminal-state observer notifications ──
[Fact]
public async Task Terminal_Delivered_EmitsResolveWithDeliveredStatus()
{
// A successful retry produces a single Delivered observer notification
// — the audit bridge maps this to both an Attempted-Delivered audit row
// and the terminal CachedResolve(Delivered) row. The S&F layer fires
// ONE notification per attempt and lets the bridge fan out as needed.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(trackedId, notification.TrackedOperationId);
Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome);
}
[Fact]
public async Task Terminal_Parked_OnMaxRetries_EmitsParkedMaxRetries()
{
// Configure handler to throw transient every time.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("Connection refused"));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP", maxRetries: 2);
// Two sweeps -> RetryCount climbs to 2 -> parked on the second sweep.
await _service.RetryPendingMessagesAsync();
await _service.RetryPendingMessagesAsync();
Assert.Equal(2, _observer.Notifications.Count);
Assert.Equal(CachedCallAttemptOutcome.TransientFailure, _observer.Notifications[0].Outcome);
Assert.Equal(CachedCallAttemptOutcome.ParkedMaxRetries, _observer.Notifications[1].Outcome);
Assert.Equal(trackedId, _observer.Notifications[1].TrackedOperationId);
}
[Fact]
public async Task Lifecycle_RetryFail_RetrySucceed_EmitsExpectedSequence()
{
var calls = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, _ =>
{
calls++;
if (calls == 1) throw new HttpRequestException("transient");
return Task.FromResult(true);
});
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP", maxRetries: 5);
await _service.RetryPendingMessagesAsync();
await _service.RetryPendingMessagesAsync();
Assert.Equal(2, _observer.Notifications.Count);
Assert.Equal(CachedCallAttemptOutcome.TransientFailure, _observer.Notifications[0].Outcome);
Assert.Equal(1, _observer.Notifications[0].RetryCount);
Assert.Equal(CachedCallAttemptOutcome.Delivered, _observer.Notifications[1].Outcome);
Assert.Equal(trackedId, _observer.Notifications[1].TrackedOperationId);
}
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
[Fact]
public async Task Attempt_CarriesExecutionIdAndSourceScript_FromBufferedMessage()
{
// A buffered cached call carries the originating script execution's
// ExecutionId + SourceScript. The retry sweep must surface both on the
// CachedCallAttemptContext handed to the observer so the audit bridge
// can stamp them on the retry-loop cached rows.
var executionId = Guid.NewGuid();
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("HTTP 503"));
var trackedId = TrackedOperationId.New();
await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem,
"ERP",
"""{"payload":"x"}""",
originInstanceName: "Plant.Pump42",
maxRetries: 5,
retryInterval: TimeSpan.Zero,
attemptImmediateDelivery: false,
messageId: trackedId.ToString(),
executionId: executionId,
sourceScript: "Plant.Pump42/OnTick");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(executionId, notification.ExecutionId);
Assert.Equal("Plant.Pump42/OnTick", notification.SourceScript);
}
[Fact]
public async Task Attempt_NullExecutionIdAndSourceScript_SurfaceAsNull()
{
// Back-compat: a row buffered without ExecutionId / SourceScript (legacy
// enqueue path) must surface them as null on the context, not throw.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Null(notification.ExecutionId);
Assert.Null(notification.SourceScript);
}
[Fact]
public async Task TerminalResolve_CarriesExecutionIdAndSourceScript()
{
// The terminal Delivered notification must also carry the threaded
// provenance so the CachedResolve audit row is correlated.
var executionId = Guid.NewGuid();
_service.RegisterDeliveryHandler(StoreAndForwardCategory.CachedDbWrite,
_ => Task.FromResult(true));
var trackedId = TrackedOperationId.New();
await _service.EnqueueAsync(
StoreAndForwardCategory.CachedDbWrite,
"myDb",
"""{"payload":"x"}""",
originInstanceName: "Plant.Tank",
maxRetries: 3,
retryInterval: TimeSpan.Zero,
attemptImmediateDelivery: false,
messageId: trackedId.ToString(),
executionId: executionId,
sourceScript: "Plant.Tank/OnAlarm");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome);
Assert.Equal(executionId, notification.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", notification.SourceScript);
}
// ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
[Fact]
public async Task Attempt_CarriesParentExecutionId_FromBufferedMessage()
{
// A cached call enqueued from an inbound-API-routed script run carries
// the spawning execution's ParentExecutionId. The retry sweep must
// surface it on the CachedCallAttemptContext beside ExecutionId so the
// audit bridge can stamp it on the retry-loop cached rows.
var parentExecutionId = Guid.NewGuid();
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("HTTP 503"));
var trackedId = TrackedOperationId.New();
await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem,
"ERP",
"""{"payload":"x"}""",
originInstanceName: "Plant.Pump42",
maxRetries: 5,
retryInterval: TimeSpan.Zero,
attemptImmediateDelivery: false,
messageId: trackedId.ToString(),
parentExecutionId: parentExecutionId);
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(parentExecutionId, notification.ParentExecutionId);
}
[Fact]
public async Task Attempt_NullParentExecutionId_SurfacesAsNull()
{
// Non-routed run: the originating script was not spawned by an
// inbound-API request, so no ParentExecutionId is threaded. It must
// surface as null on the context, not throw.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Null(notification.ParentExecutionId);
}
[Fact]
public async Task TerminalResolve_CarriesParentExecutionId()
{
// The terminal Delivered notification must also carry the threaded
// ParentExecutionId so the CachedResolve audit row correlates back to
// the spawning inbound-API execution.
var parentExecutionId = Guid.NewGuid();
_service.RegisterDeliveryHandler(StoreAndForwardCategory.CachedDbWrite,
_ => Task.FromResult(true));
var trackedId = TrackedOperationId.New();
await _service.EnqueueAsync(
StoreAndForwardCategory.CachedDbWrite,
"myDb",
"""{"payload":"x"}""",
originInstanceName: "Plant.Tank",
maxRetries: 3,
retryInterval: TimeSpan.Zero,
attemptImmediateDelivery: false,
messageId: trackedId.ToString(),
parentExecutionId: parentExecutionId);
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome);
Assert.Equal(parentExecutionId, notification.ParentExecutionId);
}
// ── Best-effort contract: observer throws must NOT corrupt retry bookkeeping ──
[Fact]
public async Task Observer_Throws_DoesNotCorruptRetryCount()
{
_observer.ThrowOnNotify = new InvalidOperationException("simulated audit failure");
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP");
// Must not throw — observer is best-effort.
await _service.RetryPendingMessagesAsync();
// The message was delivered (handler returned true) so it should be gone.
var msg = await _storage.GetMessageByIdAsync(trackedId.ToString());
Assert.Null(msg);
}
}
@@ -0,0 +1,254 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// Notification Outbox: tests for the site Store-and-Forward notification delivery
/// handler. "Delivering" a buffered notification means forwarding it to the central
/// cluster (via the site communication actor) and treating central's
/// <see cref="NotificationSubmitAck"/> as the delivery outcome.
/// </summary>
public class NotificationForwarderTests : TestKit
{
private static readonly TimeSpan ForwardTimeout = TimeSpan.FromSeconds(2);
/// <summary>
/// Builds a buffered notification S&amp;F message whose payload matches the shape
/// produced by the site <c>Notify.Send</c> enqueue path (Task 19): a serialized
/// <see cref="NotificationSubmit"/> carrying a script-generated
/// <see cref="NotificationSubmit.NotificationId"/>. The S&amp;F message
/// <see cref="StoreAndForwardMessage.Id"/> equals that same id.
/// </summary>
private static StoreAndForwardMessage BufferedNotification(
string id = "msg-1", string listName = "Operators",
string subject = "Pump alarm", string message = "Pump 3 tripped",
string? originInstance = "Plant.Pump3", string? sourceScript = "alarmScript",
Guid? originExecutionId = null)
{
var payload = JsonSerializer.Serialize(new NotificationSubmit(
NotificationId: id,
ListName: listName,
Subject: subject,
Body: message,
// SourceSiteId is re-stamped by the forwarder; the enqueue side leaves it blank.
SourceSiteId: string.Empty,
SourceInstanceId: originInstance,
SourceScript: sourceScript,
SiteEnqueuedAt: DateTimeOffset.UtcNow,
OriginExecutionId: originExecutionId));
return new StoreAndForwardMessage
{
Id = id,
Category = StoreAndForwardCategory.Notification,
Target = listName,
PayloadJson = payload,
OriginInstanceName = originInstance,
};
}
[Fact]
public async Task Deliver_ForwardsNotificationSubmitToCentralTarget_AndReturnsTrueOnAccept()
{
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
var msg = BufferedNotification(
id: "msg-1", listName: "Operators", subject: "Pump alarm",
message: "Pump 3 tripped", originInstance: "Plant.Pump3");
var deliverTask = forwarder.DeliverAsync(msg);
// The central target receives a NotificationSubmit whose fields map from the
// buffered payload; reply Accepted so the handler completes as delivered.
var submit = centralProbe.ExpectMsg<NotificationSubmit>();
Assert.Equal("msg-1", submit.NotificationId);
Assert.Equal("Operators", submit.ListName);
Assert.Equal("Pump alarm", submit.Subject);
Assert.Equal("Pump 3 tripped", submit.Body);
// SourceSiteId is re-stamped by the forwarder from its own site id.
Assert.Equal("site-7", submit.SourceSiteId);
Assert.Equal("Plant.Pump3", submit.SourceInstanceId);
// The originating script travels through from the buffered payload.
Assert.Equal("alarmScript", submit.SourceScript);
centralProbe.Reply(new NotificationSubmitAck(submit.NotificationId, Accepted: true, Error: null));
Assert.True(await deliverTask);
}
[Fact]
public async Task Deliver_PreservesOriginExecutionId_FromBufferedPayload()
{
// Audit Log #23: the buffered payload's OriginExecutionId is the per-run
// id stamped at Notify.Send time. The forwarder re-stamps only the four
// fields it authoritatively owns (NotificationId, ListName, SourceSiteId,
// SourceInstanceId) via the `with` expression — OriginExecutionId is
// preserved precisely BY being absent from that `with` block. This test
// pins that: if OriginExecutionId is ever added to the `with` expression
// (e.g. reset to null), the forwarded NotificationSubmit would lose the
// per-run id and central could not echo it onto NotifyDeliver rows.
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
var executionId = Guid.NewGuid();
var msg = BufferedNotification(id: "msg-exec", originExecutionId: executionId);
var deliverTask = forwarder.DeliverAsync(msg);
var submit = centralProbe.ExpectMsg<NotificationSubmit>();
Assert.Equal(executionId, submit.OriginExecutionId);
centralProbe.Reply(new NotificationSubmitAck(submit.NotificationId, Accepted: true, Error: null));
Assert.True(await deliverTask);
}
[Fact]
public async Task Deliver_FallsBackToTarget_WhenPayloadListNameIsEmpty()
{
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
// A buffered payload carrying an empty-string ListName: the empty value must not
// be forwarded — the forwarder falls back to the S&F message Target instead.
var payload = JsonSerializer.Serialize(new NotificationSubmit(
NotificationId: "msg-empty-list",
ListName: "",
Subject: "Pump alarm",
Body: "Pump 3 tripped",
SourceSiteId: string.Empty,
SourceInstanceId: "Plant.Pump3",
SourceScript: null,
SiteEnqueuedAt: DateTimeOffset.UtcNow));
var msg = new StoreAndForwardMessage
{
Id = "msg-empty-list",
Category = StoreAndForwardCategory.Notification,
Target = "Operators",
PayloadJson = payload,
OriginInstanceName = "Plant.Pump3",
};
var deliverTask = forwarder.DeliverAsync(msg);
var submit = centralProbe.ExpectMsg<NotificationSubmit>();
Assert.Equal("Operators", submit.ListName);
centralProbe.Reply(new NotificationSubmitAck(submit.NotificationId, Accepted: true, Error: null));
Assert.True(await deliverTask);
}
[Fact]
public async Task Deliver_ThrowsTransient_WhenAckIsNotAccepted()
{
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
var deliverTask = forwarder.DeliverAsync(BufferedNotification());
var submit = centralProbe.ExpectMsg<NotificationSubmit>();
centralProbe.Reply(new NotificationSubmitAck(
submit.NotificationId, Accepted: false, Error: "central rejected"));
// A non-accepted ack is a transient failure — the handler throws so the S&F
// engine keeps the message buffered and retries the forward.
await Assert.ThrowsAnyAsync<Exception>(() => deliverTask);
}
[Fact]
public async Task Deliver_ThrowsTransient_WhenNoReplyWithinTimeout()
{
// A probe that never replies stands in for central being unreachable.
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", TimeSpan.FromMilliseconds(300));
// No reply within the timeout → transient failure → throw.
await Assert.ThrowsAnyAsync<Exception>(() => forwarder.DeliverAsync(BufferedNotification()));
}
[Fact]
public async Task Deliver_UsesStableNotificationId_AcrossRetriesOfSameMessage()
{
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
var buffered = BufferedNotification(id: "stable-msg-id");
var first = forwarder.DeliverAsync(buffered);
var submit1 = centralProbe.ExpectMsg<NotificationSubmit>();
centralProbe.Reply(new NotificationSubmitAck(submit1.NotificationId, true, null));
await first;
var second = forwarder.DeliverAsync(buffered);
var submit2 = centralProbe.ExpectMsg<NotificationSubmit>();
centralProbe.Reply(new NotificationSubmitAck(submit2.NotificationId, true, null));
await second;
// The NotificationId is the central idempotency key — it must be identical for
// every forward attempt of the same buffered S&F message.
Assert.Equal(submit1.NotificationId, submit2.NotificationId);
Assert.Equal("stable-msg-id", submit1.NotificationId);
}
[Fact]
public async Task Deliver_CorruptJsonPayload_ReturnsTrue_AndDoesNotForwardAnything()
{
// Regression test for StoreAndForward-018. The design doc forbids parking
// notifications ("notifications do not park — they are retried at the fixed
// forward interval until central acks"; Component-StoreAndForward.md). The
// previous implementation returned false on a corrupt payload, which the S&F
// engine interprets as a permanent failure and parks the row — contradicting
// the invariant. The fix: discard a corrupt buffered notification by
// returning true (engine clears the buffer via its normal success path),
// with a Warning log line carrying the row id and a payload preview.
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
var corrupt = new StoreAndForwardMessage
{
Id = "msg-corrupt",
Category = StoreAndForwardCategory.Notification,
Target = "Operators",
PayloadJson = "{not-valid-json",
OriginInstanceName = "Plant.Pump3",
};
Assert.True(await forwarder.DeliverAsync(corrupt));
// The corrupt-payload path must NOT round-trip to central — no
// NotificationSubmit / no Ask. ExpectNoMsg confirms nothing was forwarded.
centralProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[Fact]
public async Task Deliver_NullDeserializedPayload_ReturnsTrue_AndDoesNotForwardAnything()
{
// The companion case to corrupt JSON: the payload is valid JSON but
// deserialises to null (e.g. "null"). Same treatment per StoreAndForward-018
// — discard rather than park.
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
var nullPayload = new StoreAndForwardMessage
{
Id = "msg-null",
Category = StoreAndForwardCategory.Notification,
Target = "Operators",
PayloadJson = "null",
OriginInstanceName = "Plant.Pump3",
};
Assert.True(await forwarder.DeliverAsync(nullPayload));
centralProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
}
@@ -0,0 +1,173 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.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);
}
}
@@ -0,0 +1,168 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.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&amp;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&amp;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);
}
}
@@ -0,0 +1,181 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// WP-11: Tests for async replication to standby.
/// </summary>
public class ReplicationServiceTests : IAsyncLifetime, IDisposable
{
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly ReplicationService _replicationService;
public ReplicationServiceTests()
{
var dbName = $"RepTests_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
_keepAlive = new SqliteConnection(connStr);
_keepAlive.Open();
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
var options = new StoreAndForwardOptions { ReplicationEnabled = true };
_replicationService = new ReplicationService(
options, NullLogger<ReplicationService>.Instance);
}
public async Task InitializeAsync() => await _storage.InitializeAsync();
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose() => _keepAlive.Dispose();
[Fact]
public void ReplicateEnqueue_NoHandler_DoesNotThrow()
{
var msg = CreateMessage("rep1");
_replicationService.ReplicateEnqueue(msg);
}
[Fact]
public async Task ReplicateEnqueue_WithHandler_ForwardsOperation()
{
ReplicationOperation? captured = null;
_replicationService.SetReplicationHandler(op =>
{
captured = op;
return Task.CompletedTask;
});
var msg = CreateMessage("rep2");
_replicationService.ReplicateEnqueue(msg);
await Task.Delay(200);
Assert.NotNull(captured);
Assert.Equal(ReplicationOperationType.Add, captured!.OperationType);
Assert.Equal("rep2", captured.MessageId);
}
[Fact]
public async Task ReplicateRemove_WithHandler_ForwardsRemoveOperation()
{
ReplicationOperation? captured = null;
_replicationService.SetReplicationHandler(op =>
{
captured = op;
return Task.CompletedTask;
});
_replicationService.ReplicateRemove("rep3");
await Task.Delay(200);
Assert.NotNull(captured);
Assert.Equal(ReplicationOperationType.Remove, captured!.OperationType);
Assert.Equal("rep3", captured.MessageId);
}
[Fact]
public async Task ReplicatePark_WithHandler_ForwardsParkOperation()
{
ReplicationOperation? captured = null;
_replicationService.SetReplicationHandler(op =>
{
captured = op;
return Task.CompletedTask;
});
var msg = CreateMessage("rep4");
_replicationService.ReplicatePark(msg);
await Task.Delay(200);
Assert.NotNull(captured);
Assert.Equal(ReplicationOperationType.Park, captured!.OperationType);
}
[Fact]
public async Task ApplyReplicatedOperationAsync_Add_EnqueuesMessage()
{
var msg = CreateMessage("apply1");
var operation = new ReplicationOperation(ReplicationOperationType.Add, "apply1", msg);
await _replicationService.ApplyReplicatedOperationAsync(operation, _storage);
var retrieved = await _storage.GetMessageByIdAsync("apply1");
Assert.NotNull(retrieved);
}
[Fact]
public async Task ApplyReplicatedOperationAsync_Remove_DeletesMessage()
{
var msg = CreateMessage("apply2");
await _storage.EnqueueAsync(msg);
var operation = new ReplicationOperation(ReplicationOperationType.Remove, "apply2", null);
await _replicationService.ApplyReplicatedOperationAsync(operation, _storage);
var retrieved = await _storage.GetMessageByIdAsync("apply2");
Assert.Null(retrieved);
}
[Fact]
public async Task ApplyReplicatedOperationAsync_Park_UpdatesStatus()
{
var msg = CreateMessage("apply3");
await _storage.EnqueueAsync(msg);
var operation = new ReplicationOperation(ReplicationOperationType.Park, "apply3", msg);
await _replicationService.ApplyReplicatedOperationAsync(operation, _storage);
var retrieved = await _storage.GetMessageByIdAsync("apply3");
Assert.NotNull(retrieved);
Assert.Equal(StoreAndForwardMessageStatus.Parked, retrieved!.Status);
}
[Fact]
public void ReplicateEnqueue_WhenReplicationDisabled_DoesNothing()
{
var options = new StoreAndForwardOptions { ReplicationEnabled = false };
var service = new ReplicationService(options, NullLogger<ReplicationService>.Instance);
bool handlerCalled = false;
service.SetReplicationHandler(_ => { handlerCalled = true; return Task.CompletedTask; });
service.ReplicateEnqueue(CreateMessage("disabled1"));
Assert.False(handlerCalled);
}
[Fact]
public async Task ReplicateEnqueue_HandlerThrows_DoesNotPropagateException()
{
_replicationService.SetReplicationHandler(_ =>
throw new InvalidOperationException("standby down"));
_replicationService.ReplicateEnqueue(CreateMessage("err1"));
await Task.Delay(200);
// No exception -- fire-and-forget, best-effort
}
private static StoreAndForwardMessage CreateMessage(string id)
{
return new StoreAndForwardMessage
{
Id = id,
Category = StoreAndForwardCategory.ExternalSystem,
Target = "target",
PayloadJson = "{}",
RetryCount = 0,
MaxRetries = 50,
RetryIntervalMs = 30000,
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending
};
}
}
@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// WP-9: Tests for StoreAndForwardOptions defaults and configuration.
/// </summary>
public class StoreAndForwardOptionsTests
{
[Fact]
public void DefaultOptions_HasReasonableDefaults()
{
var options = new StoreAndForwardOptions();
Assert.Equal("./data/store-and-forward.db", options.SqliteDbPath);
Assert.True(options.ReplicationEnabled);
Assert.Equal(TimeSpan.FromSeconds(30), options.DefaultRetryInterval);
Assert.Equal(50, options.DefaultMaxRetries);
Assert.Equal(TimeSpan.FromSeconds(10), options.RetryTimerInterval);
}
[Fact]
public void Options_CanBeCustomized()
{
var options = new StoreAndForwardOptions
{
SqliteDbPath = "/custom/path.db",
ReplicationEnabled = false,
DefaultRetryInterval = TimeSpan.FromMinutes(5),
DefaultMaxRetries = 100,
RetryTimerInterval = TimeSpan.FromSeconds(30)
};
Assert.Equal("/custom/path.db", options.SqliteDbPath);
Assert.False(options.ReplicationEnabled);
Assert.Equal(TimeSpan.FromMinutes(5), options.DefaultRetryInterval);
Assert.Equal(100, options.DefaultMaxRetries);
}
}
@@ -0,0 +1,206 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// StoreAndForward-001: the active node must forward every buffer operation
/// (add / remove / park) to the standby via the ReplicationService, so a
/// failover does not lose the buffer.
/// </summary>
public class StoreAndForwardReplicationTests : IAsyncLifetime, IDisposable
{
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _service;
private readonly List<ReplicationOperation> _replicated = new();
public StoreAndForwardReplicationTests()
{
var connStr = $"Data Source=ReplTests_{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 = true,
};
var replication = new ReplicationService(options, NullLogger<ReplicationService>.Instance);
replication.SetReplicationHandler(op =>
{
lock (_replicated) _replicated.Add(op);
return Task.CompletedTask;
});
_service = new StoreAndForwardService(
_storage, options, NullLogger<StoreAndForwardService>.Instance, replication);
}
public async Task InitializeAsync() => await _storage.InitializeAsync();
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose() => _keepAlive.Dispose();
/// <summary>Replication is fire-and-forget (Task.Run); poll until the expected ops arrive.</summary>
private async Task<List<ReplicationOperation>> WaitForReplicationAsync(int count)
{
for (var i = 0; i < 100; i++)
{
lock (_replicated)
if (_replicated.Count >= count) return _replicated.ToList();
await Task.Delay(20);
}
lock (_replicated) return _replicated.ToList();
}
[Fact]
public async Task BufferingAMessage_ReplicatesAnAddOperation()
{
// No handler registered → message is buffered → an Add is replicated.
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""");
Assert.True(result.WasBuffered);
var ops = await WaitForReplicationAsync(1);
Assert.Contains(ops, o =>
o.OperationType == ReplicationOperationType.Add && o.MessageId == result.MessageId);
}
[Fact]
public async Task SuccessfulRetry_ReplicatesARemoveOperation()
{
var calls = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => ++calls == 1
? throw new HttpRequestException("transient")
: Task.FromResult(true));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""");
await _service.RetryPendingMessagesAsync();
var ops = await WaitForReplicationAsync(2);
Assert.Contains(ops, o => o.OperationType == ReplicationOperationType.Add);
Assert.Contains(ops, o =>
o.OperationType == ReplicationOperationType.Remove && o.MessageId == result.MessageId);
}
[Fact]
public async Task ParkedMessage_ReplicatesAParkOperation()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("always fails"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""", maxRetries: 1);
await _service.RetryPendingMessagesAsync();
var ops = await WaitForReplicationAsync(2);
Assert.Contains(ops, o =>
o.OperationType == ReplicationOperationType.Park && o.MessageId == result.MessageId);
}
/// <summary>
/// StoreAndForward-016: an operator discarding a parked message must replicate
/// a Remove so the standby's copy is also deleted (otherwise the discarded
/// message reappears in the parked list after a failover).
/// </summary>
[Fact]
public async Task DiscardingAParkedMessage_ReplicatesARemoveOperation()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("always fails"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""", maxRetries: 1);
await _service.RetryPendingMessagesAsync(); // -> parked
await WaitForReplicationAsync(2);
var discarded = await _service.DiscardParkedMessageAsync(result.MessageId);
Assert.True(discarded);
var ops = await WaitForReplicationAsync(3);
Assert.Contains(ops, o =>
o.OperationType == ReplicationOperationType.Remove && o.MessageId == result.MessageId);
}
/// <summary>
/// StoreAndForward-016: an operator retrying a parked message must replicate a
/// Requeue so the standby's copy moves back to Pending (otherwise it stays
/// Parked on the standby and the operator's retry is lost across a failover).
/// </summary>
[Fact]
public async Task RetryingAParkedMessage_ReplicatesARequeueOperation()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("always fails"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""", maxRetries: 1);
await _service.RetryPendingMessagesAsync(); // -> parked
await WaitForReplicationAsync(2);
var retried = await _service.RetryParkedMessageAsync(result.MessageId);
Assert.True(retried);
var ops = await WaitForReplicationAsync(3);
var requeue = ops.SingleOrDefault(o =>
o.OperationType == ReplicationOperationType.Requeue && o.MessageId == result.MessageId);
Assert.NotNull(requeue);
Assert.NotNull(requeue!.Message);
Assert.Equal(StoreAndForwardMessageStatus.Pending, requeue.Message!.Status);
}
/// <summary>
/// StoreAndForward-016: the standby applies a Requeue by moving its row back to
/// Pending with retry_count = 0, mirroring the active node's local state.
/// </summary>
[Fact]
public async Task ApplyReplicatedOperation_Requeue_MovesStandbyRowBackToPending()
{
var replication = new ReplicationService(
new StoreAndForwardOptions { ReplicationEnabled = true },
NullLogger<ReplicationService>.Instance);
var parked = new StoreAndForwardMessage
{
Id = "requeue1",
Category = StoreAndForwardCategory.ExternalSystem,
Target = "api",
PayloadJson = "{}",
RetryCount = 5,
MaxRetries = 1,
RetryIntervalMs = 0,
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Parked,
};
await _storage.EnqueueAsync(parked);
var requeued = new StoreAndForwardMessage
{
Id = parked.Id,
Category = parked.Category,
Target = parked.Target,
PayloadJson = parked.PayloadJson,
RetryCount = 0,
MaxRetries = parked.MaxRetries,
RetryIntervalMs = parked.RetryIntervalMs,
CreatedAt = parked.CreatedAt,
Status = StoreAndForwardMessageStatus.Pending,
};
await replication.ApplyReplicatedOperationAsync(
new ReplicationOperation(ReplicationOperationType.Requeue, parked.Id, requeued),
_storage);
var row = await _storage.GetMessageByIdAsync(parked.Id);
Assert.NotNull(row);
Assert.Equal(StoreAndForwardMessageStatus.Pending, row!.Status);
Assert.Equal(0, row.RetryCount);
}
}
@@ -0,0 +1,585 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// WP-10/12/13/14: Tests for the StoreAndForwardService retry engine and management.
/// </summary>
public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
{
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _service;
private readonly StoreAndForwardOptions _options;
public StoreAndForwardServiceTests()
{
var dbName = $"SvcTests_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
_keepAlive = new SqliteConnection(connStr);
_keepAlive.Open();
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
_options = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromMinutes(10)
};
_service = new StoreAndForwardService(
_storage, _options, NullLogger<StoreAndForwardService>.Instance);
}
public async Task InitializeAsync() => await _storage.InitializeAsync();
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose() => _keepAlive.Dispose();
// ── WP-10: Immediate delivery ──
[Fact]
public async Task EnqueueAsync_ImmediateDeliverySuccess_ReturnsAcceptedNotBuffered()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api.example.com",
"""{"method":"Test"}""", "Pump1");
Assert.True(result.Accepted);
Assert.False(result.WasBuffered);
}
[Fact]
public async Task EnqueueAsync_PermanentFailure_ReturnsNotAccepted()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(false));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api.example.com",
"""{"method":"Test"}""");
Assert.False(result.Accepted);
Assert.False(result.WasBuffered);
}
[Fact]
public async Task EnqueueAsync_TransientFailure_BuffersForRetry()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("Connection refused"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api.example.com",
"""{"method":"Test"}""", "Pump1");
Assert.True(result.Accepted);
Assert.True(result.WasBuffered);
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.NotNull(msg);
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
// StoreAndForward-003: RetryCount counts sweep retries only; the immediate
// attempt is attempt 0, so a freshly buffered message has RetryCount 0.
Assert.Equal(0, msg.RetryCount);
}
[Fact]
public async Task EnqueueAsync_NoHandler_BuffersForLater()
{
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.Notification, "alerts@company.com",
"""{"subject":"Alert"}""");
Assert.True(result.Accepted);
Assert.True(result.WasBuffered);
}
// ── WP-10: Retry engine ──
[Fact]
public async Task RetryPendingMessagesAsync_SuccessfulRetry_RemovesMessage()
{
int callCount = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ =>
{
callCount++;
if (callCount == 1) throw new HttpRequestException("fail");
return Task.FromResult(true);
});
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""");
Assert.True(result.WasBuffered);
await _service.RetryPendingMessagesAsync();
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Null(msg);
}
[Fact]
public async Task RetryPendingMessagesAsync_MaxRetriesReached_ParksMessage()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("always fails"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 2);
// StoreAndForward-003: MaxRetries bounds sweep retries (not the immediate
// attempt), so a message with MaxRetries=2 needs two retry sweeps to park.
await _service.RetryPendingMessagesAsync();
var afterFirst = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(StoreAndForwardMessageStatus.Pending, afterFirst!.Status);
await _service.RetryPendingMessagesAsync();
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.NotNull(msg);
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
}
// ── StoreAndForward-003: retry-count accounting ──
[Fact]
public async Task RetryPendingMessagesAsync_MaxRetriesOne_PerformsExactlyOneRetryBeforeParking()
{
// The immediate attempt is attempt 0; MaxRetries=1 must allow exactly one
// retry sweep before parking. The pre-fix off-by-one parked with zero retries.
var attempts = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => { Interlocked.Increment(ref attempts); throw new HttpRequestException("always fails"); });
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 1);
// After the immediate failed attempt the message is buffered, not parked.
var buffered = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(StoreAndForwardMessageStatus.Pending, buffered!.Status);
Assert.Equal(1, attempts); // only the immediate attempt so far
await _service.RetryPendingMessagesAsync();
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
Assert.Equal(2, attempts); // immediate attempt + exactly one retry
Assert.Equal(1, msg.RetryCount); // one sweep retry recorded
}
// ── StoreAndForward-005: sweep-vs-management race hardening ──
[Fact]
public async Task RetryMessageAsync_StatusChangedDuringDelivery_SweepParkWriteIsSkipped()
{
// StoreAndForward-005: the retry sweep's state-changing writes must be
// conditional on the status it observed, so a concurrent operator action that
// moved the row out of Pending (e.g. between the sweep's snapshot load and its
// park write) is not silently overwritten by the sweep's stale view.
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
attemptImmediateDelivery: false, maxRetries: 1);
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
async msg =>
{
// Simulate an operator action winning the race: the row leaves Pending
// (here: parked) while the sweep is still mid-delivery. The sweep would
// otherwise unconditionally re-write this row from its stale snapshot.
var parkedOutFromUnderTheSweep = new StoreAndForwardMessage
{
Id = msg.Id, Category = msg.Category, Target = msg.Target,
PayloadJson = msg.PayloadJson, RetryCount = 7,
MaxRetries = msg.MaxRetries, RetryIntervalMs = msg.RetryIntervalMs,
CreatedAt = msg.CreatedAt, LastAttemptAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Parked,
LastError = "operator/other writer"
};
await _storage.UpdateMessageAsync(parkedOutFromUnderTheSweep);
throw new HttpRequestException("transient — sweep will try to park");
});
await _service.RetryPendingMessagesAsync();
// The sweep observed Pending; the row is now Parked with the other writer's
// RetryCount (7), not the sweep's (1). The sweep's conditional write was skipped.
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.NotNull(msg);
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
Assert.Equal(7, msg.RetryCount);
Assert.Equal("operator/other writer", msg.LastError);
}
[Fact]
public async Task RetryPendingMessagesAsync_PermanentFailureOnRetry_ParksMessage()
{
int callCount = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ =>
{
callCount++;
if (callCount == 1) throw new HttpRequestException("transient");
return Task.FromResult(false);
});
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""");
await _service.RetryPendingMessagesAsync();
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.NotNull(msg);
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
}
// ── WP-12: Parked message management ──
[Fact]
public async Task RetryParkedMessageAsync_MovesBackToQueue()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 1);
await _service.RetryPendingMessagesAsync();
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
var retried = await _service.RetryParkedMessageAsync(result.MessageId);
Assert.True(retried);
msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
Assert.Equal(0, msg.RetryCount);
}
/// <summary>
/// StoreAndForward-017: the Retry activity-log entry must carry the parked
/// message's true category, not a hard-coded ExternalSystem.
/// </summary>
[Fact]
public async Task RetryParkedMessageAsync_ActivityUsesMessageRealCategory()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
_ => throw new HttpRequestException("fail"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.Notification, "ops-list", """{}""",
maxRetries: 1);
await _service.RetryPendingMessagesAsync(); // -> parked
var categories = new List<StoreAndForwardCategory>();
_service.OnActivity += (action, category, _) =>
{
if (action == "Retry") categories.Add(category);
};
var retried = await _service.RetryParkedMessageAsync(result.MessageId);
Assert.True(retried);
Assert.Equal(new[] { StoreAndForwardCategory.Notification }, categories);
}
/// <summary>
/// StoreAndForward-017: the Discard activity-log entry must carry the parked
/// message's true category, not a hard-coded ExternalSystem.
/// </summary>
[Fact]
public async Task DiscardParkedMessageAsync_ActivityUsesMessageRealCategory()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.CachedDbWrite,
_ => throw new HttpRequestException("fail"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.CachedDbWrite, "site-db", """{}""",
maxRetries: 1);
await _service.RetryPendingMessagesAsync(); // -> parked
var categories = new List<StoreAndForwardCategory>();
_service.OnActivity += (action, category, _) =>
{
if (action == "Discard") categories.Add(category);
};
var discarded = await _service.DiscardParkedMessageAsync(result.MessageId);
Assert.True(discarded);
Assert.Equal(new[] { StoreAndForwardCategory.CachedDbWrite }, categories);
}
[Fact]
public async Task DiscardParkedMessageAsync_PermanentlyRemoves()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 1);
await _service.RetryPendingMessagesAsync();
var discarded = await _service.DiscardParkedMessageAsync(result.MessageId);
Assert.True(discarded);
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Null(msg);
}
[Fact]
public async Task GetParkedMessagesAsync_ReturnsPaginatedResults()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
for (int i = 0; i < 3; i++)
{
await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, $"api{i}", """{}""",
maxRetries: 1);
}
await _service.RetryPendingMessagesAsync();
var (messages, total) = await _service.GetParkedMessagesAsync(
StoreAndForwardCategory.ExternalSystem, 1, 2);
Assert.Equal(2, messages.Count);
Assert.True(total >= 3);
}
// ── WP-13: Messages survive instance deletion ──
[Fact]
public async Task MessagesForInstance_SurviveAfterDeletion()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""", "Pump1");
await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api2", """{}""", "Pump1");
var count = await _service.GetMessageCountForInstanceAsync("Pump1");
Assert.Equal(2, count);
}
// ── WP-14: Health metrics ──
[Fact]
public async Task GetBufferDepthAsync_ReturnsCorrectDepth()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
_service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
_ => throw new HttpRequestException("fail"));
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "api1", """{}""");
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "api2", """{}""");
await _service.EnqueueAsync(StoreAndForwardCategory.Notification, "email", """{}""");
var depth = await _service.GetBufferDepthAsync();
Assert.True(depth.GetValueOrDefault(StoreAndForwardCategory.ExternalSystem) >= 2);
Assert.True(depth.GetValueOrDefault(StoreAndForwardCategory.Notification) >= 1);
}
[Fact]
public async Task OnActivity_RaisedOnEnqueue()
{
var activities = new List<string>();
_service.OnActivity += (action, _, _) => activities.Add(action);
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "api", """{}""");
Assert.Contains("Delivered", activities);
}
[Fact]
public async Task OnActivity_RaisedOnBuffer()
{
var activities = new List<string>();
_service.OnActivity += (action, _, _) => activities.Add(action);
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "api", """{}""");
Assert.Contains("Queued", activities);
}
// ── StoreAndForward-009: faulting activity subscriber must not corrupt delivery ──
[Fact]
public async Task EnqueueAsync_ImmediateDeliverySuccess_FaultingActivitySubscriber_StillReportsDelivered()
{
// StoreAndForward-009: a throwing OnActivity subscriber (e.g. the site event
// log) must not be misclassified as a transient delivery failure. Pre-fix the
// subscriber's exception escaped RaiseActivity, was caught by EnqueueAsync's
// transient-failure handler, and a successfully delivered message was buffered.
_service.OnActivity += (_, _, _) => throw new InvalidOperationException("logging blew up");
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""");
Assert.True(result.Accepted);
Assert.False(result.WasBuffered); // delivered, NOT buffered
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Null(msg); // nothing left in the buffer
}
[Fact]
public async Task RetryMessageAsync_FaultingActivitySubscriber_DoesNotIncrementRetryCount()
{
// StoreAndForward-009: a throwing subscriber raised after a successful retry
// delivery must not be caught by the retry-failure handler and counted as a
// transient failure.
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
attemptImmediateDelivery: false, maxRetries: 5);
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
_service.OnActivity += (_, _, _) => throw new InvalidOperationException("logging blew up");
await _service.RetryPendingMessagesAsync();
// The retry succeeded; the message must be gone, not re-buffered with a bumped count.
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Null(msg);
}
// ── WP-10: Per-source-entity retry settings ──
[Fact]
public async Task EnqueueAsync_CustomRetrySettings_Respected()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("fail"));
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 100,
retryInterval: TimeSpan.FromSeconds(60));
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.Equal(100, msg!.MaxRetries);
Assert.Equal(60000, msg.RetryIntervalMs);
}
// ── attemptImmediateDelivery: false — caller already attempted delivery ──
[Fact]
public async Task EnqueueAsync_AttemptImmediateDeliveryFalse_BuffersWithoutInvokingHandler()
{
// A caller that has already made its own delivery attempt passes
// attemptImmediateDelivery: false so the request is not dispatched twice.
var handlerCalls = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => { Interlocked.Increment(ref handlerCalls); return Task.FromResult(true); });
var result = await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
attemptImmediateDelivery: false);
Assert.Equal(0, handlerCalls); // handler NOT invoked at enqueue time
Assert.True(result.WasBuffered);
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
Assert.NotNull(msg);
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
// StoreAndForward-003: the caller's own attempt is attempt 0; RetryCount
// counts only sweep retries, so a freshly buffered message has RetryCount 0.
Assert.Equal(0, msg.RetryCount);
}
// ─── StoreAndForward-024: StopAsync waits for the in-flight sweep ───
/// <summary>
/// StoreAndForward-024: <see cref="StoreAndForwardService.StopAsync"/> must
/// not return until any in-flight retry sweep has completed (or the bounded
/// shutdown timeout fires). Pre-fix it disposed the timer and returned
/// immediately, leaving a mid-flight sweep touching disposed dependencies.
/// </summary>
[Fact]
public async Task StopAsync_AwaitsInFlightRetrySweep_BeforeReturning()
{
// Build a service whose timer fires almost immediately, with a handler
// that pauses in the middle of delivery so we can observe StopAsync's
// wait behaviour.
var dbName = $"StopWait_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
using var keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
var storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
await storage.InitializeAsync();
var options = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
// Fire almost immediately so the sweep is in-flight by the time we call StopAsync.
RetryTimerInterval = TimeSpan.FromMilliseconds(20),
};
var service = new StoreAndForwardService(
storage, options, NullLogger<StoreAndForwardService>.Instance);
// Pre-seed a buffered message so the sweep has work to do, and a
// handler that blocks until we release it.
var handlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseHandler = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var handlerCompleted = false;
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, async _ =>
{
handlerEntered.TrySetResult();
await releaseHandler.Task;
handlerCompleted = true;
return true;
});
var seed = await service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
attemptImmediateDelivery: false);
Assert.True(seed.WasBuffered);
await service.StartAsync();
// Wait until the timer-driven sweep has called into the handler.
var entered = await Task.WhenAny(handlerEntered.Task, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.Same(handlerEntered.Task, entered);
Assert.False(handlerCompleted, "Handler should still be paused inside the sweep.");
// Kick StopAsync — it must NOT return until the sweep finishes. Run the
// release on a background task so we can prove StopAsync is awaiting.
var stopTask = service.StopAsync();
Assert.False(stopTask.IsCompleted,
"StopAsync returned before the in-flight sweep was given a chance to finish.");
// Release the handler — StopAsync should now complete shortly.
releaseHandler.SetResult();
await stopTask.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(handlerCompleted,
"Sweep handler must have finished before StopAsync returned.");
}
}
@@ -0,0 +1,632 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// WP-9: Tests for SQLite persistence layer.
/// Uses in-memory SQLite with a kept-alive connection for test isolation.
/// </summary>
public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
{
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly string _dbName;
public StoreAndForwardStorageTests()
{
_dbName = $"StorageTests_{Guid.NewGuid():N}";
var connStr = $"Data Source={_dbName};Mode=Memory;Cache=Shared";
// Keep one connection alive so the in-memory DB persists
_keepAlive = new SqliteConnection(connStr);
_keepAlive.Open();
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
}
public async Task InitializeAsync() => await _storage.InitializeAsync();
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose()
{
_keepAlive.Dispose();
}
[Fact]
public async Task EnqueueAsync_StoresMessage()
{
var message = CreateMessage("msg1", StoreAndForwardCategory.ExternalSystem);
await _storage.EnqueueAsync(message);
var retrieved = await _storage.GetMessageByIdAsync("msg1");
Assert.NotNull(retrieved);
Assert.Equal("msg1", retrieved!.Id);
Assert.Equal(StoreAndForwardCategory.ExternalSystem, retrieved.Category);
Assert.Equal("target1", retrieved.Target);
}
[Fact]
public async Task EnqueueAsync_AllCategories()
{
await _storage.EnqueueAsync(CreateMessage("es1", StoreAndForwardCategory.ExternalSystem));
await _storage.EnqueueAsync(CreateMessage("n1", StoreAndForwardCategory.Notification));
await _storage.EnqueueAsync(CreateMessage("db1", StoreAndForwardCategory.CachedDbWrite));
var es = await _storage.GetMessageByIdAsync("es1");
var n = await _storage.GetMessageByIdAsync("n1");
var db = await _storage.GetMessageByIdAsync("db1");
Assert.Equal(StoreAndForwardCategory.ExternalSystem, es!.Category);
Assert.Equal(StoreAndForwardCategory.Notification, n!.Category);
Assert.Equal(StoreAndForwardCategory.CachedDbWrite, db!.Category);
}
[Fact]
public async Task RemoveMessageAsync_RemovesSuccessfully()
{
await _storage.EnqueueAsync(CreateMessage("rm1", StoreAndForwardCategory.ExternalSystem));
await _storage.RemoveMessageAsync("rm1");
var retrieved = await _storage.GetMessageByIdAsync("rm1");
Assert.Null(retrieved);
}
[Fact]
public async Task UpdateMessageAsync_UpdatesFields()
{
var message = CreateMessage("upd1", StoreAndForwardCategory.ExternalSystem);
await _storage.EnqueueAsync(message);
message.RetryCount = 5;
message.LastAttemptAt = DateTimeOffset.UtcNow;
message.Status = StoreAndForwardMessageStatus.Parked;
message.LastError = "Connection refused";
await _storage.UpdateMessageAsync(message);
var retrieved = await _storage.GetMessageByIdAsync("upd1");
Assert.Equal(5, retrieved!.RetryCount);
Assert.Equal(StoreAndForwardMessageStatus.Parked, retrieved.Status);
Assert.Equal("Connection refused", retrieved.LastError);
}
[Fact]
public async Task GetMessagesForRetryAsync_ReturnsOnlyPendingMessages()
{
var pending = CreateMessage("pend1", StoreAndForwardCategory.ExternalSystem);
pending.Status = StoreAndForwardMessageStatus.Pending;
await _storage.EnqueueAsync(pending);
var parked = CreateMessage("park1", StoreAndForwardCategory.ExternalSystem);
parked.Status = StoreAndForwardMessageStatus.Parked;
await _storage.EnqueueAsync(parked);
await _storage.UpdateMessageAsync(parked);
var forRetry = await _storage.GetMessagesForRetryAsync();
Assert.All(forRetry, m => Assert.Equal(StoreAndForwardMessageStatus.Pending, m.Status));
}
[Fact]
public async Task GetMessagesForRetryAsync_NonZeroInterval_ExcludesNotYetDueIncludesDue()
{
// StoreAndForward-013: exercise the julianday elapsed-time comparison with a
// non-zero retry interval. A message attempted just now must NOT be due; one
// attempted long ago must be due.
var notDue = CreateMessage("notdue", StoreAndForwardCategory.ExternalSystem);
notDue.RetryIntervalMs = (long)TimeSpan.FromHours(1).TotalMilliseconds;
notDue.LastAttemptAt = DateTimeOffset.UtcNow;
await _storage.EnqueueAsync(notDue);
var due = CreateMessage("due", StoreAndForwardCategory.ExternalSystem);
due.RetryIntervalMs = (long)TimeSpan.FromMinutes(5).TotalMilliseconds;
due.LastAttemptAt = DateTimeOffset.UtcNow.AddHours(-2);
await _storage.EnqueueAsync(due);
var neverAttempted = CreateMessage("never", StoreAndForwardCategory.ExternalSystem);
neverAttempted.RetryIntervalMs = (long)TimeSpan.FromHours(1).TotalMilliseconds;
neverAttempted.LastAttemptAt = null;
await _storage.EnqueueAsync(neverAttempted);
var forRetry = await _storage.GetMessagesForRetryAsync();
var ids = forRetry.Select(m => m.Id).ToHashSet();
Assert.DoesNotContain("notdue", ids);
Assert.Contains("due", ids);
Assert.Contains("never", ids);
}
[Fact]
public async Task GetParkedMessagesAsync_ReturnsParkedOnly()
{
var msg = CreateMessage("prk1", StoreAndForwardCategory.Notification);
msg.Status = StoreAndForwardMessageStatus.Parked;
await _storage.EnqueueAsync(msg);
await _storage.UpdateMessageAsync(msg);
var (messages, total) = await _storage.GetParkedMessagesAsync();
Assert.True(total > 0);
Assert.All(messages, m => Assert.Equal(StoreAndForwardMessageStatus.Parked, m.Status));
}
[Fact]
public async Task RetryParkedMessageAsync_MovesToPending()
{
var msg = CreateMessage("retry1", StoreAndForwardCategory.ExternalSystem);
msg.Status = StoreAndForwardMessageStatus.Parked;
msg.RetryCount = 10;
await _storage.EnqueueAsync(msg);
await _storage.UpdateMessageAsync(msg);
var success = await _storage.RetryParkedMessageAsync("retry1");
Assert.True(success);
var retrieved = await _storage.GetMessageByIdAsync("retry1");
Assert.Equal(StoreAndForwardMessageStatus.Pending, retrieved!.Status);
Assert.Equal(0, retrieved.RetryCount);
}
[Fact]
public async Task RetryParkedMessageAsync_ClearsLastAttemptAt_SoMessageIsImmediatelyDue()
{
// StoreAndForward-010: a re-queued parked message must be unambiguously due
// for the next sweep regardless of its (stale) last_attempt_at. Use a large
// retry interval so a leftover timestamp would otherwise exclude the message.
var msg = CreateMessage("requeue1", StoreAndForwardCategory.ExternalSystem);
msg.RetryIntervalMs = (long)TimeSpan.FromHours(1).TotalMilliseconds;
msg.LastAttemptAt = DateTimeOffset.UtcNow; // recent attempt
msg.Status = StoreAndForwardMessageStatus.Parked;
await _storage.EnqueueAsync(msg);
await _storage.UpdateMessageAsync(msg);
var requeued = await _storage.RetryParkedMessageAsync("requeue1");
Assert.True(requeued);
var retrieved = await _storage.GetMessageByIdAsync("requeue1");
Assert.Null(retrieved!.LastAttemptAt);
// It must appear in the retry-due set even though the configured interval
// (1 hour) has not elapsed since the original attempt.
var due = await _storage.GetMessagesForRetryAsync();
Assert.Contains(due, m => m.Id == "requeue1");
}
[Fact]
public async Task DiscardParkedMessageAsync_RemovesMessage()
{
var msg = CreateMessage("disc1", StoreAndForwardCategory.ExternalSystem);
msg.Status = StoreAndForwardMessageStatus.Parked;
await _storage.EnqueueAsync(msg);
await _storage.UpdateMessageAsync(msg);
var success = await _storage.DiscardParkedMessageAsync("disc1");
Assert.True(success);
var retrieved = await _storage.GetMessageByIdAsync("disc1");
Assert.Null(retrieved);
}
[Fact]
public async Task GetBufferDepthByCategoryAsync_ReturnsCorrectCounts()
{
await _storage.EnqueueAsync(CreateMessage("bd1", StoreAndForwardCategory.ExternalSystem));
await _storage.EnqueueAsync(CreateMessage("bd2", StoreAndForwardCategory.ExternalSystem));
await _storage.EnqueueAsync(CreateMessage("bd3", StoreAndForwardCategory.Notification));
var depth = await _storage.GetBufferDepthByCategoryAsync();
Assert.True(depth.GetValueOrDefault(StoreAndForwardCategory.ExternalSystem) >= 2);
}
[Fact]
public async Task GetMessageCountByOriginInstanceAsync_ReturnsCount()
{
var msg1 = CreateMessage("oi1", StoreAndForwardCategory.ExternalSystem);
msg1.OriginInstanceName = "Pump1";
await _storage.EnqueueAsync(msg1);
var msg2 = CreateMessage("oi2", StoreAndForwardCategory.Notification);
msg2.OriginInstanceName = "Pump1";
await _storage.EnqueueAsync(msg2);
var count = await _storage.GetMessageCountByOriginInstanceAsync("Pump1");
Assert.Equal(2, count);
}
[Fact]
public async Task GetParkedMessagesAsync_Pagination()
{
for (int i = 0; i < 5; i++)
{
var msg = CreateMessage($"page{i}", StoreAndForwardCategory.ExternalSystem);
msg.Status = StoreAndForwardMessageStatus.Parked;
await _storage.EnqueueAsync(msg);
await _storage.UpdateMessageAsync(msg);
}
var (page1, total) = await _storage.GetParkedMessagesAsync(pageNumber: 1, pageSize: 2);
Assert.Equal(2, page1.Count);
Assert.True(total >= 5);
var (page2, _) = await _storage.GetParkedMessagesAsync(pageNumber: 2, pageSize: 2);
Assert.Equal(2, page2.Count);
}
[Fact]
public async Task GetParkedMessagesAsync_TransactionedReads_CountMatchesFullResultSet()
{
// StoreAndForward-006: the COUNT(*) and paged SELECT now run inside one
// transaction so they share a consistent snapshot. This functional check
// guards the fix — it verifies the transaction wiring did not break paging:
// the reported TotalCount and the rows assembled across all pages agree, and
// a page wide enough to hold every parked row contains exactly TotalCount rows.
for (int i = 0; i < 25; i++)
{
var m = CreateMessage($"txn-{i}", StoreAndForwardCategory.ExternalSystem);
m.Status = StoreAndForwardMessageStatus.Parked;
await _storage.EnqueueAsync(m);
await _storage.UpdateMessageAsync(m);
}
var (wholePage, wholeTotal) = await _storage.GetParkedMessagesAsync(pageNumber: 1, pageSize: 1000);
Assert.Equal(25, wholeTotal);
Assert.Equal(wholeTotal, wholePage.Count);
var collected = new List<string>();
int reportedTotal = -1;
for (int page = 1; ; page++)
{
var (rows, total) = await _storage.GetParkedMessagesAsync(pageNumber: page, pageSize: 7);
reportedTotal = total;
collected.AddRange(rows.Select(r => r.Id));
if (rows.Count < 7) break;
}
Assert.Equal(reportedTotal, collected.Count);
Assert.Equal(25, collected.Distinct().Count());
}
[Fact]
public async Task GetMessageCountByStatusAsync_ReturnsAccurateCount()
{
var msg = CreateMessage("cnt1", StoreAndForwardCategory.ExternalSystem);
await _storage.EnqueueAsync(msg);
var count = await _storage.GetMessageCountByStatusAsync(StoreAndForwardMessageStatus.Pending);
Assert.True(count >= 1);
}
// ── Audit Log #23 (ExecutionId Task 4): execution_id / source_script ──
[Fact]
public async Task EnqueueAsync_RoundTripsExecutionIdAndSourceScript()
{
// A cached call buffered on a transient failure carries the originating
// script execution's ExecutionId + SourceScript; both must survive a
// persist + read-back so the retry loop can stamp them on audit rows.
var executionId = Guid.NewGuid();
var message = CreateMessage("exec1", StoreAndForwardCategory.ExternalSystem);
message.ExecutionId = executionId;
message.SourceScript = "Plant.Pump42/OnTick";
await _storage.EnqueueAsync(message);
var retrieved = await _storage.GetMessageByIdAsync("exec1");
Assert.NotNull(retrieved);
Assert.Equal(executionId, retrieved!.ExecutionId);
Assert.Equal("Plant.Pump42/OnTick", retrieved.SourceScript);
}
[Fact]
public async Task EnqueueAsync_NullExecutionIdAndSourceScript_RoundTripAsNull()
{
// Non-cached-call enqueues (notifications) supply neither field — they
// must round-trip as null rather than throwing or coercing.
var message = CreateMessage("noexec1", StoreAndForwardCategory.Notification);
Assert.Null(message.ExecutionId);
Assert.Null(message.SourceScript);
await _storage.EnqueueAsync(message);
var retrieved = await _storage.GetMessageByIdAsync("noexec1");
Assert.NotNull(retrieved);
Assert.Null(retrieved!.ExecutionId);
Assert.Null(retrieved.SourceScript);
}
[Fact]
public async Task ExecutionIdAndSourceScript_SurviveRetrySweepRead()
{
// The retry sweep reads due rows via GetMessagesForRetryAsync; the new
// fields must be present on that read path too (it is the path that
// feeds the CachedCallAttemptContext).
var executionId = Guid.NewGuid();
var message = CreateMessage("sweep1", StoreAndForwardCategory.CachedDbWrite);
message.ExecutionId = executionId;
message.SourceScript = "Plant.Tank/OnAlarm";
message.LastAttemptAt = null; // due immediately
await _storage.EnqueueAsync(message);
var due = await _storage.GetMessagesForRetryAsync();
var row = Assert.Single(due, m => m.Id == "sweep1");
Assert.Equal(executionId, row.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", row.SourceScript);
}
[Fact]
public async Task LegacyRowWithoutNewColumns_ReadsBackAsNull()
{
// Back-compat: a row persisted by a build that pre-dates the
// execution_id / source_script columns must still deserialize, with
// ExecutionId / SourceScript reading back as null. Simulate the legacy
// schema by dropping the table and recreating it without the columns,
// inserting directly, then running InitializeAsync (which ALTER-adds
// the columns) and reading the row back.
await using (var setup = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared"))
{
await setup.OpenAsync();
await using var drop = setup.CreateCommand();
drop.CommandText = @"
DROP TABLE IF EXISTS sf_messages;
CREATE TABLE sf_messages (
id TEXT PRIMARY KEY,
category INTEGER NOT NULL,
target TEXT NOT NULL,
payload_json TEXT NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 50,
retry_interval_ms INTEGER NOT NULL DEFAULT 30000,
created_at TEXT NOT NULL,
last_attempt_at TEXT,
status INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
origin_instance TEXT
);
INSERT INTO sf_messages (id, category, target, payload_json, created_at, status)
VALUES ('legacy1', 0, 'ERP', '{}', '2026-01-01T00:00:00.0000000+00:00', 0);";
await drop.ExecuteNonQueryAsync();
}
// InitializeAsync must additively ALTER-in the new columns without
// disturbing the pre-existing legacy row.
await _storage.InitializeAsync();
var retrieved = await _storage.GetMessageByIdAsync("legacy1");
Assert.NotNull(retrieved);
Assert.Equal("legacy1", retrieved!.Id);
Assert.Null(retrieved.ExecutionId);
Assert.Null(retrieved.SourceScript);
}
[Fact]
public async Task MalformedExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep()
{
// Defensive read path: a corrupt (non-null, non-GUID) execution_id must
// be treated as "no execution id" rather than throwing FormatException
// — a single bad row must not abort the whole GetMessagesForRetryAsync
// sweep, which reads many rows. Persist two due rows, then corrupt the
// execution_id of one directly in the DB.
var goodId = Guid.NewGuid();
var good = CreateMessage("good1", StoreAndForwardCategory.ExternalSystem);
good.ExecutionId = goodId;
good.LastAttemptAt = null; // due immediately
await _storage.EnqueueAsync(good);
var bad = CreateMessage("bad1", StoreAndForwardCategory.ExternalSystem);
bad.ExecutionId = Guid.NewGuid();
bad.LastAttemptAt = null; // due immediately
await _storage.EnqueueAsync(bad);
await using (var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared"))
{
await conn.OpenAsync();
await using var corrupt = conn.CreateCommand();
corrupt.CommandText =
"UPDATE sf_messages SET execution_id = 'not-a-guid' WHERE id = 'bad1';";
await corrupt.ExecuteNonQueryAsync();
}
// The sweep must not throw; the corrupt row reads back with a null
// ExecutionId, the well-formed row keeps its value.
var due = await _storage.GetMessagesForRetryAsync();
Assert.Null(Assert.Single(due, m => m.Id == "bad1").ExecutionId);
Assert.Equal(goodId, Assert.Single(due, m => m.Id == "good1").ExecutionId);
// The single-row read path is equally defensive.
var retrieved = await _storage.GetMessageByIdAsync("bad1");
Assert.NotNull(retrieved);
Assert.Null(retrieved!.ExecutionId);
}
[Fact]
public async Task InitializeAsync_IsIdempotent_WhenColumnsAlreadyExist()
{
// The additive ALTER must not fail on a second InitializeAsync call
// (SQLite has no ADD COLUMN IF NOT EXISTS — the probe must skip it).
await _storage.InitializeAsync();
await _storage.InitializeAsync();
var message = CreateMessage("idem1", StoreAndForwardCategory.ExternalSystem);
message.ExecutionId = Guid.NewGuid();
await _storage.EnqueueAsync(message);
var retrieved = await _storage.GetMessageByIdAsync("idem1");
Assert.NotNull(retrieved);
Assert.Equal(message.ExecutionId, retrieved!.ExecutionId);
}
// ── Audit Log #23 (ParentExecutionId Task 6): parent_execution_id ──
[Fact]
public async Task EnqueueAsync_RoundTripsParentExecutionId()
{
// A cached call buffered from an inbound-API-routed script run carries
// the spawning execution's ParentExecutionId; it must survive a persist
// + read-back so the retry loop can stamp it on audit rows.
var parentExecutionId = Guid.NewGuid();
var message = CreateMessage("parent1", StoreAndForwardCategory.ExternalSystem);
message.ParentExecutionId = parentExecutionId;
await _storage.EnqueueAsync(message);
var retrieved = await _storage.GetMessageByIdAsync("parent1");
Assert.NotNull(retrieved);
Assert.Equal(parentExecutionId, retrieved!.ParentExecutionId);
}
[Fact]
public async Task EnqueueAsync_NullParentExecutionId_RoundTripsAsNull()
{
// A non-routed run supplies no ParentExecutionId — it must round-trip
// as null rather than throwing or coercing.
var message = CreateMessage("noparent1", StoreAndForwardCategory.ExternalSystem);
Assert.Null(message.ParentExecutionId);
await _storage.EnqueueAsync(message);
var retrieved = await _storage.GetMessageByIdAsync("noparent1");
Assert.NotNull(retrieved);
Assert.Null(retrieved!.ParentExecutionId);
}
[Fact]
public async Task ParentExecutionId_SurvivesRetrySweepRead()
{
// The retry sweep reads due rows via GetMessagesForRetryAsync; the new
// parent_execution_id field must be present on that read path too — it
// is the path that feeds the CachedCallAttemptContext.
var parentExecutionId = Guid.NewGuid();
var message = CreateMessage("psweep1", StoreAndForwardCategory.CachedDbWrite);
message.ParentExecutionId = parentExecutionId;
message.LastAttemptAt = null; // due immediately
await _storage.EnqueueAsync(message);
var due = await _storage.GetMessagesForRetryAsync();
var row = Assert.Single(due, m => m.Id == "psweep1");
Assert.Equal(parentExecutionId, row.ParentExecutionId);
}
[Fact]
public async Task LegacyRowWithoutParentExecutionIdColumn_ReadsBackAsNull()
{
// Back-compat: a row persisted by a build that pre-dates the
// parent_execution_id column must still deserialize, with
// ParentExecutionId reading back as null. Simulate the pre-Task-6
// schema (which already has execution_id / source_script from the
// ExecutionId rollout) by recreating the table without
// parent_execution_id, inserting directly, then running InitializeAsync
// which ALTER-adds the column.
await using (var setup = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared"))
{
await setup.OpenAsync();
await using var drop = setup.CreateCommand();
drop.CommandText = @"
DROP TABLE IF EXISTS sf_messages;
CREATE TABLE sf_messages (
id TEXT PRIMARY KEY,
category INTEGER NOT NULL,
target TEXT NOT NULL,
payload_json TEXT NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 50,
retry_interval_ms INTEGER NOT NULL DEFAULT 30000,
created_at TEXT NOT NULL,
last_attempt_at TEXT,
status INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
origin_instance TEXT,
execution_id TEXT,
source_script TEXT
);
INSERT INTO sf_messages (id, category, target, payload_json, created_at, status)
VALUES ('plegacy1', 0, 'ERP', '{}', '2026-01-01T00:00:00.0000000+00:00', 0);";
await drop.ExecuteNonQueryAsync();
}
// InitializeAsync must additively ALTER-in parent_execution_id without
// disturbing the pre-existing legacy row.
await _storage.InitializeAsync();
var retrieved = await _storage.GetMessageByIdAsync("plegacy1");
Assert.NotNull(retrieved);
Assert.Equal("plegacy1", retrieved!.Id);
Assert.Null(retrieved.ParentExecutionId);
}
[Fact]
public async Task MalformedParentExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep()
{
// Defensive read path: a corrupt (non-null, non-GUID) parent_execution_id
// must be treated as "no parent execution id" rather than throwing
// FormatException — a single bad row must not abort the whole
// GetMessagesForRetryAsync sweep.
var goodParent = Guid.NewGuid();
var good = CreateMessage("pgood1", StoreAndForwardCategory.ExternalSystem);
good.ParentExecutionId = goodParent;
good.LastAttemptAt = null; // due immediately
await _storage.EnqueueAsync(good);
var bad = CreateMessage("pbad1", StoreAndForwardCategory.ExternalSystem);
bad.ParentExecutionId = Guid.NewGuid();
bad.LastAttemptAt = null; // due immediately
await _storage.EnqueueAsync(bad);
await using (var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared"))
{
await conn.OpenAsync();
await using var corrupt = conn.CreateCommand();
corrupt.CommandText =
"UPDATE sf_messages SET parent_execution_id = 'not-a-guid' WHERE id = 'pbad1';";
await corrupt.ExecuteNonQueryAsync();
}
var due = await _storage.GetMessagesForRetryAsync();
Assert.Null(Assert.Single(due, m => m.Id == "pbad1").ParentExecutionId);
Assert.Equal(goodParent, Assert.Single(due, m => m.Id == "pgood1").ParentExecutionId);
var retrieved = await _storage.GetMessageByIdAsync("pbad1");
Assert.NotNull(retrieved);
Assert.Null(retrieved!.ParentExecutionId);
}
private static StoreAndForwardMessage CreateMessage(string id, StoreAndForwardCategory category)
{
return new StoreAndForwardMessage
{
Id = id,
Category = category,
Target = "target1",
PayloadJson = """{"method":"Test","args":{}}""",
RetryCount = 0,
MaxRetries = 50,
RetryIntervalMs = 30000,
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending
};
}
[Fact]
public async Task InitializeAsync_FileInMissingDirectory_CreatesDirectory()
{
// SQLite creates the database file on demand but not its parent directory;
// the storage must create the directory itself or OpenAsync fails with
// "unable to open database file" (the cause of the SiteActorPathTests failures).
var directory = Path.Combine(Path.GetTempPath(), "sf-storage-test-" + Guid.NewGuid().ToString("N"));
var dbPath = Path.Combine(directory, "store-and-forward.db");
Assert.False(Directory.Exists(directory));
try
{
var storage = new StoreAndForwardStorage(
$"Data Source={dbPath}", NullLogger<StoreAndForwardStorage>.Instance);
await storage.InitializeAsync();
Assert.True(Directory.Exists(directory));
Assert.True(File.Exists(dbPath));
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ZB.MOM.WW.ScadaBridge.StoreAndForward.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,4 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false
}