using System.Data.Common; using Microsoft.Data.Sqlite; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// /// Flow Debugger outbound calls (plan 2026-09-06, Task 4 / D7): every /// call made by a central method script writes ONE /// DbOutbound.DbWrite row through — best-effort, /// never into the script — using the site's AuditingDbCommand vocabulary /// ({"op":"write","rowsAffected":N} / {"op":"read","rowsReturned":N}), and /// hands the same instance to the flow-trace sink first when one is present. /// public class InboundDatabaseHelperAuditTests { // One shared-cache in-memory database per gateway instance. Such a database lives only // while a connection to it is open, so the gateway ROOTS its keep-alive connection for // its own lifetime — an unrooted local would be GC-finalized mid-test under load (the // executor test's Roslyn compile is long enough) and the table would silently vanish. private sealed class SqliteGateway : IDatabaseGateway { private readonly string _cs = $"DataSource=file:dbaudit-{Guid.NewGuid():N}?mode=memory&cache=shared"; private readonly SqliteConnection _keepAlive; public SqliteGateway() { _keepAlive = new SqliteConnection(_cs); _keepAlive.Open(); using var cmd = _keepAlive.CreateCommand(); cmd.CommandText = "CREATE TABLE Machine(Code TEXT, SAPID TEXT); INSERT INTO Machine VALUES('Z28061A','131453');"; cmd.ExecuteNonQuery(); } public async Task GetConnectionAsync(string name, CancellationToken ct = default) { var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; } public Task CachedWriteAsync(string c, string s, IReadOnlyDictionary? p = null, string? o = null, CancellationToken ct = default, TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null) => throw new NotImplementedException(); } private sealed class ThrowingGateway : IDatabaseGateway { public Task GetConnectionAsync(string name, CancellationToken ct = default) => throw new InvalidOperationException("no such connection: " + name); public Task CachedWriteAsync(string c, string s, IReadOnlyDictionary? p = null, string? o = null, CancellationToken ct = default, TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null) => throw new NotImplementedException(); } private static SqliteGateway SeededGateway() => new(); private readonly List _written = new(); private readonly ICentralAuditWriter _writer = Substitute.For(); private readonly INodeIdentityProvider _node = Substitute.For(); private readonly Guid _exec = Guid.NewGuid(); private readonly Guid _parent = Guid.NewGuid(); public InboundDatabaseHelperAuditTests() { _writer.WriteAsync(Arg.Do(e => _written.Add(e)), Arg.Any()).Returns(Task.CompletedTask); _node.NodeName.Returns("central-a"); } private InboundDatabaseHelper Helper(IDatabaseGateway? gateway = null, Action? sink = null, ICentralAuditWriter? writer = null) => new(gateway ?? SeededGateway(), CancellationToken.None, TimeSpan.Zero, auditWriter: writer ?? _writer, nodeIdentity: _node, executionId: _exec, parentExecutionId: _parent, methodName: "movein", traceSink: sink); [Fact] public async Task ExecuteAsync_WritesDbWriteDeliveredRow_WithRowsAffected() { var helper = Helper(); var n = await helper.ExecuteAsync("MesDb", "UPDATE Machine SET Code=@c WHERE SAPID=@s", new { c = "Z28061A", s = "131453" }); Assert.Equal(1, n); var evt = Assert.Single(_written); var row = evt.AsRow(); Assert.Equal(AuditChannel.DbOutbound, row.Channel); Assert.Equal(AuditKind.DbWrite, row.Kind); Assert.Equal(AuditStatus.Delivered, row.Status); Assert.Equal("MesDb", evt.Target); Assert.Equal("api:movein", evt.Actor); Assert.Equal("central-a", evt.SourceNode); Assert.Equal(_exec, row.ExecutionId); Assert.Equal(_parent, row.ParentExecutionId); Assert.Null(row.CorrelationId); Assert.NotNull(row.DurationMs); Assert.Null(row.ErrorMessage); Assert.Equal("{\"op\":\"write\",\"rowsAffected\":1}", row.Extra); Assert.Contains("\"sql\":\"UPDATE Machine SET Code=@c WHERE SAPID=@s\"", row.RequestSummary); Assert.Contains("\"@s\":\"131453\"", row.RequestSummary); Assert.Contains("\"@c\":\"Z28061A\"", row.RequestSummary); } [Fact] public async Task QueryAsync_WritesReadRow_WithRowsReturned() { var helper = Helper(); var rows = await helper.QueryAsync("MesDb", "SELECT Code, SAPID FROM Machine WHERE SAPID=@s", new { s = "131453" }); Assert.Single(rows); var row = Assert.Single(_written).AsRow(); Assert.Equal(AuditKind.DbWrite, row.Kind); Assert.Equal(AuditStatus.Delivered, row.Status); Assert.Equal("{\"op\":\"read\",\"rowsReturned\":1}", row.Extra); } [Fact] public async Task QuerySingleAsync_WritesReadRow_ZeroRowsWhenNoMatch() { var helper = Helper(); var code = await helper.QuerySingleAsync("MesDb", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "nope" }); Assert.Null(code); var row = Assert.Single(_written).AsRow(); Assert.Equal("{\"op\":\"read\",\"rowsReturned\":0}", row.Extra); } [Fact] public async Task ThrowingGateway_WritesFailedRow_AndRethrowsToScript() { var helper = Helper(new ThrowingGateway()); var ex = await Assert.ThrowsAsync( () => helper.ExecuteAsync("Missing", "DELETE FROM Machine", null)); Assert.Contains("no such connection", ex.Message); var evt = Assert.Single(_written); var row = evt.AsRow(); Assert.Equal(AuditStatus.Failed, row.Status); Assert.Equal("Missing", evt.Target); Assert.Equal(ex.Message, row.ErrorMessage); Assert.Equal("{\"op\":\"write\",\"rowsAffected\":0}", row.Extra); Assert.Contains("\"sql\":\"DELETE FROM Machine\"", row.RequestSummary); } [Fact] public async Task AuditWriterFaulting_DoesNotFailTheCall() { var syncThrow = Substitute.For(); syncThrow.WriteAsync(Arg.Any(), Arg.Any()) .Returns(_ => throw new InvalidOperationException("audit down")); var faultedTask = Substitute.For(); faultedTask.WriteAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromException(new InvalidOperationException("audit down"))); var n1 = await Helper(writer: syncThrow).ExecuteAsync("MesDb", "UPDATE Machine SET Code=Code", null); var n2 = await Helper(writer: faultedTask).ExecuteAsync("MesDb", "UPDATE Machine SET Code=Code", null); Assert.Equal(1, n1); Assert.Equal(1, n2); } [Fact] public async Task TraceSink_ReceivesTheSameInstance_BeforeTheWriter() { var order = new List<(string Who, AuditEvent Evt)>(); _writer.WriteAsync(Arg.Do(e => order.Add(("writer", e))), Arg.Any()).Returns(Task.CompletedTask); var helper = Helper(sink: e => order.Add(("sink", e))); await helper.ExecuteAsync("MesDb", "UPDATE Machine SET Code=Code", null); Assert.Equal(new[] { "sink", "writer" }, order.Select(o => o.Who)); Assert.Same(order[0].Evt, order[1].Evt); } [Fact] public async Task ThrowingSink_DoesNotFailTheCall_AndWriterStillGetsTheRow() { var helper = Helper(sink: _ => throw new InvalidOperationException("bad sink")); var n = await helper.ExecuteAsync("MesDb", "UPDATE Machine SET Code=Code", null); Assert.Equal(1, n); Assert.Single(_written); } [Fact] public async Task NoWriterNoSink_EmitsNothing_AndStillWorks() { var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None); var n = await helper.ExecuteAsync("MesDb", "UPDATE Machine SET Code=Code", null); Assert.Equal(1, n); Assert.Empty(_written); } [Fact] public async Task Executor_WiresWriterNodeIdentityExecutionIdAndRouteSink_IntoTheHelper() { // End-to-end through InboundScriptExecutor: the DbOutbound row carries the inbound // request's ExecutionId, "api:{method}" as actor, SourceNode from DI, and the same // instance reaches the RouteHelper's trace sink (the Flow Debugger's Live path). var services = new ServiceCollection(); services.AddSingleton(SeededGateway()); services.AddSingleton(_writer); services.AddSingleton(_node); using var provider = services.BuildServiceProvider(); var executor = new InboundScriptExecutor(NullLogger.Instance, provider); var sunk = new List(); var route = new RouteHelper(Substitute.For(), Substitute.For()) .WithTraceSink(sunk.Add); var method = new ApiMethod( "movein", "return await Database.ExecuteAsync(\"MesDb\", \"UPDATE Machine SET Code=@c WHERE SAPID=@s\", new { c = \"Z28061A\", s = \"131453\" });") { Id = 1, TimeoutSeconds = 10, ReturnDefinition = null }; var inboundExecution = Guid.NewGuid(); var result = await executor.ExecuteAsync( method, new Dictionary(), route, TimeSpan.FromSeconds(10), parentExecutionId: inboundExecution, trace: true); Assert.True(result.Success, result.ErrorMessage); var evt = Assert.Single(_written); var row = evt.AsRow(); Assert.Equal(AuditChannel.DbOutbound, row.Channel); Assert.Equal(inboundExecution, row.ExecutionId); Assert.Null(row.ParentExecutionId); Assert.Equal("api:movein", evt.Actor); Assert.Equal("central-a", evt.SourceNode); Assert.Same(evt, Assert.Single(sunk)); } }