using Microsoft.Extensions.Logging.Abstractions; using Moq; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Integration; using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Enums; using ScadaLink.SiteRuntime.Scripts; namespace ScadaLink.SiteRuntime.Tests.Scripts; /// /// Audit Log #23 — M3 Bundle E (Task E3): every script-initiated /// ExternalSystem.CachedCall emits exactly one CachedSubmit /// combined-telemetry packet at enqueue time, returns a fresh /// , and threads that id down to the /// store-and-forward layer so the retry-loop emissions (Tasks E4/E5) can join /// them by id. The audit emission is best-effort: a thrown forwarder must /// never abort the script's call, and the original /// must surface to the caller unchanged. /// public class ExternalSystemCachedCallEmissionTests { private sealed class CapturingForwarder : ICachedCallTelemetryForwarder { public List Telemetry { get; } = new(); public Exception? ThrowOnForward { get; set; } public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default) { if (ThrowOnForward != null) { return Task.FromException(ThrowOnForward); } Telemetry.Add(telemetry); return Task.CompletedTask; } } private const string SiteId = "site-77"; private const string InstanceName = "Plant.Pump42"; private const string SourceScript = "ScriptActor:CheckPressure"; private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, ICachedCallTelemetryForwarder? forwarder) { return new ScriptRuntimeContext.ExternalSystemHelper( client, InstanceName, NullLogger.Instance, auditWriter: null, siteId: SiteId, sourceScript: SourceScript, cachedForwarder: forwarder); } [Fact] public async Task CachedCall_EmitsSubmitTelemetry_OnEnqueue() { var client = new Mock(); client .Setup(c => c.CachedCallAsync( "ERP", "GetOrder", It.IsAny?>(), InstanceName, It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); var helper = CreateHelper(client.Object, forwarder); var trackedId = await helper.CachedCall("ERP", "GetOrder"); Assert.NotEqual(default, trackedId); Assert.Single(forwarder.Telemetry); var packet = forwarder.Telemetry[0]; Assert.Equal(AuditChannel.ApiOutbound, packet.Audit.Channel); Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind); Assert.Equal(AuditStatus.Submitted, packet.Audit.Status); Assert.Equal("ERP.GetOrder", packet.Audit.Target); Assert.Equal(trackedId.Value, packet.Audit.CorrelationId); Assert.Equal(AuditForwardState.Pending, packet.Audit.ForwardState); // Operational mirror — same id, Submitted, RetryCount 0, not terminal. Assert.Equal(trackedId, packet.Operational.TrackedOperationId); Assert.Equal("ApiOutbound", packet.Operational.Channel); Assert.Equal("ERP.GetOrder", packet.Operational.Target); Assert.Equal(SiteId, packet.Operational.SourceSite); Assert.Equal("Submitted", packet.Operational.Status); Assert.Equal(0, packet.Operational.RetryCount); Assert.Null(packet.Operational.LastError); Assert.Null(packet.Operational.TerminalAtUtc); } [Fact] public async Task CachedCall_ReturnsTrackedOperationId() { var client = new Mock(); client .Setup(c => c.CachedCallAsync( It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); var helper = CreateHelper(client.Object, forwarder); var id1 = await helper.CachedCall("ERP", "GetOrder"); var id2 = await helper.CachedCall("ERP", "GetOrder"); Assert.NotEqual(default, id1); Assert.NotEqual(default, id2); Assert.NotEqual(id1, id2); // Both ids were threaded into the client invocations. client.Verify(c => c.CachedCallAsync( "ERP", "GetOrder", It.IsAny?>(), InstanceName, It.IsAny(), id1), Times.Once); client.Verify(c => c.CachedCallAsync( "ERP", "GetOrder", It.IsAny?>(), InstanceName, It.IsAny(), id2), Times.Once); } [Fact] public async Task CachedCall_ForwarderThrows_StillReturnsTrackedOperationId_OriginalCallProceeds() { var client = new Mock(); client .Setup(c => c.CachedCallAsync( It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder { ThrowOnForward = new InvalidOperationException("simulated forwarder outage"), }; var helper = CreateHelper(client.Object, forwarder); // Must not throw — best-effort emission contract. var trackedId = await helper.CachedCall("ERP", "GetOrder"); Assert.NotEqual(default, trackedId); // The underlying call still ran exactly once. client.Verify(c => c.CachedCallAsync( "ERP", "GetOrder", It.IsAny?>(), InstanceName, It.IsAny(), trackedId), Times.Once); } [Fact] public async Task CachedCall_Provenance_Populated_FromContext() { var client = new Mock(); client .Setup(c => c.CachedCallAsync( It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); var helper = CreateHelper(client.Object, forwarder); await helper.CachedCall("ERP", "GetOrder"); var packet = Assert.Single(forwarder.Telemetry); Assert.Equal(SiteId, packet.Audit.SourceSiteId); Assert.Equal(InstanceName, packet.Audit.SourceInstanceId); Assert.Equal(SourceScript, packet.Audit.SourceScript); Assert.Equal(SiteId, packet.Operational.SourceSite); } [Fact] public async Task CachedCall_NoForwarder_StillReturnsTrackedOperationId() { // Forwarder not wired (tests / minimal hosts) — must still return a // fresh id and invoke the underlying call. var client = new Mock(); client .Setup(c => c.CachedCallAsync( It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var helper = CreateHelper(client.Object, forwarder: null); var trackedId = await helper.CachedCall("ERP", "GetOrder"); Assert.NotEqual(default, trackedId); client.Verify(c => c.CachedCallAsync( "ERP", "GetOrder", It.IsAny?>(), InstanceName, It.IsAny(), trackedId), Times.Once); } }