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; /// /// 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!)); } }