fix(inbound): log swallowed scope-creation failure + test scope disposal on script throw

This commit is contained in:
Joseph Doherty
2026-06-16 21:51:33 -04:00
parent daff1446d8
commit d4ec84d5fb
2 changed files with 52 additions and 2 deletions
@@ -251,9 +251,14 @@ public class InboundScriptExecutor
{ {
scope = _serviceProvider.CreateScope(); scope = _serviceProvider.CreateScope();
} }
catch (InvalidOperationException) catch (InvalidOperationException ex)
{ {
// No scope factory available (provider does not support scoping). // 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 try
@@ -599,6 +599,51 @@ public class InboundScriptExecutorTests
Assert.Contains("99", result.ResultJson!); 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 // SQLite-backed IDatabaseGateway fake (mirrors InboundDatabaseHelperTests). The
// shared in-memory db is seeded with Machine(Code,SAPID)=('Z28061A','131453'). // shared in-memory db is seeded with Machine(Code,SAPID)=('Z28061A','131453').
private sealed class SqliteGateway : IDatabaseGateway private sealed class SqliteGateway : IDatabaseGateway