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_is_propagated_not_swallowed() { // OperationCanceledException is re-thrown (unlike ordinary writer failures, which are swallowed). var after = new RecordingWriter(); var sut = new CompositeAuditWriter(new IAuditWriter[] { new CancellingWriter(), after }); await Assert.ThrowsAsync(() => sut.WriteAsync(Evt())); } }