From 9f17d7efa204fb1345ae30fde7f2d3ddc07056ae Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 5 Aug 2026 06:16:35 -0400 Subject: [PATCH] feat(mtconnect): agent-client observation write (HTTP POST, AllowPut surface) --- .../IMTConnectAgentClient.cs | 85 ++++- .../MTConnectAgentClient.cs | 169 +++++++++- .../CannedAgentClient.cs | 26 ++ .../MTConnectAgentClientWriteTests.cs | 307 ++++++++++++++++++ 4 files changed, 580 insertions(+), 7 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs index 1d41561e..09543a11 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs @@ -1,5 +1,27 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; +/// +/// The result of a call, as the +/// Agent answered it. A transport failure is never represented here — it throws (see the +/// method's contract), so an outcome always means "the Agent replied, and this is what it said". +/// +/// +/// true when the Agent took the observation: a 2xx response whose body is not an +/// MTConnectError document. false is a refusal, and +/// then always carries text. +/// +/// +/// Why the Agent refused, in its own words where it supplied any — the joined +/// errorCode: text of the MTConnectError document, or the HTTP status line for a +/// non-2xx answer. null if and only if is true. +/// +/// This is Agent-authored text destined for a log line, never for a driver's +/// LastError (which is type-only by repo-wide discipline) and never for an OPC UA +/// status code's own message. +/// +/// +public sealed record MTConnectWriteOutcome(bool Accepted, string? AgentError); + /// /// The seam between the MTConnect driver and an MTConnect Agent's HTTP surface /// (/probe, /current, /sample). This interface is transport-neutral by @@ -17,7 +39,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// /sample long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries; /// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and /// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only -/// needs to implement these three instance members; is a static +/// needs to implement these four instance members; is a static /// protocol rule that lives here, rather than on the implementation, so a consumer written /// against this seam never has to reference the concrete client. /// @@ -88,6 +110,67 @@ public interface IMTConnectAgentClient : IDisposable /// Cancellation token; cancelling is the contracted way to end the stream. IAsyncEnumerable SampleAsync(long from, CancellationToken ct); + /// + /// Sets the Agent's observation for one DataItem, through the Agent's AllowPut + /// surface (POST {agent}/{deviceName}?{dataItemId}={value}). + /// + /// + /// + /// This is observation injection, not machine command. The value lands in the + /// Agent's observation buffer exactly as if an adapter had reported it — every consumer + /// of that Agent then sees it, and the next adapter/SHDR update for the same DataItem + /// overwrites it. Nothing is sent to the machine. It is also gated twice and off at both + /// gates by default: the Agent's own AllowPut (agent.cfg) and the driver's + /// write.enabled. + /// + /// + /// A refusal is a return value; a transport failure is an exception. The split is + /// the whole point of the outcome type, and it is what lets the driver map the two to + /// different OPC UA status codes without inspecting exception text: + /// + /// + /// The Agent answered and took the observation (2xx, body empty or not an + /// MTConnectError) ⇒ . An + /// empty 2xx body is acceptance — that is what a cppagent actually sends, + /// so treating a body-less answer as suspect would refuse every successful write. + /// + /// + /// The Agent answered and refused — a non-2xx status, or an + /// MTConnectError document served (as Agents do) under HTTP 200 ⇒ + /// not accepted, with the Agent's own text in + /// . The 200-with-error shape is + /// why the status code alone can never decide this. + /// + /// + /// The Agent could not be reached or did not answer in time ⇒ throws + /// (, or a for + /// the per-call deadline; when + /// is cancelled or the client is disposed). + /// Folding these into a false outcome would make "the machine's Agent is + /// unreachable" indistinguishable from "the Agent rejected this DataItem" — + /// identical to the caller, opposite in what an operator must do about it. + /// + /// + /// + /// + /// The write is a URL, so escaping is this seam's job, not the caller's. + /// and are passed unescaped + /// and are percent-encoded by the implementation. A caller that pre-escaped would + /// double-encode and write a literal %20 into the Agent's buffer, which the Agent + /// accepts — so the corruption is silent and shows up only as a wrong value on read-back. + /// + /// + /// + /// The device that owns the DataItem — the path segment of the request. Required even on a + /// single-device Agent, because the Agent's PUT surface is addressed per device. + /// + /// The DataItem's id, as declared by /probe. Unescaped. + /// The value to report, already rendered to its invariant string form. Unescaped. + /// Cancellation token. + /// What the Agent said — see the remarks. + Task WriteObservationAsync( + string deviceName, string dataItemId, string value, CancellationToken cancellationToken); + /// /// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True /// when the chunk's oldest retained sequence is strictly newer than diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs index 8ce654b1..a3b39450 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Text; +using System.Xml; +using System.Xml.Linq; using Microsoft.Extensions.Logging; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; @@ -75,6 +77,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable private readonly ILogger? _logger; private readonly Uri _probeUri; private readonly Uri _currentUri; + private readonly string _agentRoot; private readonly string _sampleUriBase; private readonly string _sampleQuerySuffix; private readonly TimeSpan _requestTimeout; @@ -133,6 +136,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable // Agent and an idle one both emit at least one chunk per heartbeat. _streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs); + _agentRoot = ResolveAgentRoot(options); _probeUri = BuildRequestUri(options, "probe"); _currentUri = BuildRequestUri(options, "current"); _sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri; @@ -242,6 +246,139 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable } } + /// + /// + /// Issued on _http, the unary client — the write is a short request/response + /// and must be bounded by RequestTimeoutMs like the other unary legs. Sending it on + /// the streaming client would inherit that client's infinite timeout, and a frozen Agent + /// would park a write forever with nothing to bound it (the streaming leg's heartbeat + /// watchdog protects a multipart body, not a one-shot POST). + /// + public async Task WriteObservationAsync( + string deviceName, string dataItemId, string value, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(deviceName); + ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId); + ArgumentNullException.ThrowIfNull(value); + + var uri = BuildWriteUri(deviceName, dataItemId, value); + + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token); + deadline.CancelAfter(_requestTimeout); + + using var request = new HttpRequestMessage(HttpMethod.Post, uri); + + HttpResponseMessage response; + try + { + response = await _http.SendAsync(request, deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!lifetime.IsCancellationRequested) + { + throw TimedOut(uri); + } + + using (response) + { + string body; + try + { + body = await response.Content.ReadAsStringAsync(deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!lifetime.IsCancellationRequested) + { + throw TimedOut(uri); + } + + // Deliberately NOT EnsureSuccessStatusCode: a refused write is an answer, and the caller + // needs it as a value it can map to a per-tag status code rather than as an exception + // indistinguishable from the Agent being unreachable. + var agentError = DescribeAgentError(body); + if (!response.IsSuccessStatusCode) + { + var status = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".TrimEnd(); + + return new MTConnectWriteOutcome( + Accepted: false, agentError is null ? status : $"{status}: {agentError}"); + } + + // An MTConnectError document under HTTP 200 is how an Agent actually refuses; the status + // code alone would report it as a success. + return agentError is null + ? new MTConnectWriteOutcome(Accepted: true, AgentError: null) + : new MTConnectWriteOutcome(Accepted: false, agentError); + } + } + + /// + /// The Agent's AllowPut URL: the device as a path segment, the DataItem id and value + /// as a query pair. Every caller-supplied part is percent-encoded here — see the seam's + /// contract for why escaping cannot be the caller's job. + /// + private Uri BuildWriteUri(string deviceName, string dataItemId, string value) + { + var device = Uri.EscapeDataString(deviceName.Trim()); + var query = $"{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}"; + + return new Uri($"{_agentRoot}/{device}?{query}", UriKind.Absolute); + } + + /// + /// The Agent's own refusal text when is an MTConnectError + /// document, else null (including for an empty body, a non-XML body, and any other + /// MTConnect document). + /// + /// + /// Read by only, like every other document this driver parses: + /// the namespace URI carries the MTConnect version, so a qualified match would silently stop + /// recognising errors the day an Agent is upgraded — and an unrecognised error reads as a + /// successful write. + /// + private static string? DescribeAgentError(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + + XElement? root; + try + { + root = XDocument.Parse(body).Root; + } + catch (XmlException) + { + // Not XML at all — an Agent that answered 2xx with a plain-text or HTML body has not + // refused anything, so this is not the place to invent a failure. + return null; + } + + if (root is null || !MTConnectXml.IsNamed(root, "MTConnectError")) + { + return null; + } + + var errors = root.Descendants() + .Where(e => MTConnectXml.IsNamed(e, "Error")) + .Select(e => + { + var code = MTConnectXml.OptionalAttribute(e, "errorCode"); + var text = e.Value.Trim(); + + return code is null ? text : $"{code}: {text}"; + }) + .Where(text => text.Length > 0) + .ToList(); + + // An error document with no readable detail is still a refusal; reporting it as null would + // turn it back into an acceptance. + return errors.Count == 0 + ? "The Agent answered with an MTConnectError document carrying no error detail." + : string.Join("; ", errors); + } + /// public void Dispose() { @@ -379,7 +516,31 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable return boundary; } - private static Uri BuildRequestUri(MTConnectDriverOptions options, string request) + /// + /// A read path's request URI, scoped to the configured device. Reads , + /// so it may only be called after that field is assigned — which the constructor does first. + /// + private Uri BuildRequestUri(MTConnectDriverOptions options, string request) + { + var deviceName = options.DeviceName?.Trim(); + var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}"; + + return new Uri($"{_agentRoot}{deviceSegment}/{request}", UriKind.Absolute); + } + + /// + /// Validates and reduces it to the base every + /// request path is appended to: its left part up to and including the path (so a proxied + /// Agent keeps its prefix), with any trailing slash removed so callers can append + /// /segment unconditionally. + /// + /// + /// Extracted rather than duplicated because the write path resolves its device at call time, + /// not at construction: two copies of this rule would drift, and the drift would surface as + /// writes landing on a URL the reads never used — against an Agent that answers 200 either + /// way. + /// + private static string ResolveAgentRoot(MTConnectDriverOptions options) { var agentUri = options.AgentUri?.Trim(); if (string.IsNullOrEmpty(agentUri)) @@ -395,11 +556,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable $"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options)); } - var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/'); - var deviceName = options.DeviceName?.Trim(); - var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}"; - - return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute); + return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/'); } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs index 521e0e98..d6627661 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs @@ -355,6 +355,32 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable } } + /// + /// Every write this Agent was asked to take, in order — the record a driver-level write test + /// asserts against (which DataItem, on which device, carrying what). + /// + public ConcurrentQueue<(string DeviceName, string DataItemId, string Value)> Writes { get; } = new(); + + /// + /// What answers with. Defaults to acceptance; set it to a + /// refusal to script the Agent's MTConnectError leg. + /// + public MTConnectWriteOutcome WriteOutcome { get; set; } = new(Accepted: true, AgentError: null); + + /// When set, a write throws this instead of answering — the transport-failure leg. + public Exception? WriteFailure { get; set; } + + /// + public Task WriteObservationAsync( + string deviceName, string dataItemId, string value, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + cancellationToken.ThrowIfCancellationRequested(); + Writes.Enqueue((deviceName, dataItemId, value)); + + return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure; + } + /// /// Queues a chunk onto the scripted /sample stream and waits until the consumer has /// processed it. This is the deterministic replacement for "wait a bit and hope the pump diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs new file mode 100644 index 00000000..cadf4af1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs @@ -0,0 +1,307 @@ +using System.Net; +using System.Text; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// The wire contract of — the Agent's +/// AllowPut surface. Everything here runs through a stubbed +/// via the internal test-seam constructor, so the verb, the +/// exact request URI, and each response shape are asserted with no socket and no Agent. +/// +/// +/// +/// The URI assertions are the point of this class, not incidental detail. A write is +/// nothing but a URL: get the escaping or the device segment wrong and the Agent still +/// answers 200, having stored a value under a DataItem id nobody asked about — a corruption +/// with no error anywhere, visible only on a later read-back. There is no encoder to unit +/// test, so this is the only place that shape is pinned. +/// +/// +/// The refusal legs pin the split the seam contract rests on: an Agent that answered +/// and said no is a returned outcome, while an Agent that could not be reached throws. The +/// 200-carrying-an-MTConnectError case is why the status code alone can never decide +/// it — that is the shape a real cppagent uses to report every objection. +/// +/// +[Trait("Category", "Unit")] +public sealed class MTConnectAgentClientWriteTests +{ + // ── stub handler ───────────────────────────────────────────────────────── + + /// Answers every request with a canned response (or throws) via a delegate, with no socket. + private sealed class StubHandler(Func> respond) + : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => + respond(request, ct); + } + + /// Records the requests it is handed, then answers each with . + private sealed class RecordingHandler(Func respond) : HttpMessageHandler + { + public List<(HttpMethod Method, Uri Uri)> Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Requests.Add((request.Method, request.RequestUri!)); + + return Task.FromResult(respond(request)); + } + } + + private static MTConnectDriverOptions Options(string agentUri = "http://stub-agent:5000") => + new() { AgentUri = agentUri }; + + private static HttpResponseMessage Ok(string body = "") => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "text/plain"), + }; + + private static HttpResponseMessage XmlOk(string xml) => new(HttpStatusCode.OK) + { + Content = new StringContent(xml, Encoding.UTF8, "text/xml"), + }; + + /// Exactly the shape a cppagent uses to refuse: an error document served under HTTP 200. + private const string MTConnectErrorXml = + """ + + +
+ + Cannot find device data item: nosuchitem + + + """; + + // ── verb + URI ─────────────────────────────────────────────────────────── + + [Fact] + public async Task A_write_is_issued_as_a_POST() + { + var handler = new RecordingHandler(_ => Ok()); + using var client = new MTConnectAgentClient(Options(), handler); + + await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None); + + handler.Requests.ShouldHaveSingleItem().Method.ShouldBe(HttpMethod.Post); + } + + /// + /// The device is a path segment and the DataItem id/value are a query pair — + /// the Agent's documented AllowPut shape. Asserted as the whole URI rather than in parts, + /// because a fragment-wise assertion passes on a URI that is assembled in the wrong order. + /// + [Fact] + public async Task The_request_uri_is_the_agents_AllowPut_shape() + { + var handler = new RecordingHandler(_ => Ok()); + using var client = new MTConnectAgentClient(Options(), handler); + + await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None); + + handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri + .ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=hello"); + } + + /// + /// A value carrying a space, an & and a = — each of which silently ends the + /// query pair early if it reaches the wire raw — plus a DataItem id needing the same + /// treatment. Unescaped, the Agent would take a 200 and store the truncated value. + /// + [Fact] + public async Task The_dataItemId_and_value_are_percent_encoded() + { + var handler = new RecordingHandler(_ => Ok()); + using var client = new MTConnectAgentClient(Options(), handler); + + await client.WriteObservationAsync( + "Stub Device", "dev1 msg&x", "Coolant low = 20% & rising", CancellationToken.None); + + handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri.ShouldBe( + "http://stub-agent:5000/Stub%20Device?dev1%20msg%26x=Coolant%20low%20%3D%2020%25%20%26%20rising"); + } + + /// + /// The base URI's own path is preserved (an Agent behind a reverse-proxy prefix) and a + /// trailing slash does not double up — the same left-part rule the read paths use. + /// + [Fact] + public async Task A_based_agent_uri_keeps_its_path_prefix_and_does_not_double_its_slash() + { + var handler = new RecordingHandler(_ => Ok()); + using var client = new MTConnectAgentClient(Options("http://stub-agent:5000/mtconnect/"), handler); + + await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None); + + handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri + .ShouldBe("http://stub-agent:5000/mtconnect/StubDevice?dev1_msg=hi"); + } + + /// + /// The deviceName argument decides the path — not the options' device scope. A + /// multi-device Agent is addressed per write, so an implementation that reused the read + /// paths' scoped URI would write every DataItem onto one device. + /// + [Fact] + public async Task The_device_segment_comes_from_the_argument_not_the_configured_device_scope() + { + var handler = new RecordingHandler(_ => Ok()); + using var client = new MTConnectAgentClient( + new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", DeviceName = "ScopedDevice" }, + handler); + + await client.WriteObservationAsync("OtherDevice", "dev2_msg", "hi", CancellationToken.None); + + handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri + .ShouldBe("http://stub-agent:5000/OtherDevice?dev2_msg=hi"); + } + + // ── acceptance ─────────────────────────────────────────────────────────── + + /// + /// An empty 2xx body is what a real cppagent sends on success. Treating a body-less answer + /// as anything but acceptance would refuse every write that actually worked. + /// + [Fact] + public async Task An_empty_200_is_accepted() + { + using var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok()))); + + var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None); + + outcome.Accepted.ShouldBeTrue(); + outcome.AgentError.ShouldBeNull(); + } + + /// A 200 carrying a non-MTConnect body is still acceptance — only an error document refuses. + [Fact] + public async Task A_200_whose_body_is_not_an_error_document_is_accepted() + { + using var client = new MTConnectAgentClient( + Options(), new StubHandler((_, _) => Task.FromResult(Ok("OK")))); + + var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None); + + outcome.Accepted.ShouldBeTrue(); + outcome.AgentError.ShouldBeNull(); + } + + // ── refusal ────────────────────────────────────────────────────────────── + + /// + /// The load-bearing refusal: HTTP 200 with an MTConnectError body. + /// EnsureSuccessStatusCode never fires here, so an implementation that trusted the + /// status code would report every rejected write as a success. + /// + [Fact] + public async Task An_MTConnectError_under_200_is_a_refusal_carrying_the_agents_own_text() + { + using var client = new MTConnectAgentClient( + Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(MTConnectErrorXml)))); + + var outcome = await client.WriteObservationAsync( + "StubDevice", "nosuchitem", "hi", CancellationToken.None); + + outcome.Accepted.ShouldBeFalse(); + outcome.AgentError.ShouldNotBeNull(); + outcome.AgentError.ShouldContain("UNSUPPORTED"); + outcome.AgentError.ShouldContain("Cannot find device data item: nosuchitem"); + } + + [Fact] + public async Task A_non_success_status_is_a_refusal_naming_the_status() + { + using var client = new MTConnectAgentClient( + Options(), + new StubHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent(string.Empty), + }))); + + var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None); + + outcome.Accepted.ShouldBeFalse(); + outcome.AgentError.ShouldNotBeNull(); + outcome.AgentError.ShouldContain("404"); + } + + // ── transport failure throws, never a false outcome ────────────────────── + + /// + /// "Unreachable" must stay distinguishable from "rejected". Returning + /// Accepted = false here would collapse a comms fault into a per-tag refusal, which + /// the driver maps to a different status code and an operator resolves differently. + /// + [Fact] + public async Task A_transport_failure_propagates_rather_than_becoming_a_refusal() + { + var boom = new HttpRequestException("dial failed"); + using var client = new MTConnectAgentClient( + Options(), new StubHandler((_, _) => Task.FromException(boom))); + + var thrown = await Should.ThrowAsync( + () => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None)); + + thrown.ShouldBeSameAs(boom); + } + + /// + /// A frozen Agent must surface as a bounded naming the URI, + /// not park the caller — the same per-call deadline discipline the read legs carry + /// (arch-review R2-01). + /// + [Fact(Timeout = 10000)] + public async Task A_frozen_agent_times_out_within_the_per_call_deadline() + { + using var client = new MTConnectAgentClient( + new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", RequestTimeoutMs = 300 }, + new StubHandler(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct); + + throw new UnreachableException(); + })); + + await Should.ThrowAsync( + () => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None)); + } + + /// Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis. + private sealed class UnreachableException : Exception; + + /// Cancelling is cancellation, never a refusal — the caller's own token wins over the deadline. + [Fact] + public async Task A_cancelled_token_surfaces_as_cancellation_not_as_a_refusal() + { + using var cts = new CancellationTokenSource(); + using var client = new MTConnectAgentClient( + Options(), + new StubHandler(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct); + + throw new UnreachableException(); + })); + + var write = client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", cts.Token); + await cts.CancelAsync(); + + await Should.ThrowAsync(() => write); + } + + // ── disposal ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Writing_through_a_disposed_client_throws_ObjectDisposedException() + { + var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok()))); + client.Dispose(); + + await Should.ThrowAsync( + () => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None)); + } +}