feat(notifications): SMS notifications retry under SmsConfiguration.MaxRetries/RetryDelay (SMTP policy remains the Email + fallback policy) — wires the dead fields

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 04:07:03 -04:00
parent f0dd016207
commit 178ae35308
5 changed files with 173 additions and 38 deletions
@@ -684,4 +684,78 @@ public class NotificationOutboxActorDispatchTests : TestKit
// Strictly sequential: never more than one delivery in flight at once.
Assert.Equal(1, adapter.HighWater);
}
// ── Task 18: per-type retry policy (SMS honors SmsConfiguration) ──────────
private void SetupSmsRetryPolicy(int maxRetries, TimeSpan retryDelay)
{
var config = new SmsConfiguration("ACtestaccountsid", "+15550000000")
{
MaxRetries = maxRetries,
RetryDelay = retryDelay,
};
_notificationRepository.GetSmsConfigurationAsync(Arg.Any<CancellationToken>())
.Returns(config);
}
[Fact]
public void SmsNotification_TransientFailure_UsesSmsRetryPolicy()
{
// Task 18: 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)
// would leave it Retrying with RetryCount=2 — the assertion below fails.
SetupSmtpRetryPolicy(maxRetries: 10, retryDelay: TimeSpan.FromMinutes(1));
SetupSmsRetryPolicy(maxRetries: 1, retryDelay: TimeSpan.FromMinutes(5));
var notification = MakeNotification(type: NotificationType.Sms, retryCount: 1);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("twilio 503"),
type: NotificationType.Sms);
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n =>
n.Status == NotificationStatus.Parked &&
n.RetryCount == 2 &&
n.LastError == "twilio 503"),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void EmailNotification_StillUsesSmtpPolicy()
{
// Task 18: 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));
SetupSmsRetryPolicy(maxRetries: 1, retryDelay: TimeSpan.FromMinutes(5));
var before = DateTimeOffset.UtcNow;
var notification = MakeNotification(type: NotificationType.Email, retryCount: 1);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"),
type: NotificationType.Email);
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
_outboxRepository.Received(1).UpdateAsync(
Arg.Is<Notification>(n =>
n.Status == NotificationStatus.Retrying &&
n.RetryCount == 2 &&
n.NextAttemptAt != null &&
n.NextAttemptAt > before + TimeSpan.FromMinutes(2) &&
n.LastError == "smtp timeout"),
Arg.Any<CancellationToken>());
});
}
}