Compare commits

...

7 Commits

Author SHA1 Message Date
Joseph Doherty d4ec84d5fb fix(inbound): log swallowed scope-creation failure + test scope disposal on script throw 2026-06-16 22:00:10 -04:00
Joseph Doherty daff1446d8 feat(inbound): expose read-only Database helper on InboundScriptContext 2026-06-16 22:00:10 -04:00
Joseph Doherty 16fc62bfa0 test(inbound): add namespace + Query() coverage for InboundDatabaseHelper 2026-06-16 22:00:10 -04:00
Joseph Doherty 90ac746fdc feat(inbound): read-only InboundDatabaseHelper for inbound scripts 2026-06-16 22:00:10 -04:00
Joseph Doherty f63055c296 chore(inbound): task-persistence for Ipsen MES MoveIn plan 2026-06-16 22:00:10 -04:00
Joseph Doherty 6fbf3e71fd docs(inbound): implementation plan for Ipsen MES MoveIn 2026-06-16 22:00:10 -04:00
Joseph Doherty d8ccad6f54 docs(inbound): design for Ipsen MES MoveIn -> reactor MES receiver
SAPID(+side) -> BTDB Machine.SAPID -> Code -> instance; inbound script does
the lookup via a new scoped read-only DB helper, then routes to a new T1
template script that gates on MoveInReadyFlag and writes the MoveIn to the
correct Left/Right MES receiver. -LT deferred.
2026-06-16 22:00:10 -04:00
8 changed files with 923 additions and 0 deletions
@@ -0,0 +1,188 @@
# Design — Ipsen MES MoveIn → reactor MES receiver
**Date:** 2026-06-16
**Status:** Approved (design); implementation pending
**Scope:** ScadaBridge — inbound API `POST /api/IpsenMESMoveIn`, a small inbound-script DB
capability, and a new template script on the reactor template that writes a MoveIn to the
correct MES-receiver child.
**Target environment:** production `wonder-app-vd03` (Central + Site, ConfigDb `ScadaBridge`
on `wonder-app-vd03,1433`; reactors live on Site 2). BTDB = db-connection id 1 (`dbo.Machine`
on `wonder-sql-vd03/BT`).
## 1. Problem
The deployed `IpsenMESMoveIn` inbound method is a stub (`Script = Parameters["SAPID"]`). It must
become a real MoveIn: take the MES payload, resolve which reactor instance + side it targets,
and write the values onto that reactor's correct MES-receiver child — but only when that
receiver is ready to accept a MoveIn.
## 2. Verified facts (current system)
- **Inbound `/api` script context** (`InboundScriptContext`, `InboundScriptExecutor.cs`)
exposes only `Parameters`, `Route`, `CancellationToken`. **No DB access.** It returns a
JSON object validated against the method's `ReturnDefinition`; a thrown exception becomes a
generic HTTP 500 ("Internal script error"), and a return-shape mismatch becomes a 500
("…did not match its return definition"). `Route.To(instanceCode).Call(scriptName, params)`
routes to the instance's site and returns that script's result object. RouteHelper can only
target an **instance code** (no shared-script entry point).
- **Template/site scripts** (`ScriptGlobals` / `ScriptRuntimeContext`) expose
`Database.Connection(name)` + `Database.CachedWrite(...)`, `Attributes[...]`,
`Children["<comp>"].Attributes/.SetAttribute/.GetAttribute`, `Instance.SetAttribute` (string
value) which routes a `SetStaticAttributeCommand` to the Instance Actor → for data-connected
attributes the DCL performs the **authorized Galaxy write**, synchronously, throwing on
failure.
- **`DatabaseGateway`/`IDatabaseGateway`** is registered via `AddExternalSystemGateway()` in
**`Host/Program.cs`** (shared root) and `Host/SiteServiceRegistration.cs`. So the **Central**
host can also open BTDB. `DatabaseConnectionDefinition` has no `SiteId` (global).
- **Reactor template** = `IpsenFurnaceTitan` (TemplateId **1**). Instances **`Z28061`** (id 1)
and **`Z28062`** (id 2), both Site 2, Enabled. Children (TemplateCompositions on T1):
`LeftMESReceiver` (→T5), `RightMESReceiver` (→T6), `LeftReactorSide` (→T8),
`RightReactorSide` (→T9). **No leak-test receiver.** No existing TemplateScripts/SharedScripts.
- **MES-receiver attributes** live on base template T3 `MESReceiver` (full set;
`MoveInPartNumbers` + `MoveInWorkOrderNumbers` are `List<String>`; also `MoveInType`). The
derived T5/T6 still carry stale `String` placeholder rows for those two array attrs — expected
to be `IsInherited` placeholders skipped at flatten time (the T3 `List` definition wins);
**must be verified** before relying on array writes.
- **BTDB `dbo.Machine`** (141 rows): match column **`SAPID`** (nvarchar 10) → result column
**`Code`** (nvarchar 50); also `ZTag`, `Name`. The side suffix is baked into `Code`
(`Z28061A` = T5A, `Z28061B` = T5B); `ZTag` of the A-row = bare `Z28061`. `SAPID` is null on
the present reactor rows and there are no `Z28062*` rows — i.e. these are partial test rows;
production must have `SAPID` populated. The ScadaBridge instance UniqueName is the **bare**
reactor (`Z28061`/`Z28062`).
## 3. Decisions
1. **Lookup:** input value = `<SAP#>-<side>` (e.g. `131453-A`). Strip the suffix → SAP#;
`SELECT Code FROM dbo.Machine WHERE SAPID=@sap`; the matched `Code` with a single trailing
`A`/`B` removed = the **instance UniqueName**; the suffix maps `A`→Left, `B`→Right.
2. **Leak test deferred:** only `-A`/`-B` are handled now. Any other suffix (`-A-LT`,
`-B-LT`, missing, unknown) → `WasSuccessful=false` with an "unsupported side/target" message.
Leak test can be added later once its MES-receiver child + Galaxy reference exist.
3. **Architecture:** the **inbound script does the lookup** via a new scoped, read-only DB helper
on the inbound context (named connections only; no raw `DbConnection`; parameterized). Single
routed hop to the reactor template script. (Chosen over a config-only resolver-instance +
2-hop approach.)
4. **Write set (Q1):** the reactor script writes every provided param that maps to a receiver
attribute, **except** `MoveInReadyFlag` (read-only gate) and `SAPID` (routing only).
`MoveInCompleteFlag` / `MoveInSuccessfulFlag` / `MoveInErrorText` are treated as **PLC-owned
outputs and are NOT written** (even though the API accepts the first three as params).
Written: `MoveInMesContainerNum`, `MoveInBatchID`, `MoveInJobSequenceNumber`,
`MoveInNumberWorkOrders`, `MoveInWorkOrderNumbers[]`, `MoveInPartNumbers[]`,
`MoveInOperatorName`, `MoveInFlag`.
5. **Return BatchID (Q2):** echo the input `MoveInBatchID` (no read-back).
## 4. Components
### A. ScadaBridge source change — inbound read-only DB helper
- New `InboundDatabaseHelper` backed by `IDatabaseGateway`, surface:
`T? QuerySingle<T>(string conn, string sql, object? args = null)` and
`IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(string conn, string sql, object? args = null)`.
**No write methods.**
- Add `Database` property to `InboundScriptContext`; construct it in `InboundScriptExecutor`
from a service scope (gateway is scoped). Add the helper's assembly to the script
`ScriptOptions.WithReferences(...)` and confirm `ForbiddenApiChecker` still passes (the script
references only `Database.QuerySingle`, never `System.Data`).
- Unit tests with a fake `IDatabaseGateway`.
### B. Rewrite inbound `/api/IpsenMESMoveIn` script (data, via management API)
Pseudocode (always returns the 3-field shape; never throws out):
```csharp
try {
var raw = (Parameters["SAPID"] as string ?? "").Trim();
var dash = raw.IndexOf('-');
if (dash < 0) return new { WasSuccessful = false, ErrorText = "SAPID missing -A/-B side suffix", BatchID = 0 };
var sap = raw.Substring(0, dash);
var suf = raw.Substring(dash + 1); // "A", "B", "A-LT", ...
var side = suf == "A" ? "Left" : suf == "B" ? "Right" : null;
if (side == null) return new { WasSuccessful = false, ErrorText = $"Unsupported side '{suf}'", BatchID = 0 };
var code = Database.QuerySingle<string>("BTDB",
"SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s", new { s = sap });
if (string.IsNullOrEmpty(code)) return new { WasSuccessful = false, ErrorText = $"No machine for SAP {sap}", BatchID = 0 };
var instance = (code.EndsWith("A") || code.EndsWith("B")) ? code.Substring(0, code.Length - 1) : code;
return await Route.To(instance).Call("IpsenMoveIn", new {
side,
MoveInMesContainerNum = Parameters["MoveInMesContainerNum"],
MoveInBatchID = Parameters["MoveInBatchID"],
MoveInJobSequenceNumber= Parameters["MoveInJobSequenceNumber"],
MoveInNumberWorkOrders = Parameters["MoveInNumberWorkOrders"],
MoveInWorkOrderNumbers = Parameters["MoveInWorkOrderNumbers"],
MoveInPartNumbers = Parameters["MoveInPartNumbers"],
MoveInOperatorName = Parameters["MoveInOperatorName"],
MoveInFlag = Parameters["MoveInFlag"],
});
} catch (Exception ex) {
return new { WasSuccessful = false, ErrorText = "MoveIn failed: " + ex.Message, BatchID = 0 };
}
```
`ParameterDefinitions` / `ReturnDefinition` unchanged from today.
### C. New template script `IpsenMoveIn` on T1 (data, via management API)
- On-demand (no trigger). Parameters: `side` + the written MoveIn fields. Return:
`{ WasSuccessful, ErrorText, BatchID }`.
- Logic:
```csharp
var recv = Children[ (string)Parameters["side"] == "Right" ? "RightMESReceiver" : "LeftMESReceiver" ];
var batchId = Parameters.Get<int>("MoveInBatchID");
var ready = recv.Attributes["MoveInReadyFlag"]; // live Galaxy read
if (!(ready is bool b && b)) // tolerate bool/"True"/1
return new { WasSuccessful = false, ErrorText = "MoveInReadyFlag is not true", BatchID = batchId };
await recv.SetAttribute("MoveInMesContainerNum", ...);
await recv.SetAttribute("MoveInBatchID", ...);
await recv.SetAttribute("MoveInJobSequenceNumber", ...);
await recv.SetAttribute("MoveInNumberWorkOrders", ...);
await recv.SetAttribute("MoveInWorkOrderNumbers", <array-encoded>); // see §5
await recv.SetAttribute("MoveInPartNumbers", <array-encoded>);
await recv.SetAttribute("MoveInOperatorName", ...);
await recv.SetAttribute("MoveInFlag", ...);
return new { WasSuccessful = true, ErrorText = "", BatchID = batchId };
```
- `MoveInReadyFlag` readiness check tolerates the value arriving as `bool`, `"True"`, or `1`.
## 5. Open implementation items (not blockers)
- **Array write encoding:** `SetAttribute(name, string)` takes a string. Confirm how the recent
multivalue-array support expects a `List<String>` value to be written from a script (JSON
array string vs. an `Attributes[name] = list` path). If arrays can't be written from a script
today, add a small ScadaBridge item to support it.
- **Derived placeholders:** verify T5/T6's stale `String` rows for the two array attrs are
`IsInherited=true` (skipped at flatten so the T3 `List` types apply). If they are real
overrides, fix them.
- **SAPID format:** match is exact-string; live rows store leading zeros ("002797"). Confirm MES
sends the SAP in the stored format, or normalize in the lookup.
- **Multiple/ambiguous SAP rows:** `SELECT TOP 1`; if a SAP maps to >1 row they must resolve to
the same bare instance — otherwise surface a clear error.
## 6. Error handling / return contract
Every path returns the validated `{WasSuccessful, ErrorText, BatchID}`:
- success → `{true, "", batchId}`
- not ready → `{false, "MoveInReadyFlag is not true", batchId}`
- bad/missing side, unknown SAP, route/instance-not-found, write failure → `{false, "<reason>", 0}`
## 7. Testing
- Unit: `InboundDatabaseHelper` + inbound-script DB path (fake `IDatabaseGateway`); inbound
parse/route logic; reactor script gate + write-set (fake instance/children).
- On-box dry run: `POST /api/IpsenMESMoveIn` with a known SAP+`-A` → resolves to `Z28061`,
gates when `MoveInReadyFlag` false, writes when true (verify values land on the Galaxy
receiver via the gateway, as in the earlier enrichment). Negative: bad suffix, unknown SAP,
not-ready.
## 8. Deployment
1. Implement + test Component A; build ScadaBridge.
2. Rebuild + redeploy ScadaBridge Central on `wonder-app-vd03` (preserve `app\data\`;
apply any EF migration BEFORE boot — Central is Production, no auto-migrate).
3. Apply Component B (inbound script update) + Component C (new T1 template script) via the
management API; redeploy config so the reactor instances pick up the new script.
4. Verify on-box; commit source change; keep this design doc + the implementation plan.
## 9. Out of scope
- Leak-test (`-LT`) routing/receivers.
- Writing PLC-output flags (Complete/Successful/ErrorText) from the MoveIn.
- `Z28062` BTDB data completeness (operational data fix, not code).
+408
View File
@@ -0,0 +1,408 @@
# Ipsen MES MoveIn Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Turn the stub `POST /api/IpsenMESMoveIn` into a real MoveIn that resolves the SAP number to a reactor instance via BTDB, then writes the MoveIn onto the correct Left/Right MES-receiver child — gated on that receiver's `MoveInReadyFlag`.
**Architecture:** The inbound `/api` script gains a scoped, read-only DB helper (`Database.QuerySingle`) so it can do the `dbo.Machine.SAPID → Code` lookup, derive the bare instance code, then `Route.To(instance).Call("IpsenMoveIn", {…params, side})`. A new on-demand template script `IpsenMoveIn` on the reactor template (`IpsenFurnaceTitan`, T1) gates on `MoveInReadyFlag` and writes the fields onto `Children["{side}MESReceiver"]` (DCL → Galaxy authorized write).
**Tech Stack:** .NET 10, Roslyn CSharpScript (inbound + template scripts), Akka.NET (Instance/Script actors), `IDatabaseGateway`/ADO.NET (SQL Server BTDB), ScadaBridge management API (`POST /management`).
**Design doc:** `docs/plans/2026-06-16-ipsen-mes-movein-design.md`. **Branch:** `feat/ipsen-mes-movein`.
**Decisions locked:** input = `<SAP#>-<side>` (`-A`=Left, `-B`=Right; `-LT`/other → `WasSuccessful=false`); match `Machine.SAPID`, `Code` (minus a trailing `A`/`B`) = instance; write all provided fields except `MoveInReadyFlag` (gate) and `SAPID` (routing); `MoveInComplete/Successful/ErrorText` are PLC-owned (not written); return `BatchID` = echoed input `MoveInBatchID`.
**Key facts (verified):** inbound globals = `Parameters`/`Route`/`CancellationToken` (no DB) → add `Database`. `IDatabaseGateway.GetConnectionAsync(name)` registered on Central via `Host/Program.cs`. RouteHelper only targets an instance code. Mgmt commands: `UpdateApiMethod {apiMethodId,script,timeoutSeconds,parameterDefinitions?,returnDefinition?}`, `AddTemplateScript {templateId,name,code,triggerType?,triggerConfiguration?,isLocked,parameterDefinitions?,returnDefinition?}`. On-demand script → `triggerType:"call"`. Array attrs: write via the `Attributes[name]=value` indexer which runs `AttributeValueCodec.Encode` (IEnumerable/JsonElement → JSON array string), and `InstanceActor` decodes List attrs and rejects malformed list JSON. Reactor = T1 `IpsenFurnaceTitan`, instances `Z28061`/`Z28062` (Site 2); children `LeftMESReceiver`/`RightMESReceiver`; full MoveIn set on base T3 (arrays `List<String>`). ApiMethod id = **1**. Trust policy forbids System.IO/Reflection/Net/Threading/Process/Interop/Win32 + `dynamic`/`Activator`; **System.Text.Json + anonymous types + await are allowed**; keep ADO internal to the helper regardless.
---
## Task 1: InboundDatabaseHelper (read-only DB for inbound scripts)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs`
**Step 1 — Write the failing test** (uses an in-memory SQLite `IDatabaseGateway` fake; add `Microsoft.Data.Sqlite` to the test project if not already referenced):
```csharp
using System.Data.Common;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using Xunit;
public class InboundDatabaseHelperTests
{
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 SeededGateway()
{
// shared in-memory db kept alive by an open keep-alive connection
var keep = new SqliteConnection("DataSource=file:movein?mode=memory&cache=shared");
keep.Open();
using var cmd = keep.CreateCommand();
cmd.CommandText = "CREATE TABLE Machine(Code TEXT, SAPID TEXT); INSERT INTO Machine VALUES('Z28061A','131453');";
cmd.ExecuteNonQuery();
return new SqliteGateway("DataSource=file:movein?mode=memory&cache=shared");
}
[Fact]
public void QuerySingle_returns_first_column_with_bound_parameter()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var code = helper.QuerySingle<string>("BTDB",
"SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" });
Assert.Equal("Z28061A", code);
}
[Fact]
public void QuerySingle_returns_default_when_no_rows()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var code = helper.QuerySingle<string>("BTDB",
"SELECT Code FROM Machine WHERE SAPID=@s", new { s = "999999" });
Assert.Null(code);
}
}
```
**Step 2 — Run it, expect FAIL** (`InboundDatabaseHelper` not defined):
`dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests --filter InboundDatabaseHelperTests`
**Step 3 — Implement** `InboundDatabaseHelper.cs`:
```csharp
using System.Data.Common;
using System.Globalization;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// Read-only database access exposed to inbound API scripts. All ADO.NET stays
/// internal here — scripts call QuerySingle/Query by name and never reference
/// System.Data. Named connections only; parameters are bound (anonymous-object
/// properties become @-prefixed SQL parameters), never string-concatenated.
/// </summary>
public sealed class InboundDatabaseHelper
{
private readonly IDatabaseGateway _gateway;
private readonly CancellationToken _ct;
public InboundDatabaseHelper(IDatabaseGateway gateway, CancellationToken ct)
{ _gateway = gateway; _ct = ct; }
/// <summary>First column of the first row converted to T (default if no rows).</summary>
public T? QuerySingle<T>(string connectionName, string sql, object? parameters = null)
{
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
AddParameters(cmd, parameters);
var result = cmd.ExecuteScalar();
if (result is null or DBNull) return default;
if (result is T t) return t;
return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture);
}
/// <summary>All rows as column→value dictionaries (case-insensitive keys).</summary>
public IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(
string connectionName, string sql, object? parameters = null)
{
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
AddParameters(cmd, parameters);
using var reader = cmd.ExecuteReader();
var rows = new List<IReadOnlyDictionary<string, object?>>();
while (reader.Read())
{
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
{
var v = reader.GetValue(i);
row[reader.GetName(i)] = v is DBNull ? null : v;
}
rows.Add(row);
}
return rows;
}
private static void AddParameters(DbCommand cmd, object? parameters)
{
if (parameters is null) return;
foreach (var prop in parameters.GetType().GetProperties())
{
var p = cmd.CreateParameter();
p.ParameterName = "@" + prop.Name;
p.Value = prop.GetValue(parameters) ?? DBNull.Value;
cmd.Parameters.Add(p);
}
}
}
```
**Step 4 — Run tests, expect PASS.**
**Step 5 — Commit:**
`git add src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs && git commit -m "feat(inbound): read-only InboundDatabaseHelper for inbound scripts"`
**Acceptance:** `QuerySingle<T>` binds anonymous-object params, returns first column or default; ADO.NET stays internal.
---
## Task 2: Expose `Database` to inbound scripts (wire context + executor)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 1)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs` (the `InboundScriptContext` class ~321-353; the `ExecuteAsync` context construction ~246-249; add `using Microsoft.Extensions.DependencyInjection;`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs` (add cases)
**Step 1 — Write failing tests:** (a) a script body `return new { v = Database.QuerySingle<string>("BTDB","SELECT Code FROM Machine WHERE SAPID=@s", new { s = (string)Parameters["sap"] }) };` resolves via a seeded SQLite gateway; (b) an existing script that does NOT use `Database` still compiles + runs (no regression). Use the same SQLite-gateway fake from Task 1; construct the executor with a real `IServiceProvider` (e.g. `new ServiceCollection().AddScoped<IDatabaseGateway>(_ => gateway).BuildServiceProvider()`).
**Step 2 — Run, expect FAIL** (`Database` not a member of context).
**Step 3 — Implement.** Add the property + ctor param to `InboundScriptContext`:
```csharp
public class InboundScriptContext
{
public ScriptParameters Parameters { get; }
public RouteHelper Route { get; }
public InboundDatabaseHelper Database { get; } // NEW
public CancellationToken CancellationToken { get; }
public InboundScriptContext(
IReadOnlyDictionary<string, object?> parameters,
RouteHelper route,
InboundDatabaseHelper database, // NEW
CancellationToken cancellationToken = default)
{
Parameters = new ScriptParameters(parameters);
Route = route;
Database = database;
CancellationToken = cancellationToken;
}
}
```
In `ExecuteAsync`, build the helper from a DI scope (gateway is scoped) and pass it. Replace the existing `var context = new InboundScriptContext(...)` block:
```csharp
using var scope = _serviceProvider.CreateScope();
var gateway = scope.ServiceProvider.GetRequiredService<IDatabaseGateway>();
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token);
var context = new InboundScriptContext(
parameters,
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
dbHelper,
cts.Token);
```
Add `using Microsoft.Extensions.DependencyInjection;` at the top. No `ScriptOptions.WithReferences` change is needed — `InboundDatabaseHelper` lives in the same assembly as `RouteHelper`, which is already referenced (line ~157). Leave imports as-is (script calls `Database.QuerySingle`, fully qualified through the globals object; no `using` required).
**Step 4 — Run tests, expect PASS** (both the Database-using script and the no-Database script).
**Step 5 — Commit:**
`git add -p` the two files; `git commit -m "feat(inbound): expose read-only Database helper on InboundScriptContext"`
**Acceptance:** inbound scripts can call `Database.QuerySingle`; the scope is disposed after each execution; existing no-DB scripts still compile + run; `ScriptTrustValidator` still passes (no forbidden API in the helper-call text).
---
## Task 3: Build + regression-test ScadaBridge
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Task 2)
**Files:** none (verification)
**Steps:**
1. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0 errors.
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests` → all green.
3. `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests` → all green (trust model unaffected).
4. If anything fails, fix before proceeding (do NOT deploy a red build).
**Acceptance:** clean build + green InboundAPI & ScriptAnalysis suites.
---
## Task 4: Commit Component A
**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** none
**Steps:** ensure Tasks 1–2 are committed; `git log --oneline -3` shows the two feature commits on `feat/ipsen-mes-movein`. (No push unless the user asks.)
---
## Task 5: Deploy ScadaBridge Central (Component A) to wonder-app-vd03
**Classification:** high-risk (production deploy)
**Estimated implement time:** ~6 min
**Parallelizable with:** none (depends on Tasks 3–4)
**Requires explicit go-ahead before running.**
**Steps (mirror this session's deploy mechanics):**
1. Publish self-contained: `dotnet publish src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj -c Release -r win-x64 --self-contained -o <out>`.
2. Zip `out`; SFTP to `C:\Users\dohertj2\Desktop\win64` (servecli `-p 2222 -i ~/.ssh/servecli_wonder`).
3. On box (PS via `/tmp/wonder-ps.sh`): stop `ScadaBridge-Central`; back up current `E:\ApiInstall\ScadaBridge\app`; Expand-Archive; **swap the app dir preserving `app\data\` and all `appsettings*.json`**; restart `ScadaBridge-Central`.
4. No new EF migration in Component A (no schema change) — confirm none was generated; if one was, apply it BEFORE boot (Central is Production, no auto-migrate).
5. Verify: service Running; health endpoint 200; no startup errors in log; `POST /api/IpsenMESMoveIn` still reachable (stub behaviour OK at this point — script not yet updated).
**Acceptance:** Central runs the new binary with the `Database` capability; data preserved; health green.
---
## Task 6: Apply Component B — update the inbound `IpsenMESMoveIn` script
**Classification:** high-risk (production data)
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 7 (different surfaces; both depend on Task 5)
**Requires explicit go-ahead.**
**Step 1 — Script body** (the `script` payload value):
```csharp
try {
var raw = (Parameters["SAPID"] as string ?? "").Trim();
var dash = raw.IndexOf('-');
if (dash < 0)
return new { WasSuccessful = false, ErrorText = "SAPID missing -A/-B side suffix", BatchID = 0 };
var sap = raw.Substring(0, dash);
var suf = raw.Substring(dash + 1); // "A","B","A-LT",...
var side = suf == "A" ? "Left" : suf == "B" ? "Right" : null;
if (side == null)
return new { WasSuccessful = false, ErrorText = "Unsupported side suffix '" + suf + "' (only -A/-B supported)", BatchID = 0 };
var code = Database.QuerySingle<string>("BTDB",
"SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s", new { s = sap });
if (string.IsNullOrEmpty(code))
return new { WasSuccessful = false, ErrorText = "No machine found for SAP " + sap, BatchID = 0 };
var instance = (code.EndsWith("A") || code.EndsWith("B")) ? code.Substring(0, code.Length - 1) : code;
return await Route.To(instance).Call("IpsenMoveIn", new {
side,
MoveInMesContainerNum = Parameters["MoveInMesContainerNum"],
MoveInBatchID = Parameters["MoveInBatchID"],
MoveInJobSequenceNumber = Parameters["MoveInJobSequenceNumber"],
MoveInNumberWorkOrders = Parameters["MoveInNumberWorkOrders"],
MoveInWorkOrderNumbers = Parameters["MoveInWorkOrderNumbers"],
MoveInPartNumbers = Parameters["MoveInPartNumbers"],
MoveInOperatorName = Parameters["MoveInOperatorName"],
MoveInFlag = Parameters["MoveInFlag"],
});
} catch (Exception ex) {
return new { WasSuccessful = false, ErrorText = "MoveIn failed: " + ex.Message, BatchID = 0 };
}
```
**Step 2 — Apply** via management API on the box (no auth under DisableLogin):
`POST http://localhost:8085/management` with
```json
{"command":"UpdateApiMethod","payload":{"apiMethodId":1,"script":"<the script above>","timeoutSeconds":30,"parameterDefinitions":"<existing ParameterDefinitions verbatim>","returnDefinition":"<existing ReturnDefinition verbatim>"}}
```
Preserve the current `ParameterDefinitions`/`ReturnDefinition` (read them first; they already declare the 13 inputs incl. the two arrays, and the `{WasSuccessful,ErrorText,BatchID}` return). Use a here-string/file for the script to avoid JSON-escaping pain.
**Step 3 — Verify** the method recompiled: a malformed-side call (`SAPID:"x"` with no dash) returns `{WasSuccessful:false, ErrorText:"SAPID missing -A/-B side suffix", BatchID:0}` (proves the new script is live and DB-capable without needing a ready receiver).
**Acceptance:** `UpdateApiMethod` succeeds; method recompiles; negative path returns the structured error.
---
## Task 7: Apply Component C — `IpsenMoveIn` template script on T1
**Classification:** high-risk (production data)
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 6 (depends on Task 5)
**Requires explicit go-ahead.**
**Pre-step — verify two facts (local + box):**
- Local: confirm `CompositionAccessor`/`AttributeAccessor` indexer **setter** (`src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs`) routes through `AttributeValueCodec.Encode` (it does — `Attributes[name]=value`). Use the indexer setter for ALL writes so List values encode to JSON arrays automatically.
- Box: confirm T5/T6 (`LeftMESReceiver`/`RightMESReceiver`) array-attr rows are `IsInherited=true` placeholders (so the T3 `List` types apply at flatten) — `SELECT TemplateId,Name,DataType,ElementDataType,IsInherited FROM dbo.TemplateAttributes WHERE Name IN ('MoveInWorkOrderNumbers','MoveInPartNumbers')`. If any derived row is a real override with `DataType='String'`, fix it (delete the placeholder / re-inherit) before relying on array writes.
**Step 1 — Script body** (the `code` payload value):
```csharp
var side = (Parameters["side"] as string) ?? "Left";
var recv = Children[side == "Right" ? "RightMESReceiver" : "LeftMESReceiver"];
int batchId = 0;
try { batchId = Convert.ToInt32(Parameters["MoveInBatchID"] ?? 0); } catch { }
var ready = recv.Attributes["MoveInReadyFlag"];
var rs = ready?.ToString();
bool isReady = (ready is bool b && b)
|| rs == "1" || string.Equals(rs, "true", StringComparison.OrdinalIgnoreCase);
if (!isReady)
return new { WasSuccessful = false, ErrorText = "MoveInReadyFlag is not true", BatchID = batchId };
try {
recv.Attributes["MoveInMesContainerNum"] = Parameters["MoveInMesContainerNum"];
recv.Attributes["MoveInBatchID"] = Parameters["MoveInBatchID"];
recv.Attributes["MoveInJobSequenceNumber"] = Parameters["MoveInJobSequenceNumber"];
recv.Attributes["MoveInNumberWorkOrders"] = Parameters["MoveInNumberWorkOrders"];
recv.Attributes["MoveInWorkOrderNumbers"] = Parameters["MoveInWorkOrderNumbers"]; // List<String> -> JSON
recv.Attributes["MoveInPartNumbers"] = Parameters["MoveInPartNumbers"]; // List<String> -> JSON
recv.Attributes["MoveInOperatorName"] = Parameters["MoveInOperatorName"];
recv.Attributes["MoveInFlag"] = Parameters["MoveInFlag"];
} catch (Exception ex) {
return new { WasSuccessful = false, ErrorText = "Write failed: " + ex.Message, BatchID = batchId };
}
return new { WasSuccessful = true, ErrorText = "", BatchID = batchId };
```
**Step 2 — Apply** via management API:
```json
{"command":"AddTemplateScript","payload":{"templateId":1,"name":"IpsenMoveIn","code":"<the script above>","triggerType":"call","triggerConfiguration":null,"isLocked":false,"parameterDefinitions":null,"returnDefinition":null}}
```
(`triggerType:"call"` → on-demand only. `parameterDefinitions`/`returnDefinition` can stay null — the inbound passes a typed anonymous object and the result is validated by the inbound method's own `ReturnDefinition`.)
**Step 3 — Redeploy config** so the Site picks up the new T1 script on its instances (use the same deploy/redeploy path used in this session — management deploy or `POST /api/deployments`). Confirm `Z28061`/`Z28062` are re-applied.
**Acceptance:** `AddTemplateScript` succeeds (script id returned); after redeploy the reactor instances expose the `IpsenMoveIn` call-only script.
---
## Task 8: On-box end-to-end verification
**Classification:** standard (production verify)
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Tasks 6, 7)
**Steps (use a SAP that resolves to a deployed reactor — confirm/populate `Machine.SAPID` for the target first; e.g. set `Z28061A.SAPID` to a known test value, or use the existing data):**
1. **Resolve + not-ready:** with the target receiver's `MoveInReadyFlag` false (read it at the gateway first), `POST /api/IpsenMESMoveIn {"SAPID":"<sap>-A", ...minimal payload}` → expect `{WasSuccessful:false, ErrorText:"MoveInReadyFlag is not true", BatchID:<echo>}`.
2. **Happy path:** when `MoveInReadyFlag` is true, POST a full payload (incl. `MoveInWorkOrderNumbers`/`MoveInPartNumbers` arrays) → expect `{WasSuccessful:true, ErrorText:"", BatchID:<echo>}`; then verify the values landed on the Galaxy receiver via the MxGateway (read `MESReceiver_023.MoveInBatchID` / `.MoveInWorkOrderNumbers` etc., as in the earlier enrichment) — especially confirm the **arrays** wrote correctly.
3. **Negatives:** bad suffix (`-A-LT`, none) → unsupported-side error; unknown SAP → "No machine found" error.
4. If array writes don't land correctly, adjust the encoding in the Task 7 script (e.g. pre-serialize arrays with `System.Text.Json` — allowed) and re-apply via `UpdateTemplateScript {scriptId,...}`.
**Acceptance:** resolution, the `MoveInReadyFlag` gate, scalar + array writes, and all negative paths behave per the design; values verified live on the Galaxy receiver.
---
## Post-completion
- Commit any source adjustments; keep this plan + the design doc on `feat/ipsen-mes-movein`. Push/merge only when the user asks.
- Update memory: the inbound MoveIn flow + the new `Database` inbound capability + `IpsenMoveIn` T1 script.
- Out of scope (future): leak-test (`-LT`) receivers/routing; writing PLC-output flags; `Z28062` BTDB data completeness.
@@ -0,0 +1,14 @@
{
"planPath": "docs/plans/2026-06-16-ipsen-mes-movein.md",
"tasks": [
{"id": 1, "subject": "Task 1: InboundDatabaseHelper (read-only DB for inbound scripts)", "classification": "standard", "status": "pending"},
{"id": 2, "subject": "Task 2: Expose Database to inbound scripts (context + executor)", "classification": "high-risk", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Build + regression-test ScadaBridge", "classification": "small", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: Commit Component A", "classification": "trivial", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: Deploy ScadaBridge Central (Component A) to wonder-app-vd03", "classification": "high-risk", "status": "pending", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: Apply Component B - update inbound IpsenMESMoveIn script", "classification": "high-risk", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 7: Apply Component C - IpsenMoveIn template script on T1", "classification": "high-risk", "status": "pending", "blockedBy": [5]},
{"id": 8, "subject": "Task 8: On-box end-to-end verification", "classification": "standard", "status": "pending", "blockedBy": [6, 7]}
],
"lastUpdated": "2026-06-16"
}
@@ -0,0 +1,70 @@
using System.Data.Common;
using System.Globalization;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// Read-only database access exposed to inbound API scripts. All ADO.NET stays
/// internal here — scripts call QuerySingle/Query by name and never reference
/// System.Data. Named connections only; parameters are bound (anonymous-object
/// properties become @-prefixed SQL parameters), never string-concatenated.
/// </summary>
public sealed class InboundDatabaseHelper
{
private readonly IDatabaseGateway? _gateway;
private readonly CancellationToken _ct;
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct)
{ _gateway = gateway; _ct = ct; }
/// <summary>First column of the first row converted to T (default if no rows).</summary>
public T? QuerySingle<T>(string connectionName, string sql, object? parameters = null)
{
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
AddParameters(cmd, parameters);
var result = cmd.ExecuteScalar();
if (result is null or DBNull) return default;
if (result is T t) return t;
return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture);
}
/// <summary>All rows as column→value dictionaries (case-insensitive keys).</summary>
public IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(
string connectionName, string sql, object? parameters = null)
{
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
AddParameters(cmd, parameters);
using var reader = cmd.ExecuteReader();
var rows = new List<IReadOnlyDictionary<string, object?>>();
while (reader.Read())
{
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
{
var v = reader.GetValue(i);
row[reader.GetName(i)] = v is DBNull ? null : v;
}
rows.Add(row);
}
return rows;
}
private static void AddParameters(DbCommand cmd, object? parameters)
{
if (parameters is null) return;
foreach (var prop in parameters.GetType().GetProperties())
{
var p = cmd.CreateParameter();
p.ParameterName = "@" + prop.Name;
p.Value = prop.GetValue(parameters) ?? DBNull.Value;
cmd.Parameters.Add(p);
}
}
}
@@ -2,8 +2,10 @@ using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
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;
@@ -234,8 +236,36 @@ public class InboundScriptExecutor
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
// IpsenMES MoveIn: expose a read-only Database helper to the script.
// Resolve the gateway from a fresh DI scope (declared outside the try so it
// lives until after the script handler runs and is disposed here) so a scoped
// IDatabaseGateway is honoured. GetService (not GetRequiredService) so a method
// that never touches Database still runs even when no IDatabaseGateway is
// registered; the helper throws on first use if built without a gateway.
//
// A service provider that cannot produce a scope (e.g. a bare test double with
// no IServiceScopeFactory) is tolerated: the gateway is simply unavailable, so
// a script not using Database still runs, while one that does fails on use.
IServiceScope? scope = null;
try
{
scope = _serviceProvider.CreateScope();
}
catch (InvalidOperationException ex)
{
// No scope factory available (e.g. a non-scoping test-double provider).
// In production this should never happen; log so a genuine container
// misconfiguration is visible rather than silently disabling Database.
_logger.LogWarning(ex,
"Could not create a DI scope for method {Method}; Database will be unavailable to its script",
method.Name);
}
try
{
var gateway = scope?.ServiceProvider.GetService<IDatabaseGateway>();
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token);
// InboundAPI-016: bind the route helper to the method deadline so a
// routed Route.To(...).Call(...) inherits the method-level timeout
// without the script having to thread the context token by hand.
@@ -246,6 +276,7 @@ public class InboundScriptExecutor
var context = new InboundScriptContext(
parameters,
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
dbHelper,
cts.Token);
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
@@ -312,6 +343,12 @@ public class InboundScriptExecutor
// WP-5: Safe error message, no internal details
return new InboundScriptResult(false, null, "Internal script error");
}
finally
{
// Dispose the per-execution DI scope (if one was created) only after the
// script handler has finished running and its result been serialized.
scope?.Dispose();
}
}
}
@@ -330,6 +367,11 @@ public class InboundScriptContext
/// </summary>
public RouteHelper Route { get; }
/// <summary>
/// Read-only database access for the script (named connections; bound parameters).
/// </summary>
public InboundDatabaseHelper Database { get; }
/// <summary>
/// The cancellation token for script execution.
/// </summary>
@@ -340,14 +382,17 @@ public class InboundScriptContext
/// </summary>
/// <param name="parameters">The input parameters for the script.</param>
/// <param name="route">The route helper for cross-site routing.</param>
/// <param name="database">The read-only database helper for the script.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public InboundScriptContext(
IReadOnlyDictionary<string, object?> parameters,
RouteHelper route,
InboundDatabaseHelper database,
CancellationToken cancellationToken = default)
{
Parameters = new ScriptParameters(parameters);
Route = route;
Database = database;
CancellationToken = cancellationToken;
}
}
@@ -0,0 +1,68 @@
using System.Data.Common;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
public class InboundDatabaseHelperTests
{
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 SeededGateway()
{
var keep = new SqliteConnection("DataSource=file:movein?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?mode=memory&cache=shared");
}
[Fact]
public void QuerySingle_returns_first_column_with_bound_parameter()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var code = helper.QuerySingle<string>("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" });
Assert.Equal("Z28061A", code);
}
[Fact]
public void QuerySingle_returns_default_when_no_rows()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var code = helper.QuerySingle<string>("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "999999" });
Assert.Null(code);
}
[Fact]
public void Query_returns_row_with_case_insensitive_keys()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var rows = helper.Query("BTDB", "SELECT Code, SAPID FROM Machine WHERE SAPID=@s", new { s = "131453" });
Assert.Single(rows);
var row = rows[0];
Assert.Equal("Z28061A", row["code"]);
Assert.Equal("Z28061A", row["CODE"]);
}
[Fact]
public void Query_returns_empty_list_when_no_rows_match()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var rows = helper.Query("BTDB", "SELECT Code, SAPID FROM Machine WHERE SAPID=@s", new { s = "999999" });
Assert.Empty(rows);
}
}
@@ -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,131 @@ 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!);
}
[Fact]
public async Task ScriptThrows_DisposesDiScope()
{
// When the script handler throws, ExecuteAsync must still dispose the
// per-execution DI scope it created (regression guard for the finally block).
var scopeServiceProvider = Substitute.For<IServiceProvider>();
// GetService<IDatabaseGateway>() returns null — script never needs it.
scopeServiceProvider.GetService(typeof(IDatabaseGateway)).Returns((object?)null);
var scope = Substitute.For<IServiceScope>();
scope.ServiceProvider.Returns(scopeServiceProvider);
var factory = Substitute.For<IServiceScopeFactory>();
factory.CreateScope().Returns(scope);
// Wire the factory into the provider so CreateScope() extension finds it.
var provider = Substitute.For<IServiceProvider>();
provider.GetService(typeof(IServiceScopeFactory)).Returns(factory);
var executor = new InboundScriptExecutor(
NullLogger<InboundScriptExecutor>.Instance, provider);
// A pre-registered handler that throws; ReturnDefinition=null so validation
// is not the failure path.
var method = new ApiMethod("throws", "throw new Exception(\"boom\");")
{
Id = 1,
TimeoutSeconds = 10,
ReturnDefinition = null,
};
executor.RegisterHandler("throws", _ => throw new Exception("boom"));
var result = await executor.ExecuteAsync(
method,
new Dictionary<string, object?>(),
_route,
TimeSpan.FromSeconds(10));
// The executor must swallow the script exception and return a non-success result.
Assert.False(result.Success);
// The scope must have been disposed via the finally block.
scope.Received(1).Dispose();
}
// 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;
@@ -15,6 +15,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />