using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Integration; using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.AuditLog.Tests.Site.Telemetry; /// /// Bundle E Tasks E4/E5 bridge tests. The bridge ingests /// notifications from the S&F /// retry loop and routes them through /// as one or two packets: /// /// Per-attempt: one ApiCallCached/DbWriteCached Attempted row. /// Terminal (Delivered/PermanentFailure/ParkedMaxRetries): adds a CachedResolve row carrying the terminal Status. /// /// public class CachedCallLifecycleBridgeTests { private readonly ICachedCallTelemetryForwarder _forwarder = Substitute.For(); private readonly TrackedOperationId _id = TrackedOperationId.New(); private CachedCallLifecycleBridge CreateSut() => new( _forwarder, NullLogger.Instance); private CachedCallAttemptContext Ctx( CachedCallAttemptOutcome outcome, string channel = "ApiOutbound", int retryCount = 1, string? lastError = null, int? httpStatus = null) => new( TrackedOperationId: _id, Channel: channel, Target: "ERP.GetOrder", SourceSite: "site-77", Outcome: outcome, RetryCount: retryCount, LastError: lastError, HttpStatus: httpStatus, CreatedAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc), OccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), DurationMs: 42, SourceInstanceId: "Plant.Pump42"); [Fact] public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve() { var captured = new List(); _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) .Returns(Task.CompletedTask); var sut = CreateSut(); await sut.OnAttemptCompletedAsync(Ctx( CachedCallAttemptOutcome.TransientFailure, retryCount: 2, lastError: "HTTP 503", httpStatus: 503)); var packet = Assert.Single(captured); Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind); Assert.Equal(AuditStatus.Attempted, packet.Audit.Status); Assert.Equal(503, packet.Audit.HttpStatus); Assert.Equal("HTTP 503", packet.Audit.ErrorMessage); Assert.Equal(_id.Value, packet.Audit.CorrelationId); Assert.Equal("Attempted", packet.Operational.Status); Assert.Equal(2, packet.Operational.RetryCount); Assert.Null(packet.Operational.TerminalAtUtc); } [Fact] public async Task Delivered_EmitsAttemptedRow_AndCachedResolveDelivered() { var captured = new List(); _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) .Returns(Task.CompletedTask); var sut = CreateSut(); await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered)); Assert.Equal(2, captured.Count); var attempted = captured[0]; Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind); Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status); Assert.Equal("Attempted", attempted.Operational.Status); Assert.Null(attempted.Operational.TerminalAtUtc); var resolve = captured[1]; Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind); Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status); Assert.Equal("Delivered", resolve.Operational.Status); Assert.NotNull(resolve.Operational.TerminalAtUtc); Assert.Equal(_id.Value, resolve.Audit.CorrelationId); } [Fact] public async Task PermanentFailure_EmitsAttempted_AndCachedResolveParked() { var captured = new List(); _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) .Returns(Task.CompletedTask); var sut = CreateSut(); await sut.OnAttemptCompletedAsync(Ctx( CachedCallAttemptOutcome.PermanentFailure, lastError: "Permanent failure (handler returned false)")); Assert.Equal(2, captured.Count); Assert.Equal(AuditKind.ApiCallCached, captured[0].Audit.Kind); Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind); Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status); Assert.Equal("Parked", captured[1].Operational.Status); } [Fact] public async Task ParkedMaxRetries_EmitsAttempted_AndCachedResolveParked() { var captured = new List(); _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) .Returns(Task.CompletedTask); var sut = CreateSut(); await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.ParkedMaxRetries)); Assert.Equal(2, captured.Count); Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind); Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status); } [Fact] public async Task DbChannel_MapsToDbWriteCachedKind_AndDbOutboundChannel() { var captured = new List(); _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) .Returns(Task.CompletedTask); var sut = CreateSut(); await sut.OnAttemptCompletedAsync(Ctx( CachedCallAttemptOutcome.Delivered, channel: "DbOutbound")); Assert.Equal(2, captured.Count); Assert.Equal(AuditKind.DbWriteCached, captured[0].Audit.Kind); Assert.Equal(AuditChannel.DbOutbound, captured[0].Audit.Channel); Assert.Equal("DbOutbound", captured[0].Operational.Channel); Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind); Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.Channel); } [Fact] public async Task BridgeDoesNotThrow_WhenForwarderThrows() { _forwarder .ForwardAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromException(new InvalidOperationException("forwarder down"))); var sut = CreateSut(); // Must not throw — best-effort emission. await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered)); } [Fact] public async Task BridgePopulatesProvenance_FromAttemptContext() { CachedCallTelemetry? captured = null; _forwarder.ForwardAsync(Arg.Do(t => captured = t), Arg.Any()) .Returns(Task.CompletedTask); var sut = CreateSut(); await sut.OnAttemptCompletedAsync(Ctx( CachedCallAttemptOutcome.TransientFailure, retryCount: 3, lastError: "transient", httpStatus: 500)); Assert.NotNull(captured); Assert.Equal("site-77", captured!.Audit.SourceSiteId); Assert.Equal("Plant.Pump42", captured.Audit.SourceInstanceId); Assert.Equal("ERP.GetOrder", captured.Audit.Target); Assert.Equal(42, captured.Audit.DurationMs); Assert.Equal(_id.Value, captured.Audit.CorrelationId); } }