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; using ScadaLink.NotificationOutbox.Tests.TestSupport; namespace ScadaLink.NotificationOutbox.Tests; /// /// Task 16: Tests for the daily purge job — the /// periodic sweep that bulk-deletes terminal notification rows older than /// via /// . /// public class NotificationOutboxActorPurgeTests : TestKit { private readonly INotificationOutboxRepository _outboxRepository = Substitute.For(); private readonly INotificationRepository _notificationRepository = Substitute.For(); private IServiceProvider BuildServiceProvider() { var services = new ServiceCollection(); services.AddScoped(_ => _outboxRepository); services.AddScoped(_ => _notificationRepository); return services.BuildServiceProvider(); } /// /// Creates the actor with both the dispatch and purge timers set to a long interval so /// neither periodic timer fires during a test — purge ticks are sent manually instead. /// private IActorRef CreateActor(NotificationOutboxOptions? options = null) { return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor( BuildServiceProvider(), options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1), PurgeInterval = TimeSpan.FromHours(1), }, new NoOpCentralAuditWriter(), NullLogger.Instance))); } [Fact] public void PurgeTick_DeletesTerminalRows_WithCutoffAtUtcNowMinusTerminalRetention() { var retention = TimeSpan.FromDays(30); var options = new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1), PurgeInterval = TimeSpan.FromHours(1), TerminalRetention = retention, }; _outboxRepository .DeleteTerminalOlderThanAsync(Arg.Any(), Arg.Any()) .Returns(3); var actor = CreateActor(options); var expectedCutoff = DateTimeOffset.UtcNow - retention; actor.Tell(InternalMessages.PurgeTick.Instance); AwaitAssert(() => _outboxRepository.Received(1).DeleteTerminalOlderThanAsync( Arg.Is(cutoff => (cutoff - expectedCutoff).Duration() < TimeSpan.FromMinutes(1)), Arg.Any())); } [Fact] public void FaultedPurge_DoesNotCrashActor_AndSubsequentPurgeStillRuns() { var calls = 0; _outboxRepository .DeleteTerminalOlderThanAsync(Arg.Any(), Arg.Any()) .Returns(_ => { calls++; // First purge faults; the second returns normally to prove the actor lives on. if (calls == 1) { throw new InvalidOperationException("db down"); } return Task.FromResult(0); }); var actor = CreateActor(); // First tick: the purge faults internally but must be handled and not kill the actor. actor.Tell(InternalMessages.PurgeTick.Instance); AwaitAssert(() => _outboxRepository.Received(1).DeleteTerminalOlderThanAsync( Arg.Any(), Arg.Any())); // Second tick: if the faulted purge had crashed the actor, this would never run. actor.Tell(InternalMessages.PurgeTick.Instance); AwaitAssert(() => _outboxRepository.Received(2).DeleteTerminalOlderThanAsync( Arg.Any(), Arg.Any())); } }