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).
222 lines
8.7 KiB
C#
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);
|
|
}
|
|
}
|