Files
scadalink-design/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs
Joseph Doherty 0149ce6180 feat(auditlog): site script-side emitters stamp ExecutionId
Move the per-script-execution Guid on ScriptRuntimeContext from
_auditCorrelationId to _executionId, and stamp it into the dedicated
AuditEvent.ExecutionId column on every script-side audit row:

- Sync ApiCall / DbWrite: ExecutionId set; CorrelationId reverts to
  null (a sync one-shot call has no operation lifecycle).
- Cached-call script-side rows (CachedSubmit, immediate-completion
  ApiCallCached + CachedResolve) and NotifySend: ExecutionId set;
  CorrelationId unchanged (per-operation TrackedOperationId /
  NotificationId).

Renames the threaded ctor param/field across ExternalSystemHelper,
DatabaseHelper, AuditingDbConnection and AuditingDbCommand, and threads
the id through NotifyHelper/NotifyTarget. The S&F retry-loop cached rows
(CachedCallLifecycleBridge) are out of scope here.
2026-05-21 15:05:00 -04:00

184 lines
7.2 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 E6): every script-initiated
/// <c>Database.CachedWrite</c> emits exactly one <c>CachedSubmit</c>
/// combined-telemetry packet at enqueue time on the <c>DbOutbound</c>
/// channel, returns a fresh <see cref="TrackedOperationId"/>, and threads
/// the id into the database gateway so the store-and-forward retry loop can
/// emit per-attempt + terminal telemetry under the same id.
/// </summary>
public class DatabaseCachedWriteEmissionTests
{
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:WriteAudit";
/// <summary>
/// Audit Log #23: 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.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
ICachedCallTelemetryForwarder? forwarder)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
InstanceName,
NullLogger.Instance,
// Audit Log #23: the per-execution id stamped into ExecutionId on
// every script-side row. Cached rows keep CorrelationId =
// TrackedOperationId (the per-operation lifecycle id).
TestExecutionId,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder);
}
[Fact]
public async Task CachedWrite_EmitsSubmitTelemetry_OnEnqueue_KindCachedSubmit_ChannelDbOutbound()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal(AuditChannel.DbOutbound, packet.Audit.Channel);
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal("myDb", packet.Audit.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.CorrelationId);
Assert.Equal(TestExecutionId, packet.Audit.ExecutionId);
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
Assert.Equal("DbOutbound", packet.Operational.Channel);
Assert.Equal("myDb", 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.TerminalAtUtc);
}
[Fact]
public async Task CachedWrite_ProvenancePopulated()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
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 CachedWrite_ReturnsTrackedOperationId_ThreadsIdToGateway()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
Times.Once);
}
[Fact]
public async Task CachedWrite_ForwarderThrows_StillReturnsTrackedOperationId()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder
{
ThrowOnForward = new InvalidOperationException("simulated forwarder outage"),
};
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
Times.Once);
}
}