feat(notif): NotificationOutboxActor + CentralAuditWriter wired (#23 M4)
M4 Bundle B (B1) — add the central-only ICentralAuditWriter implementation and inject it into NotificationOutboxActor so subsequent tasks (B2/B3) can route attempt + terminal lifecycle events through the direct-write audit path. - CentralAuditWriter: thin wrapper around IAuditLogRepository.InsertIfNotExistsAsync; scope-per-call (matches AuditLogIngestActor / NotificationOutboxActor pattern); stamps IngestedAtUtc; swallows all internal failures (alog.md §13). - Registered as a singleton in AddAuditLog. - NotificationOutboxActor ctor takes ICentralAuditWriter (validated non-null). - Host wiring resolves the writer once from the root provider and passes it into the singleton's Props.Create call. - Existing TestKit fixtures updated with a NoOpCentralAuditWriter helper so tests that don't exercise audit emission still compile and pass.
This commit is contained in:
82
src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs
Normal file
82
src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Central;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
|
||||||
|
/// Wraps <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> as a best-effort
|
||||||
|
/// audit emission path for components that originate audit events ON the central
|
||||||
|
/// node (Notification Outbox dispatch, Inbound API) — NOT for site telemetry
|
||||||
|
/// ingest (that path is the SiteAudit → AuditLogIngestActor batched flow).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Best-effort contract.</b> Audit-write failures NEVER abort the user-facing
|
||||||
|
/// action (alog.md §13). The writer catches every exception thrown by repository
|
||||||
|
/// resolution or the insert call, logs at warning, and returns successfully.
|
||||||
|
/// Callers may still wrap the call in their own try/catch (defensive — the writer
|
||||||
|
/// is supposed to swallow).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Scope-per-call resolution.</b> <see cref="IAuditLogRepository"/> is a SCOPED
|
||||||
|
/// EF Core service (registered by <c>ScadaLink.ConfigurationDatabase</c>). The
|
||||||
|
/// writer itself is registered as a singleton (so all callers share one instance),
|
||||||
|
/// so it cannot hold a scope across calls — it opens a fresh
|
||||||
|
/// <see cref="IServiceScope"/> per <see cref="WriteAsync"/> invocation, mirroring
|
||||||
|
/// the per-message scope pattern used by <c>AuditLogIngestActor</c> and
|
||||||
|
/// <c>NotificationOutboxActor</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Idempotency.</b> Persistence is via <c>InsertIfNotExistsAsync</c>, so a
|
||||||
|
/// double-emitted event (same <see cref="AuditEvent.EventId"/>) is a silent
|
||||||
|
/// no-op — the writer is safe to call from any number of dispatch paths.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<CentralAuditWriter> _logger;
|
||||||
|
|
||||||
|
public CentralAuditWriter(IServiceProvider services, ILogger<CentralAuditWriter> logger)
|
||||||
|
{
|
||||||
|
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists <paramref name="evt"/> into the central <c>AuditLog</c> table
|
||||||
|
/// idempotently on <see cref="AuditEvent.EventId"/>. Stamps
|
||||||
|
/// <see cref="AuditEvent.IngestedAtUtc"/> from the central-side clock.
|
||||||
|
/// Internal failures are logged and swallowed — never thrown.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (evt is null)
|
||||||
|
{
|
||||||
|
// Defensive — a null event is a programming bug at the caller and
|
||||||
|
// produces no meaningful audit row. Log and return.
|
||||||
|
_logger.LogWarning("CentralAuditWriter.WriteAsync received null event; ignoring.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
|
var stamped = evt with { IngestedAtUtc = DateTime.UtcNow };
|
||||||
|
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Audit failure NEVER aborts the user-facing action — swallow and log.
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
|
||||||
|
evt.EventId, evt.Kind, evt.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.AuditLog.Central;
|
||||||
using ScadaLink.AuditLog.Configuration;
|
using ScadaLink.AuditLog.Configuration;
|
||||||
using ScadaLink.AuditLog.Site;
|
using ScadaLink.AuditLog.Site;
|
||||||
using ScadaLink.AuditLog.Site.Telemetry;
|
using ScadaLink.AuditLog.Site.Telemetry;
|
||||||
@@ -129,6 +130,17 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<ICachedCallLifecycleObserver>(
|
services.AddSingleton<ICachedCallLifecycleObserver>(
|
||||||
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
|
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
|
||||||
|
|
||||||
|
// M4 Bundle B: central direct-write audit writer used by
|
||||||
|
// NotificationOutboxActor (Bundle B) and Inbound API (Bundle C/D) to
|
||||||
|
// emit AuditLog rows that originate ON central, not via site telemetry.
|
||||||
|
// Singleton — the writer is stateless; its per-call scope opens a fresh
|
||||||
|
// IAuditLogRepository (a SCOPED EF Core service registered by
|
||||||
|
// ScadaLink.ConfigurationDatabase). The interface (ICentralAuditWriter)
|
||||||
|
// is intentionally distinct from IAuditWriter so site composition roots
|
||||||
|
// do not accidentally bind it; central composition roots that include
|
||||||
|
// AddConfigurationDatabase get a working implementation transparently.
|
||||||
|
services.AddSingleton<ICentralAuditWriter, CentralAuditWriter>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -275,11 +275,18 @@ akka {{
|
|||||||
.GetRequiredService<IOptions<ScadaLink.NotificationOutbox.NotificationOutboxOptions>>().Value;
|
.GetRequiredService<IOptions<ScadaLink.NotificationOutbox.NotificationOutboxOptions>>().Value;
|
||||||
var outboxLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
var outboxLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||||
.CreateLogger<ScadaLink.NotificationOutbox.NotificationOutboxActor>();
|
.CreateLogger<ScadaLink.NotificationOutbox.NotificationOutboxActor>();
|
||||||
|
// M4 Bundle B: central direct-write audit writer for dispatcher attempt
|
||||||
|
// + terminal events. Resolved once from the root provider — the writer
|
||||||
|
// is a singleton and stateless, opening per-call DI scopes internally
|
||||||
|
// to resolve the scoped IAuditLogRepository.
|
||||||
|
var outboxAuditWriter = _serviceProvider
|
||||||
|
.GetRequiredService<ScadaLink.Commons.Interfaces.Services.ICentralAuditWriter>();
|
||||||
|
|
||||||
var outboxSingletonProps = ClusterSingletonManager.Props(
|
var outboxSingletonProps = ClusterSingletonManager.Props(
|
||||||
singletonProps: Props.Create(() => new ScadaLink.NotificationOutbox.NotificationOutboxActor(
|
singletonProps: Props.Create(() => new ScadaLink.NotificationOutbox.NotificationOutboxActor(
|
||||||
_serviceProvider,
|
_serviceProvider,
|
||||||
outboxOptions,
|
outboxOptions,
|
||||||
|
outboxAuditWriter,
|
||||||
outboxLogger)),
|
outboxLogger)),
|
||||||
terminationMessage: PoisonPill.Instance,
|
terminationMessage: PoisonPill.Instance,
|
||||||
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
|
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Entities.Notifications;
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.Commons.Types.Notifications;
|
using ScadaLink.Commons.Types.Notifications;
|
||||||
@@ -30,6 +32,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly NotificationOutboxOptions _options;
|
private readonly NotificationOutboxOptions _options;
|
||||||
|
private readonly ICentralAuditWriter _auditWriter;
|
||||||
private readonly ILogger<NotificationOutboxActor> _logger;
|
private readonly ILogger<NotificationOutboxActor> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,11 +48,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
public NotificationOutboxActor(
|
public NotificationOutboxActor(
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
NotificationOutboxOptions options,
|
NotificationOutboxOptions options,
|
||||||
|
ICentralAuditWriter auditWriter,
|
||||||
ILogger<NotificationOutboxActor> logger)
|
ILogger<NotificationOutboxActor> logger)
|
||||||
{
|
{
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||||
_options = options;
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_logger = logger;
|
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
|
||||||
Receive<NotificationSubmit>(HandleSubmit);
|
Receive<NotificationSubmit>(HandleSubmit);
|
||||||
Receive<InternalMessages.IngestPersisted>(HandleIngestPersisted);
|
Receive<InternalMessages.IngestPersisted>(HandleIngestPersisted);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.AuditLog.Central;
|
||||||
using ScadaLink.AuditLog.Configuration;
|
using ScadaLink.AuditLog.Configuration;
|
||||||
using ScadaLink.AuditLog.Site;
|
using ScadaLink.AuditLog.Site;
|
||||||
using ScadaLink.AuditLog.Site.Telemetry;
|
using ScadaLink.AuditLog.Site.Telemetry;
|
||||||
@@ -155,6 +156,34 @@ public class AddAuditLogTests
|
|||||||
Assert.IsType<NoOpSiteStreamAuditClient>(client);
|
Assert.IsType<NoOpSiteStreamAuditClient>(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- M4 Bundle B (B1) central direct-write audit writer -----------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddAuditLog_Registers_ICentralAuditWriter_AsCentralAuditWriter()
|
||||||
|
{
|
||||||
|
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||||
|
});
|
||||||
|
|
||||||
|
var writer = provider.GetService<ICentralAuditWriter>();
|
||||||
|
Assert.NotNull(writer);
|
||||||
|
Assert.IsType<CentralAuditWriter>(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddAuditLog_ICentralAuditWriter_IsSingleton()
|
||||||
|
{
|
||||||
|
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||||
|
});
|
||||||
|
|
||||||
|
var w1 = provider.GetService<ICentralAuditWriter>();
|
||||||
|
var w2 = provider.GetService<ICentralAuditWriter>();
|
||||||
|
Assert.Same(w1, w2);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
|
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using ScadaLink.AuditLog.Central;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests.Central;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M4 Bundle B1 — unit tests for <see cref="CentralAuditWriter"/>, the
|
||||||
|
/// central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
|
||||||
|
/// The writer is a thin wrapper around
|
||||||
|
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps
|
||||||
|
/// <see cref="AuditEvent.IngestedAtUtc"/>, resolves the (scoped) repository
|
||||||
|
/// from a fresh DI scope per call, and swallows any thrown exception —
|
||||||
|
/// audit-write failures NEVER abort the user-facing action (alog.md §13).
|
||||||
|
/// </summary>
|
||||||
|
public class CentralAuditWriterTests
|
||||||
|
{
|
||||||
|
private static AuditEvent NewEvent(Guid? eventId = null) => new()
|
||||||
|
{
|
||||||
|
EventId = eventId ?? Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||||
|
Channel = AuditChannel.Notification,
|
||||||
|
Kind = AuditKind.NotifyDeliver,
|
||||||
|
Status = AuditStatus.Attempted,
|
||||||
|
CorrelationId = Guid.NewGuid(),
|
||||||
|
Target = "ops-team",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriter()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped(_ => repo);
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
return (new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance), repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
|
||||||
|
{
|
||||||
|
var (writer, repo) = BuildWriter();
|
||||||
|
var evt = NewEvent();
|
||||||
|
|
||||||
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
|
await repo.Received(1).InsertIfNotExistsAsync(
|
||||||
|
Arg.Is<AuditEvent>(e => e.EventId == evt.EventId),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
|
||||||
|
{
|
||||||
|
var (writer, repo) = BuildWriter();
|
||||||
|
var before = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await writer.WriteAsync(NewEvent());
|
||||||
|
|
||||||
|
var after = DateTime.UtcNow;
|
||||||
|
await repo.Received(1).InsertIfNotExistsAsync(
|
||||||
|
Arg.Is<AuditEvent>(e =>
|
||||||
|
e.IngestedAtUtc != null &&
|
||||||
|
e.IngestedAtUtc >= before &&
|
||||||
|
e.IngestedAtUtc <= after),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
|
repo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("db down"));
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped(_ => repo);
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);
|
||||||
|
|
||||||
|
// Must not throw — audit failure NEVER aborts the user-facing action.
|
||||||
|
await writer.WriteAsync(NewEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
|
||||||
|
{
|
||||||
|
// Counting factory: every scope opening should resolve a new repo
|
||||||
|
// (scoped lifetime). We assert at least two distinct instances
|
||||||
|
// across two WriteAsync calls.
|
||||||
|
var instances = new List<IAuditLogRepository>();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped<IAuditLogRepository>(_ =>
|
||||||
|
{
|
||||||
|
var r = Substitute.For<IAuditLogRepository>();
|
||||||
|
instances.Add(r);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);
|
||||||
|
|
||||||
|
await writer.WriteAsync(NewEvent());
|
||||||
|
await writer.WriteAsync(NewEvent());
|
||||||
|
|
||||||
|
Assert.Equal(2, instances.Count);
|
||||||
|
Assert.NotSame(instances[0], instances[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullServices_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(
|
||||||
|
() => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullLogger_Throws()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection().BuildServiceProvider();
|
||||||
|
Assert.Throws<ArgumentNullException>(
|
||||||
|
() => new CentralAuditWriter(services, null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Entities.Notifications;
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.ConfigurationDatabase;
|
using ScadaLink.ConfigurationDatabase;
|
||||||
@@ -172,7 +174,20 @@ public class NotificationOutboxFlowTests : TestKit
|
|||||||
};
|
};
|
||||||
|
|
||||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
services, options, NullLogger<NotificationOutboxActor>.Instance)));
|
services,
|
||||||
|
options,
|
||||||
|
(ICentralAuditWriter)new NoOpCentralAuditWriter(),
|
||||||
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only no-op <see cref="ICentralAuditWriter"/>. The integration tests
|
||||||
|
/// in this file pre-date M4 Bundle B's audit-writer injection; they do not
|
||||||
|
/// assert on emission, just need a non-null collaborator.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class NoOpCentralAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static NotificationSubmit MakeSubmit(string notificationId)
|
private static NotificationSubmit MakeSubmit(string notificationId)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
namespace ScadaLink.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
|||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.NotificationOutbox.Delivery;
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
using ScadaLink.NotificationOutbox.Messages;
|
using ScadaLink.NotificationOutbox.Messages;
|
||||||
|
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||||
|
|
||||||
namespace ScadaLink.NotificationOutbox.Tests;
|
namespace ScadaLink.NotificationOutbox.Tests;
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
BuildServiceProvider(adapters),
|
BuildServiceProvider(adapters),
|
||||||
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||||
|
new NoOpCentralAuditWriter(),
|
||||||
NullLogger<NotificationOutboxActor>.Instance)));
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
|||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.NotificationOutbox.Delivery;
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
|
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||||
|
|
||||||
namespace ScadaLink.NotificationOutbox.Tests;
|
namespace ScadaLink.NotificationOutbox.Tests;
|
||||||
|
|
||||||
@@ -34,6 +35,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(),
|
||||||
|
new NoOpCentralAuditWriter(),
|
||||||
NullLogger<NotificationOutboxActor>.Instance)));
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
|||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.NotificationOutbox.Delivery;
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
using ScadaLink.NotificationOutbox.Messages;
|
using ScadaLink.NotificationOutbox.Messages;
|
||||||
|
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||||
|
|
||||||
namespace ScadaLink.NotificationOutbox.Tests;
|
namespace ScadaLink.NotificationOutbox.Tests;
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ public class NotificationOutboxActorPurgeTests : TestKit
|
|||||||
DispatchInterval = TimeSpan.FromHours(1),
|
DispatchInterval = TimeSpan.FromHours(1),
|
||||||
PurgeInterval = TimeSpan.FromHours(1),
|
PurgeInterval = TimeSpan.FromHours(1),
|
||||||
},
|
},
|
||||||
|
new NoOpCentralAuditWriter(),
|
||||||
NullLogger<NotificationOutboxActor>.Instance)));
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using ScadaLink.Commons.Messages.Notification;
|
|||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.Commons.Types.Notifications;
|
using ScadaLink.Commons.Types.Notifications;
|
||||||
using ScadaLink.NotificationOutbox.Delivery;
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
|
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||||
|
|
||||||
namespace ScadaLink.NotificationOutbox.Tests;
|
namespace ScadaLink.NotificationOutbox.Tests;
|
||||||
|
|
||||||
@@ -36,6 +37,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) },
|
||||||
|
new NoOpCentralAuditWriter(),
|
||||||
NullLogger<NotificationOutboxActor>.Instance)));
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
namespace ScadaLink.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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user