using NSubstitute; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.InboundApi; namespace ScadaLink.InboundAPI.Tests; /// /// WP-4: Tests for / — the /// cross-site Route.To() routing surface inbound API scripts use. /// /// InboundAPI-017: this surface previously had zero coverage. /// InboundAPI-016: routed calls must inherit the executing method's deadline token. /// public class RouteHelperTests { private readonly IInstanceLocator _locator = Substitute.For(); private readonly IInstanceRouter _router = Substitute.For(); private RouteHelper CreateHelper() => new(_locator, _router); private void SiteResolves(string instanceCode, string siteId) => _locator.GetSiteIdForInstanceAsync(instanceCode, Arg.Any()) .Returns(siteId); // --- Call --- [Fact] public async Task Call_HappyPath_ResolvesSiteAndReturnsValue() { SiteResolves("inst-1", "SiteA"); _router.RouteToCallAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToCallResponse( ((RouteToCallRequest)ci[1]).CorrelationId, true, 99, null, DateTimeOffset.UtcNow)); var result = await CreateHelper().To("inst-1").Call("doWork", new { x = 1 }); Assert.Equal(99, result); } [Fact] public async Task Call_GeneratesCorrelationId() { SiteResolves("inst-1", "SiteA"); RouteToCallRequest? captured = null; _router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) .Returns(ci => new RouteToCallResponse( ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); await CreateHelper().To("inst-1").Call("doWork"); Assert.NotNull(captured); Assert.True(Guid.TryParse(captured!.CorrelationId, out _)); } [Fact] public async Task Call_RemoteFailure_ThrowsInvalidOperationException() { SiteResolves("inst-1", "SiteA"); _router.RouteToCallAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToCallResponse( ((RouteToCallRequest)ci[1]).CorrelationId, false, null, "site exploded", DateTimeOffset.UtcNow)); var ex = await Assert.ThrowsAsync( () => CreateHelper().To("inst-1").Call("doWork")); Assert.Equal("site exploded", ex.Message); } [Fact] public async Task Call_UnresolvedInstance_ThrowsInvalidOperationException() { // Locator returns null → instance not found / no assigned site. _locator.GetSiteIdForInstanceAsync("ghost", Arg.Any()) .Returns((string?)null); var ex = await Assert.ThrowsAsync( () => CreateHelper().To("ghost").Call("doWork")); Assert.Contains("ghost", ex.Message); } // --- GetAttribute(s) --- [Fact] public async Task GetAttributes_HappyPath_ReturnsValues() { SiteResolves("inst-1", "SiteA"); _router.RouteToGetAttributesAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToGetAttributesResponse( ((RouteToGetAttributesRequest)ci[1]).CorrelationId, new Dictionary { ["a"] = 1, ["b"] = 2 }, true, null, DateTimeOffset.UtcNow)); var result = await CreateHelper().To("inst-1").GetAttributes(new[] { "a", "b" }); Assert.Equal(1, result["a"]); Assert.Equal(2, result["b"]); } [Fact] public async Task GetAttribute_DelegatesToBatch_AndReturnsSingleValue() { SiteResolves("inst-1", "SiteA"); _router.RouteToGetAttributesAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToGetAttributesResponse( ((RouteToGetAttributesRequest)ci[1]).CorrelationId, new Dictionary { ["temp"] = 21.5 }, true, null, DateTimeOffset.UtcNow)); var value = await CreateHelper().To("inst-1").GetAttribute("temp"); Assert.Equal(21.5, value); } [Fact] public async Task GetAttribute_AbsentKey_ReturnsNull() { SiteResolves("inst-1", "SiteA"); _router.RouteToGetAttributesAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToGetAttributesResponse( ((RouteToGetAttributesRequest)ci[1]).CorrelationId, new Dictionary(), // batch returns nothing for the key true, null, DateTimeOffset.UtcNow)); var value = await CreateHelper().To("inst-1").GetAttribute("missing"); Assert.Null(value); } [Fact] public async Task GetAttributes_RemoteFailure_ThrowsInvalidOperationException() { SiteResolves("inst-1", "SiteA"); _router.RouteToGetAttributesAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToGetAttributesResponse( ((RouteToGetAttributesRequest)ci[1]).CorrelationId, new Dictionary(), false, "read failed", DateTimeOffset.UtcNow)); var ex = await Assert.ThrowsAsync( () => CreateHelper().To("inst-1").GetAttributes(new[] { "a" })); Assert.Equal("read failed", ex.Message); } // --- SetAttribute(s) --- [Fact] public async Task SetAttribute_DelegatesToBatch_WithSingleEntry() { SiteResolves("inst-1", "SiteA"); RouteToSetAttributesRequest? captured = null; _router.RouteToSetAttributesAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) .Returns(ci => new RouteToSetAttributesResponse( ((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow)); await CreateHelper().To("inst-1").SetAttribute("setpoint", "42"); Assert.NotNull(captured); Assert.Equal("42", captured!.AttributeValues["setpoint"]); Assert.Single(captured.AttributeValues); } [Fact] public async Task SetAttributes_RemoteFailure_ThrowsInvalidOperationException() { SiteResolves("inst-1", "SiteA"); _router.RouteToSetAttributesAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToSetAttributesResponse( ((RouteToSetAttributesRequest)ci[1]).CorrelationId, false, "write rejected", DateTimeOffset.UtcNow)); var ex = await Assert.ThrowsAsync( () => CreateHelper().To("inst-1").SetAttributes( new Dictionary { ["x"] = "1" })); Assert.Equal("write rejected", ex.Message); } // --- InboundAPI-016: routed calls inherit the method deadline token --- [Fact] public async Task Call_WithNoExplicitToken_InheritsMethodDeadlineToken() { // A natural script — Route.To("x").Call("s", p) — passes no token. The routed // call must still be bounded by the executing method's timeout: the helper // bound to a deadline token must forward THAT token to the router. SiteResolves("inst-1", "SiteA"); using var deadline = new CancellationTokenSource(); CancellationToken seen = default; _router.RouteToCallAsync("SiteA", Arg.Any(), Arg.Do(t => seen = t)) .Returns(ci => new RouteToCallResponse( ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); var bound = CreateHelper().WithDeadline(deadline.Token); await bound.To("inst-1").Call("doWork"); Assert.Equal(deadline.Token, seen); } [Fact] public async Task Call_WhenMethodDeadlineCancelled_RoutedCallObservesCancellation() { // When the method timeout fires, an in-flight routed call must see the // cancellation rather than running orphaned past the deadline. SiteResolves("inst-1", "SiteA"); using var deadline = new CancellationTokenSource(); _router.RouteToCallAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(async ci => { var token = (CancellationToken)ci[2]; await Task.Delay(TimeSpan.FromSeconds(30), token); return new RouteToCallResponse( ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow); }); var bound = CreateHelper().WithDeadline(deadline.Token); deadline.CancelAfter(TimeSpan.FromMilliseconds(100)); await Assert.ThrowsAnyAsync( () => bound.To("inst-1").Call("doWork")); } [Fact] public async Task Call_ExplicitToken_OverridesDeadlineToken() { // A script that DOES pass a (tighter) token must have that token honoured. SiteResolves("inst-1", "SiteA"); using var deadline = new CancellationTokenSource(); using var explicitCts = new CancellationTokenSource(); CancellationToken seen = default; _router.RouteToCallAsync("SiteA", Arg.Any(), Arg.Do(t => seen = t)) .Returns(ci => new RouteToCallResponse( ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); var bound = CreateHelper().WithDeadline(deadline.Token); await bound.To("inst-1").Call("doWork", null, explicitCts.Token); Assert.Equal(explicitCts.Token, seen); } // --- Audit Log #23 (ParentExecutionId, T3): a routed call carries the // inbound request's ExecutionId as RouteToCallRequest.ParentExecutionId --- [Fact] public async Task Call_WithoutParentExecutionId_LeavesParentExecutionIdNull() { // A RouteHelper not bound to an inbound execution id (e.g. the Central UI // sandbox path) builds requests with ParentExecutionId null. SiteResolves("inst-1", "SiteA"); RouteToCallRequest? captured = null; _router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) .Returns(ci => new RouteToCallResponse( ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); await CreateHelper().To("inst-1").Call("doWork"); Assert.NotNull(captured); Assert.Null(captured!.ParentExecutionId); } [Fact] public async Task Call_WithParentExecutionId_CarriesItOnRouteToCallRequest() { // A RouteHelper bound to the inbound request's ExecutionId must stamp that // id onto the routed RouteToCallRequest so the site script records it as // its ParentExecutionId. SiteResolves("inst-1", "SiteA"); var inboundExecutionId = Guid.NewGuid(); RouteToCallRequest? captured = null; _router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) .Returns(ci => new RouteToCallResponse( ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); var bound = CreateHelper().WithParentExecutionId(inboundExecutionId); await bound.To("inst-1").Call("doWork"); Assert.NotNull(captured); Assert.Equal(inboundExecutionId, captured!.ParentExecutionId); // ParentExecutionId is a separate concern from the per-op CorrelationId — // the helper still mints its own routed-call correlation id. Assert.True(Guid.TryParse(captured.CorrelationId, out _)); } [Fact] public async Task WithParentExecutionId_PreservesDeadlineToken() { // The two builder methods compose — binding a parent execution id must // not drop a previously-bound deadline token. SiteResolves("inst-1", "SiteA"); using var deadline = new CancellationTokenSource(); CancellationToken seen = default; RouteToCallRequest? captured = null; _router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Do(t => seen = t)) .Returns(ci => new RouteToCallResponse( ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); var bound = CreateHelper() .WithDeadline(deadline.Token) .WithParentExecutionId(Guid.NewGuid()); await bound.To("inst-1").Call("doWork"); Assert.Equal(deadline.Token, seen); Assert.NotNull(captured!.ParentExecutionId); } [Fact] public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken() { SiteResolves("inst-1", "SiteA"); using var deadline = new CancellationTokenSource(); CancellationToken seen = default; _router.RouteToGetAttributesAsync("SiteA", Arg.Any(), Arg.Do(t => seen = t)) .Returns(ci => new RouteToGetAttributesResponse( ((RouteToGetAttributesRequest)ci[1]).CorrelationId, new Dictionary(), true, null, DateTimeOffset.UtcNow)); var bound = CreateHelper().WithDeadline(deadline.Token); await bound.To("inst-1").GetAttributes(new[] { "a" }); Assert.Equal(deadline.Token, seen); } [Fact] public async Task SetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken() { SiteResolves("inst-1", "SiteA"); using var deadline = new CancellationTokenSource(); CancellationToken seen = default; _router.RouteToSetAttributesAsync("SiteA", Arg.Any(), Arg.Do(t => seen = t)) .Returns(ci => new RouteToSetAttributesResponse( ((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow)); var bound = CreateHelper().WithDeadline(deadline.Token); await bound.To("inst-1").SetAttributes(new Dictionary { ["x"] = "1" }); Assert.Equal(deadline.Token, seen); } }