using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery; namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// /// Task 13 (arch-review 04) — verifies that an operator-initiated Retry on a parked /// notification emits a / /// audit row with status and the operator as /// Actor, closing the forensic gap (previously the lifecycle read /// Parked → Attempted → Delivered with no record of who un-parked it). Also /// asserts the audit write is best-effort — a throwing writer never fails the retry. /// public class NotificationOutboxActorRetryEmissionTests : TestKit { private readonly INotificationOutboxRepository _outboxRepository = Substitute.For(); private readonly RecordingCentralAuditWriter _auditWriter = new(); private sealed class RecordingCentralAuditWriter : ICentralAuditWriter { public List Events { get; } = new(); public Func? OnWrite { get; set; } public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) { Events.Add(evt.AsRow()); } return OnWrite?.Invoke(evt) ?? Task.CompletedTask; } } private IServiceProvider BuildServiceProvider() { var services = new ServiceCollection(); services.AddScoped(_ => _outboxRepository); return services.BuildServiceProvider(); } private IActorRef CreateActor() { return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor( BuildServiceProvider(), new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) }, (ICentralAuditWriter)_auditWriter, NullLogger.Instance))); } private static Notification MakeParked(Guid? notificationId = null) { return new Notification( (notificationId ?? Guid.NewGuid()).ToString("D"), NotificationType.Email, "ops-team", "Tank overflow", "Tank 3 level critical", "site-1") { Status = NotificationStatus.Parked, RetryCount = 5, CreatedAt = DateTimeOffset.UtcNow, }; } private List EventsByStatus(AuditStatus status) { lock (_auditWriter.Events) { return _auditWriter.Events.Where(e => e.Status == status).ToList(); } } [Fact] public void Retry_OnParked_Emits_Submitted_NotifyDeliver_AuditRow_With_Operator() { var notificationId = Guid.NewGuid(); var notification = MakeParked(notificationId); _outboxRepository.GetByIdAsync(notification.NotificationId, Arg.Any()) .Returns(notification); var actor = CreateActor(); actor.Tell(new RetryNotificationRequest( CorrelationId: "test-corr", NotificationId: notification.NotificationId, RequestedBy: "jdoe")); ExpectMsg(r => r.Success); AwaitAssert(() => { var submitted = EventsByStatus(AuditStatus.Submitted); Assert.Single(submitted); var evt = submitted[0]; Assert.Equal(AuditChannel.Notification, evt.Channel); Assert.Equal(AuditKind.NotifyDeliver, evt.Kind); Assert.Equal("jdoe", evt.Actor); Assert.Equal(notificationId, evt.CorrelationId); }); } [Fact] public void Discard_OnParked_Emits_Discarded_AuditRow_With_Operator() { var notification = MakeParked(); _outboxRepository.GetByIdAsync(notification.NotificationId, Arg.Any()) .Returns(notification); var actor = CreateActor(); actor.Tell(new DiscardNotificationRequest( CorrelationId: "test-corr", NotificationId: notification.NotificationId, RequestedBy: "jdoe")); ExpectMsg(r => r.Success); AwaitAssert(() => { var discarded = EventsByStatus(AuditStatus.Discarded); Assert.Single(discarded); Assert.Equal("jdoe", discarded[0].Actor); }); } [Fact] public void Retry_AuditFailure_DoesNotFail_TheRetry() { // Audit is best-effort: a throwing writer must NOT abort the retry — the // row is still flipped back to Pending and the response reports Success. var notification = MakeParked(); _outboxRepository.GetByIdAsync(notification.NotificationId, Arg.Any()) .Returns(notification); _auditWriter.OnWrite = _ => throw new InvalidOperationException("audit dead"); var actor = CreateActor(); actor.Tell(new RetryNotificationRequest( CorrelationId: "test-corr", NotificationId: notification.NotificationId, RequestedBy: "jdoe")); ExpectMsg(r => r.Success); AwaitAssert(() => { _outboxRepository.Received(1).UpdateAsync( Arg.Is(n => n.Status == NotificationStatus.Pending), Arg.Any()); }); } }