513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
669 lines
31 KiB
C#
669 lines
31 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
{
|
|
/// <summary>Gets the telemetry packets the forwarder has received.</summary>
|
|
public List<CachedCallTelemetry> Telemetry { get; } = new();
|
|
/// <summary>Gets or sets a value indicating whether the forwarder should throw when forwarding.</summary>
|
|
public Exception? ThrowOnForward { get; set; }
|
|
|
|
/// <inheritdoc />
|
|
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";
|
|
|
|
/// <summary>
|
|
/// A fixed per-execution id so the cached-row tests can
|
|
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
|
|
/// </summary>
|
|
private static readonly Guid TestExecutionId = Guid.NewGuid();
|
|
|
|
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
|
|
IExternalSystemClient client,
|
|
ICachedCallTelemetryForwarder? forwarder,
|
|
Guid? parentExecutionId = null)
|
|
{
|
|
return new ScriptRuntimeContext.ExternalSystemHelper(
|
|
client,
|
|
InstanceName,
|
|
NullLogger.Instance,
|
|
// The per-execution id stamped into ExecutionId on
|
|
// every script-side row. Cached rows keep CorrelationId =
|
|
// TrackedOperationId (the per-operation lifecycle id).
|
|
TestExecutionId,
|
|
auditWriter: null,
|
|
siteId: SiteId,
|
|
sourceScript: SourceScript,
|
|
cachedForwarder: forwarder,
|
|
parentExecutionId: parentExecutionId);
|
|
}
|
|
|
|
/// <summary>Verifies that a cached call emits submit telemetry on enqueue.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.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.AsRow().Channel);
|
|
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.AsRow().Kind);
|
|
Assert.Equal(AuditStatus.Submitted, packet.Audit.AsRow().Status);
|
|
Assert.Equal("ERP.GetOrder", packet.Audit.AsRow().Target);
|
|
// CorrelationId is the per-operation lifecycle id (TrackedOperationId);
|
|
// ExecutionId is the per-execution id from the runtime context.
|
|
Assert.Equal(trackedId.Value, packet.Audit.AsRow().CorrelationId);
|
|
Assert.Equal(TestExecutionId, packet.Audit.AsRow().ExecutionId);
|
|
// ForwardState is no longer a field on the canonical
|
|
// record — it is a site-storage-only concern (the SQLite writer defaults
|
|
// it to Pending on insert), so the telemetry packet no longer carries it.
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>Verifies that an immediately completed cached call captures the request arguments and the response body.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CachedCall_ImmediateCompletion_CapturesRequestArgs_AndResponseBody()
|
|
{
|
|
var client = new Mock<IExternalSystemClient>();
|
|
client
|
|
.Setup(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(client.Object, forwarder);
|
|
var args = new Dictionary<string, object?> { ["orderId"] = 42 };
|
|
await helper.CachedCall("ERP", "GetOrder", args);
|
|
|
|
// Immediate completion (WasBuffered=false) emits Submit, Attempted, Resolve.
|
|
Assert.Equal(3, forwarder.Telemetry.Count);
|
|
var submit = forwarder.Telemetry.Single(t => t.Audit.AsRow().Kind == AuditKind.CachedSubmit);
|
|
var attempted = forwarder.Telemetry.Single(t => t.Audit.AsRow().Kind == AuditKind.ApiCallCached);
|
|
var resolve = forwarder.Telemetry.Single(t => t.Audit.AsRow().Kind == AuditKind.CachedResolve);
|
|
|
|
// Every row carries the request args; the two post-call rows also carry
|
|
// the response body (Submit precedes the call, so it has no response).
|
|
Assert.Equal("{\"orderId\":42}", submit.Audit.AsRow().RequestSummary);
|
|
Assert.Null(submit.Audit.AsRow().ResponseSummary);
|
|
|
|
Assert.Equal("{\"orderId\":42}", attempted.Audit.AsRow().RequestSummary);
|
|
Assert.Equal("{\"ok\":true}", attempted.Audit.AsRow().ResponseSummary);
|
|
|
|
Assert.Equal("{\"orderId\":42}", resolve.Audit.AsRow().RequestSummary);
|
|
Assert.Equal("{\"ok\":true}", resolve.Audit.AsRow().ResponseSummary);
|
|
}
|
|
|
|
/// <summary>Verifies that a cached call returns a tracked operation id.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.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,
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
|
|
Times.Once);
|
|
client.Verify(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
id2,
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The helper → gateway hop of the
|
|
/// threading chain. The cached-call helper must forward the runtime
|
|
/// context's <c>ExecutionId</c> and <c>SourceScript</c> verbatim into
|
|
/// <see cref="IExternalSystemClient.CachedCallAsync"/> — so the buffered
|
|
/// retry loop later stamps the right provenance onto its audit rows.
|
|
/// This asserts the exact id/script (not <c>It.IsAny</c>), so a regression
|
|
/// that dropped the threading would fail here.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CachedCall_ThreadsExecutionIdAndSourceScript_IntoClient()
|
|
{
|
|
var client = new Mock<IExternalSystemClient>();
|
|
client
|
|
.Setup(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(client.Object, forwarder);
|
|
await helper.CachedCall("ERP", "GetOrder");
|
|
|
|
// The known TestExecutionId and SourceScript must reach the client
|
|
// unchanged — these are what the S&F retry loop persists and replays.
|
|
client.Verify(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.Is<Guid?>(id => id == TestExecutionId),
|
|
It.Is<string?>(s => s == SourceScript),
|
|
It.IsAny<Guid?>()),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The helper → gateway hop for
|
|
/// <c>ParentExecutionId</c>. A cached call enqueued from an inbound-API-
|
|
/// routed script run must forward the runtime context's
|
|
/// <c>ParentExecutionId</c> verbatim into
|
|
/// <see cref="IExternalSystemClient.CachedCallAsync"/> so the buffered
|
|
/// retry loop later stamps it onto its audit rows.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CachedCall_ThreadsParentExecutionId_IntoClient()
|
|
{
|
|
var parentExecutionId = Guid.NewGuid();
|
|
var client = new Mock<IExternalSystemClient>();
|
|
client
|
|
.Setup(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(client.Object, forwarder, parentExecutionId);
|
|
await helper.CachedCall("ERP", "GetOrder");
|
|
|
|
client.Verify(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(),
|
|
It.Is<Guid?>(id => id == parentExecutionId)),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A non-routed run threads a
|
|
/// <c>null</c> ParentExecutionId into the client — the additive default.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CachedCall_NonRoutedRun_ThreadsNullParentExecutionId_IntoClient()
|
|
{
|
|
var client = new Mock<IExternalSystemClient>();
|
|
client
|
|
.Setup(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(client.Object, forwarder);
|
|
await helper.CachedCall("ERP", "GetOrder");
|
|
|
|
client.Verify(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(),
|
|
It.Is<Guid?>(id => id == null)),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>Verifies that a cached call still returns the tracked operation id and proceeds when the forwarder throws.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.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,
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>Verifies that cached call provenance is populated from the context.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.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.AsRow().SourceSiteId);
|
|
Assert.Equal(InstanceName, packet.Audit.AsRow().SourceInstanceId);
|
|
Assert.Equal(SourceScript, packet.Audit.AsRow().SourceScript);
|
|
Assert.Equal(SiteId, packet.Operational.SourceSite);
|
|
}
|
|
|
|
/// <summary>Verifies that a cached call without a forwarder still returns the tracked operation id.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.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,
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>
|
|
/// When the underlying client call
|
|
/// completes immediately (no S&F buffering, <c>WasBuffered=false</c>),
|
|
/// the S&F retry loop never engages and the
|
|
/// <c>ICachedCallLifecycleObserver</c> hook never fires. The cached-call
|
|
/// helper itself must therefore emit the terminal lifecycle rows —
|
|
/// otherwise <c>Tracking.Status(id)</c> would return <c>Submitted</c>
|
|
/// forever and the audit log would be missing the <c>Attempted</c> /
|
|
/// <c>CachedResolve</c> pair the contract requires.
|
|
///
|
|
/// Expected emissions on immediate success:
|
|
/// 1. CachedSubmit / Submitted (already covered)
|
|
/// 2. ApiCallCached / Attempted
|
|
/// 3. CachedResolve / Delivered (TerminalAtUtc set)
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CachedCall_ImmediateSuccess_EmitsAttemptedAndCachedResolve()
|
|
{
|
|
var client = new Mock<IExternalSystemClient>();
|
|
client
|
|
.Setup(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
// WasBuffered=false — the immediate HTTP attempt succeeded; S&F
|
|
// is bypassed entirely.
|
|
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(client.Object, forwarder);
|
|
var trackedId = await helper.CachedCall("ERP", "GetOrder");
|
|
|
|
// Three telemetry packets emitted: Submit, Attempted, Resolve.
|
|
Assert.Equal(3, forwarder.Telemetry.Count);
|
|
|
|
var submit = forwarder.Telemetry[0];
|
|
Assert.Equal(AuditKind.CachedSubmit, submit.Audit.AsRow().Kind);
|
|
Assert.Equal(AuditStatus.Submitted, submit.Audit.AsRow().Status);
|
|
Assert.Equal(TestExecutionId, submit.Audit.AsRow().ExecutionId);
|
|
Assert.Equal(trackedId, submit.Operational.TrackedOperationId);
|
|
Assert.Null(submit.Operational.TerminalAtUtc);
|
|
|
|
var attempted = forwarder.Telemetry[1];
|
|
Assert.Equal(AuditChannel.ApiOutbound, attempted.Audit.AsRow().Channel);
|
|
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.AsRow().Kind);
|
|
Assert.Equal(AuditStatus.Attempted, attempted.Audit.AsRow().Status);
|
|
// Cached rows: CorrelationId = TrackedOperationId; ExecutionId is the
|
|
// per-execution id from the runtime context.
|
|
Assert.Equal(trackedId.Value, attempted.Audit.AsRow().CorrelationId);
|
|
Assert.Equal(TestExecutionId, attempted.Audit.AsRow().ExecutionId);
|
|
Assert.Equal("ERP.GetOrder", attempted.Audit.AsRow().Target);
|
|
Assert.Equal(trackedId, attempted.Operational.TrackedOperationId);
|
|
Assert.Equal("Attempted", attempted.Operational.Status);
|
|
Assert.Null(attempted.Operational.TerminalAtUtc);
|
|
|
|
var resolve = forwarder.Telemetry[2];
|
|
Assert.Equal(AuditChannel.ApiOutbound, resolve.Audit.AsRow().Channel);
|
|
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.AsRow().Kind);
|
|
Assert.Equal(AuditStatus.Delivered, resolve.Audit.AsRow().Status);
|
|
Assert.Equal(trackedId.Value, resolve.Audit.AsRow().CorrelationId);
|
|
Assert.Equal(TestExecutionId, resolve.Audit.AsRow().ExecutionId);
|
|
Assert.Equal(trackedId, resolve.Operational.TrackedOperationId);
|
|
Assert.Equal("Delivered", resolve.Operational.Status);
|
|
// Terminal row carries TerminalAtUtc.
|
|
Assert.NotNull(resolve.Operational.TerminalAtUtc);
|
|
|
|
// ParentExecutionId: null on every script-side cached
|
|
// row for a non-routed run.
|
|
Assert.Null(submit.Audit.AsRow().ParentExecutionId);
|
|
Assert.Null(attempted.Audit.AsRow().ParentExecutionId);
|
|
Assert.Null(resolve.Audit.AsRow().ParentExecutionId);
|
|
}
|
|
|
|
/// <summary>Verifies that a routed run stamps the parent execution id on all script-side rows.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CachedCall_RoutedRun_StampsParentExecutionId_OnAllScriptSideRows()
|
|
{
|
|
// An inbound-API-routed run
|
|
// carries the spawning execution's id; every script-side cached row
|
|
// (CachedSubmit, ApiCallCached, CachedResolve) must stamp it in
|
|
// ParentExecutionId.
|
|
var parentExecutionId = Guid.NewGuid();
|
|
var client = new Mock<IExternalSystemClient>();
|
|
client
|
|
.Setup(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(client.Object, forwarder, parentExecutionId);
|
|
await helper.CachedCall("ERP", "GetOrder");
|
|
|
|
Assert.Equal(3, forwarder.Telemetry.Count);
|
|
Assert.All(forwarder.Telemetry, t =>
|
|
Assert.Equal(parentExecutionId, t.Audit.AsRow().ParentExecutionId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The immediate-failure terminal
|
|
/// path. When the client returns <c>Success=false</c> with
|
|
/// <c>WasBuffered=false</c> (a permanent failure or a transient failure
|
|
/// without an S&F engine to buffer it), the cached-call helper must
|
|
/// still emit Attempted + CachedResolve with the failed status.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CachedCall_ImmediateFailure_EmitsAttemptedAndCachedResolveFailed()
|
|
{
|
|
var client = new Mock<IExternalSystemClient>();
|
|
client
|
|
.Setup(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.ReturnsAsync(new ExternalCallResult(
|
|
false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false));
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(client.Object, forwarder);
|
|
var trackedId = await helper.CachedCall("ERP", "GetOrder");
|
|
|
|
Assert.Equal(3, forwarder.Telemetry.Count);
|
|
|
|
var attempted = forwarder.Telemetry[1];
|
|
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.AsRow().Kind);
|
|
Assert.Equal(AuditStatus.Attempted, attempted.Audit.AsRow().Status);
|
|
// The per-attempt row carries the error message.
|
|
Assert.NotNull(attempted.Audit.AsRow().ErrorMessage);
|
|
|
|
var resolve = forwarder.Telemetry[2];
|
|
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.AsRow().Kind);
|
|
// Immediate permanent failure -> Failed audit status / operational Failed.
|
|
Assert.Equal(AuditStatus.Failed, resolve.Audit.AsRow().Status);
|
|
Assert.Equal("Failed", resolve.Operational.Status);
|
|
Assert.NotNull(resolve.Operational.TerminalAtUtc);
|
|
Assert.NotNull(resolve.Operational.LastError);
|
|
}
|
|
|
|
/// <summary>
|
|
/// When the client reports
|
|
/// <c>WasBuffered=true</c>, the helper hands the operation to S&F and
|
|
/// the retry-loop observer owns the Attempted + Resolve emissions. The
|
|
/// helper must NOT emit those rows itself (otherwise we'd get duplicate
|
|
/// Attempted + Resolve audit rows under the same tracking id).
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CachedCall_BufferedPath_DoesNotEmitTerminalTelemetryFromHelper()
|
|
{
|
|
var client = new Mock<IExternalSystemClient>();
|
|
client
|
|
.Setup(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
// S&F took ownership — Attempted + Resolve come from the
|
|
// CachedCallLifecycleBridge driven by the retry loop, not the helper.
|
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(client.Object, forwarder);
|
|
await helper.CachedCall("ERP", "GetOrder");
|
|
|
|
// Only the CachedSubmit row — no Attempted / Resolve from the helper.
|
|
var only = Assert.Single(forwarder.Telemetry);
|
|
Assert.Equal(AuditKind.CachedSubmit, only.Audit.AsRow().Kind);
|
|
}
|
|
|
|
// ── SourceNode-stamping ──
|
|
|
|
/// <summary>Verifies that a cached call stamps the source node on every site call operational row.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CachedCall_StampsSourceNode_OnEverySiteCallOperationalRow()
|
|
{
|
|
// SourceNode stamping: when the helper is constructed with
|
|
// a non-null sourceNode, every SiteCallOperational it produces
|
|
// (CachedSubmit on enqueue + the immediate-completion Attempted/
|
|
// CachedResolve pair when WasBuffered=false) carries that node name.
|
|
var client = new Mock<IExternalSystemClient>();
|
|
client
|
|
.Setup(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
// Immediate completion — helper produces all three rows itself.
|
|
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = new ScriptRuntimeContext.ExternalSystemHelper(
|
|
client.Object,
|
|
InstanceName,
|
|
NullLogger.Instance,
|
|
TestExecutionId,
|
|
auditWriter: null,
|
|
siteId: SiteId,
|
|
sourceScript: SourceScript,
|
|
cachedForwarder: forwarder,
|
|
parentExecutionId: null,
|
|
sourceNode: "node-a");
|
|
|
|
await helper.CachedCall("ERP", "GetOrder");
|
|
|
|
Assert.Equal(3, forwarder.Telemetry.Count);
|
|
Assert.All(forwarder.Telemetry, t => Assert.Equal("node-a", t.Operational.SourceNode));
|
|
}
|
|
|
|
/// <summary>Verifies that a cached call with no source node wired leaves the source node null.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CachedCall_NoSourceNodeWired_LeavesSourceNodeNull()
|
|
{
|
|
// Default CreateHelper does NOT pass sourceNode — the legacy / test
|
|
// host path. Every operational row carries null SourceNode, leaving
|
|
// central's SiteCalls.SourceNode NULL.
|
|
var client = new Mock<IExternalSystemClient>();
|
|
client
|
|
.Setup(c => c.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
|
InstanceName,
|
|
It.IsAny<CancellationToken>(),
|
|
It.IsAny<TrackedOperationId?>(),
|
|
It.IsAny<Guid?>(), It.IsAny<string?>(), It.IsAny<Guid?>()))
|
|
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
|
var forwarder = new CapturingForwarder();
|
|
|
|
var helper = CreateHelper(client.Object, forwarder);
|
|
await helper.CachedCall("ERP", "GetOrder");
|
|
|
|
var only = Assert.Single(forwarder.Telemetry);
|
|
Assert.Null(only.Operational.SourceNode);
|
|
}
|
|
}
|