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);
}
}