using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Tests.TestSupport;
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;
///
/// M4 Bundle B1 — unit tests for , the
/// central-only direct-write implementation of .
/// The writer is a thin wrapper around
/// : it stamps
/// , 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).
///
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();
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
return (new CentralAuditWriter(provider, NullLogger.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(e => e.EventId == evt.EventId),
Arg.Any());
}
[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(e =>
e.IngestedAtUtc != null &&
e.IngestedAtUtc >= before &&
e.IngestedAtUtc <= after),
Arg.Any());
}
[Fact]
public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
{
var repo = Substitute.For();
repo.InsertIfNotExistsAsync(Arg.Any(), Arg.Any())
.ThrowsAsync(new InvalidOperationException("db down"));
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(provider, NullLogger.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();
var services = new ServiceCollection();
services.AddScoped(_ =>
{
var r = Substitute.For();
instances.Add(r);
return r;
});
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(provider, NullLogger.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(
() => new CentralAuditWriter(null!, NullLogger.Instance));
}
[Fact]
public void Constructor_NullLogger_Throws()
{
var services = new ServiceCollection().BuildServiceProvider();
Assert.Throws(
() => new CentralAuditWriter(services, null!));
}
// ----- SourceNode stamping (Task 12) ----- //
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity(
INodeIdentityProvider? nodeIdentity)
{
var repo = Substitute.For();
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(
provider,
NullLogger.Instance,
filter: null,
failureCounter: null,
nodeIdentity: nodeIdentity);
return (writer, repo);
}
[Fact]
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
{
var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a"));
await writer.WriteAsync(NewEvent());
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is(e => e.SourceNode == "central-a"),
Arg.Any());
}
[Fact]
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
{
var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a"));
var evt = NewEvent() with { SourceNode = "central-b" };
await writer.WriteAsync(evt);
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is(e => e.SourceNode == "central-b"),
Arg.Any());
}
[Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
{
var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider(nodeName: null));
await writer.WriteAsync(NewEvent());
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is(e => e.SourceNode == null),
Arg.Any());
}
[Fact]
public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected()
{
// Locks the back-compat contract for the optional `nodeIdentity = null`
// ctor parameter: when no provider is wired (e.g. legacy M4 test
// composition roots), the writer must not stamp — caller value passes
// through unmodified. Distinct code path from
// "provider supplied, returns null", which the test above covers.
var (writer, repo) = BuildWriterWithIdentity(nodeIdentity: null);
await writer.WriteAsync(NewEvent() with { SourceNode = "node-z" });
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is(e => e.SourceNode == "node-z"),
Arg.Any());
}
[Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected()
{
// Same back-compat contract for the null-caller-null-provider case.
var (writer, repo) = BuildWriterWithIdentity(nodeIdentity: null);
await writer.WriteAsync(NewEvent());
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is(e => e.SourceNode == null),
Arg.Any());
}
}