feat(notification-outbox): add AddNotificationOutbox DI registration
This commit is contained in:
@@ -31,7 +31,6 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly NotificationOutboxOptions _options;
|
private readonly NotificationOutboxOptions _options;
|
||||||
private readonly ILogger<NotificationOutboxActor> _logger;
|
private readonly ILogger<NotificationOutboxActor> _logger;
|
||||||
private readonly IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> _adapters;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-flight guard for the dispatch loop. Set true at the start of a sweep and cleared
|
/// In-flight guard for the dispatch loop. Set true at the start of a sweep and cleared
|
||||||
@@ -46,13 +45,11 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
public NotificationOutboxActor(
|
public NotificationOutboxActor(
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
NotificationOutboxOptions options,
|
NotificationOutboxOptions options,
|
||||||
ILogger<NotificationOutboxActor> logger,
|
ILogger<NotificationOutboxActor> logger)
|
||||||
IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters)
|
|
||||||
{
|
{
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_options = options;
|
_options = options;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_adapters = adapters;
|
|
||||||
|
|
||||||
Receive<NotificationSubmit>(HandleSubmit);
|
Receive<NotificationSubmit>(HandleSubmit);
|
||||||
Receive<InternalMessages.IngestPersisted>(HandleIngestPersisted);
|
Receive<InternalMessages.IngestPersisted>(HandleIngestPersisted);
|
||||||
@@ -174,6 +171,11 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
/// in a try/catch so the returned task never faults — scope creation, service resolution,
|
/// in a try/catch so the returned task never faults — scope creation, service resolution,
|
||||||
/// and retry-policy resolution can all throw, and a faulted task would otherwise leave
|
/// and retry-policy resolution can all throw, and a faulted task would otherwise leave
|
||||||
/// the dispatcher's in-flight guard stuck and wedge the loop permanently.
|
/// the dispatcher's in-flight guard stuck and wedge the loop permanently.
|
||||||
|
///
|
||||||
|
/// The channel delivery adapters are resolved from the per-sweep scope, not held in a
|
||||||
|
/// field: <see cref="EmailNotificationDeliveryAdapter"/> takes a scoped
|
||||||
|
/// <see cref="INotificationRepository"/> directly, so a long-lived adapter reference on
|
||||||
|
/// this singleton actor would be a captive dependency over a disposed DbContext.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task RunDispatchPass(DateTimeOffset now)
|
private async Task RunDispatchPass(DateTimeOffset now)
|
||||||
{
|
{
|
||||||
@@ -182,6 +184,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var outboxRepository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
var outboxRepository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
||||||
var notificationRepository = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
var notificationRepository = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
||||||
|
var adapters = ResolveAdapters(scope.ServiceProvider);
|
||||||
|
|
||||||
IReadOnlyList<Notification> due;
|
IReadOnlyList<Notification> due;
|
||||||
try
|
try
|
||||||
@@ -205,7 +208,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DeliverOneAsync(notification, now, maxRetries, retryDelay, outboxRepository);
|
await DeliverOneAsync(notification, now, maxRetries, retryDelay, outboxRepository, adapters);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -238,6 +241,24 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
: (configuration.MaxRetries, configuration.RetryDelay);
|
: (configuration.MaxRetries, configuration.RetryDelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the <see cref="NotificationType"/> → adapter lookup for a dispatch sweep from
|
||||||
|
/// the registered <see cref="INotificationDeliveryAdapter"/> services in the supplied
|
||||||
|
/// scope. The last adapter registered for a given type wins, mirroring DI's last-wins
|
||||||
|
/// resolution semantics.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> ResolveAdapters(
|
||||||
|
IServiceProvider scopedServices)
|
||||||
|
{
|
||||||
|
var adapters = new Dictionary<NotificationType, INotificationDeliveryAdapter>();
|
||||||
|
foreach (var adapter in scopedServices.GetServices<INotificationDeliveryAdapter>())
|
||||||
|
{
|
||||||
|
adapters[adapter.Type] = adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return adapters;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Delivers a single notification through its channel adapter and applies the resulting
|
/// Delivers a single notification through its channel adapter and applies the resulting
|
||||||
/// status transition. A missing adapter parks the notification; otherwise the
|
/// status transition. A missing adapter parks the notification; otherwise the
|
||||||
@@ -248,9 +269,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
DateTimeOffset now,
|
DateTimeOffset now,
|
||||||
int maxRetries,
|
int maxRetries,
|
||||||
TimeSpan retryDelay,
|
TimeSpan retryDelay,
|
||||||
INotificationOutboxRepository outboxRepository)
|
INotificationOutboxRepository outboxRepository,
|
||||||
|
IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters)
|
||||||
{
|
{
|
||||||
if (!_adapters.TryGetValue(notification.Type, out var adapter))
|
if (!adapters.TryGetValue(notification.Type, out var adapter))
|
||||||
{
|
{
|
||||||
notification.Status = NotificationStatus.Parked;
|
notification.Status = NotificationStatus.Parked;
|
||||||
notification.LastError = $"no delivery adapter for type {notification.Type}";
|
notification.LastError = $"no delivery adapter for type {notification.Type}";
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
|
|
||||||
|
namespace ScadaLink.NotificationOutbox;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DI registration for the Notification Outbox component: binds
|
||||||
|
/// <see cref="NotificationOutboxOptions"/> and registers the channel delivery adapters.
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Configuration section bound to <see cref="NotificationOutboxOptions"/>.</summary>
|
||||||
|
public const string OptionsSection = "ScadaLink:NotificationOutbox";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the Notification Outbox services: the <see cref="NotificationOutboxOptions"/>
|
||||||
|
/// binding and the channel delivery adapters.
|
||||||
|
///
|
||||||
|
/// This extension covers only the outbox-specific registrations. The
|
||||||
|
/// <see cref="EmailNotificationDeliveryAdapter"/> reuses the
|
||||||
|
/// <see cref="ScadaLink.NotificationService"/> SMTP machinery —
|
||||||
|
/// <c>Func<ISmtpClientWrapper></c>, <c>OAuth2TokenService</c> and
|
||||||
|
/// <c>NotificationOptions</c> — so the caller (the Host on the central node) must also
|
||||||
|
/// call <c>AddNotificationService()</c>. Re-registering those services here would
|
||||||
|
/// duplicate them; relying on <c>AddNotificationService</c> keeps a single source of truth.
|
||||||
|
///
|
||||||
|
/// <see cref="EmailNotificationDeliveryAdapter"/> is registered <em>scoped</em> because it
|
||||||
|
/// takes a scoped <see cref="ScadaLink.Commons.Interfaces.Repositories.INotificationRepository"/>
|
||||||
|
/// directly. The <see cref="NotificationOutboxActor"/> resolves the adapters from a fresh
|
||||||
|
/// scope per dispatch sweep rather than holding them, so no scoped adapter is captured by
|
||||||
|
/// the singleton actor.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNotificationOutbox(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
services.AddOptions<NotificationOutboxOptions>()
|
||||||
|
.BindConfiguration(OptionsSection);
|
||||||
|
|
||||||
|
// Scoped: the adapter holds a scoped INotificationRepository. Registered both under
|
||||||
|
// the interface (so the dispatch sweep can enumerate every channel adapter) and as
|
||||||
|
// the concrete type (so callers and tests can resolve it directly).
|
||||||
|
services.AddScoped<EmailNotificationDeliveryAdapter>();
|
||||||
|
services.AddScoped<INotificationDeliveryAdapter>(
|
||||||
|
sp => sp.GetRequiredService<EmailNotificationDeliveryAdapter>());
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,11 +26,19 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
private readonly INotificationRepository _notificationRepository =
|
private readonly INotificationRepository _notificationRepository =
|
||||||
Substitute.For<INotificationRepository>();
|
Substitute.For<INotificationRepository>();
|
||||||
|
|
||||||
private IServiceProvider BuildServiceProvider()
|
private IServiceProvider BuildServiceProvider(
|
||||||
|
IEnumerable<INotificationDeliveryAdapter> adapters)
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddScoped(_ => _outboxRepository);
|
services.AddScoped(_ => _outboxRepository);
|
||||||
services.AddScoped(_ => _notificationRepository);
|
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();
|
return services.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,14 +75,13 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
}
|
}
|
||||||
|
|
||||||
private IActorRef CreateActor(
|
private IActorRef CreateActor(
|
||||||
IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters,
|
IEnumerable<INotificationDeliveryAdapter> adapters,
|
||||||
NotificationOutboxOptions? options = null)
|
NotificationOutboxOptions? options = null)
|
||||||
{
|
{
|
||||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
BuildServiceProvider(),
|
BuildServiceProvider(adapters),
|
||||||
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||||
NullLogger<NotificationOutboxActor>.Instance,
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
adapters)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Notification MakeNotification(
|
private static Notification MakeNotification(
|
||||||
@@ -107,10 +114,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new[] { notification });
|
.Returns(new[] { notification });
|
||||||
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||||
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
var actor = CreateActor([adapter]);
|
||||||
{
|
|
||||||
[NotificationType.Email] = adapter,
|
|
||||||
});
|
|
||||||
|
|
||||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
@@ -130,10 +134,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new[] { notification });
|
.Returns(new[] { notification });
|
||||||
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||||
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
var actor = CreateActor([adapter]);
|
||||||
{
|
|
||||||
[NotificationType.Email] = adapter,
|
|
||||||
});
|
|
||||||
|
|
||||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
@@ -158,10 +159,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new[] { notification });
|
.Returns(new[] { notification });
|
||||||
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
|
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
|
||||||
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
var actor = CreateActor([adapter]);
|
||||||
{
|
|
||||||
[NotificationType.Email] = adapter,
|
|
||||||
});
|
|
||||||
|
|
||||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
@@ -187,10 +185,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new[] { notification });
|
.Returns(new[] { notification });
|
||||||
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
|
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
|
||||||
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
var actor = CreateActor([adapter]);
|
||||||
{
|
|
||||||
[NotificationType.Email] = adapter,
|
|
||||||
});
|
|
||||||
|
|
||||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
@@ -212,10 +207,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new[] { notification });
|
.Returns(new[] { notification });
|
||||||
var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address"));
|
var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address"));
|
||||||
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
var actor = CreateActor([adapter]);
|
||||||
{
|
|
||||||
[NotificationType.Email] = adapter,
|
|
||||||
});
|
|
||||||
|
|
||||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
@@ -237,8 +229,8 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
var notification = MakeNotification();
|
var notification = MakeNotification();
|
||||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new[] { notification });
|
.Returns(new[] { notification });
|
||||||
// Empty adapter dictionary: no adapter resolves for the notification's type.
|
// No adapters registered: none resolves for the notification's type.
|
||||||
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>());
|
var actor = CreateActor([]);
|
||||||
|
|
||||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
@@ -262,7 +254,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
// failure were not handled, which would leave _dispatching stuck true forever.
|
// failure were not handled, which would leave _dispatching stuck true forever.
|
||||||
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
.Returns<IReadOnlyList<Notification>>(_ => throw new InvalidOperationException("db down"));
|
.Returns<IReadOnlyList<Notification>>(_ => throw new InvalidOperationException("db down"));
|
||||||
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>());
|
var actor = CreateActor([]);
|
||||||
|
|
||||||
// First tick: the pass faults internally but must still clear the in-flight guard.
|
// First tick: the pass faults internally but must still clear the in-flight guard.
|
||||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
@@ -287,10 +279,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
var adapter = new StubAdapter(
|
var adapter = new StubAdapter(
|
||||||
() => DeliveryOutcome.Success("ops@example.com"),
|
() => DeliveryOutcome.Success("ops@example.com"),
|
||||||
delay: TimeSpan.FromMilliseconds(800));
|
delay: TimeSpan.FromMilliseconds(800));
|
||||||
var actor = CreateActor(new Dictionary<NotificationType, INotificationDeliveryAdapter>
|
var actor = CreateActor([adapter]);
|
||||||
{
|
|
||||||
[NotificationType.Email] = adapter,
|
|
||||||
});
|
|
||||||
|
|
||||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ public class NotificationOutboxActorIngestTests : TestKit
|
|||||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
BuildServiceProvider(),
|
BuildServiceProvider(),
|
||||||
new NotificationOutboxOptions(),
|
new NotificationOutboxOptions(),
|
||||||
NullLogger<NotificationOutboxActor>.Instance,
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
new Dictionary<NotificationType, INotificationDeliveryAdapter>())));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static NotificationSubmit MakeSubmit(string? notificationId = null)
|
private static NotificationSubmit MakeSubmit(string? notificationId = null)
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ public class NotificationOutboxActorPurgeTests : TestKit
|
|||||||
DispatchInterval = TimeSpan.FromHours(1),
|
DispatchInterval = TimeSpan.FromHours(1),
|
||||||
PurgeInterval = TimeSpan.FromHours(1),
|
PurgeInterval = TimeSpan.FromHours(1),
|
||||||
},
|
},
|
||||||
NullLogger<NotificationOutboxActor>.Instance,
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
new Dictionary<NotificationType, INotificationDeliveryAdapter>())));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
|||||||
BuildServiceProvider(),
|
BuildServiceProvider(),
|
||||||
// A long dispatch interval keeps the dispatch loop from interfering with these tests.
|
// A long dispatch interval keeps the dispatch loop from interfering with these tests.
|
||||||
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||||
NullLogger<NotificationOutboxActor>.Instance,
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
new Dictionary<NotificationType, INotificationDeliveryAdapter>())));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Notification MakeNotification(
|
private static Notification MakeNotification(
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||||
<PackageReference Include="coverlet.collector" />
|
<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="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="NSubstitute" />
|
<PackageReference Include="NSubstitute" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
|
using ScadaLink.NotificationService;
|
||||||
|
|
||||||
|
namespace ScadaLink.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>ScadaLink: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(
|
||||||
|
"ScadaLink:NotificationOutbox",
|
||||||
|
NotificationOutbox.ServiceCollectionExtensions.OptionsSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddNotificationOutbox_BindsNotificationOutboxOptions_FromConfiguration()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ScadaLink: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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user