fix(inbound-api): resolve InboundAPI-014..017 — return-value validation, reflection-gateway hardening, deadline-bound routed calls, RouteHelper test coverage
This commit is contained in:
268
tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs
Normal file
268
tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
using NSubstitute;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Messages.InboundApi;
|
||||
|
||||
namespace ScadaLink.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-4: Tests for <see cref="RouteHelper"/>/<see cref="RouteTarget"/> — 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.
|
||||
/// </summary>
|
||||
public class RouteHelperTests
|
||||
{
|
||||
private readonly IInstanceLocator _locator = Substitute.For<IInstanceLocator>();
|
||||
private readonly IInstanceRouter _router = Substitute.For<IInstanceRouter>();
|
||||
|
||||
private RouteHelper CreateHelper() => new(_locator, _router);
|
||||
|
||||
private void SiteResolves(string instanceCode, string siteId) =>
|
||||
_locator.GetSiteIdForInstanceAsync(instanceCode, Arg.Any<CancellationToken>())
|
||||
.Returns(siteId);
|
||||
|
||||
// --- Call ---
|
||||
|
||||
[Fact]
|
||||
public async Task Call_HappyPath_ResolvesSiteAndReturnsValue()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.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<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.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<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, false, null, "site exploded", DateTimeOffset.UtcNow));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => 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<CancellationToken>())
|
||||
.Returns((string?)null);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => 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<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?> { ["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<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?> { ["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<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?>(), // 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<RouteToGetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?>(), false, "read failed", DateTimeOffset.UtcNow));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => 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<RouteToSetAttributesRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.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<RouteToSetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToSetAttributesResponse(
|
||||
((RouteToSetAttributesRequest)ci[1]).CorrelationId, false, "write rejected", DateTimeOffset.UtcNow));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => CreateHelper().To("inst-1").SetAttributes(
|
||||
new Dictionary<string, string> { ["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<RouteToCallRequest>(), Arg.Do<CancellationToken>(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<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.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<OperationCanceledException>(
|
||||
() => 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<RouteToCallRequest>(), Arg.Do<CancellationToken>(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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var deadline = new CancellationTokenSource();
|
||||
CancellationToken seen = default;
|
||||
_router.RouteToGetAttributesAsync("SiteA", Arg.Any<RouteToGetAttributesRequest>(), Arg.Do<CancellationToken>(t => seen = t))
|
||||
.Returns(ci => new RouteToGetAttributesResponse(
|
||||
((RouteToGetAttributesRequest)ci[1]).CorrelationId,
|
||||
new Dictionary<string, object?>(), 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<RouteToSetAttributesRequest>(), Arg.Do<CancellationToken>(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<string, string> { ["x"] = "1" });
|
||||
|
||||
Assert.Equal(deadline.Token, seen);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user