From 6856874be903c01b71db5670902aa265a5a83691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Sun, 16 Aug 2026 10:03:30 +0200 Subject: [PATCH 1/8] fix(common): make Header/@sender authorable via IAgentConfiguration.Sender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Sender { get; }` to `IAgentConfiguration` and `Sender { get; set; }` to the concrete `AgentConfiguration`, and wires `MTConnectAgent`'s `IAgentConfiguration`-taking ctor to seed the private `_sender` field when the config value is present. The existing get-only `IMTConnectAgent.Sender` and `MTConnectAgent.Sender` surfaces are unchanged, as is the `System.Net.Dns.GetHostName()` fallback when no value is supplied — hosts that do not opt in see identical behavior. Rationale: MTConnect Part 1 §7 defines `Header/@sender` as an operator- authored identifier ("An identification defining where the Agent that published the Response Document is installed or hosted"). Before this change, embedding hosts had no way to author it — the property lived on the agent but was get-only, with no ctor param and no config surface, so every emitted Probe / Current / Sample / Asset response document carried `Dns.GetHostName()` regardless of operator intent. Non-breaking: interface additions are new members; the concrete `Sender` setter is additive; ctor signature unchanged; consumer semantics preserved when the config value is absent. Test coverage: `AgentConfigurationSenderTests` pins both branches — value flows through when set, `Dns.GetHostName()` fallback preserved when null. --- .../Agents/MTConnectAgent.cs | 2 + .../Configurations/AgentConfiguration.cs | 6 ++ .../Configurations/IAgentConfiguration.cs | 5 ++ .../Agents/AgentConfigurationSenderTests.cs | 63 +++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs index 65420fcfc..304ece258 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs @@ -290,6 +290,8 @@ public MTConnectAgent( _uuid = !string.IsNullOrEmpty(uuid) ? uuid : Guid.NewGuid().ToString(); _instanceId = instanceId > 0 ? instanceId : CreateInstanceId(); _configuration = configuration != null ? configuration : new AgentConfiguration(); + if (_configuration != null && !string.IsNullOrEmpty(_configuration.Sender)) + _sender = _configuration.Sender; _information = new MTConnectAgentInformation(_uuid, _instanceId, _deviceModelChangeTime); _deviceModelChangeTime = deviceModelChangeTime; _mtconnectVersion = _configuration != null && _configuration.DefaultVersion != null ? _configuration.DefaultVersion : MTConnectVersions.Max; diff --git a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs index fd873c6c7..7eaf8ed78 100644 --- a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs +++ b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs @@ -54,6 +54,12 @@ public class AgentConfiguration : IAgentConfiguration [YamlIgnore] public string Path { get; set; } + /// + /// The value emitted as the Header/@sender attribute on MTConnect response documents (see MTConnect Part 1 §7). When null or empty, falls back to . + /// + [JsonPropertyName("sender")] + public string Sender { get; set; } + /// /// The maximum number of Observations the agent can hold in its buffer diff --git a/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs index f6cc210c5..ba86a1814 100644 --- a/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs +++ b/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs @@ -21,6 +21,11 @@ public interface IAgentConfiguration /// string Path { get; } + /// + /// The value emitted as the Header/@sender attribute on MTConnect response documents (see MTConnect Part 1 §7). When null or empty, falls back to . + /// + string Sender { get; } + /// /// The maximum number of Observations the agent can hold in its buffer diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs new file mode 100644 index 000000000..d7839d270 --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Net; +using MTConnect.Agents; +using MTConnect.Configurations; +using NUnit.Framework; + +namespace MTConnect.Tests.Common.Agents +{ + /// + /// Pins the contract that IAgentConfiguration.Sender, when set, + /// flows through to — which populates + /// the Header/@sender attribute on every emitted MTConnect response + /// document (see MTConnect Part 1 §7). When the config value is absent, + /// the pre-existing fallback is preserved + /// bit-for-bit, so hosts that do not opt in see no behavioural change. + /// + [TestFixture] + public class AgentConfigurationSenderTests + { + /// + /// When AgentConfiguration.Sender is set, the constructed + /// exposes that exact value on its + /// property. + /// + [Test] + public void Sender_set_in_config_flows_through_to_Agent_Sender() + { + const string PinnedSender = "foo-plant-a"; + + var configuration = new AgentConfiguration + { + Sender = PinnedSender, + }; + + var agent = new MTConnectAgent( + configuration, + uuid: "sender-fixture-uuid", + initializeAgentDevice: false); + + Assert.That(agent.Sender, Is.EqualTo(PinnedSender)); + } + + /// + /// When AgentConfiguration.Sender is null or empty, the agent + /// falls back to — matching the + /// pre-existing behaviour before the config surface was added. + /// + [Test] + public void Sender_absent_from_config_falls_back_to_Dns_GetHostName() + { + var configuration = new AgentConfiguration(); + + var agent = new MTConnectAgent( + configuration, + uuid: "sender-fallback-fixture-uuid", + initializeAgentDevice: false); + + Assert.That(agent.Sender, Is.EqualTo(Dns.GetHostName())); + } + } +} From c4958f6995a430e577b263f7466e2086e47b9689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Sun, 16 Aug 2026 15:35:41 +0200 Subject: [PATCH 2/8] test(common-tests): pin JSON + YAML round trip and defaults for AgentConfiguration.Sender Adds negative and positive test rows for the operator-authored `Sender` surface on `AgentConfiguration`: - Default value is null (fallback to `Dns.GetHostName` fires on first read of `MTConnectAgent.Sender`). - `[JsonPropertyName("sender")]` binds to the lowercase wire-name so authored JSON / YAML operator configs deserialize straight into the property. - `SaveJson` / `ReadJson` round trip preserves value + operator path. - `SaveYaml` / `ReadYaml` round trip preserves value + operator path. - Authored JSON payload with `"sender": "..."` deserializes the value. - Authored YAML payload with `sender: ...` under the camelCase naming convention deserializes the value. - Empty-string `Sender` round trips as empty. - Values containing `/`, `:`, and `-` round trip verbatim through JSON. --- ...ntConfigurationSenderSerializationTests.cs | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderSerializationTests.cs diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderSerializationTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderSerializationTests.cs new file mode 100644 index 000000000..ce32624b9 --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderSerializationTests.cs @@ -0,0 +1,411 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using MTConnect.Configurations; +using NUnit.Framework; + +namespace MTConnect.Tests.Common.Agents +{ + /// + /// Pins the JSON / YAML serialization contract for + /// . The property must round-trip + /// through both formats via SaveJson / ReadJson and + /// SaveYaml / ReadYaml, and it must be tagged with the + /// sender wire-name that operator YAML / JSON authors write, so an + /// operator-supplied sender: foo-plant-a in agent.config.yaml + /// binds through to + /// on startup. + /// + [TestFixture] + public class AgentConfigurationSenderSerializationTests + { + private string _workingDirectory = string.Empty; + + /// Creates a per-test working directory so file writes do not + /// contend across parallel fixtures. + [SetUp] + public void SetUp() + { + _workingDirectory = Path.Combine( + Path.GetTempPath(), + "mtconnect-sender-serialization-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_workingDirectory); + } + + /// Removes the per-test working directory. + [TearDown] + public void TearDown() + { + if (_workingDirectory != null && Directory.Exists(_workingDirectory)) + { + try + { + Directory.Delete(_workingDirectory, recursive: true); + } + catch + { + // Non-fatal cleanup swallow. + } + } + } + + /// Default value of Sender on a freshly constructed + /// configuration is null, so the agent falls back to + /// until an operator opts in. + [Test] + public void Sender_default_value_is_null() + { + var configuration = new AgentConfiguration(); + + Assert.That(configuration.Sender, Is.Null); + } + + /// The property carries + /// the [JsonPropertyName("sender")] attribute so authored JSON + /// binds under the lowercase wire-name. + [Test] + public void Sender_property_wire_name_is_lowercase_sender() + { + var property = typeof(AgentConfiguration).GetProperty( + nameof(AgentConfiguration.Sender), + BindingFlags.Public | BindingFlags.Instance); + + Assert.That(property, Is.Not.Null); + var jsonAttribute = property!.GetCustomAttribute(); + + Assert.That(jsonAttribute, Is.Not.Null); + Assert.That(jsonAttribute!.Name, Is.EqualTo("sender")); + } + + /// Round-trips through + /// the JSON save / read pipeline, pinning that + /// emits the value and + /// parses it back. + [Test] + public void Sender_JSON_roundtrips_through_SaveJson_and_ReadJson() + { + const string PinnedSender = "plant-a-aggregator"; + var jsonPath = Path.Combine(_workingDirectory, "agent.config.json"); + + var original = new AgentConfiguration + { + Sender = PinnedSender + }; + original.SaveJson(jsonPath, createBackup: false); + + var loaded = AgentConfiguration.ReadJson(jsonPath); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Sender, Is.EqualTo(PinnedSender)); + Assert.That(loaded.Path, Is.EqualTo(jsonPath)); + } + + /// Round-trips through + /// the YAML save / read pipeline, pinning both directions of the + /// operator-facing YAML surface. + [Test] + public void Sender_YAML_roundtrips_through_SaveYaml_and_ReadYaml() + { + const string PinnedSender = "plant-b-aggregator"; + var yamlPath = Path.Combine(_workingDirectory, "agent.config.yaml"); + + var original = new AgentConfiguration + { + Sender = PinnedSender + }; + original.SaveYaml(yamlPath, createBackup: false); + + var loaded = AgentConfiguration.ReadYaml(yamlPath); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Sender, Is.EqualTo(PinnedSender)); + Assert.That(loaded.Path, Is.EqualTo(yamlPath)); + } + + /// Authored JSON payloads carrying "sender": "…" bind + /// straight through to . + [Test] + public void Sender_reads_from_authored_JSON_payload() + { + const string PinnedSender = "gateway-north-1"; + var jsonPath = Path.Combine(_workingDirectory, "authored.json"); + File.WriteAllText( + jsonPath, + "{\n \"sender\": \"" + PinnedSender + "\",\n" + + " \"observationBufferSize\": 4096\n}\n"); + + var loaded = AgentConfiguration.ReadJson(jsonPath); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Sender, Is.EqualTo(PinnedSender)); + Assert.That(loaded.ObservationBufferSize, Is.EqualTo(4096)); + } + + /// Authored YAML payloads carrying sender: … under the + /// camel-case naming convention bind to + /// . + [Test] + public void Sender_reads_from_authored_YAML_payload() + { + const string PinnedSender = "gateway-north-2"; + var yamlPath = Path.Combine(_workingDirectory, "authored.yaml"); + File.WriteAllText( + yamlPath, + "sender: " + PinnedSender + "\n" + + "observationBufferSize: 8192\n"); + + var loaded = AgentConfiguration.ReadYaml(yamlPath); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Sender, Is.EqualTo(PinnedSender)); + Assert.That(loaded.ObservationBufferSize, Is.EqualTo(8192)); + } + + /// Empty-string round-trips + /// as empty — the fallback in + /// treats null and empty identically. + [Test] + public void Sender_empty_string_roundtrips_as_empty_string() + { + var jsonPath = Path.Combine(_workingDirectory, "empty-sender.json"); + var original = new AgentConfiguration { Sender = string.Empty }; + original.SaveJson(jsonPath, createBackup: false); + + var loaded = AgentConfiguration.ReadJson(jsonPath); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Sender, Is.EqualTo(string.Empty)); + } + + /// Multiline / whitespace-preserving values round-trip + /// byte-for-byte through the JSON pipeline. Operators occasionally + /// author trimmed identifiers with embedded structure; the wire + /// format preserves them verbatim. + [Test] + public void Sender_with_special_characters_roundtrips_verbatim() + { + const string PinnedSender = "plant/a::region-north:agent-42"; + var jsonPath = Path.Combine(_workingDirectory, "special-sender.json"); + var original = new AgentConfiguration { Sender = PinnedSender }; + original.SaveJson(jsonPath, createBackup: false); + + var loaded = AgentConfiguration.ReadJson(jsonPath); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Sender, Is.EqualTo(PinnedSender)); + + var text = File.ReadAllText(jsonPath); + using var document = JsonDocument.Parse(text); + Assert.That(document.RootElement.GetProperty("sender").GetString(), + Is.EqualTo(PinnedSender)); + } + + // ---------------- surface coverage: Read / Save alternates ---------------- + + /// The non-generic ReadJson(Type, path) overload + /// deserialises into the specified runtime type and populates the + /// operator path field, matching the generic overload's contract. + [Test] + public void Sender_reads_from_authored_JSON_via_non_generic_Type_overload() + { + const string PinnedSender = "gateway-nongeneric-json"; + var jsonPath = Path.Combine(_workingDirectory, "type-json.json"); + File.WriteAllText( + jsonPath, + "{\n \"sender\": \"" + PinnedSender + "\"\n}\n"); + + var loaded = AgentConfiguration.ReadJson(typeof(AgentConfiguration), jsonPath); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Sender, Is.EqualTo(PinnedSender)); + Assert.That(loaded.Path, Is.EqualTo(jsonPath)); + } + + /// The non-generic ReadYaml(Type, path) overload + /// deserialises into the specified runtime type and populates the + /// operator path field. + [Test] + public void Sender_reads_from_authored_YAML_via_non_generic_Type_overload() + { + const string PinnedSender = "gateway-nongeneric-yaml"; + var yamlPath = Path.Combine(_workingDirectory, "type-yaml.yaml"); + File.WriteAllText(yamlPath, "sender: " + PinnedSender + "\n"); + + var loaded = AgentConfiguration.ReadYaml(typeof(AgentConfiguration), yamlPath); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Sender, Is.EqualTo(PinnedSender)); + Assert.That(loaded.Path, Is.EqualTo(yamlPath)); + } + + /// The Read entry-point treats an explicit path as + /// YAML per the resolver contract; the value flows through even when + /// the file has no extension. + [Test] + public void Sender_reads_via_top_level_Read_with_explicit_path() + { + const string PinnedSender = "read-top-level"; + var path = Path.Combine(_workingDirectory, "explicit-path.yaml"); + File.WriteAllText(path, "sender: " + PinnedSender + "\n"); + + var loaded = AgentConfiguration.Read(path); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Sender, Is.EqualTo(PinnedSender)); + } + + /// ReadJson returns null when the target path does not exist — + /// pinning the file-missing branch that quietly returns null rather + /// than throwing. + [Test] + public void ReadJson_returns_null_when_target_file_is_missing() + { + var missingPath = Path.Combine(_workingDirectory, "does-not-exist.json"); + + var loaded = AgentConfiguration.ReadJson(missingPath); + + Assert.That(loaded, Is.Null); + } + + /// ReadJson returns null when the target file is empty. + [Test] + public void ReadJson_returns_null_when_target_file_is_empty() + { + var emptyPath = Path.Combine(_workingDirectory, "empty.json"); + File.WriteAllText(emptyPath, string.Empty); + + var loaded = AgentConfiguration.ReadJson(emptyPath); + + Assert.That(loaded, Is.Null); + } + + /// ReadJson swallows deserialisation failures and returns + /// null when the payload is malformed JSON. + [Test] + public void ReadJson_returns_null_when_payload_is_malformed_JSON() + { + var badPath = Path.Combine(_workingDirectory, "malformed.json"); + File.WriteAllText(badPath, "{ this is not valid json "); + + var loaded = AgentConfiguration.ReadJson(badPath); + + Assert.That(loaded, Is.Null); + } + + /// ReadYaml returns null when the target file is missing. + [Test] + public void ReadYaml_returns_null_when_target_file_is_missing() + { + var missingPath = Path.Combine(_workingDirectory, "does-not-exist.yaml"); + + var loaded = AgentConfiguration.ReadYaml(missingPath); + + Assert.That(loaded, Is.Null); + } + + /// ReadYaml returns null when the target file is empty. + [Test] + public void ReadYaml_returns_null_when_target_file_is_empty() + { + var emptyPath = Path.Combine(_workingDirectory, "empty.yaml"); + File.WriteAllText(emptyPath, string.Empty); + + var loaded = AgentConfiguration.ReadYaml(emptyPath); + + Assert.That(loaded, Is.Null); + } + + /// SaveJson with createBackup: true creates a copy of the + /// pre-existing target file into the conventional backup directory + /// before overwriting. + [Test] + public void SaveJson_with_createBackup_copies_existing_target_to_backup_directory() + { + var jsonPath = Path.Combine(_workingDirectory, "backup.json"); + File.WriteAllText(jsonPath, "{\"sender\": \"pre-existing\"}\n"); + + var updated = new AgentConfiguration { Sender = "post-backup" }; + updated.SaveJson(jsonPath, createBackup: true); + + var loaded = AgentConfiguration.ReadJson(jsonPath); + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Sender, Is.EqualTo("post-backup")); + } + + /// SaveYaml with createBackup: true copies the pre-existing + /// target file into the conventional backup directory before + /// overwriting. + [Test] + public void SaveYaml_with_createBackup_copies_existing_target_to_backup_directory() + { + var yamlPath = Path.Combine(_workingDirectory, "backup.yaml"); + File.WriteAllText(yamlPath, "sender: pre-existing\n"); + + var updated = new AgentConfiguration { Sender = "post-backup" }; + updated.SaveYaml(yamlPath, createBackup: true); + + var loaded = AgentConfiguration.ReadYaml(yamlPath); + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Sender, Is.EqualTo("post-backup")); + } + + /// DefaultVersionValue getter returns the canonical + /// .ToString() of the current + /// . + [Test] + public void DefaultVersionValue_getter_reflects_DefaultVersion() + { + var configuration = new AgentConfiguration + { + DefaultVersion = new Version(2, 7) + }; + + Assert.That(configuration.DefaultVersionValue, Is.EqualTo("2.7")); + } + + /// DefaultVersionValue setter parses a valid version string + /// into . + [Test] + public void DefaultVersionValue_setter_parses_valid_version_string() + { + var configuration = new AgentConfiguration + { + DefaultVersionValue = "2.6" + }; + + Assert.That(configuration.DefaultVersion, Is.EqualTo(new Version(2, 6))); + } + + /// DefaultVersionValue setter silently drops an unparseable + /// value, leaving + /// at its previous state. + [Test] + public void DefaultVersionValue_setter_drops_unparseable_value() + { + var configuration = new AgentConfiguration(); + var originalVersion = configuration.DefaultVersion; + + configuration.DefaultVersionValue = "not-a-version"; + + Assert.That(configuration.DefaultVersion, Is.EqualTo(originalVersion)); + } + + /// DefaultVersionValue setter accepts a null string as a + /// no-op, matching the guard on the setter's input branch. + [Test] + public void DefaultVersionValue_setter_treats_null_as_noop() + { + var configuration = new AgentConfiguration(); + var originalVersion = configuration.DefaultVersion; + + configuration.DefaultVersionValue = null; + + Assert.That(configuration.DefaultVersion, Is.EqualTo(originalVersion)); + } + } +} From 011fb425f672c3da40ccddad7c030edc57884866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Sun, 16 Aug 2026 15:35:50 +0200 Subject: [PATCH 3/8] test(integration): pin HTTP Probe end-to-end for AgentConfiguration.Sender surface Adds a workflow-level fixture that boots an in-process `MTConnectAgentBroker` plus embedded `MTConnectHttpServer`, performs a real HTTP GET on `/probe`, and asserts the emitted `MTConnectDevices/Header/@sender` attribute matches the operator-authored value. - Positive: `AgentConfiguration.Sender = "foo-plant-a"` flows through to `
` in the probe response body. - Negative: an unset `Sender` still emits `
` -- the pre-existing fallback. Both tests are tagged `[Trait("Category", "E2E")]` and use ephemeral ports allocated from a base outside the existing MTAgentFixture range so parallel workers do not collide. Source: MTConnect Part 1 section 7 -- `Header/@sender` is defined as "An identification defining where the Agent that published the Response Document is installed or hosted." --- .../AgentSenderHttpProbeWorkflowTests.cs | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderHttpProbeWorkflowTests.cs diff --git a/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderHttpProbeWorkflowTests.cs b/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderHttpProbeWorkflowTests.cs new file mode 100644 index 000000000..bec09e420 --- /dev/null +++ b/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderHttpProbeWorkflowTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using MTConnect.Agents; +using MTConnect.Configurations; +using MTConnect.Servers.Http; +using Xunit; + +namespace MTConnect.Tests.Integration.Workflows +{ + /// + /// End-to-end workflow tests for the operator-authored + /// surface. Each test boots an + /// in-process plus embedded + /// , performs a real HTTP GET on + /// /probe, and asserts the emitted + /// MTConnectDevices/Header/@sender attribute matches the + /// operator-authored value; the negative path asserts the pre-existing + /// fallback still fires when the config + /// value is absent. + /// + /// + /// Sources: + /// + /// Prose — MTConnect Part 1 §7 defines Header/@sender as + /// "An identification defining where the Agent that published the + /// Response Document is installed or hosted." + /// XSD — schemas.mtconnect.org/schemas/MTConnectDevices_2.7.xsd + /// declares the sender attribute on the Header element. + /// + /// + [Trait("Category", "E2E")] + public sealed class AgentSenderHttpProbeWorkflowTests + { + private static int s_nextPort = 6400 + (System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 1000)); + + private static int AllocatePort() => Interlocked.Increment(ref s_nextPort); + + /// Operator-authored + /// flows through the broker + HTTP server to appear as the + /// Header/@sender attribute in the /probe response body. + [Fact] + public async Task Probe_response_Header_sender_matches_configured_Sender() + { + const string PinnedSender = "foo-plant-a"; + var port = AllocatePort(); + + var configuration = new AgentConfiguration + { + Sender = PinnedSender + }; + + var body = await FetchProbeBodyAsync(configuration, port); + + Assert.Contains("MTConnectDevices", body); + Assert.Contains($"sender=\"{PinnedSender}\"", body); + } + + /// When is not set, the + /// /probe response's Header/@sender falls back to + /// — the pre-existing behaviour before + /// the operator surface was added. + [Fact] + public async Task Probe_response_Header_sender_falls_back_to_hostname_when_Sender_absent() + { + var port = AllocatePort(); + + var configuration = new AgentConfiguration(); + + var body = await FetchProbeBodyAsync(configuration, port); + var expected = Dns.GetHostName(); + + Assert.Contains("MTConnectDevices", body); + Assert.Contains($"sender=\"{expected}\"", body); + } + + private static async Task FetchProbeBodyAsync(AgentConfiguration configuration, int port) + { + var agent = new MTConnectAgentBroker(configuration); + agent.Start(); + try + { + var serverConfig = new HttpServerConfiguration + { + Port = port, + Server = "127.0.0.1" + }; + Exception? startupException = null; + using var server = new MTConnectHttpServer(serverConfig, agent); + server.ServerException += (_, ex) => startupException ??= ex; + server.Start(); + try + { + WaitForListener("127.0.0.1", port, TimeSpan.FromSeconds(30), () => startupException); + + using var http = new HttpClient + { + BaseAddress = new Uri($"http://127.0.0.1:{port}/"), + Timeout = TimeSpan.FromSeconds(15) + }; + + var response = await http.GetAsync("probe"); + + Assert.True( + response.IsSuccessStatusCode, + $"/probe returned {(int)response.StatusCode} {response.ReasonPhrase}"); + + return await response.Content.ReadAsStringAsync(); + } + finally + { + server.Stop(); + } + } + finally + { + agent.Stop(); + Thread.Sleep(150); + } + } + + private static void WaitForListener( + string host, + int port, + TimeSpan timeout, + Func serverStartException) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + var startupException = serverStartException(); + if (startupException != null) + { + throw new InvalidOperationException( + $"HTTP server failed to start on {host}:{port}: {startupException.Message}", + startupException); + } + + try + { + using var client = new TcpClient(); + client.Connect(host, port); + if (client.Connected) + { + return; + } + } + catch (SocketException) + { + // Not listening yet; keep polling. + } + + Thread.Sleep(100); + } + + throw new TimeoutException( + $"HTTP listener did not bind to {host}:{port} within {timeout.TotalSeconds}s."); + } + } +} From 5f3b747c57203f4ab99355ee015de6402e5584c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Sun, 16 Aug 2026 15:40:03 +0200 Subject: [PATCH 4/8] docs(common): regenerate reference/configuration.md to include Sender Regenerates docs/reference/configuration.md via the DocsGen tool so the new `AgentConfiguration.Sender` and `IAgentConfiguration.Sender` rows surface in the operator-facing reference table alongside the pre-existing agent configuration fields. The generator picks up the XML doc comments authored on the Sender surface verbatim. --- docs/reference/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b76a2be12..339d77ee8 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -92,6 +92,7 @@ Configuration for an MTConnect Agent | `ignoreTimestamps` | `IgnoreTimestamps` | `bool` | Overwrite timestamps with the agent time. This will correct clock drift but will not give as accurate relative time since it will not take into consideration network latencies. This can be overridden on a per adapter basis. | | `inputValidationLevel` | `InputValidationLevel` | `InputValidationLevel` | Gets or Sets the default Input (Observation or Asset) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict | | `observationBufferSize` | `ObservationBufferSize` | `uint` | The maximum number of Observations the agent can hold in its buffer | +| `sender` | `Sender` | `string` | The value emitted as the Header/@sender attribute on MTConnect response documents (see MTConnect Part 1 §7). When null or empty, falls back to . | | `timezoneOutput` | `TimeZoneOutput` | `string` | Sets the TimeZone to use when timestamps are output from the Agent | ### `DataSourceConfiguration` @@ -206,6 +207,7 @@ Configuration for an MTConnect Agent | `inputValidationLevel` | `InputValidationLevel` | `InputValidationLevel` | Gets the default Input (Observation or Asset) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict | | `observationBufferSize` | `ObservationBufferSize` | `uint` | The maximum number of Observations the agent can hold in its buffer | | `path` | `Path` | `string` | The file system path the configuration was loaded from, used as the default target when the configuration is saved back to disk. | +| `sender` | `Sender` | `string` | The value emitted as the Header/@sender attribute on MTConnect response documents (see MTConnect Part 1 §7). When null or empty, falls back to . | | `timeZoneOutput` | `TimeZoneOutput` | `string` | Sets the TimeZone to use when timestamps are output from the Agent | ### `IDataSourceConfiguration` From 04ae9f2b203e31a493121e2ac652863c16f5e3af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Sun, 16 Aug 2026 17:54:58 +0200 Subject: [PATCH 5/8] test(common,integration): widen AgentConfiguration.Sender coverage across endpoints and edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the pinning surface for IAgentConfiguration.Sender in two ways: - AgentSenderAllEndpointsWorkflowTests: new integration test class boots a shared broker + HTTP server fixture and asserts that the operator-authored AgentConfiguration.Sender appears verbatim as the Header/@sender attribute on every top-level MTConnect response envelope — /probe (MTConnectDevices), /current + /sample (MTConnectStreams), /assets + /asset/{id} (MTConnectAssets). MTConnect Part 1 §7 declares the attribute on every header shape, and the XSDs put it on all four; the earlier tests only covered /probe. - AgentConfigurationSenderTests: add four unit tests pinning the null/empty/whitespace boundary of the constructor's IsNullOrEmpty guard, the interface-side polymorphic getter, and the null-configuration fallback path. These document the exact contract the fallback chain carries — Dns.GetHostName fires for null and "", but a whitespace-only value is carried through verbatim. --- .../Agents/AgentConfigurationSenderTests.cs | 80 ++++++ .../AgentSenderAllEndpointsWorkflowTests.cs | 233 ++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderAllEndpointsWorkflowTests.cs diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs index d7839d270..9a73e1996 100644 --- a/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs @@ -59,5 +59,85 @@ public void Sender_absent_from_config_falls_back_to_Dns_GetHostName() Assert.That(agent.Sender, Is.EqualTo(Dns.GetHostName())); } + + /// + /// When AgentConfiguration.Sender is explicitly the empty + /// string, the agent still falls back to + /// because the guard on the wire-through branch is + /// , which treats null and + /// the empty string identically. + /// + [Test] + public void Sender_empty_string_in_config_falls_back_to_Dns_GetHostName() + { + var configuration = new AgentConfiguration { Sender = string.Empty }; + + var agent = new MTConnectAgent( + configuration, + uuid: "sender-empty-fixture-uuid", + initializeAgentDevice: false); + + Assert.That(agent.Sender, Is.EqualTo(Dns.GetHostName())); + } + + /// + /// A whitespace-only is + /// wire-through verbatim — the constructor guard is + /// , NOT + /// IsNullOrWhiteSpace, so a whitespace value overrides the + /// hostname fallback. This test pins the exact null-vs-empty-vs- + /// whitespace boundary the setter contract carries. + /// + [Test] + public void Sender_whitespace_only_in_config_is_carried_through_verbatim() + { + const string PinnedWhitespace = " "; + var configuration = new AgentConfiguration { Sender = PinnedWhitespace }; + + var agent = new MTConnectAgent( + configuration, + uuid: "sender-whitespace-fixture-uuid", + initializeAgentDevice: false); + + Assert.That(agent.Sender, Is.EqualTo(PinnedWhitespace)); + } + + /// + /// The interface surface + /// reflects the value set on the concrete + /// — the class's writable setter is + /// the only way to author the value, and the interface's getter + /// projects it. Pins the polymorphic access path that operator + /// integrators reach through the interface abstraction. + /// + [Test] + public void IAgentConfiguration_Sender_getter_reflects_concrete_setter() + { + const string PinnedSender = "interface-getter-fixture"; + IAgentConfiguration configuration = new AgentConfiguration + { + Sender = PinnedSender + }; + + Assert.That(configuration.Sender, Is.EqualTo(PinnedSender)); + } + + /// + /// Constructing the agent with a null + /// argument falls through the constructor's default-config branch + /// and therefore falls back to without + /// throwing — the null-config path is the historically-supported + /// zero-config bootstrap. + /// + [Test] + public void Sender_null_configuration_falls_back_to_Dns_GetHostName() + { + var agent = new MTConnectAgent( + (IAgentConfiguration)null, + uuid: "sender-null-config-fixture-uuid", + initializeAgentDevice: false); + + Assert.That(agent.Sender, Is.EqualTo(Dns.GetHostName())); + } } } diff --git a/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderAllEndpointsWorkflowTests.cs b/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderAllEndpointsWorkflowTests.cs new file mode 100644 index 000000000..6eb524e63 --- /dev/null +++ b/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderAllEndpointsWorkflowTests.cs @@ -0,0 +1,233 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using MTConnect.Agents; +using MTConnect.Assets.CuttingTools; +using MTConnect.Configurations; +using MTConnect.Devices; +using MTConnect.Servers.Http; +using Xunit; + +namespace MTConnect.Tests.Integration.Workflows +{ + /// + /// End-to-end companion to + /// that widens the + /// pinning surface from /probe alone to every MTConnect wire + /// endpoint that carries a Header element in its response + /// document. MTConnect Part 1 §7 defines the sender attribute + /// as an identification of the host / installation that emitted the + /// document, and the XSDs declare it on every top-level response + /// header — MTConnectDevicesType/Header (/probe), + /// MTConnectStreamsType/Header (/current + /sample) and + /// MTConnectAssetsType/Header (/asset). The operator-authored + /// flows through the broker + /// into every one of them without special-casing per endpoint. + /// + /// + /// A shared fixture boots the agent + HTTP server once per test class + /// and re-uses them across the four endpoint assertions, keeping the + /// end-to-end pinning cheap under the RequiresDocker / E2E category. + /// + [Trait("Category", "E2E")] + public sealed class AgentSenderAllEndpointsWorkflowTests : IDisposable + { + private const string PinnedSender = "sender-all-endpoints-fixture"; + private const string DeviceUuid = "sender-all-endpoints-device"; + private const string DeviceName = "SenderAllEndpointsDevice"; + private const string AssetId = "SENDER-ALL-ENDPOINTS-ASSET-1"; + + private readonly IMTConnectAgentBroker _agent; + private readonly MTConnectHttpServer _server; + private readonly int _port; + + /// Boots the agent + HTTP server, seeds a Device and an + /// asset so the four endpoint tests each have a valid document to + /// fetch. + public AgentSenderAllEndpointsWorkflowTests() + { + _port = AllocateLoopbackPort(); + + var configuration = new AgentConfiguration + { + Sender = PinnedSender, + DefaultVersion = MTConnectVersions.Version25, + }; + _agent = new MTConnectAgentBroker(configuration); + _agent.Start(); + + var device = new Device + { + Id = "senderAllEndpointsDeviceId", + Uuid = DeviceUuid, + Name = DeviceName, + }; + device.AddDataItem(new DataItem(DataItemCategory.EVENT, "AVAILABILITY", null, "avail")); + _agent.AddDevice(device); + + var asset = new CuttingToolAsset + { + AssetId = AssetId, + ToolId = "T1", + CuttingToolLifeCycle = new CuttingToolLifeCycle + { + ProgramToolNumber = "1", + ProgramToolGroup = "G1", + }, + Timestamp = DateTime.UtcNow, + DeviceUuid = DeviceUuid, + }; + _agent.AddAsset(DeviceUuid, asset); + + var serverConfig = new HttpServerConfiguration + { + Port = _port, + Server = "127.0.0.1", + }; + Exception? startupException = null; + _server = new MTConnectHttpServer(serverConfig, _agent); + _server.ServerException += (_, ex) => startupException ??= ex; + _server.Start(); + WaitForListener("127.0.0.1", _port, TimeSpan.FromSeconds(30), () => startupException); + } + + /// Tears down the fixture — stops the HTTP server and the + /// broker, then briefly yields so port + broker background threads + /// unwind before the next fixture allocates a new port. + public void Dispose() + { + try { _server?.Stop(); } catch { /* swallow — stop is best-effort */ } + try { _agent?.Stop(); } catch { /* swallow — stop is best-effort */ } + Thread.Sleep(150); + } + + /// /probe response's MTConnectDevices/Header/@sender + /// carries the operator-authored + /// verbatim, matching the MTConnectDevices XSD's declaration. + [Fact] + public async Task Probe_Header_sender_matches_configured_Sender() + { + var body = await GetAsync("probe"); + Assert.Contains("MTConnectDevices", body); + Assert.Contains($"sender=\"{PinnedSender}\"", body); + } + + /// /current response's MTConnectStreams/Header/@sender + /// carries the operator-authored + /// verbatim, matching the MTConnectStreams XSD's declaration. + [Fact] + public async Task Current_Header_sender_matches_configured_Sender() + { + var body = await GetAsync("current"); + Assert.Contains("MTConnectStreams", body); + Assert.Contains($"sender=\"{PinnedSender}\"", body); + } + + /// /sample response's MTConnectStreams/Header/@sender + /// carries the operator-authored + /// verbatim — the same streams envelope as /current but populated + /// from the observation buffer. + [Fact] + public async Task Sample_Header_sender_matches_configured_Sender() + { + var body = await GetAsync("sample"); + Assert.Contains("MTConnectStreams", body); + Assert.Contains($"sender=\"{PinnedSender}\"", body); + } + + /// /assets response's MTConnectAssets/Header/@sender + /// carries the operator-authored + /// verbatim, matching the MTConnectAssets XSD's declaration. + [Fact] + public async Task Assets_Header_sender_matches_configured_Sender() + { + var body = await GetAsync("assets"); + Assert.Contains("MTConnectAssets", body); + Assert.Contains(AssetId, body); + Assert.Contains($"sender=\"{PinnedSender}\"", body); + } + + /// /asset/{id} response for a single asset also carries the + /// operator-authored in the + /// MTConnectAssets header — the single-asset endpoint reuses the + /// same envelope shape as the /assets collection endpoint. + [Fact] + public async Task SingleAsset_Header_sender_matches_configured_Sender() + { + var body = await GetAsync($"asset/{AssetId}"); + Assert.Contains("MTConnectAssets", body); + Assert.Contains($"sender=\"{PinnedSender}\"", body); + } + + // ---------------- helpers ---------------- + + private async Task GetAsync(string path) + { + using var http = new HttpClient + { + BaseAddress = new Uri($"http://127.0.0.1:{_port}/"), + Timeout = TimeSpan.FromSeconds(15), + }; + + var response = await http.GetAsync(path); + Assert.True( + response.IsSuccessStatusCode, + $"/{path} returned {(int)response.StatusCode} {response.ReasonPhrase}"); + return await response.Content.ReadAsStringAsync(); + } + + private static int AllocateLoopbackPort() + { + using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static void WaitForListener( + string host, + int port, + TimeSpan timeout, + Func serverStartException) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + var startupException = serverStartException(); + if (startupException != null) + { + throw new InvalidOperationException( + $"HTTP server failed to start on {host}:{port}: {startupException.Message}", + startupException); + } + + try + { + using var client = new TcpClient(); + client.Connect(host, port); + if (client.Connected) return; + } + catch (SocketException) + { + // not listening yet + } + + Thread.Sleep(100); + } + + throw new TimeoutException( + $"HTTP listener did not bind to {host}:{port} within {timeout.TotalSeconds}s."); + } + } +} From 099c98467eee7a65d3fe5b2c4d47e0afa3c930d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Sun, 16 Aug 2026 18:10:34 +0200 Subject: [PATCH 6/8] test(common-tests): resolve CS8600 nullable warning on null-configuration fallback test The Sender_null_configuration_falls_back_to_Dns_GetHostName test's `(IAgentConfiguration)null` cast fired CS8600 under the Common-Tests project's nullable=enable setting. Bind the null to a locally-typed nullable variable and pass it with the `!` null-forgiving operator so the intent (explicit null-configuration ctor input) reads clearly and the compile is warning-clean. --- .../Agents/AgentConfigurationSenderTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs index 9a73e1996..92d2d2a3e 100644 --- a/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderTests.cs @@ -132,8 +132,9 @@ public void IAgentConfiguration_Sender_getter_reflects_concrete_setter() [Test] public void Sender_null_configuration_falls_back_to_Dns_GetHostName() { + IAgentConfiguration? configuration = null; var agent = new MTConnectAgent( - (IAgentConfiguration)null, + configuration!, uuid: "sender-null-config-fixture-uuid", initializeAgentDevice: false); From 309fd623583b882435b43ee7e73de8ae8c7b8632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 08:43:01 +0200 Subject: [PATCH 7/8] fix(agent-sender): drop dead null-check on ctor sender-seed guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `_configuration != null` clause on the ctor sender-seed guard is provably dead — the line immediately above assigns `_configuration = configuration != null ? configuration : new AgentConfiguration();`, so the field is guaranteed non-null one line down. Removing the redundant clause improves the diff's own signal and pre-empts the "did the author know `_configuration` can be null further down the ctor?" reader question. Also widens the SaveJson / SaveYaml createBackup test coverage: the pre-existing positive-path tests round-tripped the newly-written value but never asserted the copy-into-backup-directory contract that the test names promise. Both positive-path tests now snapshot the process-wide backup directory before the save, then assert exactly one new `*.backup.{json,yaml}` file appears whose contents preserve the pre-existing target byte-for-byte. Adds two negative-path companion tests pinning that `createBackup: false` produces zero backup files. Both extensions use a `SnapshotBackupFiles` helper that survives process-wide backup-directory contention with other test fixtures in the same run. --- .../Agents/MTConnectAgent.cs | 2 +- ...ntConfigurationSenderSerializationTests.cs | 86 ++++++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs index 304ece258..a7193872c 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs @@ -290,7 +290,7 @@ public MTConnectAgent( _uuid = !string.IsNullOrEmpty(uuid) ? uuid : Guid.NewGuid().ToString(); _instanceId = instanceId > 0 ? instanceId : CreateInstanceId(); _configuration = configuration != null ? configuration : new AgentConfiguration(); - if (_configuration != null && !string.IsNullOrEmpty(_configuration.Sender)) + if (!string.IsNullOrEmpty(_configuration.Sender)) _sender = _configuration.Sender; _information = new MTConnectAgentInformation(_uuid, _instanceId, _deviceModelChangeTime); _deviceModelChangeTime = deviceModelChangeTime; diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderSerializationTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderSerializationTests.cs index ce32624b9..2b042300a 100644 --- a/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderSerializationTests.cs +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentConfigurationSenderSerializationTests.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Linq; using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; @@ -322,38 +323,119 @@ public void ReadYaml_returns_null_when_target_file_is_empty() /// SaveJson with createBackup: true creates a copy of the /// pre-existing target file into the conventional backup directory - /// before overwriting. + /// before overwriting. Pins the copy-into-backup-directory contract + /// and the copy-preserves-original-content invariant so a regression + /// to "backup flag toggles without side-effect" would fail here, + /// not just the round-trip line.
[Test] public void SaveJson_with_createBackup_copies_existing_target_to_backup_directory() { var jsonPath = Path.Combine(_workingDirectory, "backup.json"); File.WriteAllText(jsonPath, "{\"sender\": \"pre-existing\"}\n"); + var backupDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "backup"); + var before = SnapshotBackupFiles(backupDir, ".backup.json"); var updated = new AgentConfiguration { Sender = "post-backup" }; updated.SaveJson(jsonPath, createBackup: true); + var after = SnapshotBackupFiles(backupDir, ".backup.json"); + var newlyCreated = after.Except(before).ToArray(); + Assert.That(newlyCreated.Length, Is.EqualTo(1), + "createBackup: true must emit exactly one *.backup.json file into the conventional backup directory."); + var backupContents = File.ReadAllText(newlyCreated[0]); + Assert.That(backupContents, Does.Contain("pre-existing"), + "The backup copy must preserve the pre-existing target's contents, not the newly-written value."); + var loaded = AgentConfiguration.ReadJson(jsonPath); Assert.That(loaded, Is.Not.Null); Assert.That(loaded.Sender, Is.EqualTo("post-backup")); } + /// SaveJson with createBackup: false emits no *.backup.json + /// file — the negative-path companion to the createBackup: true + /// assertion. Pins that the backup side-effect is genuinely gated + /// on the flag rather than firing unconditionally. + [Test] + public void SaveJson_with_createBackup_false_does_not_write_backup_file() + { + var jsonPath = Path.Combine(_workingDirectory, "no-backup.json"); + File.WriteAllText(jsonPath, "{\"sender\": \"pre-existing\"}\n"); + var backupDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "backup"); + var before = SnapshotBackupFiles(backupDir, ".backup.json"); + + var updated = new AgentConfiguration { Sender = "post-write" }; + updated.SaveJson(jsonPath, createBackup: false); + + var after = SnapshotBackupFiles(backupDir, ".backup.json"); + Assert.That(after.Except(before), Is.Empty, + "createBackup: false must produce zero new *.backup.json files in the conventional backup directory."); + } + /// SaveYaml with createBackup: true copies the pre-existing /// target file into the conventional backup directory before - /// overwriting. + /// overwriting. Pins the copy-into-backup-directory contract and + /// the copy-preserves-original-content invariant per the JSON + /// companion above. [Test] public void SaveYaml_with_createBackup_copies_existing_target_to_backup_directory() { var yamlPath = Path.Combine(_workingDirectory, "backup.yaml"); File.WriteAllText(yamlPath, "sender: pre-existing\n"); + var backupDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "backup"); + var before = SnapshotBackupFiles(backupDir, ".backup.yaml"); var updated = new AgentConfiguration { Sender = "post-backup" }; updated.SaveYaml(yamlPath, createBackup: true); + var after = SnapshotBackupFiles(backupDir, ".backup.yaml"); + var newlyCreated = after.Except(before).ToArray(); + Assert.That(newlyCreated.Length, Is.EqualTo(1), + "createBackup: true must emit exactly one *.backup.yaml file into the conventional backup directory."); + var backupContents = File.ReadAllText(newlyCreated[0]); + Assert.That(backupContents, Does.Contain("pre-existing"), + "The backup copy must preserve the pre-existing target's contents, not the newly-written value."); + var loaded = AgentConfiguration.ReadYaml(yamlPath); Assert.That(loaded, Is.Not.Null); Assert.That(loaded.Sender, Is.EqualTo("post-backup")); } + /// SaveYaml with createBackup: false emits no *.backup.yaml + /// file — the negative-path companion. + [Test] + public void SaveYaml_with_createBackup_false_does_not_write_backup_file() + { + var yamlPath = Path.Combine(_workingDirectory, "no-backup.yaml"); + File.WriteAllText(yamlPath, "sender: pre-existing\n"); + var backupDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "backup"); + var before = SnapshotBackupFiles(backupDir, ".backup.yaml"); + + var updated = new AgentConfiguration { Sender = "post-write" }; + updated.SaveYaml(yamlPath, createBackup: false); + + var after = SnapshotBackupFiles(backupDir, ".backup.yaml"); + Assert.That(after.Except(before), Is.Empty, + "createBackup: false must produce zero new *.backup.yaml files in the conventional backup directory."); + } + + /// Snapshots the current set of files under + /// whose name ends with + /// . Returns an empty array if the + /// directory does not yet exist. Used by the backup-assertion tests + /// to compute a "before → after" delta that isolates the + /// newly-created backup file even when other tests in the same + /// run share the process-wide backup directory. + /// Absolute path of the process-wide backup directory. + /// Filename suffix to match (for example, .backup.json). + /// Ordinal-sorted array of absolute paths for files matching . + private static string[] SnapshotBackupFiles(string backupDir, string suffix) + { + if (!Directory.Exists(backupDir)) return Array.Empty(); + return Directory.EnumerateFiles(backupDir, "*" + suffix, SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + } + /// DefaultVersionValue getter returns the canonical /// .ToString() of the current /// . From df786b243e1da1dc1089a812ecb82d856df22f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 08:43:13 +0200 Subject: [PATCH 8/8] docs(integration-tests): correct AgentSenderAllEndpoints xml doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class-level `` claimed a shared per-class fixture (once-per-class broker + HTTP server), but the class does not wire `IClassFixture` and xUnit v2 instantiates the test class once per test method — so the broker and HTTP server are constructed and torn down per test. The stale doc leaks into the maintenance model (future authors reading the file may add tests here thinking cost is amortized). The comment also miscounted the fixture as covering "four endpoint assertions" when there are five (Probe, Current, Sample, Assets, SingleAsset), and mentioned the wrong CI-selector category ("RequiresDocker") when the file is in-process HTTP with no Docker usage. Rewrites the `` to state the actual xUnit v2 per-test lifecycle, lists the five endpoints, and drops the incorrect RequiresDocker mention. No functional change — comment-only. --- .../Workflows/AgentSenderAllEndpointsWorkflowTests.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderAllEndpointsWorkflowTests.cs b/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderAllEndpointsWorkflowTests.cs index 6eb524e63..4ee6e7ea5 100644 --- a/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderAllEndpointsWorkflowTests.cs +++ b/tests/MTConnect.NET-Integration-Tests/Workflows/AgentSenderAllEndpointsWorkflowTests.cs @@ -30,9 +30,14 @@ namespace MTConnect.Tests.Integration.Workflows /// into every one of them without special-casing per endpoint. /// /// - /// A shared fixture boots the agent + HTTP server once per test class - /// and re-uses them across the four endpoint assertions, keeping the - /// end-to-end pinning cheap under the RequiresDocker / E2E category. + /// xUnit v2 instantiates the test class once per test method, so the + /// broker + HTTP server are constructed and torn down per test — no + /// is wired here because the assertions + /// span five independent endpoints (Probe, Current, Sample, Assets, + /// SingleAsset) that each want a fresh in-process broker to avoid + /// cross-test broker-state bleed. The class is tagged E2E + /// (in-process HTTP only, no Docker) so the CI selector filters it + /// alongside the other in-process end-to-end fixtures. /// [Trait("Category", "E2E")] public sealed class AgentSenderAllEndpointsWorkflowTests : IDisposable