using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces; 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 E2 tests for . The /// forwarder is the site-side dual emitter: every cached-call lifecycle event /// writes one to and one /// operational tracking-row mutation to . /// Audit-emission contract: best-effort — a thrown writer or tracking store /// must be logged and swallowed; the forwarder must never propagate the /// exception to the calling script. /// public class CachedCallTelemetryForwarderTests { private readonly IAuditWriter _writer = Substitute.For(); private readonly IOperationTrackingStore _tracking = Substitute.For(); private readonly TrackedOperationId _id = TrackedOperationId.New(); private readonly DateTime _now = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc); private CachedCallTelemetryForwarder CreateSut() => new( _writer, _tracking, NullLogger.Instance); private CachedCallTelemetry SubmitPacket() => new( Audit: new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = _now, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.CachedSubmit, CorrelationId = _id.Value, SourceSiteId = "site-1", SourceInstanceId = "inst-1", SourceScript = "ScriptActor:doStuff", Target = "ERP.GetOrder", Status = AuditStatus.Submitted, ForwardState = AuditForwardState.Pending, }, Operational: new SiteCallOperational( TrackedOperationId: _id, Channel: "ApiOutbound", Target: "ERP.GetOrder", SourceSite: "site-1", Status: "Submitted", RetryCount: 0, LastError: null, HttpStatus: null, CreatedAtUtc: _now, UpdatedAtUtc: _now, TerminalAtUtc: null)); private CachedCallTelemetry AttemptedPacket(int retryCount = 1, string? lastError = "HTTP 500", int? httpStatus = 500) => new( Audit: new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = _now, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCallCached, CorrelationId = _id.Value, SourceSiteId = "site-1", Target = "ERP.GetOrder", Status = AuditStatus.Attempted, HttpStatus = httpStatus, ErrorMessage = lastError, ForwardState = AuditForwardState.Pending, }, Operational: new SiteCallOperational( TrackedOperationId: _id, Channel: "ApiOutbound", Target: "ERP.GetOrder", SourceSite: "site-1", Status: "Attempted", RetryCount: retryCount, LastError: lastError, HttpStatus: httpStatus, CreatedAtUtc: _now, UpdatedAtUtc: _now, TerminalAtUtc: null)); private CachedCallTelemetry ResolvePacket(string status = "Delivered") => new( Audit: new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = _now, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.CachedResolve, CorrelationId = _id.Value, SourceSiteId = "site-1", Target = "ERP.GetOrder", Status = Enum.Parse(status), ForwardState = AuditForwardState.Pending, }, Operational: new SiteCallOperational( TrackedOperationId: _id, Channel: "ApiOutbound", Target: "ERP.GetOrder", SourceSite: "site-1", Status: status, RetryCount: 2, LastError: null, HttpStatus: null, CreatedAtUtc: _now, UpdatedAtUtc: _now, TerminalAtUtc: _now)); [Fact] public async Task ForwardAsync_Submit_WritesAuditEvent_AndRecordsEnqueue() { var sut = CreateSut(); var packet = SubmitPacket(); await sut.ForwardAsync(packet, CancellationToken.None); // Audit row: one WriteAsync of the submit event. await _writer.Received(1).WriteAsync( Arg.Is(e => e.EventId == packet.Audit.EventId && e.Kind == AuditKind.CachedSubmit && e.Status == AuditStatus.Submitted), Arg.Any()); // Tracking row: insert-if-not-exists with kind discriminator. await _tracking.Received(1).RecordEnqueueAsync( _id, "ApiOutbound", "ERP.GetOrder", "inst-1", "ScriptActor:doStuff", Arg.Any()); await _tracking.DidNotReceiveWithAnyArgs().RecordAttemptAsync( default, default!, default, default, default, default); await _tracking.DidNotReceiveWithAnyArgs().RecordTerminalAsync( default, default!, default, default, default); } [Fact] public async Task ForwardAsync_Attempted_WritesAuditEvent_AndRecordsAttempt() { var sut = CreateSut(); var packet = AttemptedPacket(retryCount: 2, lastError: "HTTP 503", httpStatus: 503); await sut.ForwardAsync(packet, CancellationToken.None); await _writer.Received(1).WriteAsync( Arg.Is(e => e.EventId == packet.Audit.EventId && e.Kind == AuditKind.ApiCallCached && e.Status == AuditStatus.Attempted), Arg.Any()); await _tracking.Received(1).RecordAttemptAsync( _id, "Attempted", 2, "HTTP 503", 503, Arg.Any()); await _tracking.DidNotReceiveWithAnyArgs().RecordEnqueueAsync( default, default!, default, default, default, default); await _tracking.DidNotReceiveWithAnyArgs().RecordTerminalAsync( default, default!, default, default, default); } [Fact] public async Task ForwardAsync_Resolve_WritesAuditEvent_AndRecordsTerminal() { var sut = CreateSut(); var packet = ResolvePacket("Delivered"); await sut.ForwardAsync(packet, CancellationToken.None); await _writer.Received(1).WriteAsync( Arg.Is(e => e.EventId == packet.Audit.EventId && e.Kind == AuditKind.CachedResolve && e.Status == AuditStatus.Delivered), Arg.Any()); await _tracking.Received(1).RecordTerminalAsync( _id, "Delivered", null, null, Arg.Any()); await _tracking.DidNotReceiveWithAnyArgs().RecordEnqueueAsync( default, default!, default, default, default, default); await _tracking.DidNotReceiveWithAnyArgs().RecordAttemptAsync( default, default!, default, default, default, default); } [Fact] public async Task ForwardAsync_WriterThrows_Logs_DoesNotPropagate() { _writer.WriteAsync(Arg.Any(), Arg.Any()) .Throws(new InvalidOperationException("primary down")); var sut = CreateSut(); // Must not throw. await sut.ForwardAsync(SubmitPacket(), CancellationToken.None); // Tracking still attempted — emission of the two halves is independent // so a writer outage cannot starve the operational row (and vice-versa). await _tracking.Received(1).RecordEnqueueAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task ForwardAsync_TrackingStoreThrows_Logs_DoesNotPropagate() { _tracking.RecordEnqueueAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new InvalidOperationException("sqlite locked")); var sut = CreateSut(); await sut.ForwardAsync(SubmitPacket(), CancellationToken.None); // Writer still attempted — emission halves are independent. await _writer.Received(1).WriteAsync( Arg.Any(), Arg.Any()); } [Fact] public async Task ForwardAsync_NullPacket_Throws() { var sut = CreateSut(); await Assert.ThrowsAsync( () => sut.ForwardAsync(null!, CancellationToken.None)); } }