feat(mtconnect): agent-client observation write (HTTP POST, AllowPut surface)
This commit is contained in:
@@ -355,6 +355,32 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public ConcurrentQueue<(string DeviceName, string DataItemId, string Value)> Writes { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="WriteObservationAsync"/> answers with. Defaults to acceptance; set it to a
|
||||
/// refusal to script the Agent's <c>MTConnectError</c> leg.
|
||||
/// </summary>
|
||||
public MTConnectWriteOutcome WriteOutcome { get; set; } = new(Accepted: true, AgentError: null);
|
||||
|
||||
/// <summary>When set, a write throws this instead of answering — the transport-failure leg.</summary>
|
||||
public Exception? WriteFailure { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MTConnectWriteOutcome> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a chunk onto the scripted <c>/sample</c> stream and waits until the consumer has
|
||||
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The wire contract of <see cref="MTConnectAgentClient.WriteObservationAsync"/> — the Agent's
|
||||
/// <c>AllowPut</c> surface. Everything here runs through a stubbed
|
||||
/// <see cref="HttpMessageHandler"/> 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The URI assertions are the point of this class, not incidental detail.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The refusal legs pin the split the seam contract rests on:</b> an Agent that answered
|
||||
/// and said no is a returned outcome, while an Agent that could not be reached throws. The
|
||||
/// 200-carrying-an-<c>MTConnectError</c> case is why the status code alone can never decide
|
||||
/// it — that is the shape a real cppagent uses to report every objection.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MTConnectAgentClientWriteTests
|
||||
{
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
/// <summary>Records the requests it is handed, then answers each with <paramref name="respond"/>.</summary>
|
||||
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
public List<(HttpMethod Method, Uri Uri)> Requests { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> 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"),
|
||||
};
|
||||
|
||||
/// <summary>Exactly the shape a cppagent uses to refuse: an error document served under HTTP 200.</summary>
|
||||
private const string MTConnectErrorXml =
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
||||
<Header creationTime="2026-08-05T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
|
||||
<Errors>
|
||||
<Error errorCode="UNSUPPORTED">Cannot find device data item: nosuchitem</Error>
|
||||
</Errors>
|
||||
</MTConnectError>
|
||||
""";
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The device is a <b>path segment</b> and the DataItem id/value are a <b>query pair</b> —
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A value carrying a space, an <c>&</c> and a <c>=</c> — 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.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>deviceName</c> argument decides the path — <b>not</b> 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.
|
||||
/// </summary>
|
||||
[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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>A 200 carrying a non-MTConnect body is still acceptance — only an error document refuses.</summary>
|
||||
[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("<html><body>OK</body></html>"))));
|
||||
|
||||
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
|
||||
|
||||
outcome.Accepted.ShouldBeTrue();
|
||||
outcome.AgentError.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ── refusal ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The load-bearing refusal: HTTP 200 with an <c>MTConnectError</c> body.
|
||||
/// <c>EnsureSuccessStatusCode</c> never fires here, so an implementation that trusted the
|
||||
/// status code would report every rejected write as a success.
|
||||
/// </summary>
|
||||
[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 ──────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// "Unreachable" must stay distinguishable from "rejected". Returning
|
||||
/// <c>Accepted = false</c> 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.
|
||||
/// </summary>
|
||||
[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<HttpResponseMessage>(boom)));
|
||||
|
||||
var thrown = await Should.ThrowAsync<HttpRequestException>(
|
||||
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
|
||||
|
||||
thrown.ShouldBeSameAs(boom);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A frozen Agent must surface as a bounded <see cref="TimeoutException"/> naming the URI,
|
||||
/// not park the caller — the same per-call deadline discipline the read legs carry
|
||||
/// (arch-review R2-01).
|
||||
/// </summary>
|
||||
[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<TimeoutException>(
|
||||
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <summary>Cancelling is cancellation, never a refusal — the caller's own token wins over the deadline.</summary>
|
||||
[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<OperationCanceledException>(() => 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<ObjectDisposedException>(
|
||||
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user