namespace ZB.MOM.WW.Audit.Tests; public class CompositeAuditWriterTests { private sealed class RecordingWriter : IAuditWriter { public int Count; public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Count++; return Task.CompletedTask; } } private sealed class ThrowingWriter : IAuditWriter { public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("boom"); } private sealed class CancellingWriter : IAuditWriter { public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new OperationCanceledException(); } private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = "a", Action = "x", Outcome = AuditOutcome.Success }; [Fact] public async Task Fans_out_to_all_writers() { var a = new RecordingWriter(); var b = new RecordingWriter(); await new CompositeAuditWriter(new IAuditWriter[] { a, b }).WriteAsync(Evt()); Assert.Equal(1, a.Count); Assert.Equal(1, b.Count); } [Fact] public async Task One_failing_writer_does_not_stop_the_others() { var after = new RecordingWriter(); var sut = new CompositeAuditWriter(new IAuditWriter[] { new ThrowingWriter(), after }); await sut.WriteAsync(Evt()); // must not throw Assert.Equal(1, after.Count); } [Fact] public async Task Cancellation_does_not_surface_to_the_caller() { // Per the IAuditWriter hard contract ("must not throw to the caller"), an // OperationCanceledException from an inner writer is swallowed like any other failure — // it must NOT abort the user-facing action that produced the event. var after = new RecordingWriter(); var sut = new CompositeAuditWriter(new IAuditWriter[] { new CancellingWriter(), after }); await sut.WriteAsync(Evt()); // must not throw Assert.Equal(1, after.Count); // drain continues past the cancelled writer } [Fact] public async Task Empty_writer_list_is_a_no_op() { var sut = new CompositeAuditWriter(Array.Empty()); await sut.WriteAsync(Evt()); // must not throw } [Fact] public async Task Null_writer_entry_is_swallowed_and_does_not_stop_the_others() { // A null inner writer faults the await; the best-effort seam swallows it (like any // other writer failure) and continues draining the remaining writers. var after = new RecordingWriter(); var sut = new CompositeAuditWriter(new IAuditWriter?[] { null, after }!); await sut.WriteAsync(Evt()); // must not throw Assert.Equal(1, after.Count); } }