using Akka.Actor; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using Moq; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types.Enums; using ScadaLink.SiteRuntime.Scripts; namespace ScadaLink.SiteRuntime.Tests.Scripts; /// /// Audit Log #23 — execution-correlation tests exercised through a full /// : /// /// /// /// The ?? Guid.NewGuid() fallback in the /// ctor: when no execution id is supplied (tag-change / timer-triggered /// executions) a fresh, non-empty id is minted and stamped on the emitted rows. /// /// /// The execution-wide contract: an ExternalSystem.Call and a sync /// Database write performed through ONE context share a single /// . The per-operation /// stays null for these sync one-shot /// calls — a sync call has no operation lifecycle. /// /// /// public class ExecutionCorrelationContextTests { /// /// In-memory capturing every emitted event /// (mirrors the CapturingAuditWriter stubs in /// / /// ). /// private sealed class CapturingAuditWriter : IAuditWriter { public List Events { get; } = new(); public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; } } private const string InstanceName = "Plant.Pump42"; private const string ConnectionName = "machineData"; /// /// Builds a full wired with the external /// system client, database gateway and audit writer the cross-helper test /// needs. The actor refs are — the /// integration helpers (ExternalSystem / Database) never touch them — and /// defaults to null so the ctor's /// ?? Guid.NewGuid() fallback is exercised unless a test supplies one. /// private static ScriptRuntimeContext CreateContext( IExternalSystemClient? externalSystemClient, IDatabaseGateway? databaseGateway, IAuditWriter? auditWriter, Guid? executionId = null, Guid? parentExecutionId = null) { var compilationService = new ScriptCompilationService( NullLogger.Instance); var sharedScriptLibrary = new SharedScriptLibrary( compilationService, NullLogger.Instance); return new ScriptRuntimeContext( ActorRefs.Nobody, ActorRefs.Nobody, sharedScriptLibrary, currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5), instanceName: InstanceName, logger: NullLogger.Instance, externalSystemClient: externalSystemClient, databaseGateway: databaseGateway, storeAndForward: null, siteCommunicationActor: null, siteId: "site-77", sourceScript: "ScriptActor:OnTick", auditWriter: auditWriter, operationTrackingStore: null, cachedForwarder: null, executionId: executionId, parentExecutionId: parentExecutionId); } /// /// Reads a private / field off a /// . The ParentExecutionId plumbing (Audit /// Log #23, Task 4) only stores the value on the context — no emitter stamps /// it onto an audit row yet (that is Task 5) — so the field is inspected /// directly rather than through an emitted row. /// private static object? ReadPrivateField(ScriptRuntimeContext context, string fieldName) { var field = typeof(ScriptRuntimeContext).GetField( fieldName, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); Assert.NotNull(field); return field!.GetValue(context); } /// /// Spin up a fresh in-memory SQLite database with a tiny single-table /// schema. The keep-alive root must outlive any auditing wrapper the test /// exercises (mirrors DatabaseSyncEmissionTests.NewInMemoryDb). /// private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive) { var dbName = $"db-{Guid.NewGuid():N}"; var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; keepAlive = new SqliteConnection(connStr); keepAlive.Open(); using (var seed = keepAlive.CreateCommand()) { seed.CommandText = "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);"; seed.ExecuteNonQuery(); } var live = new SqliteConnection(connStr); live.Open(); return live; } [Fact] public async Task NoExecutionIdSupplied_SyncCall_StampsFreshNonEmptyExecutionId() { // No executionId argument — the ScriptRuntimeContext ctor's // `?? Guid.NewGuid()` fallback must mint one (this is the unsupplied-id // branch every other audit test bypasses by passing an explicit id). var client = new Mock(); client .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var writer = new CapturingAuditWriter(); var context = CreateContext(client.Object, databaseGateway: null, writer); await context.ExternalSystem.Call("ERP", "GetOrder"); var evt = Assert.Single(writer.Events); Assert.NotNull(evt.ExecutionId); Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); // A sync one-shot call has no operation lifecycle — CorrelationId is null. Assert.Null(evt.CorrelationId); } [Fact] public async Task SameContext_ApiCallAndDbWrite_ShareTheSameExecutionId() { // The execution-wide contract: an ExternalSystem.Call AND a sync // Database write performed through ONE ScriptRuntimeContext must both // carry the same ExecutionId, so an audit reader can tie every // trust-boundary action from one script run together. using var keepAlive = new SqliteConnection("Data Source=ecc;Mode=Memory;Cache=Shared"); var innerDb = NewInMemoryDb(out var _); var client = new Mock(); client .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var gateway = new Mock(); gateway .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .ReturnsAsync(innerDb); var writer = new CapturingAuditWriter(); var context = CreateContext(client.Object, gateway.Object, writer); // 1) outbound API call through the context's ExternalSystem helper. await context.ExternalSystem.Call("ERP", "GetOrder"); // 2) sync DB write through the SAME context's Database helper. await using (var conn = await context.Database.Connection(ConnectionName)) await using (var cmd = conn.CreateCommand()) { cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')"; await cmd.ExecuteNonQueryAsync(); } Assert.Equal(2, writer.Events.Count); var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound); var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound); Assert.NotNull(apiRow.ExecutionId); Assert.NotEqual(Guid.Empty, apiRow.ExecutionId!.Value); // The ApiCall row and the DbWrite row, emitted by two different helpers // resolved off one context, carry the identical ExecutionId. Assert.Equal(apiRow.ExecutionId, dbRow.ExecutionId); // Both are sync one-shot calls — neither carries a CorrelationId. Assert.Null(apiRow.CorrelationId); Assert.Null(dbRow.CorrelationId); } [Fact] public void ParentExecutionIdSupplied_StoredVerbatim_AndOwnExecutionIdIsFreshAndDistinct() { // Audit Log #23 (ParentExecutionId, Task 4): an inbound-API-routed call // supplies the spawning execution's ExecutionId as the routed script's // ParentExecutionId. The context must store that value verbatim AND // still mint its OWN fresh ExecutionId — the routed script is a new // execution, it does not inherit the parent's id. var parentExecutionId = Guid.NewGuid(); var context = CreateContext( externalSystemClient: null, databaseGateway: null, auditWriter: null, // executionId omitted — the ctor's `?? Guid.NewGuid()` fallback runs. parentExecutionId: parentExecutionId); var storedParent = ReadPrivateField(context, "_parentExecutionId"); var ownExecutionId = ReadPrivateField(context, "_executionId"); // The parent id is carried through untouched. Assert.Equal(parentExecutionId, storedParent); // The routed script's own ExecutionId is freshly generated, non-empty, // and NOT the parent id — they are separate correlation values. Assert.NotNull(ownExecutionId); var ownId = Assert.IsType(ownExecutionId); Assert.NotEqual(Guid.Empty, ownId); Assert.NotEqual(parentExecutionId, ownId); } [Fact] public void NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNull() { // A normal (tag-change / timer) script run is not inbound-API-routed — // no ParentExecutionId is supplied, so _parentExecutionId stays null // while the run still gets its own fresh ExecutionId. var context = CreateContext( externalSystemClient: null, databaseGateway: null, auditWriter: null); var storedParent = ReadPrivateField(context, "_parentExecutionId"); var ownExecutionId = ReadPrivateField(context, "_executionId"); Assert.Null(storedParent); var ownId = Assert.IsType(ownExecutionId); Assert.NotEqual(Guid.Empty, ownId); } }