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:
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;
|
||||
|
||||
public class DeliveryOutcomeTests
|
||||
{
|
||||
/// <summary>Verifies that a success outcome carries the resolved targets and no error.</summary>
|
||||
[Fact]
|
||||
public void Success_SetsResolvedTargets_AndNoError()
|
||||
{
|
||||
@@ -14,6 +15,7 @@ public class DeliveryOutcomeTests
|
||||
Assert.Null(outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transient outcome carries an error and no resolved targets.</summary>
|
||||
[Fact]
|
||||
public void Transient_SetsError_AndNoResolvedTargets()
|
||||
{
|
||||
@@ -24,6 +26,7 @@ public class DeliveryOutcomeTests
|
||||
Assert.Null(outcome.ResolvedTargets);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a permanent outcome carries an error and no resolved targets.</summary>
|
||||
[Fact]
|
||||
public void Permanent_SetsError_AndNoResolvedTargets()
|
||||
{
|
||||
|
||||
+29
-1
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;
|
||||
|
||||
/// <summary>
|
||||
/// Task 5: Tests for the EWS transport branch of the Email outbox delivery adapter —
|
||||
/// Tests for the EWS transport branch of the Email outbox delivery adapter —
|
||||
/// the transport parse, the EWS config-shape validation (each failure permanent and
|
||||
/// short-circuiting before the sender is touched), the request the sender receives,
|
||||
/// the typed transient/permanent outcome mapping, and the proof that an unset or
|
||||
@@ -100,6 +100,8 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that delivery over the EWS transport goes through the EWS sender and returns success.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_EwsTransport_SendsThroughEwsSenderAndReturnsSuccess()
|
||||
{
|
||||
@@ -129,6 +131,8 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that credentials are split on the first colon only so a password may contain colons.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_PasswordContainsColon_SplitsOnFirstColonOnly()
|
||||
{
|
||||
@@ -143,6 +147,8 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transient EWS exception maps to a transient outcome.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_EwsTransientException_ReturnsTransient()
|
||||
{
|
||||
@@ -157,6 +163,8 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
Assert.Contains("server busy", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a permanent EWS exception maps to a permanent outcome.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_EwsPermanentException_ReturnsPermanent()
|
||||
{
|
||||
@@ -171,6 +179,8 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
Assert.Contains("unauthorized", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unclassified sender exception maps to a permanent outcome and redacts the credential.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_UnclassifiedSenderException_ReturnsPermanentAndRedactsCredential()
|
||||
{
|
||||
@@ -186,6 +196,8 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
Assert.DoesNotContain("super-secret-password", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cancelled token throws an operation-canceled exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_CancelledToken_ThrowsOperationCanceledException()
|
||||
{
|
||||
@@ -209,6 +221,8 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
() => adapter.DeliverAsync(MakeNotification(), cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-HTTPS endpoint returns a permanent failure without calling the sender.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_NonHttpsEndpoint_ReturnsPermanentWithoutCallingSender()
|
||||
{
|
||||
@@ -222,6 +236,8 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
await AssertSenderUntouched();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-absolute endpoint returns a permanent failure without calling the sender.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_NonAbsoluteEndpoint_ReturnsPermanentWithoutCallingSender()
|
||||
{
|
||||
@@ -235,6 +251,8 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
await AssertSenderUntouched();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-basic auth type returns a permanent failure without calling the sender.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_NonBasicAuthType_ReturnsPermanentWithoutCallingSender()
|
||||
{
|
||||
@@ -248,6 +266,9 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
await AssertSenderUntouched();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that malformed credentials return a permanent failure without calling the sender.</summary>
|
||||
/// <param name="credentials">The malformed credentials string, or null.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -267,6 +288,8 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
await AssertSenderUntouched();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown transport returns a permanent failure without calling the sender.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_UnknownTransport_ReturnsPermanentWithoutCallingSender()
|
||||
{
|
||||
@@ -283,6 +306,8 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that delivery with no EWS sender registered returns a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_NoEwsSenderRegistered_ReturnsPermanent()
|
||||
{
|
||||
@@ -296,6 +321,9 @@ public class EmailNotificationDeliveryAdapterEwsTests
|
||||
Assert.Contains("registered", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMTP or unset transport takes the SMTP path.</summary>
|
||||
/// <param name="transport">The configured transport, or null when unset.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("Smtp")]
|
||||
|
||||
+26
-1
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;
|
||||
|
||||
/// <summary>
|
||||
/// Task 12: Tests for the Email outbox delivery adapter — list/recipient/SMTP-config
|
||||
/// Tests for the Email outbox delivery adapter — list/recipient/SMTP-config
|
||||
/// resolution, SMTP send, and the three-way (Success / Permanent / Transient) outcome
|
||||
/// classification.
|
||||
/// </summary>
|
||||
@@ -59,12 +59,15 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { smtpConfig });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the adapter reports the Email notification type.</summary>
|
||||
[Fact]
|
||||
public void Type_IsEmail()
|
||||
{
|
||||
Assert.Equal(NotificationType.Email, CreateAdapter().Type);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful delivery returns success with the resolved targets.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_HappyPath_ReturnsSuccessWithResolvedTargets()
|
||||
{
|
||||
@@ -80,6 +83,8 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
Assert.Null(outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing notification list returns a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_ListNotFound_ReturnsPermanent()
|
||||
{
|
||||
@@ -93,6 +98,8 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
Assert.Contains("not found", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a list with no recipients returns a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_NoRecipients_ReturnsPermanent()
|
||||
{
|
||||
@@ -107,6 +114,8 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
Assert.Contains("recipient", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing SMTP configuration returns a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_NoSmtpConfig_ReturnsPermanent()
|
||||
{
|
||||
@@ -125,6 +134,8 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
Assert.Contains("SMTP", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a permanent SMTP exception returns a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_SmtpPermanentException_ReturnsPermanent()
|
||||
{
|
||||
@@ -141,6 +152,8 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
Assert.Contains("mailbox unavailable", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMTP 5xx command exception returns a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_Smtp5xxCommandException_ReturnsPermanent()
|
||||
{
|
||||
@@ -157,6 +170,8 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a socket exception returns a transient failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_SocketException_ReturnsTransient()
|
||||
{
|
||||
@@ -173,6 +188,8 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
Assert.NotNull(outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMTP 4xx command exception returns a transient failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_Smtp4xxCommandException_ReturnsTransient()
|
||||
{
|
||||
@@ -189,6 +206,8 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unclassified exception returns a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_UnclassifiedException_ReturnsPermanent()
|
||||
{
|
||||
@@ -205,6 +224,8 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
Assert.NotNull(outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a malformed recipient address returns a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_MalformedRecipientAddress_ReturnsPermanent()
|
||||
{
|
||||
@@ -230,6 +251,8 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
Assert.Contains(malformed, outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cancelled token throws an <c>OperationCanceledException</c>.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_CancelledToken_ThrowsOperationCanceledException()
|
||||
{
|
||||
@@ -265,6 +288,8 @@ public class EmailNotificationDeliveryAdapterTests
|
||||
() => adapter.DeliverAsync(MakeNotification(), cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when several SMTP configurations exist the one with the lowest id wins.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MultipleSmtpConfigs_LowestIdWins()
|
||||
{
|
||||
|
||||
+52
@@ -32,7 +32,9 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
{
|
||||
private readonly Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>> _responders;
|
||||
|
||||
/// <summary>The HTTP requests received by the handler, in order.</summary>
|
||||
public List<HttpRequestMessage> Requests { get; } = new();
|
||||
/// <summary>The request bodies received by the handler, in order.</summary>
|
||||
public List<string> RequestBodies { get; } = new();
|
||||
|
||||
private ScriptedHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, Task<HttpResponseMessage>>> responders)
|
||||
@@ -41,6 +43,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
}
|
||||
|
||||
/// <summary>One queued response per status code (one per recipient POST).</summary>
|
||||
/// <param name="statusCodes">The status codes to queue, one per expected request.</param>
|
||||
/// <returns>A handler that answers each request with the next queued status code.</returns>
|
||||
public static ScriptedHttpMessageHandler ForStatuses(params HttpStatusCode[] statusCodes) =>
|
||||
new(statusCodes.Select(code =>
|
||||
new Func<HttpRequestMessage, Task<HttpResponseMessage>>(
|
||||
@@ -50,9 +54,12 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
}))));
|
||||
|
||||
/// <summary>Custom per-request responders (e.g. to throw a transport exception).</summary>
|
||||
/// <param name="responders">The responders to queue, one per expected request.</param>
|
||||
/// <returns>A handler that answers each request with the next queued responder.</returns>
|
||||
public static ScriptedHttpMessageHandler ForResponders(
|
||||
params Func<HttpRequestMessage, Task<HttpResponseMessage>>[] responders) => new(responders);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -116,12 +123,15 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the adapter reports the SMS notification type.</summary>
|
||||
[Fact]
|
||||
public void Type_IsSms()
|
||||
{
|
||||
Assert.Equal(NotificationType.Sms, CreateAdapter(ScriptedHttpMessageHandler.ForStatuses()).Type);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that delivery to a single recipient with a 201 response succeeds and returns the resolved targets.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_SingleRecipient201_ReturnsSuccessWithResolvedTargets()
|
||||
{
|
||||
@@ -154,6 +164,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
_httpClientFactory.Received(1).CreateClient(SmsNotificationDeliveryAdapter.HttpClientName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that delivery to several recipients that are all accepted succeeds.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_MultiRecipient201_AllAccepted_ReturnsSuccess()
|
||||
{
|
||||
@@ -169,6 +181,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that delivery uses the messaging service SID when no from number is configured.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_UsesMessagingServiceSid_WhenFromNumberAbsent()
|
||||
{
|
||||
@@ -191,6 +205,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.DoesNotContain("From=", body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a 500 response is classified as a transient failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_500_ReturnsTransient()
|
||||
{
|
||||
@@ -204,6 +220,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.NotNull(outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a 429 response is classified as a transient failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_429_ReturnsTransient()
|
||||
{
|
||||
@@ -216,6 +234,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an HTTP request exception is classified as a transient failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_HttpRequestException_ReturnsTransient()
|
||||
{
|
||||
@@ -229,6 +249,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a timeout is classified as a transient failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_Timeout_ReturnsTransient()
|
||||
{
|
||||
@@ -244,6 +266,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a 400 response is classified as a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_400_ReturnsPermanent()
|
||||
{
|
||||
@@ -257,6 +281,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.NotNull(outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a 401 response is classified as a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_401_ReturnsPermanent()
|
||||
{
|
||||
@@ -269,6 +295,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a mix of accepted and permanently bad recipients succeeds and notes the bad number.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_MixAcceptedAndPermanentBad_ReturnsSuccessWithBadNumberNoted()
|
||||
{
|
||||
@@ -285,6 +313,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Contains("+1BADNUMBER", outcome.ResolvedTargets);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a mix of accepted and transiently failed recipients is classified as transient.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_MixAcceptedAndTransient_ReturnsTransient()
|
||||
{
|
||||
@@ -299,6 +329,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transient failure mid-list short-circuits so the remaining recipients are not attempted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MidListTransient_ShortCircuits_RemainingRecipientsNotAttempted()
|
||||
{
|
||||
@@ -317,6 +349,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Single(handler.Requests);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing notification list is classified as a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_ListNotFound_ReturnsPermanent()
|
||||
{
|
||||
@@ -331,6 +365,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Contains("not found", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a list with no phone recipients is classified as a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_NoPhoneRecipients_ReturnsPermanent()
|
||||
{
|
||||
@@ -350,6 +386,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Contains("no SMS recipients", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing SMS configuration is classified as a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_NoSmsConfig_ReturnsPermanent()
|
||||
{
|
||||
@@ -367,6 +405,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Contains("SMS configuration", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an incomplete configuration missing the auth token is classified as a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_IncompleteConfig_MissingAuthToken_ReturnsPermanent()
|
||||
{
|
||||
@@ -385,6 +425,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Contains("incomplete", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an incomplete configuration with neither a from number nor a messaging service SID is classified as a permanent failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_IncompleteConfig_NoFromAndNoMessagingServiceSid_ReturnsPermanent()
|
||||
{
|
||||
@@ -407,6 +449,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.Contains("incomplete", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that delivery defaults to the Twilio base URL when no API base URL is configured.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_DefaultsToTwilioBaseUrl_WhenApiBaseUrlAbsent()
|
||||
{
|
||||
@@ -426,6 +470,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.StartsWith("https://api.twilio.com/2010-04-01/Accounts/", request.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the auth token never appears in a returned error.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_AuthToken_NeverAppearsInReturnedError()
|
||||
{
|
||||
@@ -457,6 +503,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.DoesNotContain(base64Credential, outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the auth token never appears on a transport exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_AuthToken_NeverAppearsOnTransportException()
|
||||
{
|
||||
@@ -473,6 +521,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.DoesNotContain(AuthToken, outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a long message body is truncated to the maximum message length.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_LongBody_TruncatedToMaxMessageLength()
|
||||
{
|
||||
@@ -495,6 +545,8 @@ public class SmsNotificationDeliveryAdapterTests
|
||||
Assert.DoesNotContain(new string('x', 100), body);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that caller cancellation propagates out of delivery.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deliver_CallerCancellation_Propagates()
|
||||
{
|
||||
|
||||
+4
@@ -26,6 +26,7 @@ public class NotificationIngestTypeStampingTests : TestKit
|
||||
private readonly INotificationRepository _listRepository =
|
||||
Substitute.For<INotificationRepository>();
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>NotificationIngestTypeStampingTests</c> class.</summary>
|
||||
public NotificationIngestTypeStampingTests()
|
||||
{
|
||||
// Default: a fresh insert succeeds. Individual tests configure the list lookup.
|
||||
@@ -72,6 +73,7 @@ public class NotificationIngestTypeStampingTests : TestKit
|
||||
.Returns(new NotificationList(name) { Type = type });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ingesting a notification for an email-typed list stamps the type as email.</summary>
|
||||
[Fact]
|
||||
public void Ingest_EmailTypedList_StampsEmail()
|
||||
{
|
||||
@@ -90,6 +92,7 @@ public class NotificationIngestTypeStampingTests : TestKit
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ingesting a notification for an SMS-typed list stamps the type as SMS.</summary>
|
||||
[Fact]
|
||||
public void Ingest_SmsTypedList_StampsSms()
|
||||
{
|
||||
@@ -108,6 +111,7 @@ public class NotificationIngestTypeStampingTests : TestKit
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ingesting a notification for a missing list stamps the default type of email.</summary>
|
||||
[Fact]
|
||||
public void Ingest_MissingList_StampsEmailDefault()
|
||||
{
|
||||
|
||||
+15
-3
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Kpi;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="NotificationOutboxKpiSampleSource"/> — the M6 KPI sample source that
|
||||
/// Tests for <see cref="NotificationOutboxKpiSampleSource"/> — the KPI sample source that
|
||||
/// snapshots the Notification Outbox delivery KPIs (global / per-site / per-node) into the
|
||||
/// central KPI-history store.
|
||||
/// </summary>
|
||||
@@ -46,6 +46,7 @@ public class NotificationOutboxKpiSampleSourceTests
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
Microsoft.Extensions.Options.Options.Create(kpiHistoryOptions));
|
||||
|
||||
/// <summary>Verifies that the sample source reports the notification outbox as its source.</summary>
|
||||
[Fact]
|
||||
public void Source_IsNotificationOutbox()
|
||||
{
|
||||
@@ -58,6 +59,7 @@ public class NotificationOutboxKpiSampleSourceTests
|
||||
// live in the shared KpiMetrics catalog. Their VALUES are persisted in the KpiSample
|
||||
// store and rendered by the trend UI, so a rename would silently orphan history —
|
||||
// pin the exact historical strings here.
|
||||
/// <summary>Verifies that the notification outbox metric catalog preserves its historical string values.</summary>
|
||||
[Fact]
|
||||
public void KpiMetricCatalog_NotificationOutbox_PreservesHistoricalStringValues()
|
||||
{
|
||||
@@ -68,6 +70,8 @@ public class NotificationOutboxKpiSampleSourceTests
|
||||
Assert.Equal("oldestPendingAgeSeconds", KpiMetrics.NotificationOutbox.OldestPendingAgeSeconds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that collecting passes cutoffs anchored on the captured-at time.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CollectAsync_PassesCutoffsAnchoredOnCapturedAt()
|
||||
{
|
||||
@@ -88,6 +92,8 @@ public class NotificationOutboxKpiSampleSourceTests
|
||||
expectedStuckCutoff, expectedDeliveredSince, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that collecting emits global, per-site and per-node samples with the expected tuples.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CollectAsync_EmitsGlobalSiteAndNodeSamples_WithExpectedTuples()
|
||||
{
|
||||
@@ -152,15 +158,17 @@ public class NotificationOutboxKpiSampleSourceTests
|
||||
AssertSample(samples, "oldestPendingAgeSeconds", KpiScopes.Node, "node-a", 60);
|
||||
}
|
||||
|
||||
// P5-5 (arch-review Low): the KPI-history rollup fold sums every deliveredLastInterval
|
||||
// The KPI-history rollup fold sums every deliveredLastInterval
|
||||
// sample recorded in an hour, which over-counts unless each sample is a non-overlapping
|
||||
// delta covering exactly one recorder tick (KpiHistoryOptions.SampleInterval). Before the
|
||||
// fix, this class recorded the RAW DeliveredKpiWindow-windowed count verbatim — with a
|
||||
// 15-minute DeliveredKpiWindow sampled every 1-minute SampleInterval, 60 overlapping
|
||||
// 15-minute counts summed to ~15x the true hourly total. Proven against the unfixed
|
||||
// computation: temporarily short-circuiting DeliveredIntervalScaleFactor to always return
|
||||
// 1.0 (i.e. "record the raw count verbatim", the pre-P5-5 behavior) makes this test fail
|
||||
// 1.0 (i.e. "record the raw count verbatim", the old behavior) makes this test fail
|
||||
// (30 != 2); restoring the real scale factor passes it.
|
||||
/// <summary>Verifies that the delivered-last-interval count is rescaled onto the KPI history sample interval.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CollectAsync_RescalesDeliveredLastInterval_OntoKpiHistorySampleInterval()
|
||||
{
|
||||
@@ -191,6 +199,8 @@ public class NotificationOutboxKpiSampleSourceTests
|
||||
Assert.Equal(2.0, delivered.Value, precision: 6);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the oldest pending age is omitted when it is null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CollectAsync_OmitsOldestPendingAge_WhenNull()
|
||||
{
|
||||
@@ -219,6 +229,8 @@ public class NotificationOutboxKpiSampleSourceTests
|
||||
AssertSample(samples, "deliveredLastInterval", KpiScopes.Global, null, 0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that collecting returns an empty list, never null, when there is nothing to report.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CollectAsync_ReturnsEmptyList_NeverNull_WhenNothingToReport()
|
||||
{
|
||||
|
||||
+19
-3
@@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// M4 Bundle B (B2) — verifies the <see cref="NotificationOutboxActor"/>
|
||||
/// Verifies the <see cref="NotificationOutboxActor"/>
|
||||
/// dispatcher loop emits exactly ONE
|
||||
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
||||
/// audit row with <see cref="AuditStatus.Attempted"/> per attempt regardless of
|
||||
@@ -39,12 +39,15 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
/// </summary>
|
||||
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
// C3 (Task 2.5): store the decomposed row view so assertions keep
|
||||
// Store the decomposed row view so assertions keep
|
||||
// reading the ScadaBridge domain fields (Channel/Kind/Status/…) as
|
||||
// typed properties; the canonical record carries them in DetailsJson.
|
||||
/// <summary>Gets the decomposed audit rows captured by the writer.</summary>
|
||||
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
||||
/// <summary>Gets or sets an optional callback invoked on every write, used to simulate writer faults.</summary>
|
||||
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events)
|
||||
@@ -74,10 +77,14 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
private readonly Func<DeliveryOutcome> _outcome;
|
||||
public int CallCount;
|
||||
|
||||
/// <summary>Initializes the adapter with the outcome factory it returns on each delivery.</summary>
|
||||
/// <param name="outcome">Factory producing the delivery outcome for each call.</param>
|
||||
public StubAdapter(Func<DeliveryOutcome> outcome) { _outcome = outcome; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DeliveryOutcome> DeliverAsync(
|
||||
Notification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -138,6 +145,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful attempt emits one NotifyDeliver audit event with Attempted status.</summary>
|
||||
[Fact]
|
||||
public void Attempt_Success_EmitsOneEvent_KindNotifyDeliver_StatusAttempted()
|
||||
{
|
||||
@@ -170,10 +178,11 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the attempt event carries the notification's origin execution id as its execution id.</summary>
|
||||
[Fact]
|
||||
public void Attempt_CarriesOriginExecutionId_AsExecutionId()
|
||||
{
|
||||
// Audit Log #23: the Attempted NotifyDeliver row must echo the
|
||||
// The Attempted NotifyDeliver row must echo the
|
||||
// notification's OriginExecutionId so all rows for one run share an id.
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var executionId = Guid.NewGuid();
|
||||
@@ -193,6 +202,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null origin execution id yields a null execution id on the attempt event.</summary>
|
||||
[Fact]
|
||||
public void Attempt_NullOriginExecutionId_HasNullExecutionId()
|
||||
{
|
||||
@@ -213,6 +223,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the attempt event carries the notification's origin parent execution id as its parent execution id.</summary>
|
||||
[Fact]
|
||||
public void Attempt_CarriesOriginParentExecutionId_AsParentExecutionId()
|
||||
{
|
||||
@@ -237,6 +248,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null origin parent execution id yields a null parent execution id on the attempt event.</summary>
|
||||
[Fact]
|
||||
public void Attempt_NullOriginParentExecutionId_HasNullParentExecutionId()
|
||||
{
|
||||
@@ -257,6 +269,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transient failure emits an Attempted event with the error message set.</summary>
|
||||
[Fact]
|
||||
public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
|
||||
{
|
||||
@@ -278,6 +291,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a permanent failure emits an Attempted event with the error message set.</summary>
|
||||
[Fact]
|
||||
public void Attempt_PermanentFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
|
||||
{
|
||||
@@ -299,6 +313,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing audit writer does not prevent the delivery state update from succeeding.</summary>
|
||||
[Fact]
|
||||
public void AuditWriter_Throws_DeliveryStateUpdate_StillSucceeds()
|
||||
{
|
||||
@@ -323,6 +338,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the attempt event records its occurrence time in UTC.</summary>
|
||||
[Fact]
|
||||
public void Attempt_RecordsOccurredAtUtc_AsUtc()
|
||||
{
|
||||
|
||||
+5
-1
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// M4 Bundle B (B1) — verifies <see cref="NotificationOutboxActor"/> accepts an
|
||||
/// Verifies <see cref="NotificationOutboxActor"/> accepts an
|
||||
/// <see cref="ICentralAuditWriter"/> at construction so subsequent bundle tasks
|
||||
/// (B2/B3) can route attempt + terminal lifecycle events through the central
|
||||
/// direct-write audit path.
|
||||
@@ -32,8 +32,10 @@ public class NotificationOutboxActorAuditInjectionTests : TestKit
|
||||
/// </summary>
|
||||
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
/// <summary>Gets the audit events recorded by this writer.</summary>
|
||||
public List<AuditEvent> Events { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events)
|
||||
@@ -44,6 +46,7 @@ public class NotificationOutboxActorAuditInjectionTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the actor can be constructed with an audit writer without throwing.</summary>
|
||||
[Fact]
|
||||
public void Actor_ConstructedWith_ICentralAuditWriter_NoException()
|
||||
{
|
||||
@@ -65,6 +68,7 @@ public class NotificationOutboxActorAuditInjectionTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that constructing the actor with a null audit writer throws.</summary>
|
||||
[Fact]
|
||||
public void Actor_NullAuditWriter_Throws()
|
||||
{
|
||||
|
||||
+60
-12
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 14: Tests for the <see cref="NotificationOutboxActor"/> dispatcher loop — the
|
||||
/// Tests for the <see cref="NotificationOutboxActor"/> dispatcher loop — the
|
||||
/// periodic sweep that claims due notifications via
|
||||
/// <see cref="INotificationOutboxRepository.GetDueAsync"/>, delivers each through the
|
||||
/// matching <see cref="INotificationDeliveryAdapter"/>, and applies the resulting status
|
||||
@@ -52,6 +52,10 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
private readonly Func<DeliveryOutcome> _outcome;
|
||||
private readonly TimeSpan _delay;
|
||||
|
||||
/// <summary>Initializes the adapter with the outcome factory, an optional delivery delay and the notification type it serves.</summary>
|
||||
/// <param name="outcome">Produces the outcome returned for each delivery.</param>
|
||||
/// <param name="delay">Optional delay applied to each delivery.</param>
|
||||
/// <param name="type">The notification type this adapter serves.</param>
|
||||
public StubAdapter(Func<DeliveryOutcome> outcome, TimeSpan? delay = null,
|
||||
NotificationType type = NotificationType.Email)
|
||||
{
|
||||
@@ -62,8 +66,10 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
|
||||
public int CallCount;
|
||||
|
||||
/// <inheritdoc />
|
||||
public NotificationType Type { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeliveryOutcome> DeliverAsync(
|
||||
Notification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -110,6 +116,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
.Returns(new[] { config });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a dispatch tick claims due notifications and invokes the adapter.</summary>
|
||||
[Fact]
|
||||
public void DispatchTick_ClaimsDueNotifications_AndInvokesAdapter()
|
||||
{
|
||||
@@ -130,6 +137,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMS notification is routed to the SMS adapter rather than the email adapter.</summary>
|
||||
[Fact]
|
||||
public void Dispatch_SmsTypedNotification_RoutesToSmsAdapter_NotEmailAdapter()
|
||||
{
|
||||
@@ -159,6 +167,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful delivery marks the notification delivered with the resolved targets.</summary>
|
||||
[Fact]
|
||||
public void Success_MarksNotificationDelivered_WithResolvedTargets()
|
||||
{
|
||||
@@ -184,6 +193,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transient failure below the maximum retries marks the notification as retrying and schedules the next attempt.</summary>
|
||||
[Fact]
|
||||
public void TransientFailure_BelowMaxRetries_MarksRetrying_AndSchedulesNextAttempt()
|
||||
{
|
||||
@@ -209,11 +219,12 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transient failure that reaches the maximum retries parks the notification.</summary>
|
||||
[Fact]
|
||||
public void TransientFailure_ReachingMaxRetries_MarksParked()
|
||||
{
|
||||
SetupSmtpRetryPolicy(maxRetries: 3, retryDelay: TimeSpan.FromMinutes(1));
|
||||
// RetryCount starts at max-1; the failed attempt increments it to max.
|
||||
// RetryCount starts one below the maximum; the failed attempt increments it to the maximum.
|
||||
var notification = MakeNotification(retryCount: 2);
|
||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
@@ -232,6 +243,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a permanent failure parks the notification and records the last error.</summary>
|
||||
[Fact]
|
||||
public void PermanentFailure_MarksParked_WithLastError()
|
||||
{
|
||||
@@ -255,6 +267,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a notification with no adapter for its type is parked with an explanatory error.</summary>
|
||||
[Fact]
|
||||
public void NoAdapterForType_MarksParked_WithExplanatoryError()
|
||||
{
|
||||
@@ -279,6 +292,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a faulted dispatch pass clears the in-flight guard so the next tick still runs.</summary>
|
||||
[Fact]
|
||||
public void FaultedDispatchPass_ClearsInFlightGuard_SoNextTickStillRuns()
|
||||
{
|
||||
@@ -329,10 +343,11 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
duration: TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transient failure with a maximum retry count of zero retries using the fallback instead of parking immediately.</summary>
|
||||
[Fact]
|
||||
public void TransientFailure_WithZeroMaxRetries_RetriesUsingFallback_DoesNotParkImmediately()
|
||||
{
|
||||
// NO-002: SmtpConfiguration.MaxRetries=0 used to satisfy 1 >= 0 on the very first
|
||||
// SmtpConfiguration.MaxRetries=0 used to satisfy 1 >= 0 on the very first
|
||||
// transient failure and park the row without a single retry. ResolveRetryPolicyAsync
|
||||
// now clamps non-positive MaxRetries to the FallbackMaxRetries (10) so transient
|
||||
// failures actually retry before parking.
|
||||
@@ -357,10 +372,11 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transient failure with a negative maximum retry count retries using the fallback instead of parking immediately.</summary>
|
||||
[Fact]
|
||||
public void TransientFailure_WithNegativeMaxRetries_RetriesUsingFallback_DoesNotParkImmediately()
|
||||
{
|
||||
// NO-002: a negative MaxRetries reaches ResolveRetryPolicyAsync just as -1 — same
|
||||
// A negative MaxRetries reaches ResolveRetryPolicyAsync just as -1 — same
|
||||
// park-immediately bug. Clamp to FallbackMaxRetries.
|
||||
SetupSmtpRetryPolicy(maxRetries: -1, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var notification = MakeNotification(retryCount: 0);
|
||||
@@ -383,10 +399,11 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-positive retry delay is replaced by the fallback delay rather than zero.</summary>
|
||||
[Fact]
|
||||
public void TransientFailure_WithNonPositiveRetryDelay_UsesFallbackDelay_NotZero()
|
||||
{
|
||||
// NO-002: a non-positive RetryDelay would burn-loop the dispatcher because
|
||||
// A non-positive RetryDelay would burn-loop the dispatcher because
|
||||
// NextAttemptAt would equal now. Clamp to FallbackRetryDelay (1 min) so the
|
||||
// schedule actually advances.
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.Zero);
|
||||
@@ -410,10 +427,11 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stopping the actor cancels an in-flight delivery and leaves the row non-terminal.</summary>
|
||||
[Fact]
|
||||
public void PostStop_CancelsInFlightDelivery_LeavesRowNonTerminal()
|
||||
{
|
||||
// NO-003: the dispatcher used to drop the CancellationToken on its way into
|
||||
// The dispatcher used to drop the CancellationToken on its way into
|
||||
// the channel adapter, so a coordinated shutdown had to wait the full SMTP
|
||||
// connect/auth/send timeout per in-flight notification before the sweep
|
||||
// finished. The actor now passes a lifecycle-scoped token; cancelling it on
|
||||
@@ -456,7 +474,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
|
||||
// ── Register row 45: the delivery adapter is resolved PER DELIVERY ────────
|
||||
//
|
||||
// Supersedes NotificationOutbox-006 ("resolve the adapter dictionary once for the
|
||||
// Supersedes an earlier optimisation ("resolve the adapter dictionary once for the
|
||||
// actor's lifetime"). That optimisation cached scoped adapter instances — which hold
|
||||
// a scoped INotificationRepository over an EF DbContext — in an actor-lifetime DI
|
||||
// scope, so MaxParallelDeliveries concurrent deliveries all ran on ONE DbContext and
|
||||
@@ -478,11 +496,14 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
private int _arrived;
|
||||
private int _overlaps;
|
||||
|
||||
/// <summary>Initializes the ledger with the number of deliveries expected to reach the rendezvous.</summary>
|
||||
/// <param name="expectedArrivals">The number of arrivals that completes the rendezvous.</param>
|
||||
public DeliveryConcurrencyLedger(int expectedArrivals) => _expectedArrivals = expectedArrivals;
|
||||
|
||||
/// <summary>Number of second-operation-on-one-context collisions observed.</summary>
|
||||
public int Overlaps => Volatile.Read(ref _overlaps);
|
||||
|
||||
/// <summary>Records one overlapping use of the same simulated context.</summary>
|
||||
public void RecordOverlap() => Interlocked.Increment(ref _overlaps);
|
||||
|
||||
/// <summary>
|
||||
@@ -491,6 +512,8 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
/// timeout matters for the shared-context (pre-fix) run, where the colliding
|
||||
/// delivery throws instead of arriving and the rendezvous can never complete.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token that cancels the wait.</param>
|
||||
/// <returns>A task that completes once every delivery has arrived or the safety timeout elapses.</returns>
|
||||
public async Task ArriveAndWaitAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Increment(ref _arrived) >= _expectedArrivals)
|
||||
@@ -517,8 +540,13 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
private readonly DeliveryConcurrencyLedger _ledger;
|
||||
private int _inFlight;
|
||||
|
||||
/// <summary>Initializes the probe with the ledger it reports to.</summary>
|
||||
/// <param name="ledger">The ledger that records overlaps and coordinates the rendezvous.</param>
|
||||
public SimulatedDbContextProbe(DeliveryConcurrencyLedger ledger) => _ledger = ledger;
|
||||
|
||||
/// <summary>Enters the simulated context, failing if another operation is already in flight, and waits at the rendezvous.</summary>
|
||||
/// <param name="cancellationToken">Token that cancels the wait.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task EnterAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Increment(ref _inFlight) > 1)
|
||||
@@ -550,10 +578,14 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
{
|
||||
private readonly INotificationRepository _repository;
|
||||
|
||||
/// <summary>Initializes the adapter with the scoped repository it works through.</summary>
|
||||
/// <param name="repository">The scoped notification repository.</param>
|
||||
public RepositoryBackedAdapter(INotificationRepository repository) => _repository = repository;
|
||||
|
||||
/// <inheritdoc />
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeliveryOutcome> DeliverAsync(
|
||||
Notification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -579,6 +611,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parallel deliveries each get their own adapter and DbContext.</summary>
|
||||
[Fact]
|
||||
public void ParallelDeliveries_EachGetTheirOwnAdapterAndDbContext()
|
||||
{
|
||||
@@ -649,6 +682,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
Assert.Equal(0, ledger.Overlaps);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the adapter is resolved per delivery rather than once per actor lifetime.</summary>
|
||||
[Fact]
|
||||
public void Dispatch_ResolvesAdapterPerDelivery_NotOncePerActorLifetime()
|
||||
{
|
||||
@@ -703,11 +737,15 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentBag<INotificationDeliveryAdapter> _seen;
|
||||
|
||||
/// <summary>Initializes the adapter with the bag that records each adapter instance used.</summary>
|
||||
/// <param name="seen">The bag that collects the adapter instances that performed deliveries.</param>
|
||||
public RecordingStubAdapter(
|
||||
System.Collections.Concurrent.ConcurrentBag<INotificationDeliveryAdapter> seen) => _seen = seen;
|
||||
|
||||
/// <inheritdoc />
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DeliveryOutcome> DeliverAsync(
|
||||
Notification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -716,6 +754,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that overlapping ticks while a dispatch is in flight do not claim notifications concurrently.</summary>
|
||||
[Fact]
|
||||
public void OverlappingTicks_WhileDispatchInFlight_DoNotClaimConcurrently()
|
||||
{
|
||||
@@ -739,14 +778,14 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
duration: TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
// ── Task 25: bounded parallel delivery ───────────────────────────────────
|
||||
// ── Bounded parallel delivery ───────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Adapter that records the concurrent-invocation high-water mark. Each
|
||||
/// <see cref="DeliverAsync"/> increments a live counter, notes the peak, then blocks
|
||||
/// on a shared gate until <paramref name="releaseWhen"/> deliveries are concurrently
|
||||
/// in flight — proving real overlap. A safety timeout releases the gate so a
|
||||
/// parallelism-1 run (which can never reach <c>releaseWhen</c> concurrently) still
|
||||
/// run with a parallelism of one (which can never reach <c>releaseWhen</c> concurrently) still
|
||||
/// drains instead of deadlocking.
|
||||
/// </summary>
|
||||
private sealed class ConcurrencyHighWaterAdapter : INotificationDeliveryAdapter
|
||||
@@ -758,12 +797,17 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
private int _highWater;
|
||||
private int _entered;
|
||||
|
||||
/// <summary>Initializes the adapter with the concurrency level that releases the gate.</summary>
|
||||
/// <param name="releaseWhen">The number of concurrent deliveries that releases the gate.</param>
|
||||
public ConcurrencyHighWaterAdapter(int releaseWhen) => _releaseWhen = releaseWhen;
|
||||
|
||||
/// <inheritdoc />
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
|
||||
/// <summary>Gets the highest number of deliveries observed in flight at once.</summary>
|
||||
public int HighWater => Volatile.Read(ref _highWater);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeliveryOutcome> DeliverAsync(
|
||||
Notification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -800,6 +844,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
: Array.Empty<Notification>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with a maximum parallelism of four, deliveries run concurrently and all reach a terminal state.</summary>
|
||||
[Fact]
|
||||
public void Dispatch_WithMaxParallelFour_DeliversConcurrently_AndAllReachTerminal()
|
||||
{
|
||||
@@ -830,6 +875,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
duration: TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with a maximum parallelism of one, deliveries run sequentially and all reach a terminal state.</summary>
|
||||
[Fact]
|
||||
public void Dispatch_WithMaxParallelOne_DeliversSequentially_AndAllReachTerminal()
|
||||
{
|
||||
@@ -856,7 +902,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
Assert.Equal(1, adapter.HighWater);
|
||||
}
|
||||
|
||||
// ── Task 18: per-type retry policy (SMS honors SmsConfiguration) ──────────
|
||||
// ── Per-type retry policy (SMS honors SmsConfiguration) ──────────
|
||||
|
||||
private void SetupSmsRetryPolicy(int maxRetries, TimeSpan retryDelay)
|
||||
{
|
||||
@@ -869,10 +915,11 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
.Returns(config);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transient failure on an SMS notification uses the SMS retry policy.</summary>
|
||||
[Fact]
|
||||
public void SmsNotification_TransientFailure_UsesSmsRetryPolicy()
|
||||
{
|
||||
// Task 18: an SMS-typed notification resolves its retry policy from the SMS
|
||||
// An SMS-typed notification resolves its retry policy from the SMS
|
||||
// configuration, NOT the shared SMTP policy. The SMTP policy (MaxRetries=10)
|
||||
// would keep this row Retrying; the SMS policy (MaxRetries=1) parks it after
|
||||
// this attempt (RetryCount 1 -> 2 >= 1). A pre-fix dispatcher (SMTP-for-all)
|
||||
@@ -899,10 +946,11 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an email notification still uses the SMTP retry policy.</summary>
|
||||
[Fact]
|
||||
public void EmailNotification_StillUsesSmtpPolicy()
|
||||
{
|
||||
// Task 18: an Email row continues to resolve against the SMTP policy even when a
|
||||
// An Email row continues to resolve against the SMTP policy even when a
|
||||
// (tighter) SMS policy is present. SMTP MaxRetries=10 keeps it Retrying
|
||||
// (RetryCount 1 -> 2 < 10) and schedules the next attempt with the SMTP delay.
|
||||
SetupSmtpRetryPolicy(maxRetries: 10, retryDelay: TimeSpan.FromMinutes(3));
|
||||
|
||||
+12
-3
@@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 13: Tests for the <see cref="NotificationOutboxActor"/> ingest path — building a
|
||||
/// Tests for the <see cref="NotificationOutboxActor"/> ingest path — building a
|
||||
/// <see cref="Notification"/> from a <see cref="NotificationSubmit"/>, persisting it via
|
||||
/// <see cref="INotificationOutboxRepository.InsertIfNotExistsAsync"/>, and acking the sender.
|
||||
/// </summary>
|
||||
@@ -59,6 +59,7 @@ public class NotificationOutboxActorIngestTests : TestKit
|
||||
SourceNode: sourceNode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a notification submit persists the mapped notification and acks it as accepted.</summary>
|
||||
[Fact]
|
||||
public void NotificationSubmit_PersistsMappedNotification_AndAcksAccepted()
|
||||
{
|
||||
@@ -90,10 +91,11 @@ public class NotificationOutboxActorIngestTests : TestKit
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the origin execution id is copied onto the persisted notification.</summary>
|
||||
[Fact]
|
||||
public void NotificationSubmit_CopiesOriginExecutionId_OntoPersistedNotification()
|
||||
{
|
||||
// Audit Log #23: the originating script execution's id rides on the
|
||||
// The originating script execution's id rides on the
|
||||
// NotificationSubmit and must be persisted on the Notification row so
|
||||
// the dispatcher can later echo it onto NotifyDeliver audit rows.
|
||||
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
|
||||
@@ -110,6 +112,7 @@ public class NotificationOutboxActorIngestTests : TestKit
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null origin execution id is persisted as null.</summary>
|
||||
[Fact]
|
||||
public void NotificationSubmit_NullOriginExecutionId_PersistsNull()
|
||||
{
|
||||
@@ -126,6 +129,7 @@ public class NotificationOutboxActorIngestTests : TestKit
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the origin parent execution id is copied onto the persisted notification.</summary>
|
||||
[Fact]
|
||||
public void NotificationSubmit_CopiesOriginParentExecutionId_OntoPersistedNotification()
|
||||
{
|
||||
@@ -146,6 +150,7 @@ public class NotificationOutboxActorIngestTests : TestKit
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null origin parent execution id is persisted as null.</summary>
|
||||
[Fact]
|
||||
public void NotificationSubmit_NullOriginParentExecutionId_PersistsNull()
|
||||
{
|
||||
@@ -162,6 +167,7 @@ public class NotificationOutboxActorIngestTests : TestKit
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a duplicate submit, where the repository returns false, is still acked as accepted.</summary>
|
||||
[Fact]
|
||||
public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted()
|
||||
{
|
||||
@@ -178,6 +184,7 @@ public class NotificationOutboxActorIngestTests : TestKit
|
||||
Assert.Null(ack.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a repository exception results in an ack that is not accepted and carries the error.</summary>
|
||||
[Fact]
|
||||
public void RepositoryThrows_AcksNotAcceptedWithError()
|
||||
{
|
||||
@@ -195,10 +202,11 @@ public class NotificationOutboxActorIngestTests : TestKit
|
||||
Assert.Contains("database unavailable", ack.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the source node is copied onto the persisted notification.</summary>
|
||||
[Fact]
|
||||
public void NotificationSubmit_CopiesSourceNode_OntoPersistedNotification()
|
||||
{
|
||||
// SourceNode-stamping (Task 13): the originating site's node name (node-a/node-b)
|
||||
// SourceNode stamping: the originating site's node name (node-a/node-b)
|
||||
// rides on the NotificationSubmit and must be persisted on the Notification row so
|
||||
// central observers (KPIs, audit drill-ins, ops dashboards) can see which node
|
||||
// emitted the notification.
|
||||
@@ -215,6 +223,7 @@ public class NotificationOutboxActorIngestTests : TestKit
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null source node is persisted as null.</summary>
|
||||
[Fact]
|
||||
public void NotificationSubmit_NullSourceNode_PersistsNull()
|
||||
{
|
||||
|
||||
+3
-1
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 16: Tests for the <see cref="NotificationOutboxActor"/> daily purge job — the
|
||||
/// Tests for the <see cref="NotificationOutboxActor"/> daily purge job — the
|
||||
/// periodic sweep that bulk-deletes terminal notification rows older than
|
||||
/// <see cref="NotificationOutboxOptions.TerminalRetention"/> via
|
||||
/// <see cref="INotificationOutboxRepository.DeleteTerminalOlderThanAsync"/>.
|
||||
@@ -51,6 +51,7 @@ public class NotificationOutboxActorPurgeTests : TestKit
|
||||
NullLogger<NotificationOutboxActor>.Instance)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a purge tick deletes terminal rows using a cutoff of the current UTC time minus the terminal retention.</summary>
|
||||
[Fact]
|
||||
public void PurgeTick_DeletesTerminalRows_WithCutoffAtUtcNowMinusTerminalRetention()
|
||||
{
|
||||
@@ -76,6 +77,7 @@ public class NotificationOutboxActorPurgeTests : TestKit
|
||||
Arg.Any<CancellationToken>()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a faulted purge does not crash the actor and a later purge still runs.</summary>
|
||||
[Fact]
|
||||
public void FaultedPurge_DoesNotCrashActor_AndSubsequentPurgeStillRuns()
|
||||
{
|
||||
|
||||
+30
-6
@@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 15: Tests for the <see cref="NotificationOutboxActor"/> query surface — the
|
||||
/// Tests for the <see cref="NotificationOutboxActor"/> query surface — the
|
||||
/// paginated outbox query, single-notification status query, manual retry and discard
|
||||
/// of parked notifications, and the KPI snapshot.
|
||||
/// </summary>
|
||||
@@ -60,6 +60,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the query passes the request filter to the repository and maps rows to summaries.</summary>
|
||||
[Fact]
|
||||
public void Query_PassesFilterFromRequest_AndMapsRowsToSummaries()
|
||||
{
|
||||
@@ -115,6 +116,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Assert.Equal("site-1", staleSummary.SourceSiteId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a notification id filter is passed to the repository as an exact match.</summary>
|
||||
[Fact]
|
||||
public void Query_WithNotificationIdFilter_PassesExactMatchToRepositoryFilter()
|
||||
{
|
||||
@@ -149,6 +151,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
1, 50, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a status filter string is parsed to the status enum on the repository filter.</summary>
|
||||
[Fact]
|
||||
public void Query_WithStatusFilterString_ParsesToEnumOnFilter()
|
||||
{
|
||||
@@ -183,10 +186,11 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
1, 50, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the source node filter is passed through and the source node is projected onto each summary.</summary>
|
||||
[Fact]
|
||||
public void Query_PassesSourceNodeFilter_AndProjectsSourceNodeOntoSummary()
|
||||
{
|
||||
// Task 16: the Notifications page's new Node filter input pushes a
|
||||
// The Notifications page's new Node filter input pushes a
|
||||
// value into NotificationOutboxQueryRequest.SourceNodeFilter; the actor
|
||||
// must thread it onto NotificationOutboxFilter.SourceNode AND mirror
|
||||
// the row's SourceNode column onto the response summaries.
|
||||
@@ -222,6 +226,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
1, 50, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a repository failure during a query replies with a failure and an empty list.</summary>
|
||||
[Fact]
|
||||
public void Query_RepositoryThrows_RepliesFailureWithEmptyList()
|
||||
{
|
||||
@@ -243,6 +248,9 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Assert.Empty(response.Notifications);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a status query is answered as found only when the verified site owns the stored notification.</summary>
|
||||
/// <param name="verifiedSite">The site id the caller is verified as.</param>
|
||||
/// <param name="found">Whether the status query is expected to report the notification as found.</param>
|
||||
[Theory]
|
||||
[InlineData("site-1", true)]
|
||||
[InlineData("site-other", false)]
|
||||
@@ -256,6 +264,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
if (!found) { Assert.Null(response.LastError); Assert.Empty(response.Status); }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a status query for an existing notification replies with the row detail.</summary>
|
||||
[Fact]
|
||||
public void StatusQuery_Found_RepliesWithRowDetail()
|
||||
{
|
||||
@@ -274,6 +283,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Assert.Equal("smtp timeout", response.LastError);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a status query for a missing notification replies that it was not found.</summary>
|
||||
[Fact]
|
||||
public void StatusQuery_NotFound_RepliesFoundFalse()
|
||||
{
|
||||
@@ -290,6 +300,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Assert.Equal(0, response.RetryCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that retrying a parked notification resets it to Pending and succeeds.</summary>
|
||||
[Fact]
|
||||
public void Retry_ParkedNotification_ResetsToPending_AndSucceeds()
|
||||
{
|
||||
@@ -315,6 +326,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that retrying a notification that is not parked fails.</summary>
|
||||
[Fact]
|
||||
public void Retry_NonParkedNotification_Fails()
|
||||
{
|
||||
@@ -330,6 +342,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
_repository.DidNotReceive().TryUpdateFromParkedAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that retrying a missing notification fails.</summary>
|
||||
[Fact]
|
||||
public void Retry_MissingNotification_Fails()
|
||||
{
|
||||
@@ -348,7 +361,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
/// <summary>
|
||||
/// The row is read successfully but the retention purge deletes it before the
|
||||
/// write lands, so the CAS write matches nothing. The re-read that follows a
|
||||
/// lost CAS (arch-review 2026-08-19 P5-4) finds nothing either, and the
|
||||
/// lost CAS finds nothing either, and the
|
||||
/// operator must be told the notification is gone — reporting a re-queue
|
||||
/// that never happened is what a blind ExecuteUpdate would silently do (the
|
||||
/// pre-ExecuteUpdate predecessor threw DbUpdateConcurrencyException here).
|
||||
@@ -391,8 +404,8 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PROOF OF FAILURE for arch-review 2026-08-19 P5-4. Models the race the
|
||||
/// finding describes: the operator's read sees Parked, but by the time the
|
||||
/// Proof of failure for the lost-CAS race. Models the race in which
|
||||
/// the operator's read sees Parked, but by the time the
|
||||
/// write lands the dispatcher has already delivered the row (a detached
|
||||
/// PipeTo task interleaving with the dispatcher's own write). The mechanism
|
||||
/// is proven deterministically via a mocked call sequence, not a wall-clock
|
||||
@@ -459,6 +472,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
_repository.DidNotReceive().UpdateAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that discarding a parked notification marks it Discarded and succeeds.</summary>
|
||||
[Fact]
|
||||
public void Discard_ParkedNotification_MarksDiscarded_AndSucceeds()
|
||||
{
|
||||
@@ -477,6 +491,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that discarding a notification that is not parked fails.</summary>
|
||||
[Fact]
|
||||
public void Discard_NonParkedNotification_Fails()
|
||||
{
|
||||
@@ -492,6 +507,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
_repository.DidNotReceive().TryUpdateFromParkedAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that discarding a missing notification fails.</summary>
|
||||
[Fact]
|
||||
public void Discard_MissingNotification_Fails()
|
||||
{
|
||||
@@ -506,6 +522,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Assert.Contains("not found", response.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a detail request for a known id returns the full detail with the body and resolved targets.</summary>
|
||||
[Fact]
|
||||
public void DetailRequest_KnownId_ReturnsFullDetail_WithBodyAndResolvedTargets()
|
||||
{
|
||||
@@ -545,6 +562,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Assert.Equal("node-a", detail.SourceNode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a detail request for an unknown id returns not found.</summary>
|
||||
[Fact]
|
||||
public void DetailRequest_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
@@ -562,6 +580,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Assert.Contains("not found", response.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a KPI request computes the KPIs and maps the snapshot.</summary>
|
||||
[Fact]
|
||||
public void KpiRequest_ComputesKpis_AndMapsSnapshot()
|
||||
{
|
||||
@@ -592,6 +611,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a repository failure during a KPI request replies with a failure response.</summary>
|
||||
[Fact]
|
||||
public void KpiRequest_RepositoryThrows_RepliesFailureResponse()
|
||||
{
|
||||
@@ -615,6 +635,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Assert.Null(response.OldestPendingAge);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a per-site KPI request replies with per-site snapshots.</summary>
|
||||
[Fact]
|
||||
public void PerSiteKpiRequest_RepliesWithPerSiteSnapshots()
|
||||
{
|
||||
@@ -639,6 +660,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a repository fault during a per-site KPI request replies unsuccessfully.</summary>
|
||||
[Fact]
|
||||
public void PerSiteKpiRequest_RepositoryFault_RepliesUnsuccessful()
|
||||
{
|
||||
@@ -657,8 +679,9 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Assert.Empty(response.Sites);
|
||||
}
|
||||
|
||||
// ── Per-node KPI (T6: M5.2 per-node stuck-count KPIs) ──────────────────
|
||||
// ── Per-node KPI (per-node stuck-count KPIs) ──────────────────
|
||||
|
||||
/// <summary>Verifies that a per-node KPI request replies with per-node snapshots.</summary>
|
||||
[Fact]
|
||||
public void PerNodeKpiRequest_RepliesWithPerNodeSnapshots()
|
||||
{
|
||||
@@ -685,6 +708,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a repository fault during a per-node KPI request replies unsuccessfully.</summary>
|
||||
[Fact]
|
||||
public void PerNodeKpiRequest_RepositoryFault_RepliesUnsuccessful()
|
||||
{
|
||||
|
||||
+7
-1
@@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 13 (arch-review 04) — verifies that an operator-initiated Retry on a parked
|
||||
/// Verifies that an operator-initiated Retry on a parked
|
||||
/// notification emits a <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
||||
/// audit row with status <see cref="AuditStatus.Submitted"/> and the operator as
|
||||
/// <c>Actor</c>, closing the forensic gap (previously the lifecycle read
|
||||
@@ -31,9 +31,12 @@ public class NotificationOutboxActorRetryEmissionTests : TestKit
|
||||
|
||||
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
/// <summary>Gets the audit rows recorded by the writer.</summary>
|
||||
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
||||
/// <summary>Gets or sets a callback invoked on each write, allowing a test to simulate a failing writer.</summary>
|
||||
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events)
|
||||
@@ -85,6 +88,7 @@ public class NotificationOutboxActorRetryEmissionTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that retrying a parked notification emits a submitted notify-deliver audit row carrying the operator.</summary>
|
||||
[Fact]
|
||||
public void Retry_OnParked_Emits_Submitted_NotifyDeliver_AuditRow_With_Operator()
|
||||
{
|
||||
@@ -110,6 +114,7 @@ public class NotificationOutboxActorRetryEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that discarding a parked notification emits a discarded audit row carrying the operator.</summary>
|
||||
[Fact]
|
||||
public void Discard_OnParked_Emits_Discarded_AuditRow_With_Operator()
|
||||
{
|
||||
@@ -130,6 +135,7 @@ public class NotificationOutboxActorRetryEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an audit write failure does not fail the retry.</summary>
|
||||
[Fact]
|
||||
public void Retry_AuditFailure_DoesNotFail_TheRetry()
|
||||
{
|
||||
|
||||
+24
-5
@@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// M4 Bundle B (B3) — verifies the <see cref="NotificationOutboxActor"/>
|
||||
/// Verifies the <see cref="NotificationOutboxActor"/>
|
||||
/// emits a second
|
||||
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
||||
/// audit row carrying the terminal status (Delivered, Parked, Discarded) on
|
||||
@@ -36,11 +36,14 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
|
||||
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
// C3 (Task 2.5): store the decomposed row view so assertions keep
|
||||
// Store the decomposed row view so assertions keep
|
||||
// reading the ScadaBridge domain fields as typed properties.
|
||||
/// <summary>Gets the decomposed audit rows captured by this writer.</summary>
|
||||
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
||||
/// <summary>Gets or sets an optional callback invoked on each write.</summary>
|
||||
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events)
|
||||
@@ -69,10 +72,14 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
{
|
||||
private readonly Func<DeliveryOutcome> _outcome;
|
||||
|
||||
/// <summary>Initializes the adapter with the outcome factory used for each delivery.</summary>
|
||||
/// <param name="outcome">Produces the outcome returned for each delivery.</param>
|
||||
public StubAdapter(Func<DeliveryOutcome> outcome) { _outcome = outcome; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DeliveryOutcome> DeliverAsync(
|
||||
Notification notification, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_outcome());
|
||||
@@ -129,6 +136,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a delivered notification emits a terminal event with Delivered status.</summary>
|
||||
[Fact]
|
||||
public void Terminal_Delivered_EmitsEvent_StatusDelivered()
|
||||
{
|
||||
@@ -152,10 +160,11 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the terminal delivered event carries the notification's origin execution id as its execution id.</summary>
|
||||
[Fact]
|
||||
public void Terminal_Delivered_CarriesOriginExecutionId_AsExecutionId()
|
||||
{
|
||||
// Audit Log #23: the terminal NotifyDeliver row must echo the
|
||||
// The terminal NotifyDeliver row must echo the
|
||||
// notification's OriginExecutionId so it shares the per-run id with
|
||||
// the site-emitted NotifySend row.
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
@@ -176,6 +185,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null origin execution id yields a null execution id on the terminal delivered event.</summary>
|
||||
[Fact]
|
||||
public void Terminal_Delivered_NullOriginExecutionId_HasNullExecutionId()
|
||||
{
|
||||
@@ -196,6 +206,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the terminal delivered event carries the notification's origin parent execution id as its parent execution id.</summary>
|
||||
[Fact]
|
||||
public void Terminal_Delivered_CarriesOriginParentExecutionId_AsParentExecutionId()
|
||||
{
|
||||
@@ -220,6 +231,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null origin parent execution id yields a null parent execution id on the terminal delivered event.</summary>
|
||||
[Fact]
|
||||
public void Terminal_Delivered_NullOriginParentExecutionId_HasNullParentExecutionId()
|
||||
{
|
||||
@@ -240,6 +252,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a permanent failure emits a terminal event with Parked status.</summary>
|
||||
[Fact]
|
||||
public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked()
|
||||
{
|
||||
@@ -261,10 +274,11 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the terminal parked event carries the notification's origin execution id as its execution id.</summary>
|
||||
[Fact]
|
||||
public void Terminal_Parked_CarriesOriginExecutionId_AsExecutionId()
|
||||
{
|
||||
// Audit Log #23: the Parked terminal NotifyDeliver row flows through the
|
||||
// The Parked terminal NotifyDeliver row flows through the
|
||||
// same BuildNotifyDeliverEvent path as the Delivered row, so it must
|
||||
// likewise echo the notification's OriginExecutionId — sharing the
|
||||
// per-run id with the site-emitted NotifySend row.
|
||||
@@ -286,11 +300,12 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transient failure that reaches the maximum retries emits a terminal event with Parked status.</summary>
|
||||
[Fact]
|
||||
public void Terminal_Parked_OnTransientReachingMaxRetries_EmitsEvent_StatusParked()
|
||||
{
|
||||
SetupSmtpRetryPolicy(maxRetries: 3, retryDelay: TimeSpan.FromMinutes(1));
|
||||
// RetryCount starts at max-1; the failed attempt increments it to max
|
||||
// RetryCount starts one below the maximum; the failed attempt increments it to the maximum
|
||||
// which triggers the Parked terminal transition.
|
||||
var notification = MakeNotification(retryCount: 2);
|
||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
@@ -308,6 +323,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a missing adapter emits a terminal event with Parked status.</summary>
|
||||
[Fact]
|
||||
public void Terminal_Parked_OnMissingAdapter_EmitsEvent_StatusParked()
|
||||
{
|
||||
@@ -329,6 +345,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transient failure below the maximum retries does not emit a terminal row.</summary>
|
||||
[Fact]
|
||||
public void Transient_BelowMaxRetries_DoesNotEmitTerminalRow()
|
||||
{
|
||||
@@ -353,6 +370,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
Assert.Empty(EventsByStatus(AuditStatus.Discarded));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a manual discard emits a terminal event with Discarded status.</summary>
|
||||
[Fact]
|
||||
public void Terminal_Discarded_OnManualDiscard_EmitsEvent_StatusDiscarded()
|
||||
{
|
||||
@@ -378,6 +396,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a throwing audit writer does not prevent the terminal status update from succeeding.</summary>
|
||||
[Fact]
|
||||
public void AuditWriter_Throws_TerminalUpdate_StillSucceeds()
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
public class NotificationOutboxOptionsTests
|
||||
{
|
||||
/// <summary>Verifies that the default option values are the expected ones.</summary>
|
||||
[Fact]
|
||||
public void Defaults_AreExpectedValues()
|
||||
{
|
||||
|
||||
+10
-1
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Options;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// P5-5 (arch-review Low): <see cref="NotificationOutboxOptions"/> had no
|
||||
/// Tests for the options validator: <see cref="NotificationOutboxOptions"/> had no
|
||||
/// registered validator before this — the dispatch/purge/KPI cadences and the
|
||||
/// delivered-throughput KPI window (<see cref="NotificationOutboxOptions.DeliveredKpiWindow"/>)
|
||||
/// could be zero/negative with no boot-time signal, which would spin an Akka
|
||||
@@ -19,6 +19,7 @@ public class NotificationOutboxOptionsValidatorTests
|
||||
private static ValidateOptionsResult Validate(NotificationOutboxOptions options) =>
|
||||
new NotificationOutboxOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
/// <summary>Verifies that the default options are valid.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -26,6 +27,7 @@ public class NotificationOutboxOptionsValidatorTests
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero dispatch interval is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroDispatchInterval_IsRejected()
|
||||
{
|
||||
@@ -34,6 +36,7 @@ public class NotificationOutboxOptionsValidatorTests
|
||||
Assert.Contains("DispatchInterval", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero dispatch batch size is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroDispatchBatchSize_IsRejected()
|
||||
{
|
||||
@@ -42,6 +45,7 @@ public class NotificationOutboxOptionsValidatorTests
|
||||
Assert.Contains("DispatchBatchSize", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero stuck-age threshold is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroStuckAgeThreshold_IsRejected()
|
||||
{
|
||||
@@ -50,6 +54,7 @@ public class NotificationOutboxOptionsValidatorTests
|
||||
Assert.Contains("StuckAgeThreshold", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero terminal-row retention is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroTerminalRetention_IsRejected()
|
||||
{
|
||||
@@ -58,6 +63,7 @@ public class NotificationOutboxOptionsValidatorTests
|
||||
Assert.Contains("TerminalRetention", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero purge interval is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroPurgeInterval_IsRejected()
|
||||
{
|
||||
@@ -66,6 +72,7 @@ public class NotificationOutboxOptionsValidatorTests
|
||||
Assert.Contains("PurgeInterval", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero delivered-KPI window is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroDeliveredKpiWindow_IsRejected()
|
||||
{
|
||||
@@ -74,6 +81,7 @@ public class NotificationOutboxOptionsValidatorTests
|
||||
Assert.Contains("DeliveredKpiWindow", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a negative delivered-KPI window is rejected.</summary>
|
||||
[Fact]
|
||||
public void NegativeDeliveredKpiWindow_IsRejected()
|
||||
{
|
||||
@@ -82,6 +90,7 @@ public class NotificationOutboxOptionsValidatorTests
|
||||
Assert.Contains("DeliveredKpiWindow", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero maximum parallel deliveries value is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroMaxParallelDeliveries_IsRejected()
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
internal static class OutboxRepositorySubstitute
|
||||
{
|
||||
/// <summary>Creates a substitute whose writes report that the row was found.</summary>
|
||||
/// <returns>A substitute outbox repository whose writes report the row as found.</returns>
|
||||
public static INotificationOutboxRepository Healthy()
|
||||
{
|
||||
var repository = Substitute.For<INotificationOutboxRepository>();
|
||||
|
||||
@@ -2,6 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
public class ProjectSmokeTest
|
||||
{
|
||||
/// <summary>Verifies that the test project compiles and runs.</summary>
|
||||
[Fact]
|
||||
public void ProjectCompiles() => Assert.True(true);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 17: Tests for <see cref="ServiceCollectionExtensions.AddNotificationOutbox"/> — the
|
||||
/// Tests for <see cref="ServiceCollectionExtensions.AddNotificationOutbox"/> — the
|
||||
/// DI registration extension for the Notification Outbox component. The extension binds
|
||||
/// <see cref="NotificationOutboxOptions"/> from the <c>ScadaBridge:NotificationOutbox</c>
|
||||
/// configuration section and registers the channel delivery adapter(s).
|
||||
@@ -44,6 +44,7 @@ public class ServiceRegistrationTests
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registration provides the notification outbox options with their defaults.</summary>
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_RegistersNotificationOutboxOptions_WithDefaults()
|
||||
{
|
||||
@@ -56,6 +57,7 @@ public class ServiceRegistrationTests
|
||||
Assert.Equal(100, options.DispatchBatchSize);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the options section name is the notification outbox configuration path.</summary>
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_OptionsSection_IsTheNotificationOutboxConfigPath()
|
||||
{
|
||||
@@ -64,6 +66,7 @@ public class ServiceRegistrationTests
|
||||
NotificationOutbox.ServiceCollectionExtensions.OptionsSection);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registration binds the notification outbox options from configuration.</summary>
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_BindsNotificationOutboxOptions_FromConfiguration()
|
||||
{
|
||||
@@ -86,6 +89,7 @@ public class ServiceRegistrationTests
|
||||
Assert.Equal(250, options.DispatchBatchSize);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registration provides the email delivery adapter.</summary>
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_RegistersEmailDeliveryAdapter()
|
||||
{
|
||||
@@ -98,6 +102,7 @@ public class ServiceRegistrationTests
|
||||
Assert.Equal(NotificationType.Email, adapter.Type);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registration provides the SMS delivery adapter.</summary>
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_RegistersSmsDeliveryAdapter()
|
||||
{
|
||||
@@ -110,6 +115,7 @@ public class ServiceRegistrationTests
|
||||
Assert.Equal(NotificationType.Sms, adapter.Type);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registration provides both adapters as notification delivery adapters.</summary>
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_RegistersBothAdapters_AsINotificationDeliveryAdapter()
|
||||
{
|
||||
@@ -132,6 +138,7 @@ public class ServiceRegistrationTests
|
||||
Assert.Contains(adapters, a => a is SmsNotificationDeliveryAdapter);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registration provides the SMS options with their defaults.</summary>
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_RegistersSmsOptions_WithDefaults()
|
||||
{
|
||||
|
||||
+2
-1
@@ -5,11 +5,12 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only no-op <see cref="ICentralAuditWriter"/>. Used by existing
|
||||
/// NotificationOutboxActor TestKit fixtures whose tests pre-date the M4 Bundle B
|
||||
/// NotificationOutboxActor TestKit fixtures whose tests pre-date the
|
||||
/// audit-writer injection — they don't care about audit emission, they just
|
||||
/// need a non-null collaborator so the actor's constructor succeeds.
|
||||
/// </summary>
|
||||
internal sealed class NoOpCentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user