feat(mtconnect): IDriverProbe one-shot /probe reachability (Task 14)
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 14 — <see cref="MTConnectDriverProbe"/>: the AdminUI Test Connect reachability probe.
|
||||
/// Response-shape cases (non-MTConnect body, MTConnectError-under-200, a valid device model) are
|
||||
/// exercised through a stubbed <see cref="HttpMessageHandler"/> via the internal test-seam
|
||||
/// constructor, so no socket is needed; the unreachable-agent and non-positive-timeout cases use
|
||||
/// a real (deliberately unroutable/refused) TCP target, mirroring the plan's canned example.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MTConnectDriverProbeTests
|
||||
{
|
||||
private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
// ── stub handler ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Answers every request with a canned response (or throws) via a delegate, with no socket.</summary>
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
|
||||
respond(request, ct);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage XmlResponse(string xml) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(xml, System.Text.Encoding.UTF8, "application/xml"),
|
||||
};
|
||||
|
||||
private static MTConnectDriverProbe ProbeWithHandler(
|
||||
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond) =>
|
||||
new(new StubHandler(respond));
|
||||
|
||||
private const string ValidProbeXml =
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:1.3">
|
||||
<Header creationTime="2026-07-24T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
|
||||
<Devices>
|
||||
<Device id="dev1" name="StubDevice">
|
||||
<DataItems>
|
||||
<DataItem id="dev1_avail" category="EVENT" type="AVAILABILITY"/>
|
||||
</DataItems>
|
||||
</Device>
|
||||
</Devices>
|
||||
</MTConnectDevices>
|
||||
""";
|
||||
|
||||
private const string MTConnectErrorXml =
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
||||
<Header creationTime="2026-07-24T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
|
||||
<Errors>
|
||||
<Error errorCode="NO_DEVICE">Could not find the device named 'nope'.</Error>
|
||||
</Errors>
|
||||
</MTConnectError>
|
||||
""";
|
||||
|
||||
private const string NotMTConnectXml =
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Foo><Bar/></Foo>
|
||||
""";
|
||||
|
||||
// ── from the plan (verbatim) ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_on_unreachable_agent_returns_not_ok_and_does_not_throw()
|
||||
{
|
||||
var probe = new MTConnectDriverProbe();
|
||||
var r = await probe.ProbeAsync("{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromMilliseconds(300), default);
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_on_blank_config_returns_not_ok()
|
||||
=> (await new MTConnectDriverProbe().ProbeAsync("{}", TimeSpan.FromSeconds(1), default)).Ok.ShouldBeFalse();
|
||||
|
||||
// ── unparseable JSON ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Unparseable_json_returns_not_ok_and_does_not_throw()
|
||||
{
|
||||
var probe = new MTConnectDriverProbe();
|
||||
|
||||
var r = await probe.ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNull();
|
||||
r.Message.ShouldContain("invalid", Case.Insensitive);
|
||||
}
|
||||
|
||||
// ── malformed / relative URI ─────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("not a uri")]
|
||||
[InlineData("/relative/path")]
|
||||
[InlineData("ftp://host/probe")]
|
||||
public async Task Malformed_or_non_http_agentUri_returns_not_ok(string agentUri)
|
||||
{
|
||||
var probe = new MTConnectDriverProbe();
|
||||
|
||||
var r = await probe.ProbeAsync($"{{\"agentUri\":\"{agentUri}\"}}", QuickTimeout, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ── credential redaction ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AgentUri_credentials_never_appear_in_the_message()
|
||||
{
|
||||
var probe = new MTConnectDriverProbe();
|
||||
|
||||
var r = await probe.ProbeAsync(
|
||||
"{\"agentUri\":\"http://sneaky-user:sneaky-pass@127.0.0.1:1/\"}",
|
||||
TimeSpan.FromMilliseconds(300),
|
||||
CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNull();
|
||||
r.Message.ShouldNotContain("sneaky-pass");
|
||||
r.Message.ShouldNotContain("sneaky-user:sneaky-pass");
|
||||
}
|
||||
|
||||
// ── 200 body that is not MTConnect at all ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task NonMTConnect_200_body_returns_not_ok()
|
||||
{
|
||||
var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(NotMTConnectXml)));
|
||||
|
||||
var r = await probe.ProbeAsync(
|
||||
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNull();
|
||||
r.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ── MTConnectError under HTTP 200 ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MTConnectError_under_200_returns_not_ok_with_agents_own_message()
|
||||
{
|
||||
var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(MTConnectErrorXml)));
|
||||
|
||||
var r = await probe.ProbeAsync(
|
||||
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNull();
|
||||
r.Message.ShouldContain("NO_DEVICE");
|
||||
r.Message.ShouldContain("Could not find the device named 'nope'.");
|
||||
}
|
||||
|
||||
// ── valid MTConnectDevices body ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_probe_document_returns_ok_true_with_latency()
|
||||
{
|
||||
var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(ValidProbeXml)));
|
||||
|
||||
var r = await probe.ProbeAsync(
|
||||
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeTrue();
|
||||
r.Message.ShouldNotBeNull();
|
||||
r.Latency.ShouldNotBeNull();
|
||||
r.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
|
||||
r.Latency!.Value.ShouldBeLessThan(QuickTimeout);
|
||||
}
|
||||
|
||||
// ── timeout bounded (hard hang-risk guard) ───────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5000)]
|
||||
public async Task Frozen_peer_times_out_within_the_requested_deadline_not_hang()
|
||||
{
|
||||
var probe = ProbeWithHandler(async (_, ct) =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
|
||||
throw new UnreachableException();
|
||||
});
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var r = await probe.ProbeAsync(
|
||||
"{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.FromMilliseconds(300), CancellationToken.None);
|
||||
sw.Stop();
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNull();
|
||||
r.Message.ShouldContain("timed out", Case.Insensitive);
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(4));
|
||||
}
|
||||
|
||||
// ── non-positive timeout does not mean "wait forever" ────────────────────
|
||||
|
||||
[Fact(Timeout = 8000)]
|
||||
public async Task Non_positive_timeout_falls_back_to_a_bounded_default_not_infinite()
|
||||
{
|
||||
var probe = ProbeWithHandler(async (_, ct) =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
|
||||
throw new UnreachableException();
|
||||
});
|
||||
|
||||
var r = await probe.ProbeAsync(
|
||||
"{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.Zero, CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negative_timeout_on_unreachable_agent_still_returns_promptly()
|
||||
{
|
||||
var probe = new MTConnectDriverProbe();
|
||||
|
||||
var r = await probe.ProbeAsync(
|
||||
"{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromSeconds(-1), CancellationToken.None);
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.</summary>
|
||||
private sealed class UnreachableException : Exception;
|
||||
}
|
||||
Reference in New Issue
Block a user