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:
+56
-10
@@ -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&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&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);
|
||||
|
||||
+21
-1
@@ -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&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);
|
||||
|
||||
+11
-1
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user