305 lines
12 KiB
C#
305 lines
12 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using ScadaLink.Commons.Entities.Notifications;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using ScadaLink.NotificationOutbox.Delivery;
|
|
using ScadaLink.NotificationOutbox.Messages;
|
|
|
|
namespace ScadaLink.NotificationOutbox.Tests;
|
|
|
|
/// <summary>
|
|
/// Task 14: 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
|
|
/// transition with <see cref="INotificationOutboxRepository.UpdateAsync"/>.
|
|
/// </summary>
|
|
public class NotificationOutboxActorDispatchTests : TestKit
|
|
{
|
|
private readonly INotificationOutboxRepository _outboxRepository =
|
|
Substitute.For<INotificationOutboxRepository>();
|
|
|
|
private readonly INotificationRepository _notificationRepository =
|
|
Substitute.For<INotificationRepository>();
|
|
|
|
private IServiceProvider BuildServiceProvider()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddScoped(_ => _outboxRepository);
|
|
services.AddScoped(_ => _notificationRepository);
|
|
return services.BuildServiceProvider();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stub adapter whose <see cref="DeliverAsync"/> returns a configurable outcome and
|
|
/// optionally blocks for a delay — used to exercise the overlapping-tick guard.
|
|
/// </summary>
|
|
private sealed class StubAdapter : INotificationDeliveryAdapter
|
|
{
|
|
private readonly Func<DeliveryOutcome> _outcome;
|
|
private readonly TimeSpan _delay;
|
|
|
|
public StubAdapter(Func<DeliveryOutcome> outcome, TimeSpan? delay = null)
|
|
{
|
|
_outcome = outcome;
|
|
_delay = delay ?? TimeSpan.Zero;
|
|
}
|
|
|
|
public int CallCount;
|
|
|
|
public NotificationType Type => NotificationType.Email;
|
|
|
|
public async Task<DeliveryOutcome> DeliverAsync(
|
|
Notification notification, CancellationToken cancellationToken = default)
|
|
{
|
|
Interlocked.Increment(ref CallCount);
|
|
if (_delay > TimeSpan.Zero)
|
|
{
|
|
await Task.Delay(_delay, cancellationToken);
|
|
}
|
|
|
|
return _outcome();
|
|
}
|
|
}
|
|
|
|
private IActorRef CreateActor(
|
|
IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters,
|
|
NotificationOutboxOptions? options = null)
|
|
{
|
|
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
|
BuildServiceProvider(),
|
|
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
|
NullLogger<NotificationOutboxActor>.Instance,
|
|
adapters)));
|
|
}
|
|
|
|
private static Notification MakeNotification(
|
|
NotificationType type = NotificationType.Email, int retryCount = 0)
|
|
{
|
|
return new Notification(
|
|
Guid.NewGuid().ToString(), type, "ops-team", "Subject", "Body", "site-1")
|
|
{
|
|
RetryCount = retryCount,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
};
|
|
}
|
|
|
|
private void SetupSmtpRetryPolicy(int maxRetries, TimeSpan retryDelay)
|
|
{
|
|
var config = new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
|
|
{
|
|
MaxRetries = maxRetries,
|
|
RetryDelay = retryDelay,
|
|
};
|
|
_notificationRepository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new[] { config });
|
|
}
|
|
|
|
[Fact]
|
|
public void DispatchTick_ClaimsDueNotifications_AndInvokesAdapter()
|
|
{
|
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
|
var notification = MakeNotification();
|
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(new[] { notification });
|
|
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
|
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
|
{
|
|
[NotificationType.Email] = adapter,
|
|
});
|
|
|
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
_outboxRepository.Received(1).GetDueAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
|
Assert.Equal(1, adapter.CallCount);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Success_MarksNotificationDelivered_WithResolvedTargets()
|
|
{
|
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
|
var notification = MakeNotification();
|
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(new[] { notification });
|
|
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
|
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
|
{
|
|
[NotificationType.Email] = adapter,
|
|
});
|
|
|
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
_outboxRepository.Received(1).UpdateAsync(
|
|
Arg.Is<Notification>(n =>
|
|
n.Status == NotificationStatus.Delivered &&
|
|
n.DeliveredAt != null &&
|
|
n.LastAttemptAt != null &&
|
|
n.ResolvedTargets == "ops@example.com" &&
|
|
n.LastError == null),
|
|
Arg.Any<CancellationToken>());
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TransientFailure_BelowMaxRetries_MarksRetrying_AndSchedulesNextAttempt()
|
|
{
|
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(3));
|
|
var notification = MakeNotification(retryCount: 1);
|
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(new[] { notification });
|
|
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
|
|
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
|
{
|
|
[NotificationType.Email] = 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.LastError == "smtp timeout" &&
|
|
n.LastAttemptAt != null),
|
|
Arg.Any<CancellationToken>());
|
|
});
|
|
}
|
|
|
|
[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.
|
|
var notification = MakeNotification(retryCount: 2);
|
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(new[] { notification });
|
|
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
|
|
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
|
{
|
|
[NotificationType.Email] = adapter,
|
|
});
|
|
|
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
_outboxRepository.Received(1).UpdateAsync(
|
|
Arg.Is<Notification>(n =>
|
|
n.Status == NotificationStatus.Parked &&
|
|
n.RetryCount == 3),
|
|
Arg.Any<CancellationToken>());
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void PermanentFailure_MarksParked_WithLastError()
|
|
{
|
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
|
var notification = MakeNotification();
|
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(new[] { notification });
|
|
var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address"));
|
|
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
|
{
|
|
[NotificationType.Email] = adapter,
|
|
});
|
|
|
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
_outboxRepository.Received(1).UpdateAsync(
|
|
Arg.Is<Notification>(n =>
|
|
n.Status == NotificationStatus.Parked &&
|
|
n.LastError == "invalid recipient address" &&
|
|
n.LastAttemptAt != null),
|
|
Arg.Any<CancellationToken>());
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void NoAdapterForType_MarksParked_WithExplanatoryError()
|
|
{
|
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
|
var notification = MakeNotification();
|
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(new[] { notification });
|
|
// Empty adapter dictionary: no adapter resolves for the notification's type.
|
|
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>());
|
|
|
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
_outboxRepository.Received(1).UpdateAsync(
|
|
Arg.Is<Notification>(n =>
|
|
n.Status == NotificationStatus.Parked &&
|
|
n.LastError != null &&
|
|
n.LastError.Contains("no delivery adapter") &&
|
|
n.LastAttemptAt != null),
|
|
Arg.Any<CancellationToken>());
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void FaultedDispatchPass_ClearsInFlightGuard_SoNextTickStillRuns()
|
|
{
|
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
|
// GetDueAsync throws on every call: the dispatch pass's task could fault if the
|
|
// failure were not handled, which would leave _dispatching stuck true forever.
|
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns<IReadOnlyList<Notification>>(_ => throw new InvalidOperationException("db down"));
|
|
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>());
|
|
|
|
// First tick: the pass faults internally but must still clear the in-flight guard.
|
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
|
AwaitAssert(() => _outboxRepository.Received(1).GetDueAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
|
|
|
|
// Second tick after the first completes: if the guard had wedged, this would be
|
|
// dropped and GetDueAsync would still show only one call.
|
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
|
AwaitAssert(() => _outboxRepository.Received(2).GetDueAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
|
|
}
|
|
|
|
[Fact]
|
|
public void OverlappingTicks_WhileDispatchInFlight_DoNotClaimConcurrently()
|
|
{
|
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
|
var notification = MakeNotification();
|
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(new[] { notification });
|
|
// Slow adapter keeps the first sweep in flight while the second tick arrives.
|
|
var adapter = new StubAdapter(
|
|
() => DeliveryOutcome.Success("ops@example.com"),
|
|
delay: TimeSpan.FromMilliseconds(800));
|
|
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
|
{
|
|
[NotificationType.Email] = adapter,
|
|
});
|
|
|
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
|
|
|
// Second tick is dropped by the in-flight guard: only one sweep runs.
|
|
AwaitAssert(
|
|
() => _outboxRepository.Received(1).GetDueAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>()),
|
|
duration: TimeSpan.FromSeconds(2));
|
|
}
|
|
}
|