using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ScadaLink.AuditLog.Site; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.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) => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered, Target = target, PayloadTruncated = false, ForwardState = AuditForwardState.Pending, }; /// 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(); } }