using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.AuditLog.Site; using ZB.MOM.WW.Audit; using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// /// Bundle B (M2-T4) tests for — composes the /// primary , the drop-oldest /// , and an /// health counter. /// public class FallbackAuditWriterTests { private static AuditEvent NewEvent(string? target = null) => ScadaBridgeAuditEventFactory.Create( eventId: Guid.NewGuid(), occurredAtUtc: DateTime.UtcNow, channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, status: AuditStatus.Delivered, target: target); /// Flip-switch primary writer mock. private sealed class FlipSwitchPrimary : IAuditWriter { public bool FailNext { get; set; } public List Written { get; } = new(); public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { if (FailNext) { return Task.FromException(new InvalidOperationException("primary down")); } Written.Add(evt); return Task.CompletedTask; } } [Fact] public async Task WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess() { var primary = new FlipSwitchPrimary { FailNext = true }; var ring = new RingBufferFallback(capacity: 16); var counter = Substitute.For(); var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger.Instance); var evt = NewEvent("doomed"); // Must NOT throw — audit failures are always swallowed at this layer. await fallback.WriteAsync(evt); Assert.Equal(1, ring.Count); counter.Received(1).Increment(); } [Fact] public async Task WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite() { var primary = new FlipSwitchPrimary { FailNext = true }; var ring = new RingBufferFallback(capacity: 16); var counter = Substitute.For(); var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger.Instance); var failed = new[] { NewEvent("a"), NewEvent("b"), NewEvent("c") }; foreach (var e in failed) { await fallback.WriteAsync(e); } Assert.Equal(3, ring.Count); // Primary recovers; the very next successful write should drain the // ring in FIFO order through the primary. primary.FailNext = false; var trigger = NewEvent("trigger"); await fallback.WriteAsync(trigger); Assert.Equal(0, ring.Count); // Order: the triggering event reaches the primary first (that's the // signal the primary has recovered), then the backlog drains in FIFO // submission order behind it. Assert.Equal(4, primary.Written.Count); Assert.Equal("trigger", primary.Written[0].Target); Assert.Equal("a", primary.Written[1].Target); Assert.Equal("b", primary.Written[2].Target); Assert.Equal("c", primary.Written[3].Target); } [Fact] public async Task WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty() { var primary = new FlipSwitchPrimary(); var ring = new RingBufferFallback(capacity: 16); var counter = Substitute.For(); var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger.Instance); for (int i = 0; i < 10; i++) { await fallback.WriteAsync(NewEvent()); } Assert.Equal(0, ring.Count); Assert.Equal(10, primary.Written.Count); counter.DidNotReceive().Increment(); } [Fact] public async Task WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure() { var primary = new FlipSwitchPrimary { FailNext = true }; var ring = new RingBufferFallback(capacity: 16); var counter = Substitute.For(); var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger.Instance); for (int i = 0; i < 5; i++) { await fallback.WriteAsync(NewEvent()); } counter.Received(5).Increment(); } }