docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)

Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// Audit Log #23 — M3 Bundle E Tasks E4 + E5: the store-and-forward retry
/// Cached-call lifecycle observer: 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
@@ -32,6 +32,7 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
private readonly StoreAndForwardOptions _options;
private readonly CapturingObserver _observer;
/// <summary>Initializes the test with a temporary local database, storage and observer.</summary>
public CachedCallAttemptEmissionTests()
{
_localDb = TestLocalDb.CreateTemp("E4Tests");
@@ -55,8 +56,13 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
siteId: "site-77");
}
/// <summary>Prepares the test class before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync() => await _storage.InitializeAsync();
/// <summary>Releases the resources held by this test class asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
var path = _localDb.Path;
@@ -70,9 +76,12 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
/// </summary>
private sealed class CapturingObserver : ICachedCallLifecycleObserver
{
/// <summary>The observer notifications received, in order.</summary>
public List<CachedCallAttemptContext> Notifications { get; } = new();
/// <summary>When true, the observer throws on every notification.</summary>
public Exception? ThrowOnNotify { get; set; }
/// <inheritdoc />
public Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default)
{
if (ThrowOnNotify != null)
@@ -90,7 +99,7 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
private async Task<TrackedOperationId> EnqueueBufferedAsync(
StoreAndForwardCategory category, string target, int maxRetries = 3)
{
// The TrackedOperationId is the S&F message id (Bundle E3 contract).
// The TrackedOperationId is the S&F message id (see the cached-call contract).
var trackedId = TrackedOperationId.New();
await _service.EnqueueAsync(
category,
@@ -104,8 +113,10 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
return trackedId;
}
// ── Task E4: per-attempt observer notifications ──
// ── Per-attempt observer notifications ──
/// <summary>Verifies that a failed attempt with an HTTP 500 emits attempted telemetry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Attempt_FailWithHttp500_EmitsAttemptedTelemetry()
{
@@ -129,6 +140,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Equal("Plant.Pump42", notification.SourceInstanceId);
}
/// <summary>Verifies that a successful attempt emits a delivered outcome.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Attempt_Success_EmitsDeliveredOutcome()
{
@@ -147,6 +160,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Null(notification.LastError);
}
/// <summary>Verifies that a permanent failure emits a permanent-failure outcome.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Attempt_PermanentFailure_EmitsPermanentFailureOutcome()
{
@@ -162,6 +177,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Equal(CachedCallAttemptOutcome.PermanentFailure, notification.Outcome);
}
/// <summary>Verifies that an attempt of a cached database write emits on the database outbound channel.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Attempt_CachedDbWrite_EmitsDbOutboundChannel()
{
@@ -178,6 +195,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Equal("myDb", notification.Target);
}
/// <summary>Verifies that an attempt in the notification category produces no observer notification.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Attempt_NotificationCategory_NoObserverNotification()
{
@@ -198,12 +217,14 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Empty(_observer.Notifications);
}
/// <summary>Verifies that an attempt whose message id is not a GUID produces no observer notification.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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
// Earlier cached calls (no TrackedOperationId threaded in) use a random
// GUID-N message id from S&F itself. We should still emit (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,
@@ -221,8 +242,10 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Empty(_observer.Notifications);
}
// ── Task E5: terminal-state observer notifications ──
// ── Terminal-state observer notifications ──
/// <summary>Verifies that terminal delivery emits a resolve with the delivered status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Terminal_Delivered_EmitsResolveWithDeliveredStatus()
{
@@ -242,6 +265,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome);
}
/// <summary>Verifies that parking after max retries emits a resolve with the parked-max-retries status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Terminal_Parked_OnMaxRetries_EmitsParkedMaxRetries()
{
@@ -261,6 +286,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Equal(trackedId, _observer.Notifications[1].TrackedOperationId);
}
/// <summary>Verifies that a retry-fail then retry-succeed lifecycle emits the expected sequence.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Lifecycle_RetryFail_RetrySucceed_EmitsExpectedSequence()
{
@@ -284,8 +311,10 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Equal(trackedId, _observer.Notifications[1].TrackedOperationId);
}
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
// ── ExecutionId / SourceScript ──
/// <summary>Verifies that an attempt carries the execution id and source script from the buffered message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Attempt_CarriesExecutionIdAndSourceScript_FromBufferedMessage()
{
@@ -317,6 +346,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Equal("Plant.Pump42/OnTick", notification.SourceScript);
}
/// <summary>Verifies that a null execution id and source script surface as null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Attempt_NullExecutionIdAndSourceScript_SurfaceAsNull()
{
@@ -334,6 +365,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Null(notification.SourceScript);
}
/// <summary>Verifies that a terminal resolve carries the execution id and source script.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TerminalResolve_CarriesExecutionIdAndSourceScript()
{
@@ -364,8 +397,10 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Equal("Plant.Tank/OnAlarm", notification.SourceScript);
}
// ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
// ── ParentExecutionId ──
/// <summary>Verifies that an attempt carries the parent execution id from the buffered message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Attempt_CarriesParentExecutionId_FromBufferedMessage()
{
@@ -395,6 +430,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Equal(parentExecutionId, notification.ParentExecutionId);
}
/// <summary>Verifies that a null parent execution id surfaces as null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Attempt_NullParentExecutionId_SurfacesAsNull()
{
@@ -412,6 +449,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Null(notification.ParentExecutionId);
}
/// <summary>Verifies that a terminal resolve carries the parent execution id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TerminalResolve_CarriesParentExecutionId()
{
@@ -443,6 +482,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
// ── Best-effort contract: observer throws must NOT corrupt retry bookkeeping ──
/// <summary>Verifies that an observer that throws does not corrupt the retry count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Observer_Throws_DoesNotCorruptRetryCount()
{
@@ -460,16 +501,21 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Null(msg);
}
// ── Task 19: audit-observer decoupled from the sweep via an ordered channel ──
// ── Audit-observer decoupled from the sweep via an ordered channel ──
private sealed class DelegatingObserver : ICachedCallLifecycleObserver
{
private readonly Func<CachedCallAttemptContext, Task> _onNotify;
/// <summary>Initializes the observer with the callback to invoke for each notification.</summary>
/// <param name="onNotify">The callback invoked for each attempt notification.</param>
public DelegatingObserver(Func<CachedCallAttemptContext, Task> onNotify) => _onNotify = onNotify;
/// <inheritdoc />
public Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default)
=> _onNotify(context);
}
/// <summary>Verifies that a slow observer does not block the sweep and events arrive in order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SlowObserver_DoesNotBlockTheSweep_AndEventsArriveInOrder()
{
@@ -18,7 +18,7 @@ public class NotificationForwarderTests : TestKit
/// <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
/// produced by the site <c>Notify.Send</c> enqueue path: 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.
@@ -50,6 +50,8 @@ public class NotificationForwarderTests : TestKit
};
}
/// <summary>Verifies that delivery forwards a notification submit to the central target and returns true when it is accepted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deliver_ForwardsNotificationSubmitToCentralTarget_AndReturnsTrueOnAccept()
{
@@ -80,10 +82,12 @@ public class NotificationForwarderTests : TestKit
Assert.True(await deliverTask);
}
/// <summary>Verifies that delivery preserves the origin execution id from the buffered payload.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deliver_PreservesOriginExecutionId_FromBufferedPayload()
{
// Audit Log #23: the buffered payload's OriginExecutionId is the per-run
// 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
@@ -107,6 +111,8 @@ public class NotificationForwarderTests : TestKit
Assert.True(await deliverTask);
}
/// <summary>Verifies that delivery falls back to the target when the payload's list name is empty.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deliver_FallsBackToTarget_WhenPayloadListNameIsEmpty()
{
@@ -143,6 +149,8 @@ public class NotificationForwarderTests : TestKit
Assert.True(await deliverTask);
}
/// <summary>Verifies that delivery throws a transient error when the acknowledgement is not accepted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deliver_ThrowsTransient_WhenAckIsNotAccepted()
{
@@ -161,6 +169,8 @@ public class NotificationForwarderTests : TestKit
await Assert.ThrowsAnyAsync<Exception>(() => deliverTask);
}
/// <summary>Verifies that delivery throws a transient error when no reply arrives within the timeout.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deliver_ThrowsTransient_WhenNoReplyWithinTimeout()
{
@@ -173,6 +183,8 @@ public class NotificationForwarderTests : TestKit
await Assert.ThrowsAnyAsync<Exception>(() => forwarder.DeliverAsync(BufferedNotification()));
}
/// <summary>Verifies that delivery uses a stable notification id across retries of the same message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deliver_UsesStableNotificationId_AcrossRetriesOfSameMessage()
{
@@ -198,10 +210,12 @@ public class NotificationForwarderTests : TestKit
Assert.Equal("stable-msg-id", submit1.NotificationId);
}
/// <summary>Verifies that a corrupt JSON payload returns false, is parked for forensics and is not forwarded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deliver_CorruptJsonPayload_ReturnsFalse_ParksForForensics_AndDoesNotForward()
{
// Supersedes StoreAndForward-018 (discard-on-corrupt). Returning false is the
// Supersedes the earlier discard-on-corrupt behavior. Returning false is the
// handler contract's permanent-failure signal: the engine parks the row, which
// preserves the payload for operator forensics. Retrying a corrupt payload is
// pointless; deleting it silently (and reporting Delivered) loses data with no
@@ -226,12 +240,14 @@ public class NotificationForwarderTests : TestKit
centralProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that a payload that deserializes to null returns false, is parked for forensics and is not forwarded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deliver_NullDeserializedPayload_ReturnsFalse_ParksForForensics_AndDoesNotForward()
{
// The companion case to corrupt JSON: the payload is valid JSON but
// deserialises to null (e.g. "null"). Same treatment — park (return false)
// rather than silently discard-as-delivered. Supersedes StoreAndForward-018.
// rather than silently discard-as-delivered. Supersedes the earlier discard-on-corrupt behavior.
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// StoreAndForward-013: tests for the <see cref="ParkedMessageHandlerActor"/> actor
/// 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>
@@ -18,6 +18,7 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _service;
/// <summary>Initializes a new instance of the <c>ParkedMessageHandlerActorTests</c> class.</summary>
public ParkedMessageHandlerActorTests()
{
_localDb = TestLocalDb.CreateTemp("ActorTests");
@@ -35,10 +36,15 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab
_storage, options, NullLogger<StoreAndForwardService>.Instance);
}
/// <summary>Initializes the storage before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync() => await _storage.InitializeAsync();
/// <summary>Does nothing; storage is released in <c>Dispose</c>.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisposeAsync() => Task.CompletedTask;
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -60,6 +66,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab
return result.MessageId;
}
/// <summary>Verifies that a query returns the parked entries with the method name extracted from each payload.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_ReturnsParkedEntries_WithExtractedMethodName()
{
@@ -78,6 +86,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab
Assert.Equal(StoreAndForwardCategory.ExternalSystem, response.Messages[0].Category);
}
/// <summary>Verifies that a notification payload uses its subject as the method name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_NotificationPayload_UsesSubjectAsMethodName()
{
@@ -92,6 +102,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab
Assert.Equal("Tank overflow", response.Messages[0].MethodName);
}
/// <summary>Verifies that a malformed JSON payload falls back to the category name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_MalformedJsonPayload_FallsBackToCategoryName()
{
@@ -106,6 +118,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab
Assert.Equal(StoreAndForwardCategory.CachedDbWrite.ToString(), response.Messages[0].MethodName);
}
/// <summary>Verifies that a payload with neither a method name nor a subject falls back to the category name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Query_PayloadWithoutMethodNameOrSubject_FallsBackToCategoryName()
{
@@ -118,6 +132,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab
Assert.Equal(StoreAndForwardCategory.ExternalSystem.ToString(), response.Messages[0].MethodName);
}
/// <summary>Verifies that retrying a parked message returns success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Retry_ParkedMessage_ReturnsSuccess()
{
@@ -135,6 +151,7 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
}
/// <summary>Verifies that retrying an unknown message returns a failure with an explanatory message.</summary>
[Fact]
public void Retry_UnknownMessage_ReturnsFailureWithMessage()
{
@@ -147,6 +164,8 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab
Assert.NotNull(response.ErrorMessage);
}
/// <summary>Verifies that discarding a parked message returns success and removes the message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Discard_ParkedMessage_ReturnsSuccessAndRemovesMessage()
{
@@ -163,6 +182,7 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab
Assert.Null(msg);
}
/// <summary>Verifies that discarding an unknown message returns a failure with an explanatory message.</summary>
[Fact]
public void Discard_UnknownMessage_ReturnsFailureWithMessage()
{
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// Task 5 (#22 Retry/Discard relay): tests the site-side execution of a
/// 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
@@ -23,6 +23,7 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _service;
/// <summary>Initializes a new instance of the <c>ParkedOperationRelayTests</c> class.</summary>
public ParkedOperationRelayTests()
{
_localDb = TestLocalDb.CreateTemp("RelayTests");
@@ -40,10 +41,15 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable
_storage, options, NullLogger<StoreAndForwardService>.Instance);
}
/// <summary>Initializes the storage before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync() => await _storage.InitializeAsync();
/// <summary>Does nothing; storage is released in <c>Dispose</c>.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisposeAsync() => Task.CompletedTask;
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -70,6 +76,8 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable
await _service.RetryPendingMessagesAsync();
}
/// <summary>Verifies that retrying a parked cached call resets it to pending and reports the change as applied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryParkedOperation_ParkedCachedCall_ResetsToPendingAndApplied()
{
@@ -90,6 +98,8 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
}
/// <summary>Verifies that discarding a parked cached call removes its row and reports the change as applied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DiscardParkedOperation_ParkedCachedCall_RemovesRowAndApplied()
{
@@ -107,6 +117,7 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable
Assert.Null(msg);
}
/// <summary>Verifies that retrying an unknown operation is a safe no-op.</summary>
[Fact]
public void RetryParkedOperation_UnknownOperation_IsSafeNoOp()
{
@@ -120,6 +131,8 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable
Assert.Null(ack.ErrorMessage);
}
/// <summary>Verifies that retrying a non-parked operation is a safe no-op that does not corrupt the row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryParkedOperation_NonParkedOperation_IsSafeNoOpAndDoesNotCorrupt()
{
@@ -148,6 +161,8 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable
Assert.Equal(before.RetryCount, after.RetryCount);
}
/// <summary>Verifies that discarding a non-parked operation is a safe no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DiscardParkedOperation_NonParkedOperation_IsSafeNoOp()
{
@@ -73,6 +73,7 @@ public class QueueDepthGaugeCollectionGuardTests
private static readonly Regex DeclaresCollection =
new(@"\[\s*Collection\s*\(\s*QueueDepthGaugeCollection\.Name\s*\)\s*\]", RegexOptions.Compiled);
/// <summary>Verifies that every source file that starts a service joins the queue-depth gauge collection.</summary>
[Fact]
public void EverySourceFileThatStartsAService_JoinsTheGaugeCollection()
{
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// WP-14 (telemetry follow-on): verifies the cached buffered-message counter that
/// Verifies the cached buffered-message counter that
/// backs the <c>scadabridge.store_and_forward.queue.depth</c> observable gauge tracks
/// the live (Pending) queue across the existing enqueue / drain / park / requeue paths,
/// and that the sync gauge callback reports it.
@@ -30,6 +30,7 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _service;
/// <summary>Initializes the test with a SQLite-backed store-and-forward service.</summary>
public QueueDepthGaugeTests()
{
_localDb = TestLocalDb.CreateTemp("QueueDepthTests");
@@ -49,6 +50,8 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable
_storage, options, NullLogger<StoreAndForwardService>.Instance);
}
/// <summary>Prepares the fixture before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
await _storage.InitializeAsync();
@@ -57,8 +60,11 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable
await _service.StartAsync();
}
/// <summary>Releases the resources used by this instance asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync() => await _service.StopAsync();
/// <summary>Releases the resources used by this instance.</summary>
public void Dispose()
{
var path = _localDb.Path;
@@ -91,6 +97,8 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable
return observed;
}
/// <summary>Verifies that gauge, tracks buffered depth, across enqueue drain and park.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Gauge_TracksBufferedDepth_AcrossEnqueueDrainAndPark()
{
@@ -140,12 +148,13 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable
}
/// <summary>
/// StoreAndForward-025: after a graceful <see cref="StoreAndForwardService.StopAsync"/>
/// After a graceful <see cref="StoreAndForwardService.StopAsync"/>
/// the service must deregister its queue-depth provider from the process-global gauge
/// slot, so the gauge stops reporting the stopped instance's (now-frozen) depth and the
/// provider closure no longer pins the dead service. With the provider cleared the gauge
/// falls back to 0.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StopAsync_ClearsQueueDepthProvider_GaugeFallsBackToZero()
{
@@ -176,13 +185,14 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable
}
/// <summary>
/// StoreAndForward-025 (compare-and-clear): when a newer instance has already
/// Compare-and-clear: when a newer instance has already
/// registered its provider into the process-global slot, a late
/// <see cref="StoreAndForwardService.StopAsync"/> of an older instance must NOT clear
/// the slot — the identity-checked clear only removes the slot when it still holds the
/// stopping instance's own delegate. After the late stop the gauge must still report
/// the newer instance's depth, not 0.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StopAsync_DoesNotClobberNewerInstanceProvider()
{
@@ -218,13 +228,14 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable
}
/// <summary>
/// StoreAndForward-028: a same-instance Stop→Start must re-seed the cached depth from a
/// A same-instance Stop→Start must re-seed the cached depth from a
/// clean base. <see cref="StoreAndForwardService.StopAsync"/> resets the one-time
/// registration guard so a later <see cref="StoreAndForwardService.StartAsync"/>
/// re-registers and re-seeds <c>_bufferedCount</c> from the durable Pending count; if
/// StopAsync did not also reset <c>_bufferedCount</c>, the restart would ADD the re-read
/// count on top of the leftover in-memory value, double-counting the gauge to ~2N.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_AfterStop_ReseedsFromCleanBase_NoDoubleCount()
{
@@ -261,6 +272,8 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable
await svc.StopAsync();
}
/// <summary>Verifies that gauge, seeds from existing pending rows, on start.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Gauge_SeedsFromExistingPendingRows_OnStart()
{
@@ -313,6 +326,7 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable
/// pre-increments the in-memory counter (standing in for that concurrent enqueue),
/// then starts the service over an empty store and asserts the pre-increment survives.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Gauge_SeedAddsToConcurrentPreSeedIncrement_NotClobber()
{
@@ -1,10 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// WP-9: Tests for StoreAndForwardOptions defaults and configuration.
/// Tests for StoreAndForwardOptions defaults and configuration.
/// </summary>
public class StoreAndForwardOptionsTests
{
/// <summary>Verifies that the default options have reasonable values.</summary>
[Fact]
public void DefaultOptions_HasReasonableDefaults()
{
@@ -16,6 +17,7 @@ public class StoreAndForwardOptionsTests
Assert.Equal(TimeSpan.FromSeconds(10), options.RetryTimerInterval);
}
/// <summary>Verifies that the options can be customized.</summary>
[Fact]
public void Options_CanBeCustomized()
{
@@ -32,6 +34,7 @@ public class StoreAndForwardOptionsTests
Assert.Equal(100, options.DefaultMaxRetries);
}
/// <summary>Verifies that the sweep batch limit defaults to 500.</summary>
[Fact]
public void SweepBatchLimit_Defaults_To_500() =>
Assert.Equal(500, new StoreAndForwardOptions().SweepBatchLimit);
@@ -14,6 +14,7 @@ public class StoreAndForwardOptionsValidatorTests
private static ValidateOptionsResult Validate(StoreAndForwardOptions options) =>
new StoreAndForwardOptionsValidator().Validate(Options.DefaultName, options);
/// <summary>Verifies that the default options are valid.</summary>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -21,6 +22,7 @@ public class StoreAndForwardOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a zero default retry interval is rejected.</summary>
[Fact]
public void ZeroDefaultRetryInterval_IsRejected()
{
@@ -30,6 +32,7 @@ public class StoreAndForwardOptionsValidatorTests
Assert.Contains("DefaultRetryInterval", result.FailureMessage);
}
/// <summary>Verifies that a zero retry timer interval is rejected.</summary>
[Fact]
public void ZeroRetryTimerInterval_IsRejected()
{
@@ -57,6 +60,8 @@ public class StoreAndForwardOptionsValidatorTests
// ── R2 T9: sweep-tuning eager validation (N4) ──
/// <summary>Verifies that a negative sweep batch limit fails validation.</summary>
/// <param name="limit">The negative sweep batch limit under test.</param>
[Theory]
[InlineData(-1)]
[InlineData(-500)]
@@ -67,6 +72,7 @@ public class StoreAndForwardOptionsValidatorTests
Assert.Contains("SweepBatchLimit", result.FailureMessage);
}
/// <summary>Verifies that a zero sweep batch limit is valid and means unlimited, as in the legacy behavior.</summary>
[Fact]
public void Validate_ZeroSweepBatchLimit_IsValidUnlimitedLegacy()
{
@@ -74,6 +80,8 @@ public class StoreAndForwardOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a non-positive sweep target parallelism fails validation.</summary>
/// <param name="parallelism">The non-positive parallelism value under test.</param>
[Theory]
[InlineData(0)]
[InlineData(-4)]
@@ -84,8 +92,10 @@ public class StoreAndForwardOptionsValidatorTests
Assert.Contains("SweepTargetParallelism", result.FailureMessage);
}
// ── WP2.6c: bounded observer queue capacity ──
// ── Bounded observer queue capacity ──
/// <summary>Verifies that a non-positive observer queue capacity fails validation.</summary>
/// <param name="capacity">The non-positive capacity value under test.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// </summary>
public class StoreAndForwardSchemaTests
{
/// <summary>Verifies that applying the schema is idempotent and creates every column.</summary>
[Fact]
public void Apply_IsIdempotent_AndCreatesEveryColumn()
{
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// WP-10/12/13/14: Tests for the StoreAndForwardService retry engine and management.
/// Tests for the StoreAndForwardService retry engine and management.
///
/// Register row 32: several facts here call <c>StartAsync</c>, which writes the
/// process-global queue-depth provider slot, so this class joins
@@ -24,6 +24,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
private readonly StoreAndForwardOptions _options;
private readonly List<TestLocalDb> _extraLocalDbs = new();
/// <summary>Creates a temporary local database, storage and service with a zero retry interval for the tests.</summary>
public StoreAndForwardServiceTests()
{
_localDb = TestLocalDb.CreateTemp("SvcTests");
@@ -41,10 +42,15 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
_storage, _options, NullLogger<StoreAndForwardService>.Instance);
}
/// <summary>Prepares the fixture before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync() => await _storage.InitializeAsync();
/// <summary>Releases the resources held by this fixture asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
DisposeLocalDb(_localDb);
@@ -81,8 +87,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
return new StoreAndForwardService(storage, options, NullLogger<StoreAndForwardService>.Instance);
}
// ── WP-10: Immediate delivery ──
// ── Immediate delivery ──
/// <summary>Verifies that an immediately delivered message is accepted and not buffered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_ImmediateDeliverySuccess_ReturnsAcceptedNotBuffered()
{
@@ -97,6 +105,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.False(result.WasBuffered);
}
/// <summary>Verifies that a permanent failure on immediate delivery returns a result that is not accepted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_PermanentFailure_ReturnsNotAccepted()
{
@@ -111,6 +121,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.False(result.WasBuffered);
}
/// <summary>Verifies that a transient failure on immediate delivery buffers the message for retry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_TransientFailure_BuffersForRetry()
{
@@ -127,11 +139,13 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
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
// RetryCount counts sweep retries only; the immediate
// attempt is attempt 0, so a freshly buffered message has RetryCount 0.
Assert.Equal(0, msg.RetryCount);
}
/// <summary>Verifies that a message enqueued with no registered handler is buffered for later.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_NoHandler_BuffersForLater()
{
@@ -143,8 +157,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.True(result.WasBuffered);
}
// ── WP-10: Retry engine ──
// ── Retry engine ──
/// <summary>Verifies that a successful retry removes the message from the buffer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryPendingMessagesAsync_SuccessfulRetry_RemovesMessage()
{
@@ -167,6 +183,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Null(msg);
}
/// <summary>Verifies that a message that reaches its maximum retries is parked.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryPendingMessagesAsync_MaxRetriesReached_ParksMessage()
{
@@ -177,7 +195,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
maxRetries: 2);
// StoreAndForward-003: MaxRetries bounds sweep retries (not the immediate
// 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);
@@ -190,8 +208,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
}
// ── StoreAndForward-003: retry-count accounting ──
// ── Retry-count accounting ──
/// <summary>Verifies that with a maximum of one retry exactly one retry is performed before the message is parked.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryPendingMessagesAsync_MaxRetriesOne_PerformsExactlyOneRetryBeforeParking()
{
@@ -218,12 +238,14 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(1, msg.RetryCount); // one sweep retry recorded
}
// ── StoreAndForward-005: sweep-vs-management race hardening ──
// ── Sweep-vs-management race hardening ──
/// <summary>Verifies that a sweep park write is skipped when the message status changed during delivery.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryMessageAsync_StatusChangedDuringDelivery_SweepParkWriteIsSkipped()
{
// StoreAndForward-005: the retry sweep's state-changing writes must be
// 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.
@@ -261,6 +283,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal("operator/other writer", msg.LastError);
}
/// <summary>Verifies that a permanent failure during a retry parks the message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryPendingMessagesAsync_PermanentFailureOnRetry_ParksMessage()
{
@@ -283,8 +307,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(StoreAndForwardMessageStatus.Parked, msg!.Status);
}
// ── WP-12: Parked message management ──
// ── Parked message management ──
/// <summary>Verifies that retrying a parked message moves it back to the queue.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryParkedMessageAsync_MovesBackToQueue()
{
@@ -309,9 +335,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
}
/// <summary>
/// StoreAndForward-017: the Retry activity-log entry must carry the parked
/// The Retry activity-log entry must carry the parked
/// message's true category, not a hard-coded ExternalSystem.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryParkedMessageAsync_ActivityUsesMessageRealCategory()
{
@@ -336,9 +363,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
}
/// <summary>
/// StoreAndForward-017: the Discard activity-log entry must carry the parked
/// The Discard activity-log entry must carry the parked
/// message's true category, not a hard-coded ExternalSystem.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DiscardParkedMessageAsync_ActivityUsesMessageRealCategory()
{
@@ -362,6 +390,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(new[] { StoreAndForwardCategory.CachedDbWrite }, categories);
}
/// <summary>Verifies that discarding a parked message removes it permanently.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DiscardParkedMessageAsync_PermanentlyRemoves()
{
@@ -381,6 +411,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Null(msg);
}
/// <summary>Verifies that fetching parked messages returns paginated results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetParkedMessagesAsync_ReturnsPaginatedResults()
{
@@ -403,8 +435,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.True(total >= 3);
}
// ── WP-13: Messages survive instance deletion ──
// ── Messages survive instance deletion ──
/// <summary>Verifies that buffered messages survive the deletion of their instance.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MessagesForInstance_SurviveAfterDeletion()
{
@@ -420,8 +454,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(2, count);
}
// ── WP-14: Health metrics ──
// ── Health metrics ──
/// <summary>Verifies that the buffer depth is reported correctly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetBufferDepthAsync_ReturnsCorrectDepth()
{
@@ -439,6 +475,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.True(depth.GetValueOrDefault(StoreAndForwardCategory.Notification) >= 1);
}
/// <summary>Verifies that the activity event is raised when a message is enqueued.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnActivity_RaisedOnEnqueue()
{
@@ -453,6 +491,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Contains("Delivered", activities);
}
/// <summary>Verifies that the activity event is raised when a message is buffered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnActivity_RaisedOnBuffer()
{
@@ -467,12 +507,14 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Contains("Queued", activities);
}
// ── StoreAndForward-009: faulting activity subscriber must not corrupt delivery ──
// ── Faulting activity subscriber must not corrupt delivery ──
/// <summary>Verifies that a faulting activity subscriber does not stop an immediately delivered message from being reported as delivered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_ImmediateDeliverySuccess_FaultingActivitySubscriber_StillReportsDelivered()
{
// StoreAndForward-009: a throwing OnActivity subscriber (e.g. the site event
// 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.
@@ -490,10 +532,12 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Null(msg); // nothing left in the buffer
}
/// <summary>Verifies that a faulting activity subscriber does not increment the retry count of a successful retry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryMessageAsync_FaultingActivitySubscriber_DoesNotIncrementRetryCount()
{
// StoreAndForward-009: a throwing subscriber raised after a successful retry
// 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(
@@ -511,8 +555,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Null(msg);
}
// ── WP-10: Per-source-entity retry settings ──
// ── Per-source-entity retry settings ──
/// <summary>Verifies that custom retry settings supplied at enqueue time are respected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_CustomRetrySettings_Respected()
{
@@ -531,6 +577,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
// ── attemptImmediateDelivery: false — caller already attempted delivery ──
/// <summary>Verifies that enqueuing with immediate delivery disabled buffers the message without invoking the handler.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_AttemptImmediateDeliveryFalse_BuffersWithoutInvokingHandler()
{
@@ -550,19 +598,20 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
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
// 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 ───
// ─── StopAsync waits for the in-flight sweep ───
/// <summary>
/// StoreAndForward-024: <see cref="StoreAndForwardService.StopAsync"/> must
/// <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>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StopAsync_AwaitsInFlightRetrySweep_BeforeReturning()
{
@@ -622,10 +671,12 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
"Sweep handler must have finished before StopAsync returned.");
}
// ── Task 3 (arch review 02, Stability #2): active-node delivery gate ──
// ── Active-node delivery gate ──
// The standby site node applies replicated buffer operations but must never
// deliver; the sweep runs only when the gate reports the node is active.
/// <summary>Verifies that the retry sweep skips delivery while the delivery gate reports standby.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetrySweep_SkipsDelivery_WhenDeliveryGateReportsStandby()
{
@@ -644,6 +695,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(StoreAndForwardMessageStatus.Pending, row!.Status); // row untouched
}
/// <summary>Verifies that the retry sweep resumes delivery when the gate flips to active.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetrySweep_ResumesDelivery_WhenGateFlipsActive()
{
@@ -664,6 +717,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(1, delivered);
}
/// <summary>Verifies that the retry sweep treats a throwing gate as standby.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetrySweep_TreatsThrowingGateAsStandby()
{
@@ -678,11 +733,13 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(0, delivered);
}
// ── Task 8 (arch review 02, Performance): per-target short-circuit ──
// ── Per-target short-circuit ──
// After the first transient failure to a (category, target) the remaining
// messages for that pair are skipped this sweep — N dead-target messages
// cost one timeout, not N. Skipped rows keep their RetryCount.
/// <summary>Verifies that after the first transient failure for a target the sweep short-circuits that target's remaining messages.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetrySweep_ShortCircuitsTarget_AfterFirstTransientFailure()
{
@@ -706,6 +763,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(1, attemptsByTarget["healthy-target"]); // healthy lane unaffected
}
/// <summary>Verifies that messages skipped by the sweep do not accrue a retry count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetrySweep_SkippedMessages_DoNotAccrueRetryCount()
{
@@ -726,10 +785,12 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
Assert.Equal(new[] { 0, 1 }, new[] { r1, r2 }.OrderBy(x => x).ToArray());
}
// ── Task 9 (arch review 02, Performance): parallel per-target lanes ──
// ── Parallel per-target lanes ──
// A slow/dead target no longer serializes the whole sweep; delivery within a
// single (category, target) lane stays sequential (per-target FIFO).
/// <summary>Verifies that a slow target does not block delivery to other targets.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetrySweep_SlowTarget_DoesNotBlockOtherTargets()
{
@@ -754,6 +815,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
await sweep;
}
/// <summary>Verifies that messages within one target lane are delivered sequentially.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetrySweep_WithinTargetLane_StaysSequential()
{
@@ -787,7 +850,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
}
/// <summary>
/// Register row-40 house pattern for an ABSENCE assertion (mirrors the Medium-wave
/// House pattern for an ABSENCE assertion (mirrors the Medium-wave
/// <c>SiteAlarmAggregatorActorTests.AssertHoldsFor</c> treatment, ported here async since
/// this class's facts run on the async test thread): proves <paramref name="holds"/> keeps
/// being true for the whole <paramref name="window"/> by polling, rather than a single
@@ -809,7 +872,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
}
}
// ── Task 12: deferToSweep enqueue mode ──
// ── deferToSweep enqueue mode ──
/// <summary>
/// <c>deferToSweep: true</c> must NOT invoke the delivery handler inline (the
@@ -817,6 +880,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
/// the very next sweep — <c>LastAttemptAt</c> stays null so it is not held back by
/// RetryInterval.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Enqueue_DeferToSweep_DoesNotInvokeHandlerInline_AndRowIsDueImmediately()
{
@@ -839,6 +903,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
/// healthy-path latency is milliseconds, not one timer interval — proven with a
/// timer set to an hour so only the kicked sweep can deliver.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Enqueue_DeferToSweep_AfterStart_KicksAnImmediateSweep()
{
@@ -857,6 +922,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
// ── R2 T8: _sweepTask clobber (N3) ──
/// <summary>Verifies that triggering a sweep while one is in flight does not clobber the drain handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TriggerSweep_WhileSweepInFlight_DoesNotClobberTheDrainHandle()
{
@@ -890,6 +957,8 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
}
}
/// <summary>Verifies that stopping waits for the real in-flight sweep even after a redundant trigger.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StopAsync_WaitsForTheRealInFlightSweep_EvenAfterARedundantTrigger()
{
@@ -923,22 +992,26 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
await stop.WaitAsync(TimeSpan.FromSeconds(5)); // drains the real sweep promptly once released
}
// ── WP2.6c: bounded, DropOldest observer queue + drop counter ──
// ── Bounded, DropOldest observer queue + drop counter ──
private sealed class BlockingObserver : ICachedCallLifecycleObserver
{
private readonly TaskCompletionSource _gate;
/// <summary>Creates an observer that blocks every attempt notification until the gate completes.</summary>
/// <param name="gate">The completion source that releases the blocked notifications.</param>
public BlockingObserver(TaskCompletionSource gate) => _gate = gate;
/// <inheritdoc />
public async Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default)
=> await _gate.Task;
}
/// <summary>
/// WP2.6c: the cached-call audit-observer queue is bounded — once the single-reader
/// The cached-call audit-observer queue is bounded — once the single-reader
/// pump is stuck awaiting a slow/stuck observer, further posted notifications must
/// evict the oldest queued one (DropOldest) instead of growing without bound, and
/// every eviction must increment <see cref="StoreAndForwardService.ObserverQueueDroppedCount"/>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ObserverQueue_BoundedCapacity_DropsOldestAndCountsDrops()
{
@@ -1010,15 +1083,30 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
private readonly List<(LogLevel Level, string Message)> _entries = new();
private readonly object _gate = new();
/// <summary>Gets a snapshot of the log entries captured so far.</summary>
public IReadOnlyList<(LogLevel Level, string Message)> Entries
{
get { lock (_gate) return _entries.ToList(); }
}
/// <summary>Returns no scope; the fake logger does not track scopes.</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state, ignored.</param>
/// <returns>Always null or a no-op scope.</returns>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <summary>Reports every log level as enabled.</summary>
/// <param name="logLevel">The log level being queried.</param>
/// <returns>Always true.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The type of the log state.</typeparam>
/// <param name="logLevel">The severity of the entry.</param>
/// <param name="eventId">The event identifier.</param>
/// <param name="state">The log state.</param>
/// <param name="exception">The associated exception, if any.</param>
/// <param name="formatter">Formats the state and exception into the message.</param>
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
@@ -1038,6 +1126,7 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
/// produce exactly ONE observer-queue-drop Warning log — the first-drop Warning — because
/// the episode never runs long enough to cross the periodic rollup interval.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ObserverQueue_ManyDropsInOneEpisode_LogsExactlyOneWarning()
{
@@ -1105,6 +1194,9 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
TestLocalDb.DeleteFiles(path);
}
}
/// <summary>Verifies that a delivery gate flipping to standby during delivery does not start another attempt, for the same or a different target lane.</summary>
/// <param name="differentLane">True when the second message targets a different lane than the one in flight.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// M1.7: the StoreAndForwardService emits site operational events for its own
/// The StoreAndForwardService emits site operational events for its own
/// buffer/park activity — <c>store_and_forward</c> for cached-call categories
/// (ExternalSystem / CachedDbWrite) and <c>notification</c> for the site's
/// notification forward-to-central path. Emission rides the existing
@@ -21,9 +21,14 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable
private sealed class FakeSiteEventLogger : ISiteEventLogger
{
private readonly ConcurrentQueue<Entry> _entries = new();
/// <summary>Gets the entries logged so far.</summary>
public IReadOnlyList<Entry> Entries => _entries.ToArray();
/// <summary>Returns the logged entries of one event type.</summary>
/// <param name="t">The event type to filter by.</param>
/// <returns>The logged entries whose event type matches.</returns>
public IReadOnlyList<Entry> OfType(string t) => _entries.Where(e => e.EventType == t).ToArray();
/// <inheritdoc />
public Task LogEventAsync(string eventType, string severity, string? instanceId,
string source, string message, string? details = null)
{
@@ -31,6 +36,7 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable
return Task.CompletedTask;
}
/// <inheritdoc />
public long FailedWriteCount => 0;
}
@@ -40,6 +46,7 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable
private readonly FakeSiteEventLogger _siteLog = new();
private readonly StoreAndForwardService _service;
/// <summary>Initializes the test class with a temporary LocalDb and a store-and-forward service wired to a fake site event logger.</summary>
public StoreAndForwardSiteEventTests()
{
_localDb = TestLocalDb.CreateTemp("SiteEvt");
@@ -58,8 +65,13 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable
siteEventLogger: _siteLog);
}
/// <summary>Initializes the fixture before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync() => await _storage.InitializeAsync();
/// <summary>Releases the fixture after the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
var path = _localDb.Path;
@@ -67,6 +79,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable
TestLocalDb.DeleteFiles(path);
}
/// <summary>Verifies that buffering an external system message for retry emits a store-and-forward site event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BufferForRetry_ExternalSystem_EmitsStoreAndForwardSiteEvent()
{
@@ -83,6 +97,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable
Assert.Empty(_siteLog.OfType("notification"));
}
/// <summary>Verifies that a failed notification forward emits a notification site event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ForwardFailure_Notification_EmitsNotificationSiteEvent()
{
@@ -103,6 +119,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable
Assert.Empty(_siteLog.OfType("store_and_forward"));
}
/// <summary>Verifies that a routine notification enqueue does not emit a site event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RoutineEnqueue_Notification_DoesNotEmitSiteEvent()
{
@@ -117,6 +135,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable
Assert.Empty(_siteLog.OfType("notification"));
}
/// <summary>Verifies that parking a notification emits an error-severity notification site event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Park_Notification_EmitsErrorNotificationSiteEvent()
{
@@ -136,6 +156,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable
r.Message.Contains("parked", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that parking an external system message emits an error-severity store-and-forward site event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Park_ExternalSystem_EmitsErrorStoreAndForwardSiteEvent()
{
@@ -154,6 +176,8 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable
r.Message.Contains("parked", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that a message delivered immediately does not emit a site event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliveredImmediately_DoesNotEmitSiteEvent()
{
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// WP-9: Tests for SQLite persistence layer.
/// Tests for SQLite persistence layer.
/// </summary>
/// <remarks>
/// Backed by a real temp-file LocalDb rather than the shared-cache in-memory database
@@ -20,16 +20,22 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
private readonly TestLocalDb _localDb;
private readonly StoreAndForwardStorage _storage;
/// <summary>Creates a temporary LocalDb-backed store for each test.</summary>
public StoreAndForwardStorageTests()
{
_localDb = TestLocalDb.CreateTemp("StorageTests");
_storage = new StoreAndForwardStorage(_localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
}
/// <summary>Initializes the store-and-forward storage schema.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync() => await _storage.InitializeAsync();
/// <summary>Completes immediately; the synchronous <c>Dispose</c> releases the resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
// Dispose first — the master connection anchors the WAL, so the sidecars
@@ -39,6 +45,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
TestLocalDb.DeleteFiles(path);
}
/// <summary>Verifies that enqueuing stores the message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_StoresMessage()
{
@@ -52,6 +60,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal("target1", retrieved.Target);
}
/// <summary>Verifies that enqueuing works for every message category.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_AllCategories()
{
@@ -68,6 +78,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(StoreAndForwardCategory.CachedDbWrite, db!.Category);
}
/// <summary>Verifies that removing a message deletes it from the store.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RemoveMessageAsync_RemovesSuccessfully()
{
@@ -78,6 +90,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Null(retrieved);
}
/// <summary>Verifies that updating a message persists the changed fields.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UpdateMessageAsync_UpdatesFields()
{
@@ -96,6 +110,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal("Connection refused", retrieved.LastError);
}
/// <summary>Verifies that the retry query returns only pending messages.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetMessagesForRetryAsync_ReturnsOnlyPendingMessages()
{
@@ -112,10 +128,12 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.All(forRetry, m => Assert.Equal(StoreAndForwardMessageStatus.Pending, m.Status));
}
/// <summary>Verifies that with a non-zero retry interval the retry query excludes messages not yet due and includes due ones.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetMessagesForRetryAsync_NonZeroInterval_ExcludesNotYetDueIncludesDue()
{
// StoreAndForward-013: exercise the julianday elapsed-time comparison with a
// 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);
@@ -141,6 +159,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Contains("never", ids);
}
/// <summary>Verifies that the parked query returns only parked messages.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetParkedMessagesAsync_ReturnsParkedOnly()
{
@@ -154,6 +174,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.All(messages, m => Assert.Equal(StoreAndForwardMessageStatus.Parked, m.Status));
}
/// <summary>Verifies that retrying a parked message moves it back to pending.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryParkedMessageAsync_MovesToPending()
{
@@ -171,10 +193,12 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(0, retrieved.RetryCount);
}
/// <summary>Verifies that retrying a parked message clears its last attempt time so it is immediately due.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryParkedMessageAsync_ClearsLastAttemptAt_SoMessageIsImmediatelyDue()
{
// StoreAndForward-010: a re-queued parked message must be unambiguously due
// 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);
@@ -196,6 +220,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Contains(due, m => m.Id == "requeue1");
}
/// <summary>Verifies that discarding a parked message removes it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DiscardParkedMessageAsync_RemovesMessage()
{
@@ -211,6 +237,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Null(retrieved);
}
/// <summary>Verifies that the buffer depth query returns the correct count per category.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetBufferDepthByCategoryAsync_ReturnsCorrectCounts()
{
@@ -222,6 +250,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.True(depth.GetValueOrDefault(StoreAndForwardCategory.ExternalSystem) >= 2);
}
/// <summary>Verifies that the origin-instance query returns the correct message count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetMessageCountByOriginInstanceAsync_ReturnsCount()
{
@@ -237,6 +267,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(2, count);
}
/// <summary>Verifies that parked messages are returned correctly across pages.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetParkedMessagesAsync_Pagination()
{
@@ -256,10 +288,12 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(2, page2.Count);
}
/// <summary>Verifies that the transactioned parked reads report a count that matches the full result set.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetParkedMessagesAsync_TransactionedReads_CountMatchesFullResultSet()
{
// StoreAndForward-006: the COUNT(*) and paged SELECT now run inside one
// 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
@@ -289,6 +323,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(25, collected.Distinct().Count());
}
/// <summary>Verifies that the status query returns an accurate message count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetMessageCountByStatusAsync_ReturnsAccurateCount()
{
@@ -299,8 +335,10 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.True(count >= 1);
}
// ── Audit Log #23 (ExecutionId Task 4): execution_id / source_script ──
// ── ExecutionId: execution_id / source_script ──
/// <summary>Verifies that enqueuing round-trips the execution id and source script.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_RoundTripsExecutionIdAndSourceScript()
{
@@ -320,6 +358,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal("Plant.Pump42/OnTick", retrieved.SourceScript);
}
/// <summary>Verifies that a null execution id and source script round-trip as null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_NullExecutionIdAndSourceScript_RoundTripAsNull()
{
@@ -337,6 +377,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Null(retrieved.SourceScript);
}
/// <summary>Verifies that the execution id and source script survive a retry sweep read.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecutionIdAndSourceScript_SurviveRetrySweepRead()
{
@@ -357,6 +399,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal("Plant.Tank/OnAlarm", row.SourceScript);
}
/// <summary>Verifies that a legacy row without the new columns reads back with null values.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LegacyRowWithoutNewColumns_ReadsBackAsNull()
{
@@ -401,6 +445,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Null(retrieved.SourceScript);
}
/// <summary>Verifies that a malformed execution id reads back as null without aborting the retry sweep.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MalformedExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep()
{
@@ -440,6 +486,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Null(retrieved!.ExecutionId);
}
/// <summary>Verifies that initialization is idempotent when the columns already exist.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InitializeAsync_IsIdempotent_WhenColumnsAlreadyExist()
{
@@ -456,8 +504,10 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(message.ExecutionId, retrieved!.ExecutionId);
}
// ── Audit Log #23 (ParentExecutionId Task 6): parent_execution_id ──
// ── ParentExecutionId: parent_execution_id ──
/// <summary>Verifies that enqueuing round-trips the parent execution id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_RoundTripsParentExecutionId()
{
@@ -475,6 +525,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(parentExecutionId, retrieved!.ParentExecutionId);
}
/// <summary>Verifies that a null parent execution id round-trips as null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_NullParentExecutionId_RoundTripsAsNull()
{
@@ -490,6 +542,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Null(retrieved!.ParentExecutionId);
}
/// <summary>Verifies that the parent execution id survives a retry sweep read.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ParentExecutionId_SurvivesRetrySweepRead()
{
@@ -508,12 +562,14 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Equal(parentExecutionId, row.ParentExecutionId);
}
/// <summary>Verifies that a legacy row without the parent execution id column reads back with a null value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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
// ParentExecutionId reading back as null. Simulate the pre-ParentExecutionId
// 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
@@ -554,6 +610,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Null(retrieved.ParentExecutionId);
}
/// <summary>Verifies that a malformed parent execution id reads back as null without aborting the retry sweep.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MalformedParentExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep()
{
@@ -589,8 +647,10 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Null(retrieved!.ParentExecutionId);
}
// ── Task 7 (arch review 02, Performance): bounded due-rows query ──
// ── Bounded due-rows query ──
/// <summary>Verifies that the retry query honors its limit and returns the oldest messages first.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetMessagesForRetry_HonorsLimit_OldestFirst()
{
@@ -649,6 +709,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
// library, so the shim is gone — but either way it is not this class's to assert,
// and SiteLocalDbDirectoryTests pins the outcome for the site host.
/// <summary>Verifies that initialization leaves the database in WAL journal mode.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Initialize_LeavesTheDatabaseInWalJournalMode()
{
@@ -675,8 +737,10 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
}
}
// ── Task 18: epoch-ms due-check ──
// ── Epoch-ms due-check ──
/// <summary>Verifies that the retry query uses epoch milliseconds and respects the retry interval.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetMessagesForRetry_UsesEpochMs_RespectsInterval()
{
@@ -701,6 +765,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.Single(await _storage.GetMessagesForRetryAsync());
}
/// <summary>Verifies that initialization backfills epoch milliseconds for legacy rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Initialize_BackfillsEpochMs_ForLegacyRows()
{
@@ -738,7 +804,7 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
return result is DBNull ? null : result;
}
// ── Task 20: buffer-resync storage primitives ──
// ── Buffer-resync storage primitives ──
private static StoreAndForwardMessage NewMsg(
string id,
@@ -756,6 +822,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Status = status,
};
/// <summary>Verifies that the all-messages query returns every status, oldest first, up to the limit.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAllMessages_ReturnsEveryStatus_OldestFirst_UpToLimit()
{
@@ -779,8 +847,10 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
// LocalDbConfigConvergenceTests.ANodeWithNewerLocalRows_KeepsThem_WhenAPeerSnapshotArrives
// now covers.
// ── Task 23: oldest-parked-age health signal ──
// ── Oldest-parked-age health signal ──
/// <summary>Verifies that the oldest-parked query returns the oldest parked row's creation time, or null when none are parked.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetOldestParkedCreatedAt_ReturnsOldestParkedRow_OrNull()
{