feat(inbound): expose read-only Database helper on InboundScriptContext

This commit is contained in:
Joseph Doherty
2026-06-16 21:45:13 -04:00
parent 16fc62bfa0
commit daff1446d8
3 changed files with 128 additions and 2 deletions
@@ -1,9 +1,13 @@
using System.Data.Common;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
@@ -539,6 +543,86 @@ public class InboundScriptExecutorTests
Assert.Null(captured!.ParentExecutionId);
}
// --- IpsenMES MoveIn: the read-only Database helper is exposed on the script
// context and resolves IDatabaseGateway from a DI scope per execution. ---
[Fact]
public async Task Script_UsingDatabase_QueriesViaGateway()
{
// A script that calls Database.QuerySingle runs against an executor whose
// ServiceProvider registers an IDatabaseGateway backed by in-memory SQLite.
var services = new ServiceCollection();
services.AddSingleton<IDatabaseGateway>(SeededSqliteGateway());
using var provider = services.BuildServiceProvider();
var executor = new InboundScriptExecutor(
NullLogger<InboundScriptExecutor>.Instance, provider);
var method = new ApiMethod(
"movein",
"return new { v = Database.QuerySingle<string>(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] }) };")
{
Id = 1,
TimeoutSeconds = 10,
// null ReturnDefinition → ReturnValueValidator is unconstrained.
ReturnDefinition = null,
};
var result = await executor.ExecuteAsync(
method,
new Dictionary<string, object?> { { "sap", "131453" } },
_route,
TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
Assert.Contains("Z28061A", result.ResultJson!);
}
[Fact]
public async Task Script_NotUsingDatabase_RunsWithProviderLackingGateway()
{
// A script that never touches Database must run even when no IDatabaseGateway
// is registered — the helper is built via GetService (nullable) and only
// throws on first use, which this script never reaches.
var executor = new InboundScriptExecutor(
NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
var method = new ApiMethod("no-db", "return 99;") { Id = 1, TimeoutSeconds = 10 };
var result = await executor.ExecuteAsync(
method,
new Dictionary<string, object?>(),
_route,
TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
Assert.Contains("99", result.ResultJson!);
}
// SQLite-backed IDatabaseGateway fake (mirrors InboundDatabaseHelperTests). The
// shared in-memory db is seeded with Machine(Code,SAPID)=('Z28061A','131453').
private sealed class SqliteGateway : IDatabaseGateway
{
private readonly string _cs;
public SqliteGateway(string cs) => _cs = cs;
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
{ var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
IReadOnlyDictionary<string, object?>? 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 SeededSqliteGateway()
{
var keep = new SqliteConnection("DataSource=file:movein-exec?mode=memory&cache=shared");
keep.Open(); // keep-alive: shared in-memory db lives until process exit
using var cmd = keep.CreateCommand();
cmd.CommandText = "CREATE TABLE IF NOT EXISTS Machine(Code TEXT, SAPID TEXT); DELETE FROM Machine; INSERT INTO Machine VALUES('Z28061A','131453');";
cmd.ExecuteNonQuery();
return new SqliteGateway("DataSource=file:movein-exec?mode=memory&cache=shared");
}
private sealed class CompileLogCounter
{
public int CompilationFailures;