269 lines
11 KiB
C#
269 lines
11 KiB
C#
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);
|
|
}
|
|
}
|