refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;
|
||||
|
||||
public class DeliveryOutcomeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Success_SetsResolvedTargets_AndNoError()
|
||||
{
|
||||
var outcome = DeliveryOutcome.Success("targets");
|
||||
|
||||
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||
Assert.Equal("targets", outcome.ResolvedTargets);
|
||||
Assert.Null(outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transient_SetsError_AndNoResolvedTargets()
|
||||
{
|
||||
var outcome = DeliveryOutcome.Transient("e");
|
||||
|
||||
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||
Assert.Equal("e", outcome.Error);
|
||||
Assert.Null(outcome.ResolvedTargets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Permanent_SetsError_AndNoResolvedTargets()
|
||||
{
|
||||
var outcome = DeliveryOutcome.Permanent("e");
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Equal("e", outcome.Error);
|
||||
Assert.Null(outcome.ResolvedTargets);
|
||||
}
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
using System.Net.Sockets;
|
||||
using MailKit;
|
||||
using MailKit.Net.Smtp;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
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
|
||||
/// resolution, SMTP send, and the three-way (Success / Permanent / Transient) outcome
|
||||
/// classification.
|
||||
/// </summary>
|
||||
public class EmailNotificationDeliveryAdapterTests
|
||||
{
|
||||
private readonly INotificationRepository _repository = Substitute.For<INotificationRepository>();
|
||||
private readonly ISmtpClientWrapper _smtpClient = Substitute.For<ISmtpClientWrapper>();
|
||||
|
||||
private EmailNotificationDeliveryAdapter CreateAdapter()
|
||||
{
|
||||
return new EmailNotificationDeliveryAdapter(
|
||||
_repository,
|
||||
() => _smtpClient,
|
||||
NullLogger<EmailNotificationDeliveryAdapter>.Instance);
|
||||
}
|
||||
|
||||
private static Notification MakeNotification(string listName = "ops-team")
|
||||
{
|
||||
return new Notification(
|
||||
Guid.NewGuid().ToString(),
|
||||
NotificationType.Email,
|
||||
listName,
|
||||
"Subject",
|
||||
"Body",
|
||||
"site-1");
|
||||
}
|
||||
|
||||
private void SetupHappyPath()
|
||||
{
|
||||
var list = new NotificationList("ops-team") { Id = 1 };
|
||||
var recipients = new List<NotificationRecipient>
|
||||
{
|
||||
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 },
|
||||
new("Bob", "bob@example.com") { Id = 2, NotificationListId = 1 }
|
||||
};
|
||||
var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||
{
|
||||
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
|
||||
};
|
||||
|
||||
_repository.GetListByNameAsync("ops-team").Returns(list);
|
||||
_repository.GetRecipientsByListIdAsync(1).Returns(recipients);
|
||||
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { smtpConfig });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Type_IsEmail()
|
||||
{
|
||||
Assert.Equal(NotificationType.Email, CreateAdapter().Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_HappyPath_ReturnsSuccessWithResolvedTargets()
|
||||
{
|
||||
SetupHappyPath();
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||
Assert.NotNull(outcome.ResolvedTargets);
|
||||
Assert.Contains("alice@example.com", outcome.ResolvedTargets);
|
||||
Assert.Contains("bob@example.com", outcome.ResolvedTargets);
|
||||
Assert.Null(outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_ListNotFound_ReturnsPermanent()
|
||||
{
|
||||
_repository.GetListByNameAsync("missing").Returns((NotificationList?)null);
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification("missing"));
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains("missing", outcome.Error);
|
||||
Assert.Contains("not found", outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_NoRecipients_ReturnsPermanent()
|
||||
{
|
||||
var list = new NotificationList("ops-team") { Id = 1 };
|
||||
_repository.GetListByNameAsync("ops-team").Returns(list);
|
||||
_repository.GetRecipientsByListIdAsync(1).Returns(new List<NotificationRecipient>());
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains("recipient", outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_NoSmtpConfig_ReturnsPermanent()
|
||||
{
|
||||
var list = new NotificationList("ops-team") { Id = 1 };
|
||||
_repository.GetListByNameAsync("ops-team").Returns(list);
|
||||
_repository.GetRecipientsByListIdAsync(1).Returns(new List<NotificationRecipient>
|
||||
{
|
||||
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
|
||||
});
|
||||
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration>());
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains("SMTP", outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_SmtpPermanentException_ReturnsPermanent()
|
||||
{
|
||||
SetupHappyPath();
|
||||
_smtpClient
|
||||
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new SmtpPermanentException("550 mailbox unavailable"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains("mailbox unavailable", outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_Smtp5xxCommandException_ReturnsPermanent()
|
||||
{
|
||||
SetupHappyPath();
|
||||
_smtpClient
|
||||
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new SmtpCommandException(
|
||||
SmtpErrorCode.RecipientNotAccepted, SmtpStatusCode.MailboxUnavailable, "rejected"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_SocketException_ReturnsTransient()
|
||||
{
|
||||
SetupHappyPath();
|
||||
_smtpClient
|
||||
.ConnectAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<SmtpTlsMode>(),
|
||||
Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new SocketException((int)SocketError.ConnectionRefused));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||
Assert.NotNull(outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_Smtp4xxCommandException_ReturnsTransient()
|
||||
{
|
||||
SetupHappyPath();
|
||||
_smtpClient
|
||||
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new SmtpCommandException(
|
||||
SmtpErrorCode.MessageNotAccepted, SmtpStatusCode.MailboxBusy, "try later"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_UnclassifiedException_ReturnsPermanent()
|
||||
{
|
||||
SetupHappyPath();
|
||||
_smtpClient
|
||||
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("token endpoint unreachable"));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.NotNull(outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_MalformedRecipientAddress_ReturnsPermanent()
|
||||
{
|
||||
const string malformed = "bad-recipient@";
|
||||
var list = new NotificationList("ops-team") { Id = 1 };
|
||||
_repository.GetListByNameAsync("ops-team").Returns(list);
|
||||
_repository.GetRecipientsByListIdAsync(1).Returns(new List<NotificationRecipient>
|
||||
{
|
||||
new("Mallory", malformed) { Id = 1, NotificationListId = 1 }
|
||||
});
|
||||
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration>
|
||||
{
|
||||
new("smtp.example.com", "basic", "noreply@example.com")
|
||||
{
|
||||
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
|
||||
}
|
||||
});
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||
|
||||
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||
Assert.Contains(malformed, outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deliver_CancelledToken_ThrowsOperationCanceledException()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// List/recipient/SMTP-config resolution must succeed for any token so that
|
||||
// delivery proceeds far enough to observe the cancellation at the SMTP call.
|
||||
var list = new NotificationList("ops-team") { Id = 1 };
|
||||
_repository.GetListByNameAsync("ops-team", Arg.Any<CancellationToken>()).Returns(list);
|
||||
_repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<NotificationRecipient>
|
||||
{
|
||||
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
|
||||
});
|
||||
_repository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<SmtpConfiguration>
|
||||
{
|
||||
new("smtp.example.com", "basic", "noreply@example.com")
|
||||
{
|
||||
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
|
||||
}
|
||||
});
|
||||
|
||||
// The connect call honours the cancelled token, mirroring a real SMTP client.
|
||||
_smtpClient
|
||||
.ConnectAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<SmtpTlsMode>(),
|
||||
Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new OperationCanceledException(cts.Token));
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => adapter.DeliverAsync(MakeNotification(), cts.Token));
|
||||
}
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.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.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// M4 Bundle B (B2) — 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
|
||||
/// the delivery outcome (success, transient, permanent). Terminal-state
|
||||
/// emission is covered separately in
|
||||
/// <see cref="NotificationOutboxActorTerminalEmissionTests"/>.
|
||||
/// </summary>
|
||||
public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
{
|
||||
private readonly INotificationOutboxRepository _outboxRepository =
|
||||
Substitute.For<INotificationOutboxRepository>();
|
||||
|
||||
private readonly INotificationRepository _notificationRepository =
|
||||
Substitute.For<INotificationRepository>();
|
||||
|
||||
private readonly RecordingCentralAuditWriter _auditWriter = new();
|
||||
|
||||
/// <summary>
|
||||
/// Recording writer so each test can assert on the events captured during
|
||||
/// one dispatch tick without depending on a concrete implementation.
|
||||
/// </summary>
|
||||
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
public List<AuditEvent> Events { get; } = new();
|
||||
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
||||
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events)
|
||||
{
|
||||
Events.Add(evt);
|
||||
}
|
||||
|
||||
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private IServiceProvider BuildServiceProvider(IEnumerable<INotificationDeliveryAdapter> adapters)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => _outboxRepository);
|
||||
services.AddScoped(_ => _notificationRepository);
|
||||
foreach (var adapter in adapters)
|
||||
{
|
||||
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
|
||||
}
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private sealed class StubAdapter : INotificationDeliveryAdapter
|
||||
{
|
||||
private readonly Func<DeliveryOutcome> _outcome;
|
||||
public int CallCount;
|
||||
|
||||
public StubAdapter(Func<DeliveryOutcome> outcome) { _outcome = outcome; }
|
||||
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
|
||||
public Task<DeliveryOutcome> DeliverAsync(
|
||||
Notification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref CallCount);
|
||||
return Task.FromResult(_outcome());
|
||||
}
|
||||
}
|
||||
|
||||
private IActorRef CreateActor(IEnumerable<INotificationDeliveryAdapter> adapters)
|
||||
{
|
||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||
BuildServiceProvider(adapters),
|
||||
new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||
(ICentralAuditWriter)_auditWriter,
|
||||
NullLogger<NotificationOutboxActor>.Instance)));
|
||||
}
|
||||
|
||||
private static Notification MakeNotification(
|
||||
Guid? notificationId = null,
|
||||
string sourceSite = "site-1",
|
||||
int retryCount = 0,
|
||||
Guid? originExecutionId = null,
|
||||
Guid? originParentExecutionId = null)
|
||||
{
|
||||
return new Notification(
|
||||
(notificationId ?? Guid.NewGuid()).ToString("D"),
|
||||
NotificationType.Email,
|
||||
"ops-team",
|
||||
"Tank overflow",
|
||||
"Tank 3 level critical",
|
||||
sourceSite)
|
||||
{
|
||||
RetryCount = retryCount,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SourceInstanceId = "instance-42",
|
||||
SourceScript = "AlarmScript",
|
||||
OriginExecutionId = originExecutionId,
|
||||
OriginParentExecutionId = originParentExecutionId,
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
private List<AuditEvent> EventsByStatus(AuditStatus status)
|
||||
{
|
||||
lock (_auditWriter.Events)
|
||||
{
|
||||
return _auditWriter.Events.Where(e => e.Status == status).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attempt_Success_EmitsOneEvent_KindNotifyDeliver_StatusAttempted()
|
||||
{
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var id = Guid.NewGuid();
|
||||
var notification = MakeNotification(notificationId: id, sourceSite: "site-alpha");
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
var evt = attempted[0];
|
||||
Assert.Equal(AuditChannel.Notification, evt.Channel);
|
||||
Assert.Equal(AuditKind.NotifyDeliver, evt.Kind);
|
||||
Assert.Equal(id, evt.CorrelationId);
|
||||
Assert.Equal("ops-team", evt.Target);
|
||||
Assert.Equal("site-alpha", evt.SourceSiteId);
|
||||
Assert.Equal("instance-42", evt.SourceInstanceId);
|
||||
Assert.Equal("AlarmScript", evt.SourceScript);
|
||||
// Central dispatch: Actor is the system identity (no per-call user).
|
||||
Assert.Equal("system", evt.Actor);
|
||||
// Successful attempt: no error message.
|
||||
Assert.Null(evt.ErrorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attempt_CarriesOriginExecutionId_AsExecutionId()
|
||||
{
|
||||
// Audit Log #23: 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();
|
||||
var notification = MakeNotification(originExecutionId: executionId);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
Assert.Equal(executionId, attempted[0].ExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attempt_NullOriginExecutionId_HasNullExecutionId()
|
||||
{
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var notification = MakeNotification(originExecutionId: null);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
Assert.Null(attempted[0].ExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attempt_CarriesOriginParentExecutionId_AsParentExecutionId()
|
||||
{
|
||||
// Audit Log ParentExecutionId: the Attempted NotifyDeliver row must echo
|
||||
// the notification's OriginParentExecutionId so the central dispatcher's
|
||||
// rows carry the routed run's parent id.
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
var notification = MakeNotification(originParentExecutionId: parentExecutionId);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
Assert.Equal(parentExecutionId, attempted[0].ParentExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attempt_NullOriginParentExecutionId_HasNullParentExecutionId()
|
||||
{
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var notification = MakeNotification(originParentExecutionId: null);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
Assert.Null(attempted[0].ParentExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
|
||||
{
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
Assert.Equal(AuditKind.NotifyDeliver, attempted[0].Kind);
|
||||
Assert.Equal("smtp timeout", attempted[0].ErrorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attempt_PermanentFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
|
||||
{
|
||||
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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
Assert.Equal(AuditKind.NotifyDeliver, attempted[0].Kind);
|
||||
Assert.Equal("invalid recipient address", attempted[0].ErrorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditWriter_Throws_DeliveryStateUpdate_StillSucceeds()
|
||||
{
|
||||
// Audit failure must NEVER abort the user-facing action: the delivery
|
||||
// outcome must still be persisted via UpdateAsync.
|
||||
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"));
|
||||
_auditWriter.OnWrite = _ => throw new InvalidOperationException("audit dead");
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
// Update of the notification row must still happen.
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
_outboxRepository.Received(1).UpdateAsync(
|
||||
Arg.Is<Notification>(n => n.Status == NotificationStatus.Delivered),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attempt_RecordsOccurredAtUtc_AsUtc()
|
||||
{
|
||||
// The OccurredAtUtc on the emitted event must be UTC (all timestamps
|
||||
// are UTC throughout the system).
|
||||
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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
Assert.Equal(DateTimeKind.Utc, attempted[0].OccurredAtUtc.Kind);
|
||||
});
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.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;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// M4 Bundle B (B1) — 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.
|
||||
/// </summary>
|
||||
public class NotificationOutboxActorAuditInjectionTests : TestKit
|
||||
{
|
||||
private static IServiceProvider BuildEmptyProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => Substitute.For<INotificationOutboxRepository>());
|
||||
services.AddScoped(_ => Substitute.For<INotificationRepository>());
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline NoOp writer that records calls — used to assert later tasks emit
|
||||
/// events without depending on a concrete CentralAuditWriter.
|
||||
/// </summary>
|
||||
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
public List<AuditEvent> Events { get; } = new();
|
||||
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events)
|
||||
{
|
||||
Events.Add(evt);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Actor_ConstructedWith_ICentralAuditWriter_NoException()
|
||||
{
|
||||
var writer = new RecordingCentralAuditWriter();
|
||||
// Long dispatch interval so PreStart's timer never fires during the test.
|
||||
var options = new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) };
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||
BuildEmptyProvider(),
|
||||
options,
|
||||
writer,
|
||||
NullLogger<NotificationOutboxActor>.Instance)));
|
||||
|
||||
Assert.NotNull(actor);
|
||||
// No event has been emitted yet — the writer is purely injected at this stage.
|
||||
lock (writer.Events)
|
||||
{
|
||||
Assert.Empty(writer.Events);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Actor_NullAuditWriter_Throws()
|
||||
{
|
||||
var options = new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) };
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => new NotificationOutboxActor(
|
||||
BuildEmptyProvider(),
|
||||
options,
|
||||
auditWriter: null!,
|
||||
NullLogger<NotificationOutboxActor>.Instance));
|
||||
}
|
||||
}
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
|
||||
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
|
||||
/// 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(
|
||||
IEnumerable<INotificationDeliveryAdapter> adapters)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => _outboxRepository);
|
||||
services.AddScoped(_ => _notificationRepository);
|
||||
// The actor resolves the channel adapters from its per-sweep DI scope; register
|
||||
// each stub adapter under the INotificationDeliveryAdapter service.
|
||||
foreach (var adapter in adapters)
|
||||
{
|
||||
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
|
||||
}
|
||||
|
||||
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(
|
||||
IEnumerable<INotificationDeliveryAdapter> adapters,
|
||||
NotificationOutboxOptions? options = null)
|
||||
{
|
||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||
BuildServiceProvider(adapters),
|
||||
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||
new NoOpCentralAuditWriter(),
|
||||
NullLogger<NotificationOutboxActor>.Instance)));
|
||||
}
|
||||
|
||||
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([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([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([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([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([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 });
|
||||
// No adapters registered: none resolves for the notification's type.
|
||||
var actor = CreateActor([]);
|
||||
|
||||
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([]);
|
||||
|
||||
// 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 TransientFailure_WithZeroMaxRetries_RetriesUsingFallback_DoesNotParkImmediately()
|
||||
{
|
||||
// NO-002: 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.
|
||||
SetupSmtpRetryPolicy(maxRetries: 0, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var notification = MakeNotification(retryCount: 0);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
_outboxRepository.Received(1).UpdateAsync(
|
||||
Arg.Is<Notification>(n =>
|
||||
n.Status == NotificationStatus.Retrying &&
|
||||
n.RetryCount == 1 &&
|
||||
n.NextAttemptAt != null &&
|
||||
n.LastError == "smtp timeout"),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransientFailure_WithNegativeMaxRetries_RetriesUsingFallback_DoesNotParkImmediately()
|
||||
{
|
||||
// NO-002: 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);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
_outboxRepository.Received(1).UpdateAsync(
|
||||
Arg.Is<Notification>(n =>
|
||||
n.Status == NotificationStatus.Retrying &&
|
||||
n.RetryCount == 1 &&
|
||||
n.NextAttemptAt != null &&
|
||||
n.LastError == "smtp timeout"),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransientFailure_WithNonPositiveRetryDelay_UsesFallbackDelay_NotZero()
|
||||
{
|
||||
// NO-002: 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);
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
var notification = MakeNotification(retryCount: 0);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
_outboxRepository.Received(1).UpdateAsync(
|
||||
Arg.Is<Notification>(n =>
|
||||
n.Status == NotificationStatus.Retrying &&
|
||||
n.NextAttemptAt != null &&
|
||||
n.NextAttemptAt > before + TimeSpan.FromSeconds(30)),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostStop_CancelsInFlightDelivery_LeavesRowNonTerminal()
|
||||
{
|
||||
// NO-003: 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
|
||||
// PostStop must abort the in-flight Task.Delay (standing in for an SMTP
|
||||
// send) and the row must NOT be updated to a terminal state — the next
|
||||
// active node picks it back up.
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var notification = MakeNotification();
|
||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
// Long delay simulates a slow SMTP send; the test triggers PostStop before
|
||||
// the delay would naturally elapse, so the only way the delay completes is
|
||||
// if the token wired through.
|
||||
var adapter = new StubAdapter(
|
||||
() => DeliveryOutcome.Success("ops@example.com"),
|
||||
delay: TimeSpan.FromSeconds(30));
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
// Wait until the adapter is actually in flight before stopping.
|
||||
AwaitAssert(() => Assert.Equal(1, adapter.CallCount));
|
||||
|
||||
var start = DateTimeOffset.UtcNow;
|
||||
Sys.Stop(actor);
|
||||
|
||||
// The sweep should observe cancellation promptly (well under the 30s delay).
|
||||
AwaitAssert(
|
||||
() =>
|
||||
{
|
||||
// No UpdateAsync was issued — the row is untouched and will be re-claimed
|
||||
// by the next active node.
|
||||
_outboxRepository.DidNotReceive().UpdateAsync(
|
||||
Arg.Any<Notification>(), Arg.Any<CancellationToken>());
|
||||
},
|
||||
duration: TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(DateTimeOffset.UtcNow - start < TimeSpan.FromSeconds(5),
|
||||
"PostStop did not cancel the in-flight delivery promptly.");
|
||||
}
|
||||
|
||||
// ── NotificationOutbox-006: adapter dictionary cached for the actor's lifetime ──
|
||||
|
||||
[Fact]
|
||||
public void Dispatch_ResolvesAdaptersOnce_AcrossMultipleSweeps()
|
||||
{
|
||||
// NotificationOutbox-006: adapter registration is static per process lifetime,
|
||||
// so the NotificationType -> adapter lookup must be built ONCE for the actor's
|
||||
// lifetime, not per dispatch sweep. The cache is paired with an actor-lifetime
|
||||
// DI scope (see _adaptersScope) so scoped adapter instances are reused safely.
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
// Isolated substitutes for this test — we replace the dispatcher's per-sweep
|
||||
// INotificationOutboxRepository registration with a private counting factory,
|
||||
// so we don't mutate the shared _outboxRepository field that other tests in
|
||||
// this class configure differently.
|
||||
var outboxRepository = Substitute.For<INotificationOutboxRepository>();
|
||||
outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => new[] { MakeNotification() });
|
||||
|
||||
// Counting factory: increments each time the DI container resolves an
|
||||
// INotificationDeliveryAdapter. Pre-fix this would have ticked once per
|
||||
// sweep; post-fix it ticks exactly once for the actor's lifetime.
|
||||
var resolutionCount = 0;
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => outboxRepository);
|
||||
services.AddScoped(_ => _notificationRepository);
|
||||
services.AddScoped<INotificationDeliveryAdapter>(_ =>
|
||||
{
|
||||
Interlocked.Increment(ref resolutionCount);
|
||||
return new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||
});
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||
sp,
|
||||
new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||
new NoOpCentralAuditWriter(),
|
||||
NullLogger<NotificationOutboxActor>.Instance)));
|
||||
|
||||
// Fire three sweeps end-to-end. Each waits on the previous via the
|
||||
// in-flight guard, so the UpdateAsync count climbs monotonically.
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
AwaitAssert(() => outboxRepository.Received(1).UpdateAsync(
|
||||
Arg.Any<Notification>(), Arg.Any<CancellationToken>()));
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
AwaitAssert(() => outboxRepository.Received(2).UpdateAsync(
|
||||
Arg.Any<Notification>(), Arg.Any<CancellationToken>()));
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
AwaitAssert(() => outboxRepository.Received(3).UpdateAsync(
|
||||
Arg.Any<Notification>(), Arg.Any<CancellationToken>()));
|
||||
|
||||
// The adapter resolution must have happened EXACTLY ONCE despite three
|
||||
// dispatch sweeps. Pre-fix this would have been 3 (or more).
|
||||
Assert.Equal(1, resolutionCount);
|
||||
}
|
||||
|
||||
[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([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));
|
||||
}
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
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
|
||||
/// <see cref="Notification"/> from a <see cref="NotificationSubmit"/>, persisting it via
|
||||
/// <see cref="INotificationOutboxRepository.InsertIfNotExistsAsync"/>, and acking the sender.
|
||||
/// </summary>
|
||||
public class NotificationOutboxActorIngestTests : TestKit
|
||||
{
|
||||
private readonly INotificationOutboxRepository _repository =
|
||||
Substitute.For<INotificationOutboxRepository>();
|
||||
|
||||
private IServiceProvider BuildServiceProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => _repository);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private IActorRef CreateActor()
|
||||
{
|
||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||
BuildServiceProvider(),
|
||||
new NotificationOutboxOptions(),
|
||||
new NoOpCentralAuditWriter(),
|
||||
NullLogger<NotificationOutboxActor>.Instance)));
|
||||
}
|
||||
|
||||
private static NotificationSubmit MakeSubmit(
|
||||
string? notificationId = null,
|
||||
Guid? originExecutionId = null,
|
||||
Guid? originParentExecutionId = null,
|
||||
string? sourceNode = null)
|
||||
{
|
||||
return new NotificationSubmit(
|
||||
NotificationId: notificationId ?? Guid.NewGuid().ToString(),
|
||||
ListName: "ops-team",
|
||||
Subject: "Tank overflow",
|
||||
Body: "Tank 3 level critical",
|
||||
SourceSiteId: "site-1",
|
||||
SourceInstanceId: "instance-42",
|
||||
SourceScript: "AlarmScript",
|
||||
SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero),
|
||||
OriginExecutionId: originExecutionId,
|
||||
OriginParentExecutionId: originParentExecutionId,
|
||||
SourceNode: sourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_PersistsMappedNotification_AndAcksAccepted()
|
||||
{
|
||||
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
var submit = MakeSubmit();
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
var ack = ExpectMsg<NotificationSubmitAck>();
|
||||
Assert.Equal(submit.NotificationId, ack.NotificationId);
|
||||
Assert.True(ack.Accepted);
|
||||
Assert.Null(ack.Error);
|
||||
|
||||
_repository.Received(1).InsertIfNotExistsAsync(
|
||||
Arg.Is<Notification>(n =>
|
||||
n.NotificationId == submit.NotificationId &&
|
||||
n.Type == NotificationType.Email &&
|
||||
n.ListName == submit.ListName &&
|
||||
n.Subject == submit.Subject &&
|
||||
n.Body == submit.Body &&
|
||||
n.SourceSiteId == submit.SourceSiteId &&
|
||||
n.SourceInstanceId == submit.SourceInstanceId &&
|
||||
n.SourceScript == submit.SourceScript &&
|
||||
n.SiteEnqueuedAt == submit.SiteEnqueuedAt &&
|
||||
n.Status == NotificationStatus.Pending &&
|
||||
n.CreatedAt != default),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_CopiesOriginExecutionId_OntoPersistedNotification()
|
||||
{
|
||||
// Audit Log #23: 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>())
|
||||
.Returns(true);
|
||||
var executionId = Guid.NewGuid();
|
||||
var submit = MakeSubmit(originExecutionId: executionId);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
ExpectMsg<NotificationSubmitAck>();
|
||||
_repository.Received(1).InsertIfNotExistsAsync(
|
||||
Arg.Is<Notification>(n => n.OriginExecutionId == executionId),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_NullOriginExecutionId_PersistsNull()
|
||||
{
|
||||
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
var submit = MakeSubmit(originExecutionId: null);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
ExpectMsg<NotificationSubmitAck>();
|
||||
_repository.Received(1).InsertIfNotExistsAsync(
|
||||
Arg.Is<Notification>(n => n.OriginExecutionId == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_CopiesOriginParentExecutionId_OntoPersistedNotification()
|
||||
{
|
||||
// Audit Log ParentExecutionId: the routed run's parent ExecutionId 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>())
|
||||
.Returns(true);
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
var submit = MakeSubmit(originParentExecutionId: parentExecutionId);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
ExpectMsg<NotificationSubmitAck>();
|
||||
_repository.Received(1).InsertIfNotExistsAsync(
|
||||
Arg.Is<Notification>(n => n.OriginParentExecutionId == parentExecutionId),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_NullOriginParentExecutionId_PersistsNull()
|
||||
{
|
||||
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
var submit = MakeSubmit(originParentExecutionId: null);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
ExpectMsg<NotificationSubmitAck>();
|
||||
_repository.Received(1).InsertIfNotExistsAsync(
|
||||
Arg.Is<Notification>(n => n.OriginParentExecutionId == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted()
|
||||
{
|
||||
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
var submit = MakeSubmit();
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
var ack = ExpectMsg<NotificationSubmitAck>();
|
||||
Assert.Equal(submit.NotificationId, ack.NotificationId);
|
||||
Assert.True(ack.Accepted);
|
||||
Assert.Null(ack.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepositoryThrows_AcksNotAcceptedWithError()
|
||||
{
|
||||
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("database unavailable"));
|
||||
var submit = MakeSubmit();
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
var ack = ExpectMsg<NotificationSubmitAck>();
|
||||
Assert.Equal(submit.NotificationId, ack.NotificationId);
|
||||
Assert.False(ack.Accepted);
|
||||
Assert.NotNull(ack.Error);
|
||||
Assert.Contains("database unavailable", ack.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_CopiesSourceNode_OntoPersistedNotification()
|
||||
{
|
||||
// SourceNode-stamping (Task 13): 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.
|
||||
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
var submit = MakeSubmit(sourceNode: "node-a");
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
ExpectMsg<NotificationSubmitAck>();
|
||||
_repository.Received(1).InsertIfNotExistsAsync(
|
||||
Arg.Is<Notification>(n => n.SourceNode == "node-a"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_NullSourceNode_PersistsNull()
|
||||
{
|
||||
// Submissions from a host that didn't wire INodeIdentityProvider, or from
|
||||
// pre-SourceNode-stamping clients, carry null SourceNode — the central row must
|
||||
// persist NULL rather than fall back to a placeholder.
|
||||
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
var submit = MakeSubmit(sourceNode: null);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
ExpectMsg<NotificationSubmitAck>();
|
||||
_repository.Received(1).InsertIfNotExistsAsync(
|
||||
Arg.Is<Notification>(n => n.SourceNode == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
|
||||
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
|
||||
/// periodic sweep that bulk-deletes terminal notification rows older than
|
||||
/// <see cref="NotificationOutboxOptions.TerminalRetention"/> via
|
||||
/// <see cref="INotificationOutboxRepository.DeleteTerminalOlderThanAsync"/>.
|
||||
/// </summary>
|
||||
public class NotificationOutboxActorPurgeTests : 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>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<NotificationOutboxActor>.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<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(3);
|
||||
var actor = CreateActor(options);
|
||||
|
||||
var expectedCutoff = DateTimeOffset.UtcNow - retention;
|
||||
actor.Tell(InternalMessages.PurgeTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
_outboxRepository.Received(1).DeleteTerminalOlderThanAsync(
|
||||
Arg.Is<DateTimeOffset>(cutoff =>
|
||||
(cutoff - expectedCutoff).Duration() < TimeSpan.FromMinutes(1)),
|
||||
Arg.Any<CancellationToken>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FaultedPurge_DoesNotCrashActor_AndSubsequentPurgeStillRuns()
|
||||
{
|
||||
var calls = 0;
|
||||
_outboxRepository
|
||||
.DeleteTerminalOlderThanAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.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<DateTimeOffset>(), Arg.Any<CancellationToken>()));
|
||||
|
||||
// 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<DateTimeOffset>(), Arg.Any<CancellationToken>()));
|
||||
}
|
||||
}
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
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
|
||||
/// paginated outbox query, single-notification status query, manual retry and discard
|
||||
/// of parked notifications, and the KPI snapshot.
|
||||
/// </summary>
|
||||
public class NotificationOutboxActorQueryTests : TestKit
|
||||
{
|
||||
private readonly INotificationOutboxRepository _repository =
|
||||
Substitute.For<INotificationOutboxRepository>();
|
||||
|
||||
private IServiceProvider BuildServiceProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => _repository);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private IActorRef CreateActor(NotificationOutboxOptions? options = null)
|
||||
{
|
||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||
BuildServiceProvider(),
|
||||
// A long dispatch interval keeps the dispatch loop from interfering with these tests.
|
||||
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||
new NoOpCentralAuditWriter(),
|
||||
NullLogger<NotificationOutboxActor>.Instance)));
|
||||
}
|
||||
|
||||
private static Notification MakeNotification(
|
||||
NotificationStatus status = NotificationStatus.Pending,
|
||||
DateTimeOffset? createdAt = null,
|
||||
int retryCount = 0,
|
||||
string? lastError = null,
|
||||
DateTimeOffset? deliveredAt = null)
|
||||
{
|
||||
return new Notification(
|
||||
Guid.NewGuid().ToString(), NotificationType.Email, "ops-team", "Subject", "Body", "site-1")
|
||||
{
|
||||
Status = status,
|
||||
CreatedAt = createdAt ?? DateTimeOffset.UtcNow,
|
||||
RetryCount = retryCount,
|
||||
LastError = lastError,
|
||||
DeliveredAt = deliveredAt,
|
||||
SourceInstanceId = "instance-42",
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_PassesFilterFromRequest_AndMapsRowsToSummaries()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var staleRow = MakeNotification(
|
||||
status: NotificationStatus.Pending, createdAt: now - TimeSpan.FromHours(1));
|
||||
var freshRow = MakeNotification(
|
||||
status: NotificationStatus.Pending, createdAt: now);
|
||||
_repository.QueryAsync(
|
||||
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(((IReadOnlyList<Notification>)new[] { staleRow, freshRow }, 2));
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(
|
||||
new NotificationOutboxQueryRequest(
|
||||
CorrelationId: "corr-1",
|
||||
StatusFilter: null,
|
||||
TypeFilter: null,
|
||||
SourceSiteFilter: "site-1",
|
||||
ListNameFilter: "ops-team",
|
||||
StuckOnly: false,
|
||||
SubjectKeyword: "tank",
|
||||
From: null,
|
||||
To: null,
|
||||
PageNumber: 2,
|
||||
PageSize: 25),
|
||||
TestActor);
|
||||
|
||||
var response = ExpectMsg<NotificationOutboxQueryResponse>();
|
||||
Assert.Equal("corr-1", response.CorrelationId);
|
||||
Assert.True(response.Success);
|
||||
Assert.Null(response.ErrorMessage);
|
||||
Assert.Equal(2, response.TotalCount);
|
||||
Assert.Equal(2, response.Notifications.Count);
|
||||
|
||||
_repository.Received(1).QueryAsync(
|
||||
Arg.Is<NotificationOutboxFilter>(f =>
|
||||
f.SourceSiteId == "site-1" &&
|
||||
f.ListName == "ops-team" &&
|
||||
f.SubjectKeyword == "tank" &&
|
||||
f.Status == null &&
|
||||
f.Type == null &&
|
||||
f.StuckOnly == false),
|
||||
2, 25, Arg.Any<CancellationToken>());
|
||||
|
||||
// IsStuck: the hour-old Pending row is stuck, the just-created one is not.
|
||||
var staleSummary = response.Notifications.Single(s => s.NotificationId == staleRow.NotificationId);
|
||||
var freshSummary = response.Notifications.Single(s => s.NotificationId == freshRow.NotificationId);
|
||||
Assert.True(staleSummary.IsStuck);
|
||||
Assert.False(freshSummary.IsStuck);
|
||||
Assert.Equal("Pending", staleSummary.Status);
|
||||
Assert.Equal("Email", staleSummary.Type);
|
||||
Assert.Equal("site-1", staleSummary.SourceSiteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_WithStatusFilterString_ParsesToEnumOnFilter()
|
||||
{
|
||||
_repository.QueryAsync(
|
||||
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(((IReadOnlyList<Notification>)Array.Empty<Notification>(), 0));
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(
|
||||
new NotificationOutboxQueryRequest(
|
||||
CorrelationId: "corr-2",
|
||||
StatusFilter: "Parked",
|
||||
TypeFilter: "Email",
|
||||
SourceSiteFilter: null,
|
||||
ListNameFilter: null,
|
||||
StuckOnly: true,
|
||||
SubjectKeyword: null,
|
||||
From: null,
|
||||
To: null,
|
||||
PageNumber: 1,
|
||||
PageSize: 50),
|
||||
TestActor);
|
||||
|
||||
ExpectMsg<NotificationOutboxQueryResponse>();
|
||||
|
||||
_repository.Received(1).QueryAsync(
|
||||
Arg.Is<NotificationOutboxFilter>(f =>
|
||||
f.Status == NotificationStatus.Parked &&
|
||||
f.Type == NotificationType.Email &&
|
||||
f.StuckOnly == true &&
|
||||
f.StuckCutoff != null),
|
||||
1, 50, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_PassesSourceNodeFilter_AndProjectsSourceNodeOntoSummary()
|
||||
{
|
||||
// Task 16: 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.
|
||||
var row = MakeNotification(status: NotificationStatus.Pending);
|
||||
row.SourceNode = "central-a";
|
||||
_repository.QueryAsync(
|
||||
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(((IReadOnlyList<Notification>)new[] { row }, 1));
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(
|
||||
new NotificationOutboxQueryRequest(
|
||||
CorrelationId: "corr-node",
|
||||
StatusFilter: null,
|
||||
TypeFilter: null,
|
||||
SourceSiteFilter: null,
|
||||
ListNameFilter: null,
|
||||
StuckOnly: false,
|
||||
SubjectKeyword: null,
|
||||
From: null,
|
||||
To: null,
|
||||
PageNumber: 1,
|
||||
PageSize: 50,
|
||||
SourceNodeFilter: "central-a"),
|
||||
TestActor);
|
||||
|
||||
var response = ExpectMsg<NotificationOutboxQueryResponse>();
|
||||
Assert.True(response.Success);
|
||||
Assert.Equal("central-a", response.Notifications.Single().SourceNode);
|
||||
|
||||
_repository.Received(1).QueryAsync(
|
||||
Arg.Is<NotificationOutboxFilter>(f => f.SourceNode == "central-a"),
|
||||
1, 50, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_RepositoryThrows_RepliesFailureWithEmptyList()
|
||||
{
|
||||
_repository.QueryAsync(
|
||||
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("db down"));
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(
|
||||
new NotificationOutboxQueryRequest(
|
||||
"corr-err", null, null, null, null, false, null, null, null, 1, 50),
|
||||
TestActor);
|
||||
|
||||
var response = ExpectMsg<NotificationOutboxQueryResponse>();
|
||||
Assert.Equal("corr-err", response.CorrelationId);
|
||||
Assert.False(response.Success);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
Assert.Contains("db down", response.ErrorMessage);
|
||||
Assert.Empty(response.Notifications);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusQuery_Found_RepliesWithRowDetail()
|
||||
{
|
||||
var row = MakeNotification(
|
||||
status: NotificationStatus.Retrying, retryCount: 3, lastError: "smtp timeout");
|
||||
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new NotificationStatusQuery("corr-3", row.NotificationId), TestActor);
|
||||
|
||||
var response = ExpectMsg<NotificationStatusResponse>();
|
||||
Assert.Equal("corr-3", response.CorrelationId);
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Retrying", response.Status);
|
||||
Assert.Equal(3, response.RetryCount);
|
||||
Assert.Equal("smtp timeout", response.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusQuery_NotFound_RepliesFoundFalse()
|
||||
{
|
||||
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns((Notification?)null);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new NotificationStatusQuery("corr-4", "missing-id"), TestActor);
|
||||
|
||||
var response = ExpectMsg<NotificationStatusResponse>();
|
||||
Assert.Equal("corr-4", response.CorrelationId);
|
||||
Assert.False(response.Found);
|
||||
Assert.Equal(string.Empty, response.Status);
|
||||
Assert.Equal(0, response.RetryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retry_ParkedNotification_ResetsToPending_AndSucceeds()
|
||||
{
|
||||
var row = MakeNotification(status: NotificationStatus.Parked, retryCount: 10, lastError: "gave up");
|
||||
row.NextAttemptAt = DateTimeOffset.UtcNow;
|
||||
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new RetryNotificationRequest("corr-5", row.NotificationId), TestActor);
|
||||
|
||||
var response = ExpectMsg<RetryNotificationResponse>();
|
||||
Assert.Equal("corr-5", response.CorrelationId);
|
||||
Assert.True(response.Success);
|
||||
Assert.Null(response.ErrorMessage);
|
||||
|
||||
_repository.Received(1).UpdateAsync(
|
||||
Arg.Is<Notification>(n =>
|
||||
n.NotificationId == row.NotificationId &&
|
||||
n.Status == NotificationStatus.Pending &&
|
||||
n.RetryCount == 0 &&
|
||||
n.NextAttemptAt == null &&
|
||||
n.LastError == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retry_NonParkedNotification_Fails()
|
||||
{
|
||||
var row = MakeNotification(status: NotificationStatus.Delivered);
|
||||
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new RetryNotificationRequest("corr-6", row.NotificationId), TestActor);
|
||||
|
||||
var response = ExpectMsg<RetryNotificationResponse>();
|
||||
Assert.False(response.Success);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
_repository.DidNotReceive().UpdateAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retry_MissingNotification_Fails()
|
||||
{
|
||||
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns((Notification?)null);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new RetryNotificationRequest("corr-7", "missing-id"), TestActor);
|
||||
|
||||
var response = ExpectMsg<RetryNotificationResponse>();
|
||||
Assert.False(response.Success);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
Assert.Contains("not found", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Discard_ParkedNotification_MarksDiscarded_AndSucceeds()
|
||||
{
|
||||
var row = MakeNotification(status: NotificationStatus.Parked);
|
||||
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new DiscardNotificationRequest("corr-8", row.NotificationId), TestActor);
|
||||
|
||||
var response = ExpectMsg<DiscardNotificationResponse>();
|
||||
Assert.Equal("corr-8", response.CorrelationId);
|
||||
Assert.True(response.Success);
|
||||
|
||||
_repository.Received(1).UpdateAsync(
|
||||
Arg.Is<Notification>(n => n.Status == NotificationStatus.Discarded),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Discard_NonParkedNotification_Fails()
|
||||
{
|
||||
var row = MakeNotification(status: NotificationStatus.Pending);
|
||||
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new DiscardNotificationRequest("corr-9", row.NotificationId), TestActor);
|
||||
|
||||
var response = ExpectMsg<DiscardNotificationResponse>();
|
||||
Assert.False(response.Success);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
_repository.DidNotReceive().UpdateAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Discard_MissingNotification_Fails()
|
||||
{
|
||||
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns((Notification?)null);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new DiscardNotificationRequest("corr-10", "missing-id"), TestActor);
|
||||
|
||||
var response = ExpectMsg<DiscardNotificationResponse>();
|
||||
Assert.False(response.Success);
|
||||
Assert.Contains("not found", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetailRequest_KnownId_ReturnsFullDetail_WithBodyAndResolvedTargets()
|
||||
{
|
||||
var row = MakeNotification(
|
||||
status: NotificationStatus.Delivered, retryCount: 2, lastError: "transient blip");
|
||||
row.Body = "Tank-7 has exceeded its high-level setpoint.";
|
||||
row.ResolvedTargets = "[\"ops@example.com\",\"oncall@example.com\"]";
|
||||
row.TypeData = "{\"priority\":\"high\"}";
|
||||
row.SourceScript = "HighLevelAlarm.csx";
|
||||
row.SourceNode = "node-a";
|
||||
row.SiteEnqueuedAt = DateTimeOffset.UtcNow.AddMinutes(-5);
|
||||
row.DeliveredAt = DateTimeOffset.UtcNow;
|
||||
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new NotificationDetailRequest("corr-d1", row.NotificationId), TestActor);
|
||||
|
||||
var response = ExpectMsg<NotificationDetailResponse>();
|
||||
Assert.Equal("corr-d1", response.CorrelationId);
|
||||
Assert.True(response.Success);
|
||||
Assert.Null(response.ErrorMessage);
|
||||
Assert.NotNull(response.Detail);
|
||||
var detail = response.Detail!;
|
||||
Assert.Equal(row.NotificationId, detail.NotificationId);
|
||||
Assert.Equal("Email", detail.Type);
|
||||
Assert.Equal("Delivered", detail.Status);
|
||||
Assert.Equal("Tank-7 has exceeded its high-level setpoint.", detail.Body);
|
||||
Assert.Equal("[\"ops@example.com\",\"oncall@example.com\"]", detail.ResolvedTargets);
|
||||
Assert.Equal("{\"priority\":\"high\"}", detail.TypeData);
|
||||
Assert.Equal("HighLevelAlarm.csx", detail.SourceScript);
|
||||
Assert.Equal("instance-42", detail.SourceInstanceId);
|
||||
Assert.Equal(2, detail.RetryCount);
|
||||
Assert.Equal("transient blip", detail.LastError);
|
||||
// SourceNode flows through the detail projection so the report detail
|
||||
// modal binds uniformly to the detail record (was previously read off
|
||||
// the summary).
|
||||
Assert.Equal("node-a", detail.SourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetailRequest_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns((Notification?)null);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new NotificationDetailRequest("corr-d2", "missing-id"), TestActor);
|
||||
|
||||
var response = ExpectMsg<NotificationDetailResponse>();
|
||||
Assert.Equal("corr-d2", response.CorrelationId);
|
||||
Assert.False(response.Success);
|
||||
Assert.Null(response.Detail);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
Assert.Contains("not found", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KpiRequest_ComputesKpis_AndMapsSnapshot()
|
||||
{
|
||||
var snapshot = new NotificationKpiSnapshot(
|
||||
QueueDepth: 7,
|
||||
StuckCount: 2,
|
||||
ParkedCount: 3,
|
||||
DeliveredLastInterval: 12,
|
||||
OldestPendingAge: TimeSpan.FromMinutes(4));
|
||||
_repository.ComputeKpisAsync(
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(snapshot);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new NotificationKpiRequest("corr-11"), TestActor);
|
||||
|
||||
var response = ExpectMsg<NotificationKpiResponse>();
|
||||
Assert.Equal("corr-11", response.CorrelationId);
|
||||
Assert.True(response.Success);
|
||||
Assert.Null(response.ErrorMessage);
|
||||
Assert.Equal(7, response.QueueDepth);
|
||||
Assert.Equal(2, response.StuckCount);
|
||||
Assert.Equal(3, response.ParkedCount);
|
||||
Assert.Equal(12, response.DeliveredLastInterval);
|
||||
Assert.Equal(TimeSpan.FromMinutes(4), response.OldestPendingAge);
|
||||
|
||||
_repository.Received(1).ComputeKpisAsync(
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KpiRequest_RepositoryThrows_RepliesFailureResponse()
|
||||
{
|
||||
_repository.ComputeKpisAsync(
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("kpi db down"));
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new NotificationKpiRequest("corr-12"), TestActor);
|
||||
|
||||
// A repository fault yields a failure NotificationKpiResponse, not a Status.Failure.
|
||||
var response = ExpectMsg<NotificationKpiResponse>();
|
||||
Assert.Equal("corr-12", response.CorrelationId);
|
||||
Assert.False(response.Success);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
Assert.Contains("kpi db down", response.ErrorMessage);
|
||||
Assert.Equal(0, response.QueueDepth);
|
||||
Assert.Equal(0, response.StuckCount);
|
||||
Assert.Equal(0, response.ParkedCount);
|
||||
Assert.Equal(0, response.DeliveredLastInterval);
|
||||
Assert.Null(response.OldestPendingAge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerSiteKpiRequest_RepliesWithPerSiteSnapshots()
|
||||
{
|
||||
_repository.ComputePerSiteKpisAsync(
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<SiteNotificationKpiSnapshot>
|
||||
{
|
||||
new("plant-a", 4, 1, 0, 9, TimeSpan.FromMinutes(7)),
|
||||
});
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor);
|
||||
|
||||
var response = ExpectMsg<PerSiteNotificationKpiResponse>();
|
||||
Assert.True(response.Success);
|
||||
Assert.Null(response.ErrorMessage);
|
||||
Assert.Equal("corr-ps", response.CorrelationId);
|
||||
Assert.Single(response.Sites);
|
||||
Assert.Equal("plant-a", response.Sites[0].SourceSiteId);
|
||||
|
||||
_repository.Received(1).ComputePerSiteKpisAsync(
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerSiteKpiRequest_RepositoryFault_RepliesUnsuccessful()
|
||||
{
|
||||
_repository.ComputePerSiteKpisAsync(
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("db down"));
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor);
|
||||
|
||||
var response = ExpectMsg<PerSiteNotificationKpiResponse>();
|
||||
Assert.False(response.Success);
|
||||
Assert.Equal("corr-ps", response.CorrelationId);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
Assert.Contains("db down", response.ErrorMessage);
|
||||
Assert.Empty(response.Sites);
|
||||
}
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.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.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// M4 Bundle B (B3) — 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
|
||||
/// every terminal-state transition. The B2 Attempted row is still emitted
|
||||
/// alongside the terminal one — these tests assert ONLY the terminal row
|
||||
/// presence and status.
|
||||
/// </summary>
|
||||
public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||
{
|
||||
private readonly INotificationOutboxRepository _outboxRepository =
|
||||
Substitute.For<INotificationOutboxRepository>();
|
||||
|
||||
private readonly INotificationRepository _notificationRepository =
|
||||
Substitute.For<INotificationRepository>();
|
||||
|
||||
private readonly RecordingCentralAuditWriter _auditWriter = new();
|
||||
|
||||
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
public List<AuditEvent> Events { get; } = new();
|
||||
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
||||
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events)
|
||||
{
|
||||
Events.Add(evt);
|
||||
}
|
||||
|
||||
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private IServiceProvider BuildServiceProvider(IEnumerable<INotificationDeliveryAdapter> adapters)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => _outboxRepository);
|
||||
services.AddScoped(_ => _notificationRepository);
|
||||
foreach (var adapter in adapters)
|
||||
{
|
||||
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
|
||||
}
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private sealed class StubAdapter : INotificationDeliveryAdapter
|
||||
{
|
||||
private readonly Func<DeliveryOutcome> _outcome;
|
||||
|
||||
public StubAdapter(Func<DeliveryOutcome> outcome) { _outcome = outcome; }
|
||||
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
|
||||
public Task<DeliveryOutcome> DeliverAsync(
|
||||
Notification notification, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_outcome());
|
||||
}
|
||||
|
||||
private IActorRef CreateActor(IEnumerable<INotificationDeliveryAdapter> adapters)
|
||||
{
|
||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||
BuildServiceProvider(adapters),
|
||||
new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||
(ICentralAuditWriter)_auditWriter,
|
||||
NullLogger<NotificationOutboxActor>.Instance)));
|
||||
}
|
||||
|
||||
private static Notification MakeNotification(
|
||||
NotificationStatus status = NotificationStatus.Pending,
|
||||
int retryCount = 0,
|
||||
Guid? notificationId = null,
|
||||
Guid? originExecutionId = null,
|
||||
Guid? originParentExecutionId = null)
|
||||
{
|
||||
return new Notification(
|
||||
(notificationId ?? Guid.NewGuid()).ToString("D"),
|
||||
NotificationType.Email,
|
||||
"ops-team",
|
||||
"Tank overflow",
|
||||
"Tank 3 level critical",
|
||||
"site-1")
|
||||
{
|
||||
Status = status,
|
||||
RetryCount = retryCount,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
OriginExecutionId = originExecutionId,
|
||||
OriginParentExecutionId = originParentExecutionId,
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
private List<AuditEvent> EventsByStatus(AuditStatus status)
|
||||
{
|
||||
lock (_auditWriter.Events)
|
||||
{
|
||||
return _auditWriter.Events.Where(e => e.Status == status).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Delivered_EmitsEvent_StatusDelivered()
|
||||
{
|
||||
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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var delivered = EventsByStatus(AuditStatus.Delivered);
|
||||
Assert.Single(delivered);
|
||||
var evt = delivered[0];
|
||||
Assert.Equal(AuditChannel.Notification, evt.Channel);
|
||||
Assert.Equal(AuditKind.NotifyDeliver, evt.Kind);
|
||||
Assert.Equal("ops-team", evt.Target);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Delivered_CarriesOriginExecutionId_AsExecutionId()
|
||||
{
|
||||
// Audit Log #23: 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));
|
||||
var executionId = Guid.NewGuid();
|
||||
var notification = MakeNotification(originExecutionId: executionId);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var delivered = EventsByStatus(AuditStatus.Delivered);
|
||||
Assert.Single(delivered);
|
||||
Assert.Equal(executionId, delivered[0].ExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Delivered_NullOriginExecutionId_HasNullExecutionId()
|
||||
{
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var notification = MakeNotification(originExecutionId: null);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var delivered = EventsByStatus(AuditStatus.Delivered);
|
||||
Assert.Single(delivered);
|
||||
Assert.Null(delivered[0].ExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Delivered_CarriesOriginParentExecutionId_AsParentExecutionId()
|
||||
{
|
||||
// Audit Log ParentExecutionId: the terminal NotifyDeliver row must echo
|
||||
// the notification's OriginParentExecutionId so the central dispatcher's
|
||||
// rows carry the routed run's parent id.
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
var notification = MakeNotification(originParentExecutionId: parentExecutionId);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var delivered = EventsByStatus(AuditStatus.Delivered);
|
||||
Assert.Single(delivered);
|
||||
Assert.Equal(parentExecutionId, delivered[0].ParentExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Delivered_NullOriginParentExecutionId_HasNullParentExecutionId()
|
||||
{
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var notification = MakeNotification(originParentExecutionId: null);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var delivered = EventsByStatus(AuditStatus.Delivered);
|
||||
Assert.Single(delivered);
|
||||
Assert.Null(delivered[0].ParentExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked()
|
||||
{
|
||||
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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var parked = EventsByStatus(AuditStatus.Parked);
|
||||
Assert.Single(parked);
|
||||
Assert.Equal(AuditKind.NotifyDeliver, parked[0].Kind);
|
||||
Assert.Equal("invalid recipient address", parked[0].ErrorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Parked_CarriesOriginExecutionId_AsExecutionId()
|
||||
{
|
||||
// Audit Log #23: 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.
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var executionId = Guid.NewGuid();
|
||||
var notification = MakeNotification(originExecutionId: executionId);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var parked = EventsByStatus(AuditStatus.Parked);
|
||||
Assert.Single(parked);
|
||||
Assert.Equal(executionId, parked[0].ExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[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
|
||||
// which triggers the Parked terminal transition.
|
||||
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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var parked = EventsByStatus(AuditStatus.Parked);
|
||||
Assert.Single(parked);
|
||||
Assert.Equal(AuditKind.NotifyDeliver, parked[0].Kind);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Parked_OnMissingAdapter_EmitsEvent_StatusParked()
|
||||
{
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var notification = MakeNotification();
|
||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
// No adapters registered: the missing-adapter park path runs.
|
||||
var actor = CreateActor([]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var parked = EventsByStatus(AuditStatus.Parked);
|
||||
Assert.Single(parked);
|
||||
Assert.Equal(AuditKind.NotifyDeliver, parked[0].Kind);
|
||||
Assert.Contains("no delivery adapter", parked[0].ErrorMessage!);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transient_BelowMaxRetries_DoesNotEmitTerminalRow()
|
||||
{
|
||||
// A transient failure that does not reach max-retries leaves the row
|
||||
// in Retrying — non-terminal, so no terminal audit row should be
|
||||
// emitted (only the Attempted row from B2).
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var notification = MakeNotification(retryCount: 0);
|
||||
_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([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
// Wait for the Attempted row to land so we know dispatch has run.
|
||||
AwaitAssert(() => Assert.Single(EventsByStatus(AuditStatus.Attempted)));
|
||||
|
||||
// No terminal rows of any kind.
|
||||
Assert.Empty(EventsByStatus(AuditStatus.Delivered));
|
||||
Assert.Empty(EventsByStatus(AuditStatus.Parked));
|
||||
Assert.Empty(EventsByStatus(AuditStatus.Discarded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Discarded_OnManualDiscard_EmitsEvent_StatusDiscarded()
|
||||
{
|
||||
// Wire the actor with a parked row that GetByIdAsync returns; the
|
||||
// discard handler must emit a terminal Discarded audit row.
|
||||
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||
var notification = MakeNotification(status: NotificationStatus.Parked);
|
||||
_outboxRepository.GetByIdAsync(notification.NotificationId, Arg.Any<CancellationToken>())
|
||||
.Returns(notification);
|
||||
var actor = CreateActor([]);
|
||||
|
||||
actor.Tell(new DiscardNotificationRequest(
|
||||
CorrelationId: "test-corr", NotificationId: notification.NotificationId));
|
||||
|
||||
// First wait for the discard handler to reply (handshake), then assert
|
||||
// the audit row landed.
|
||||
ExpectMsg<DiscardNotificationResponse>(r => r.Success);
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var discarded = EventsByStatus(AuditStatus.Discarded);
|
||||
Assert.Single(discarded);
|
||||
Assert.Equal(AuditKind.NotifyDeliver, discarded[0].Kind);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditWriter_Throws_TerminalUpdate_StillSucceeds()
|
||||
{
|
||||
// Audit failure NEVER aborts the user-facing action: the dispatcher
|
||||
// must still persist the Delivered status via UpdateAsync.
|
||||
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"));
|
||||
_auditWriter.OnWrite = _ => throw new InvalidOperationException("audit dead");
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
_outboxRepository.Received(1).UpdateAsync(
|
||||
Arg.Is<Notification>(n => n.Status == NotificationStatus.Delivered),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
public class NotificationOutboxOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_AreExpectedValues()
|
||||
{
|
||||
var options = new NotificationOutboxOptions();
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), options.DispatchInterval);
|
||||
Assert.Equal(100, options.DispatchBatchSize);
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), options.StuckAgeThreshold);
|
||||
Assert.Equal(TimeSpan.FromDays(365), options.TerminalRetention);
|
||||
Assert.Equal(TimeSpan.FromDays(1), options.PurgeInterval);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1), options.DeliveredKpiWindow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
public class ProjectSmokeTest
|
||||
{
|
||||
[Fact]
|
||||
public void ProjectCompiles() => Assert.True(true);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 17: 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).
|
||||
///
|
||||
/// The Host wires both <c>AddNotificationService</c> and <c>AddNotificationOutbox</c> on the
|
||||
/// central node; these tests do the same so the
|
||||
/// adapter's SMTP dependencies (<c>Func<ISmtpClientWrapper></c>, <c>OAuth2TokenService</c>,
|
||||
/// <c>NotificationOptions</c>) are satisfied. <see cref="INotificationRepository"/> — which the
|
||||
/// <see cref="EmailNotificationDeliveryAdapter"/> takes directly and is registered scoped by the
|
||||
/// Configuration Database component — is supplied here by a lightweight stub.
|
||||
/// </summary>
|
||||
public class ServiceRegistrationTests
|
||||
{
|
||||
private static ServiceProvider BuildProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Empty configuration: the options binding must still succeed and yield the
|
||||
// documented NotificationOutboxOptions defaults.
|
||||
var configuration = new ConfigurationBuilder().Build();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddLogging();
|
||||
|
||||
// INotificationRepository is registered scoped by the Configuration Database
|
||||
// component in production; a no-op stub stands in for it here.
|
||||
services.AddScoped<INotificationRepository>(_ => Substitute.For<INotificationRepository>());
|
||||
|
||||
services.AddNotificationService();
|
||||
services.AddNotificationOutbox();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_RegistersNotificationOutboxOptions_WithDefaults()
|
||||
{
|
||||
using var provider = BuildProvider();
|
||||
|
||||
var options = provider.GetRequiredService<IOptions<NotificationOutboxOptions>>().Value;
|
||||
|
||||
Assert.NotNull(options);
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), options.DispatchInterval);
|
||||
Assert.Equal(100, options.DispatchBatchSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_OptionsSection_IsTheNotificationOutboxConfigPath()
|
||||
{
|
||||
Assert.Equal(
|
||||
"ScadaBridge:NotificationOutbox",
|
||||
NotificationOutbox.ServiceCollectionExtensions.OptionsSection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_BindsNotificationOutboxOptions_FromConfiguration()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:NotificationOutbox:DispatchBatchSize"] = "250",
|
||||
})
|
||||
.Build();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddLogging();
|
||||
services.AddScoped<INotificationRepository>(_ => Substitute.For<INotificationRepository>());
|
||||
services.AddNotificationService();
|
||||
services.AddNotificationOutbox();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<NotificationOutboxOptions>>().Value;
|
||||
|
||||
Assert.Equal(250, options.DispatchBatchSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_RegistersEmailDeliveryAdapter()
|
||||
{
|
||||
using var provider = BuildProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
|
||||
var adapter = scope.ServiceProvider.GetRequiredService<EmailNotificationDeliveryAdapter>();
|
||||
|
||||
Assert.NotNull(adapter);
|
||||
Assert.Equal(NotificationType.Email, adapter.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddNotificationOutbox_RegistersEmailAdapter_AsINotificationDeliveryAdapter()
|
||||
{
|
||||
using var provider = BuildProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
|
||||
var adapters = scope.ServiceProvider.GetServices<INotificationDeliveryAdapter>().ToList();
|
||||
|
||||
var email = Assert.Single(adapters);
|
||||
Assert.IsType<EmailNotificationDeliveryAdapter>(email);
|
||||
Assert.Equal(NotificationType.Email, email.Type);
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
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
|
||||
/// 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
|
||||
{
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user