feat(mtconnect): IDriverProbe one-shot /probe reachability (Task 14)
This commit is contained in:
@@ -0,0 +1,200 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-shot MTConnect Agent reachability probe for the AdminUI's Test Connect button. Issues a
|
||||||
|
/// single <c>GET {AgentUri}/probe</c> under the supplied deadline and reports success/failure
|
||||||
|
/// with latency — it does not construct or initialize an <see cref="MTConnectDriver"/> instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Deliberately does NOT delegate to <see cref="MTConnectDriver.ParseOptions"/>.</b> That
|
||||||
|
/// method builds the full driver options document, including every authored
|
||||||
|
/// <c>MTConnectTagDefinition</c> (<c>BuildTag</c> can throw on a malformed tag entry that has
|
||||||
|
/// nothing to do with reachability). A probe only needs <c>agentUri</c>, so this type parses
|
||||||
|
/// its own minimal DTO — a config that is invalid for the driver (bad tag, bad data type) can
|
||||||
|
/// still be reachability-tested, matching the AdminUI's "does the endpoint answer" intent for
|
||||||
|
/// Test Connect. It also avoids a compile-time dependency on the factory DTO shape
|
||||||
|
/// (<c>MTConnectDriverFactoryExtensions</c>), which is authored separately (Task 15).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Deliberately DOES reuse <see cref="MTConnectAgentClient"/> and
|
||||||
|
/// <see cref="MTConnectProbeParser"/> for the actual round trip.</b> Hand-rolling the HTTP
|
||||||
|
/// call would have to re-derive the exact behaviour the client already provides: per-call
|
||||||
|
/// <see cref="HttpClient.Timeout"/> bounding, a linked-CTS deadline, and — most importantly —
|
||||||
|
/// correct handling of an Agent's <c>MTConnectError</c> document served under HTTP 200 (so
|
||||||
|
/// <c>EnsureSuccessStatusCode</c> never fires). <see cref="MTConnectProbeParser"/> already
|
||||||
|
/// lifts the Agent's own error text into the thrown exception's message; re-parsing by hand
|
||||||
|
/// here would either duplicate that logic or under-validate (accepting any 200 response as
|
||||||
|
/// healthy, which is precisely the "empty-but-successful" defect class this codebase has hit
|
||||||
|
/// before, #485). The one-off cost of a full probe-document parse is negligible next to that
|
||||||
|
/// risk.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Never throws</b> (the <see cref="IDriverProbe"/> contract): every failure — unparseable
|
||||||
|
/// JSON, missing/malformed <c>agentUri</c>, DNS/TCP failure, a non-2xx response, a 200 body
|
||||||
|
/// that is not a valid <c>MTConnectDevices</c> document, and timeout — becomes
|
||||||
|
/// <c>Ok = false</c> with a message. Genuine caller cancellation is likewise converted rather
|
||||||
|
/// than left to propagate, matching <c>ModbusDriverProbe</c>'s posture (its linked-CTS catch
|
||||||
|
/// does not distinguish the caller's token from its own deadline).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Non-positive <c>timeout</c> (arch-review 01/S-6).</b> A <c>0</c> or negative
|
||||||
|
/// timeout does NOT mean "wait forever" — it is replaced with <see cref="FallbackTimeout"/>
|
||||||
|
/// (5s, matching <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>'s own default) so an
|
||||||
|
/// operator-authorable field can never brick the probe UI.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Secrets discipline.</b> <c>AgentUri</c> may carry userinfo credentials
|
||||||
|
/// (<c>http://user:pass@host/</c>). Every message this type constructs from the URI uses a
|
||||||
|
/// redacted display form (<see cref="RedactedDisplay"/>) built ONLY from
|
||||||
|
/// <see cref="Uri.Scheme"/>/<see cref="Uri.Host"/>/<see cref="Uri.Port"/>/<see cref="Uri.AbsolutePath"/>
|
||||||
|
/// — never <see cref="Uri.UserInfo"/> and never the raw config string — so a password can
|
||||||
|
/// never reach the AdminUI via the returned <see cref="DriverProbeResult.Message"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MTConnectDriverProbe : IDriverProbe
|
||||||
|
{
|
||||||
|
/// <summary>Replaces a non-positive caller <c>timeout</c> — see the type remarks.</summary>
|
||||||
|
private static readonly TimeSpan FallbackTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Test seam: routes the reused <see cref="MTConnectAgentClient"/> through a stub handler so response-shape cases need no socket.</summary>
|
||||||
|
private readonly HttpMessageHandler? _handler;
|
||||||
|
|
||||||
|
/// <summary>Creates the probe used in production (real sockets).</summary>
|
||||||
|
public MTConnectDriverProbe()
|
||||||
|
: this(handler: null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test seam constructor — see <see cref="_handler"/>.</summary>
|
||||||
|
internal MTConnectDriverProbe(HttpMessageHandler? handler)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverType => "MTConnect";
|
||||||
|
|
||||||
|
/// <summary>Minimal probe-only config shape. Deliberately independent of the factory DTO — see the type remarks.</summary>
|
||||||
|
private sealed class MTConnectProbeConfigDto
|
||||||
|
{
|
||||||
|
public string? AgentUri { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||||
|
{
|
||||||
|
MTConnectProbeConfigDto? dto;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dto = JsonSerializer.Deserialize<MTConnectProbeConfigDto>(configJson, JsonOptions);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new(false, $"Config JSON is invalid: {ex.Message}", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto is null)
|
||||||
|
{
|
||||||
|
return new(false, "Config JSON deserialized to null.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var agentUri = dto.AgentUri?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(agentUri))
|
||||||
|
{
|
||||||
|
return new(false, "Config has no agentUri to probe.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
|
||||||
|
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
|
||||||
|
{
|
||||||
|
return new(false, "Config's agentUri is not an absolute http(s) URI.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeUri = RedactedDisplay(parsed);
|
||||||
|
|
||||||
|
// arch-review 01/S-6: a non-positive timeout must never mean "wait forever".
|
||||||
|
var effectiveTimeout = timeout > TimeSpan.Zero ? timeout : FallbackTimeout;
|
||||||
|
var requestTimeoutMs = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalMilliseconds));
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
cts.CancelAfter(effectiveTimeout);
|
||||||
|
|
||||||
|
var options = new MTConnectDriverOptions
|
||||||
|
{
|
||||||
|
AgentUri = agentUri,
|
||||||
|
RequestTimeoutMs = requestTimeoutMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new MTConnectAgentClient(options, _handler);
|
||||||
|
_ = await client.ProbeAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Covers both the caller's token and our own CancelAfter deadline firing — see the type
|
||||||
|
// remarks on why cancellation is never allowed to propagate.
|
||||||
|
return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
// MTConnectAgentClient's own request-deadline signal.
|
||||||
|
return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// Connection failure or non-2xx status. HttpRequestException messages describe the
|
||||||
|
// socket endpoint or status code, never the request URI/userinfo, so ex.Message is safe.
|
||||||
|
return new(false, $"Request to {safeUri} failed: {ex.Message}", null);
|
||||||
|
}
|
||||||
|
catch (InvalidDataException ex)
|
||||||
|
{
|
||||||
|
// MTConnectProbeParser: not well-formed XML, not an MTConnectDevices document (including
|
||||||
|
// an MTConnectError-under-200, whose own errorCode/text ex.Message already carries), or
|
||||||
|
// a document with no devices. Never includes the request URI.
|
||||||
|
return new(false, $"Agent at {safeUri} did not return a valid MTConnect device model: {ex.Message}", null);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// Defensive: MTConnectAgentClient's own AgentUri validation, reached only if this
|
||||||
|
// method's own Uri.TryCreate above passed but the client's stricter check disagreed.
|
||||||
|
// Never forward the exception's message verbatim — it can echo the raw (possibly
|
||||||
|
// credentialed) config string.
|
||||||
|
return new(false, "Config's agentUri was rejected by the MTConnect client as invalid.", null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new(false, ex.Message, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a display form of <paramref name="uri"/> for use in returned messages, composed
|
||||||
|
/// ONLY from scheme/host/port/path — never <see cref="Uri.UserInfo"/> — so a credentialed
|
||||||
|
/// <c>AgentUri</c> can never leak a password into the AdminUI.
|
||||||
|
/// </summary>
|
||||||
|
private static string RedactedDisplay(Uri uri)
|
||||||
|
{
|
||||||
|
var portSegment = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
|
||||||
|
|
||||||
|
return $"{uri.Scheme}://{uri.Host}{portSegment}{uri.AbsolutePath}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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