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);
}
}