Files
scadalink-design/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs
Joseph Doherty 42430dd10a feat(siteruntime): ExternalSystem.CachedCall emits CachedSubmit telemetry (#23 M3)
Rework ScriptRuntimeContext.ExternalSystem.CachedCall to fit the M3
combined-telemetry model:

* Mints a fresh TrackedOperationId and emits one CachedSubmit packet
  via ICachedCallTelemetryForwarder BEFORE handing the call off — the
  SiteCalls row is materialised before the first delivery attempt so
  Tracking.Status(id) can observe a Submitted row even if immediate
  delivery resolves before the helper returns.
* Threads the TrackedOperationId into IExternalSystemClient.CachedCallAsync
  as a new optional parameter (and into IDatabaseGateway.CachedWriteAsync
  for the Database mirror set up here for E6). The gateway uses the id
  as the StoreAndForward messageId so the retry loop (Tasks E4/E5) can
  recover it from StoreAndForwardMessage.Id.
* Returns the TrackedOperationId rather than ExternalCallResult — the
  script's contract is now "get a tracking handle, observe outcome via
  Tracking.Status". Best-effort emission: a thrown forwarder is logged
  + swallowed; the original call still runs and the id is still returned.

DatabaseHelper gets the matching siteId / sourceScript / forwarder
fields and a parallel CachedSubmit emitter (Channel=DbOutbound) so Task
E6's Database.CachedWrite mirror plugs in without further runtime
wiring.

New ICachedCallTelemetryForwarder seam in Commons.Interfaces.Services
so SiteRuntime depends on Commons (existing arrow) rather than
ScadaLink.AuditLog (would have introduced a new dependency).

Bundle E task E3 (and helper-shape work for E6).
2026-05-20 14:48:05 -04:00

222 lines
8.7 KiB
C#

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;
/// <summary>
/// Audit Log #23 — M3 Bundle E (Task E3): every script-initiated
/// <c>ExternalSystem.CachedCall</c> emits exactly one <c>CachedSubmit</c>
/// combined-telemetry packet at enqueue time, returns a fresh
/// <see cref="TrackedOperationId"/>, 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
/// <see cref="ExternalCallResult"/> must surface to the caller unchanged.
/// </summary>
public class ExternalSystemCachedCallEmissionTests
{
private sealed class CapturingForwarder : ICachedCallTelemetryForwarder
{
public List<CachedCallTelemetry> 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<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.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<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.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<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
id1),
Times.Once);
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
id2),
Times.Once);
}
[Fact]
public async Task CachedCall_ForwarderThrows_StillReturnsTrackedOperationId_OriginalCallProceeds()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.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<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
Times.Once);
}
[Fact]
public async Task CachedCall_Provenance_Populated_FromContext()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.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<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.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<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
Times.Once);
}
}