feat(inboundapi): mint inbound ExecutionId early, carry it as RouteToCallRequest.ParentExecutionId

This commit is contained in:
Joseph Doherty
2026-05-21 17:22:13 -04:00
parent 50430b9daa
commit d8453bfba2
8 changed files with 326 additions and 21 deletions

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.InboundApi;
namespace ScadaLink.InboundAPI.Tests;
@@ -427,6 +428,71 @@ public class InboundScriptExecutorTests
Assert.Contains("whatever", result.ResultJson!);
}
// --- Audit Log #23 (ParentExecutionId, T3): the inbound request's
// ExecutionId is threaded through ExecuteAsync onto routed calls ---
[Fact]
public async Task ExecuteAsync_WithParentExecutionId_RoutedCallCarriesItAsParentExecutionId()
{
// The endpoint hands ExecuteAsync the inbound request's ExecutionId; a
// routed Route.To(...).Call(...) inside the script must stamp that id onto
// the RouteToCallRequest as ParentExecutionId.
var inboundExecutionId = Guid.NewGuid();
var locator = Substitute.For<IInstanceLocator>();
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
var router = Substitute.For<IInstanceRouter>();
RouteToCallRequest? captured = null;
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
var route = new RouteHelper(locator, router);
var method = new ApiMethod("routes", "return 1;") { Id = 1, TimeoutSeconds = 10 };
_executor.RegisterHandler("routes", async ctx =>
{
await ctx.Route.To("inst-1").Call("doWork");
return 1;
});
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
parentExecutionId: inboundExecutionId);
Assert.True(result.Success, result.ErrorMessage);
Assert.NotNull(captured);
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
}
[Fact]
public async Task ExecuteAsync_WithoutParentExecutionId_RoutedCallHasNullParentExecutionId()
{
// ExecuteAsync called without a parent execution id (the default) routes
// calls with ParentExecutionId null.
var locator = Substitute.For<IInstanceLocator>();
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
var router = Substitute.For<IInstanceRouter>();
RouteToCallRequest? captured = null;
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
var route = new RouteHelper(locator, router);
var method = new ApiMethod("routes2", "return 1;") { Id = 1, TimeoutSeconds = 10 };
_executor.RegisterHandler("routes2", async ctx =>
{
await ctx.Route.To("inst-1").Call("doWork");
return 1;
});
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
Assert.NotNull(captured);
Assert.Null(captured!.ParentExecutionId);
}
private sealed class CompileLogCounter
{
public int CompilationFailures;