feat(inbound): routed Route.To().WaitForAttribute — contract + central path (spec §6)

This commit is contained in:
Joseph Doherty
2026-06-17 09:02:21 -04:00
parent cd15426b21
commit 0f6da8a106
7 changed files with 233 additions and 0 deletions
@@ -623,5 +623,11 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException();
// WaitForAttribute is not part of this fixture's routed-Call audit scenario;
// mirror the other non-Call methods (unexercised here).
public Task<RouteToWaitForAttributeResponse> RouteToWaitForAttributeAsync(
string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
}
@@ -1,6 +1,7 @@
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
@@ -139,6 +140,116 @@ public class RouteHelperTests
Assert.Equal("read failed", ex.Message);
}
// --- WaitForAttribute (spec §6) ---
[Fact]
public async Task WaitForAttribute_Matched_ReturnsTrue()
{
SiteResolves("inst-1", "SiteA");
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToWaitForAttributeResponse(
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
Matched: true, Value: true, Quality: "Good", TimedOut: false,
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
var matched = await CreateHelper().To("inst-1")
.WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
Assert.True(matched);
}
[Fact]
public async Task WaitForAttribute_TimedOut_ReturnsFalse()
{
SiteResolves("inst-1", "SiteA");
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToWaitForAttributeResponse(
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
Matched: false, Value: null, Quality: null, TimedOut: true,
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
var matched = await CreateHelper().To("inst-1")
.WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
Assert.False(matched);
}
[Fact]
public async Task WaitForAttribute_RoutingFailure_ThrowsInvalidOperationException()
{
// Success=false is a routing-level outcome (e.g. instance not found on the
// site), distinct from the wait outcome (Matched/TimedOut).
SiteResolves("inst-1", "SiteA");
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToWaitForAttributeResponse(
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
Matched: false, Value: null, Quality: null, TimedOut: false,
Success: false, ErrorMessage: "instance not found", DateTimeOffset.UtcNow));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateHelper().To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30)));
Assert.Equal("instance not found", ex.Message);
}
[Fact]
public async Task WaitForAttribute_EncodesTargetValue_OnRequest()
{
// Value-equality only across the wire: the target value is encoded via the
// canonical AttributeValueCodec, identical to how attribute values travel.
SiteResolves("inst-1", "SiteA");
RouteToWaitForAttributeRequest? captured = null;
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Do<RouteToWaitForAttributeRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToWaitForAttributeResponse(
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
Matched: true, Value: true, Quality: "Good", TimedOut: false,
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
await CreateHelper().To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
Assert.NotNull(captured);
Assert.Equal("Flag", captured!.AttributeName);
Assert.Equal(TimeSpan.FromSeconds(30), captured.Timeout);
Assert.Equal(AttributeValueCodec.Encode(true), captured.TargetValueEncoded);
Assert.True(Guid.TryParse(captured.CorrelationId, out _));
}
[Fact]
public async Task WaitForAttribute_WithNoExplicitToken_InheritsMethodDeadlineToken()
{
SiteResolves("inst-1", "SiteA");
using var deadline = new CancellationTokenSource();
CancellationToken seen = default;
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
.Returns(ci => new RouteToWaitForAttributeResponse(
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
Matched: false, Value: null, Quality: null, TimedOut: true,
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
var bound = CreateHelper().WithDeadline(deadline.Token);
await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
Assert.Equal(deadline.Token, seen);
}
[Fact]
public async Task WaitForAttribute_WithParentExecutionId_CarriesItOnRequest()
{
SiteResolves("inst-1", "SiteA");
var inboundExecutionId = Guid.NewGuid();
RouteToWaitForAttributeRequest? captured = null;
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Do<RouteToWaitForAttributeRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToWaitForAttributeResponse(
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
Matched: true, Value: true, Quality: "Good", TimedOut: false,
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
Assert.NotNull(captured);
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
}
// --- SetAttribute(s) ---
[Fact]