feat(mqtt): plain subscribe→OnDataChange with retained seed + ref resolver

MqttSubscriptionManager is the P1 plain-MQTT ingest path: it holds the authored
RawPath → MqttTagDefinition table behind the shared EquipmentTagRefResolver,
establishes one SUBSCRIBE per distinct authored topic, and turns each inbound
message into a LastValueCache update plus an OnDataChange notification.

Load-bearing choices, each pinned by a falsified test:

- The published reference IS the RawPath (def.Name), never the topic and never
  the TagConfig blob — DriverHostActor's dual-namespace fan-out is RawPath-keyed,
  so any other key passes every unit test and delivers nothing in production.
- One message fans out to EVERY tag on its topic; several tags routinely share a
  topic with different jsonPaths, and stopping at the first match silently starves
  the rest.
- An unauthored topic raises nothing: MQTT ingest never auto-provisions.
- Wildcard-authored topics (accepted by the parser, warned at deploy) match via
  MQTTnet's own MqttTopicFilterComparer, so '+'/'#' behave as a broker would.
  Concrete topics stay a single lookup; the wildcard scan runs only when one exists.
- Retained seeds are honoured natively on MQTT 5.0 (retain handling) AND filtered
  client-side on the retain flag, because 3.1.1 brokers always replay.
- Nothing throws on MQTTnet's dispatcher thread: a malformed payload degrades that
  tag (BadDecodingError / BadTypeMismatch), and a throwing OnDataChange subscriber
  is contained without starving the tags behind it.
- The manager issues the FIRST subscribe itself — Reconnected deliberately does not
  fire after the initial ConnectAsync.
- Reconnect re-subscribe: total failure THROWS (MqttConnection then tears the
  session down and retries under backoff, rather than serving a connected-but-deaf
  driver); a partial SUBACK rejection degrades only the denied refs, because
  retrying forever over one ACL-denied topic would take every tag dark.

MqttConnection gains a MessageReceived observer (fired on the dispatcher, throwing
observers contained) and a bounded SubscribeAsync under its own linked-CTS deadline
— deliberately not MqttClientOptions.Timeout, which already governs every MQTTnet
op. A refused filter is an outcome, not an exception; only the SUBSCRIBE itself
failing throws. Classify() is parameterised by operation name (messages unchanged
for connect).

JSONPath is a documented subset ($, $.a.b, $['a'], $.a[0]); filters/slices/
recursive descent report a miss rather than pulling in a JSONPath package for an
expression form that selects a set where a tag needs one scalar.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 16:10:07 -04:00
parent f2a9ee5d93
commit d421487bcd
3 changed files with 1861 additions and 9 deletions
@@ -0,0 +1,607 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers <see cref="MqttSubscriptionManager"/> — the Task-6 plain-MQTT ingest path: authored-tag
/// registration keyed by RawPath, topic dedupe + fan-out, payload extraction, the retained seed,
/// and the subscribe/SUBACK contract. Every test runs without a broker: messages are fed straight
/// through <see cref="MqttSubscriptionManager.HandleMessage"/> (the method MQTTnet's dispatcher
/// calls) and the subscribe leg runs against a fake <see cref="IMqttSubscribeTransport"/>.
/// </summary>
public sealed class MqttSubscriptionManagerTests
{
// OPC UA status codes, verified against Opc.Ua.StatusCodes rather than recalled.
private const uint Good = 0x00000000u;
private const uint BadDecodingError = 0x80070000u;
private const uint BadTypeMismatch = 0x80740000u;
private const uint BadNodeIdUnknown = 0x80340000u;
private const uint BadCommunicationError = 0x80050000u;
private const string OvenTempPath = "Plant/Mqtt/broker1/OvenTemp";
private const string OvenPressPath = "Plant/Mqtt/broker1/OvenPressure";
private const string OvenTopic = "f/oven/temp";
private static RawTagEntry Tag(string rawPath, string tagConfig) => new(rawPath, tagConfig, WriteIdempotent: false);
private static MqttSubscriptionManager NewManager(IMqttSubscribeTransport? transport = null) =>
new(new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1", transport);
// ---------------------------------------------------------------------------------
// Routing + the RawPath identity of the published reference
// ---------------------------------------------------------------------------------
/// <summary>
/// The plan's first snippet, corrected twice: <c>Double</c> is not a
/// <see cref="DriverDataType"/> member (<c>Float64</c> is), and the published reference is
/// asserted to BE the RawPath rather than merely to <i>contain</i> the topic — a topic-keyed
/// publish would satisfy the plan's assertion and be silently dead against
/// <c>DriverHostActor</c>'s RawPath-keyed dual-namespace fan-out.
/// </summary>
[Fact]
public async Task HandleMessage_MatchingTopic_RaisesDataChangeAtJsonPath_UnderRawPath()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
string? gotRef = null;
object? gotVal = null;
mgr.OnDataChange += (_, e) => { gotRef = e.FullReference; gotVal = e.Snapshot.Value; };
mgr.HandleMessage(OvenTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
gotVal.ShouldBe(21.5);
gotRef.ShouldBe(OvenTempPath);
gotRef.ShouldNotBe(OvenTopic);
}
/// <summary>
/// A chatty broker must never auto-provision a tag the operator did not author. Deliberately
/// run with a live, subscribed tag present rather than an empty table: an empty table makes
/// the assertion unfalsifiable — a "deliver to every authored tag" implementation would pass it
/// for want of anything to deliver to.
/// </summary>
[Fact]
public async Task HandleMessage_UnauthoredTopic_RaisesNothing()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var fired = false;
mgr.OnDataChange += (_, _) => fired = true;
mgr.HandleMessage("random/topic", "1"u8.ToArray(), retained: false);
fired.ShouldBeFalse();
mgr.Values.Read(OvenTempPath).StatusCode.ShouldNotBe(Good); // and nothing was cached for it either
}
/// <summary>
/// Two tags on ONE topic (different JSONPaths) — the message must reach BOTH. A
/// <c>TryGetValue → first match → return</c> implementation passes every single-tag test and
/// silently starves every tag after the first.
/// </summary>
[Fact]
public async Task HandleMessage_TopicSharedByTwoTags_FansOutToEveryMatchingTag()
{
var mgr = NewManager();
mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.temp","dataType":"Float64"}"""),
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.press","dataType":"Float64"}"""),
]);
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var seen = new Dictionary<string, object?>();
mgr.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
mgr.HandleMessage(OvenTopic, """{"temp":21.5,"press":1.2}"""u8.ToArray(), retained: false);
seen.Count.ShouldBe(2);
seen[OvenTempPath].ShouldBe(21.5);
seen[OvenPressPath].ShouldBe(1.2);
}
/// <summary>
/// An authored tag nobody subscribed to still caches its value (so a later
/// <c>IReadable.ReadAsync</c> answers) but must not raise <c>OnDataChange</c> — there is no
/// subscription handle to attribute it to.
/// </summary>
[Fact]
public void HandleMessage_AuthoredButUnsubscribedTag_CachesValue_RaisesNothing()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
var fired = false;
mgr.OnDataChange += (_, _) => fired = true;
mgr.HandleMessage(OvenTopic, "7.5"u8.ToArray(), retained: false);
fired.ShouldBeFalse();
mgr.Values.Read(OvenTempPath).Value.ShouldBe(7.5);
}
/// <summary>After <c>UnsubscribeAsync</c> the reference stops publishing.</summary>
[Fact]
public async Task UnsubscribeAsync_StopsRaisingForThoseReferences()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var count = 0;
mgr.OnDataChange += (_, _) => count++;
mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false);
await mgr.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
mgr.HandleMessage(OvenTopic, "2"u8.ToArray(), retained: false);
count.ShouldBe(1);
}
// ---------------------------------------------------------------------------------
// Wildcard-authored topics (accepted by the parser, warned at deploy)
// ---------------------------------------------------------------------------------
[Theory]
[InlineData("f/+/temp", "f/oven/temp", true)]
[InlineData("f/+/temp", "f/oven/deep/temp", false)] // '+' is single-level
[InlineData("f/#", "f/oven/deep/temp", true)] // '#' is multi-level
[InlineData("f/#", "g/oven/temp", false)]
public async Task HandleMessage_WildcardAuthoredTopic_UsesRealMqttFilterSemantics(
string authoredTopic, string incomingTopic, bool expectMatch)
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{authoredTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var fired = false;
mgr.OnDataChange += (_, _) => fired = true;
mgr.HandleMessage(incomingTopic, "1"u8.ToArray(), retained: false);
fired.ShouldBe(expectMatch);
}
// ---------------------------------------------------------------------------------
// Retained seed
// ---------------------------------------------------------------------------------
[Fact]
public async Task HandleMessage_RetainedMessage_SeedsWhenRetainSeedIsTrue()
{
var mgr = NewManager();
// retainSeed absent ⇒ true (the factory default).
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
object? gotVal = null;
mgr.OnDataChange += (_, e) => gotVal = e.Snapshot.Value;
mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: true);
gotVal.ShouldBe(3.5);
}
[Fact]
public async Task HandleMessage_RetainedMessage_IgnoredWhenRetainSeedIsFalse()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64","retainSeed":false}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var fired = false;
mgr.OnDataChange += (_, _) => fired = true;
mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: true);
fired.ShouldBeFalse();
// A live (non-retained) publish on the same tag still lands — retainSeed gates the seed only.
mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: false);
fired.ShouldBeTrue();
}
// ---------------------------------------------------------------------------------
// Value extraction — Json / Scalar / Raw, and per-tag degradation
// ---------------------------------------------------------------------------------
[Theory]
[InlineData("$", """21.5""", 21.5)]
[InlineData("$.value", """{"value":21.5}""", 21.5)]
[InlineData("$.a.b", """{"a":{"b":21.5}}""", 21.5)]
[InlineData("$.arr[1]", """{"arr":[1.5,21.5]}""", 21.5)]
[InlineData("$['value']", """{"value":21.5}""", 21.5)]
public async Task HandleMessage_Json_ExtractsSupportedJsonPathShapes(string jsonPath, string payload, double expected)
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"{{jsonPath.Replace("\\", "\\\\").Replace("\"", "\\\"")}}","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
snap!.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe(expected);
}
[Fact]
public async Task HandleMessage_MalformedJsonPayload_PublishesBadDecodingError_NeverThrows()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
Should.NotThrow(() => mgr.HandleMessage(OvenTopic, "{not json"u8.ToArray(), retained: false));
snap!.StatusCode.ShouldBe(BadDecodingError);
snap.Value.ShouldBeNull();
}
[Theory]
[InlineData("$.missing")]
[InlineData("$..value")] // recursive descent — outside the supported subset
[InlineData("$.arr[9]")]
public async Task HandleMessage_UnresolvableOrUnsupportedJsonPath_PublishesBadDecodingError(string jsonPath)
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"{{jsonPath}}","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, """{"value":21.5,"arr":[1]}"""u8.ToArray(), retained: false);
snap!.StatusCode.ShouldBe(BadDecodingError);
}
[Fact]
public async Task HandleMessage_ValueNotCoercibleToDeclaredDataType_PublishesBadTypeMismatch()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, """{"value":"not-a-number"}"""u8.ToArray(), retained: false);
snap!.StatusCode.ShouldBe(BadTypeMismatch);
snap.Value.ShouldBeNull();
}
[Theory]
[InlineData("Boolean", "true", true)]
[InlineData("Int32", "42", 42)]
[InlineData("Int64", "42", 42L)]
[InlineData("Float32", "1.5", 1.5f)]
[InlineData("Float64", "1.5", 1.5)]
[InlineData("String", "hello", "hello")]
public async Task HandleMessage_Scalar_ParsesByDeclaredDataType(string dataType, string payload, object expected)
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"{{dataType}}"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
snap!.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe(expected);
}
/// <summary>
/// <c>Raw</c> means "bytes as-is": the payload is published as its UTF-8 text and the declared
/// <c>dataType</c> is deliberately NOT applied (<c>Scalar</c> is the member that coerces).
/// </summary>
[Fact]
public async Task HandleMessage_Raw_PublishesUtf8TextAndIgnoresDeclaredDataType()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, "21.5"u8.ToArray(), retained: false);
snap!.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe("21.5");
}
[Fact]
public async Task HandleMessage_RawNonUtf8Payload_PublishesBadDecodingError_NeverThrows()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw","dataType":"String"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
Should.NotThrow(() => mgr.HandleMessage(OvenTopic, new byte[] { 0xC3, 0x28 }, retained: false));
snap!.StatusCode.ShouldBe(BadDecodingError);
}
/// <summary>
/// <c>HandleMessage</c> runs on MQTTnet's dispatcher thread, so nothing it calls — including a
/// misbehaving downstream subscriber — may escape and stall the client's pump.
/// </summary>
[Fact]
public async Task HandleMessage_ThrowingSubscriber_DoesNotEscapeToTheDispatcher()
{
var mgr = NewManager();
mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
]);
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var delivered = new List<string>();
mgr.OnDataChange += (_, e) =>
{
delivered.Add(e.FullReference);
throw new InvalidOperationException("downstream blew up");
};
Should.NotThrow(() => mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false));
// And a throwing subscriber must not starve the tags behind it in the fan-out.
delivered.Count.ShouldBe(2);
}
// ---------------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------------
[Fact]
public void Register_UnmappableTagConfig_IsSkipped_NeverThrows()
{
var mgr = NewManager();
var mapped = 0;
Should.NotThrow(() => mapped = mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag(OvenPressPath, """{"payloadFormat":"Scalar"}"""), // no topic ⇒ rejected by the factory
Tag("Plant/Mqtt/broker1/Junk", "not json at all"),
]));
mapped.ShouldBe(1);
mgr.TryResolve(OvenTempPath, out _).ShouldBeTrue();
mgr.TryResolve(OvenPressPath, out _).ShouldBeFalse();
}
/// <summary>Re-registering replaces the authored table wholesale — a redeploy is not additive.</summary>
[Fact]
public void Register_Twice_ReplacesTheAuthoredTable()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
mgr.Register([Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}""")]);
mgr.TryResolve(OvenTempPath, out _).ShouldBeFalse();
mgr.TryResolve(OvenPressPath, out _).ShouldBeTrue();
}
// ---------------------------------------------------------------------------------
// Filter building — one SUBSCRIBE per distinct topic, strongest QoS, retain handling
// ---------------------------------------------------------------------------------
[Fact]
public void BuildFilters_DedupesTopics_TakesStrongestQos_AndSeedsWhenAnyTagWants()
{
var a = new MqttTagDefinition("p/a", OvenTopic, MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: 0, RetainSeed: false);
var b = new MqttTagDefinition("p/b", OvenTopic, MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: 2, RetainSeed: true);
var c = new MqttTagDefinition("p/c", "f/oven/press", MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: null, RetainSeed: false);
var filters = MqttSubscriptionManager.BuildFilters([a, b, c], defaultQos: 1);
filters.Count.ShouldBe(2);
var oven = filters.Single(f => f.Topic == OvenTopic);
oven.Qos.ShouldBe(2); // strongest wins — nobody gets a weaker guarantee than authored
oven.SeedRetained.ShouldBeTrue();
var press = filters.Single(f => f.Topic == "f/oven/press");
press.Qos.ShouldBe(1); // null ⇒ the driver-level default
press.SeedRetained.ShouldBeFalse();
}
// ---------------------------------------------------------------------------------
// Subscribe / SUBACK contract
// ---------------------------------------------------------------------------------
[Fact]
public async Task SubscribeAsync_IssuesTheFirstSubscribeItself_OneFilterPerDistinctTopic()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
]);
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.Calls.Count.ShouldBe(1);
transport.Calls[0].Select(f => f.Topic).ShouldBe([OvenTopic]);
}
[Fact]
public async Task SubscribeAsync_HandleDiagnosticId_NamesModeAndFilters()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
handle.DiagnosticId.ShouldContain("Plain");
handle.DiagnosticId.ShouldContain(OvenTopic);
}
/// <summary>An already-subscribed topic is not re-SUBSCRIBEd by a second overlapping subscription.</summary>
[Fact]
public async Task SubscribeAsync_AlreadySubscribedTopic_IsNotResubscribed()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await mgr.SubscribeAsync([OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.Calls.Count.ShouldBe(1);
}
[Fact]
public async Task SubscribeAsync_UnknownReference_MarksItBadNodeIdUnknown_AndDoesNotThrow()
{
var mgr = NewManager();
mgr.Register([]);
await Should.NotThrowAsync(() =>
mgr.SubscribeAsync(["Plant/Mqtt/broker1/Nope"], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
mgr.Values.Read("Plant/Mqtt/broker1/Nope").StatusCode.ShouldBe(BadNodeIdUnknown);
}
/// <summary>
/// A SUBACK failure on the <c>ISubscribable</c> path degrades the affected references to Bad
/// and returns a handle — it must never hang, and must never throw out of the OPC UA server's
/// subscribe call.
/// </summary>
[Fact]
public async Task SubscribeAsync_SubackFailure_DegradesRefsToBad_ReturnsHandle_DoesNotThrow()
{
var transport = new FakeTransport { Reject = _ => true };
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
// Not wrapped in Should.NotThrowAsync: an escaping exception fails this test on its own, and
// the handle must still come back.
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
handle.ShouldNotBeNull();
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
}
[Fact]
public async Task SubscribeAsync_TransportThrows_DegradesRefsToBad_DoesNotThrow()
{
var transport = new FakeTransport { Throw = new InvalidOperationException("broker gone") };
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await Should.NotThrowAsync(() =>
mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
}
// ---------------------------------------------------------------------------------
// Reconnect re-subscribe
// ---------------------------------------------------------------------------------
[Fact]
public async Task OnReconnectedAsync_ReSubscribesEveryLiveTopic_Idempotently()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
transport.Calls.Count.ShouldBe(3); // initial + two reconnects
transport.Calls[1].Select(f => f.Topic).ShouldBe([OvenTopic]);
transport.Calls[2].Select(f => f.Topic).ShouldBe([OvenTopic]);
}
/// <summary>
/// A total re-subscribe failure THROWS: <see cref="MqttConnection"/>'s documented contract is
/// that a throwing <c>Reconnected</c> subscriber tears the session down and retries under
/// backoff, and a connected-but-deaf client is the one outcome this driver must never serve.
/// </summary>
[Fact]
public async Task OnReconnectedAsync_TotalFailure_Throws_SoTheSessionIsTornDownAndRetried()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.Throw = new InvalidOperationException("broker gone");
await Should.ThrowAsync<Exception>(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
}
/// <summary>
/// A PARTIAL rejection (e.g. one ACL-denied topic) must NOT throw — tearing the session down
/// forever because one of many topics is denied would take the whole driver dark. Only the
/// denied references degrade.
/// </summary>
[Fact]
public async Task OnReconnectedAsync_PartialRejection_DoesNotThrow_OnlyDeniedRefsDegrade()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
]);
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false);
transport.Reject = t => t == "f/oven/press";
await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(BadCommunicationError);
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
}
/// <summary>Records every subscribe call so dedupe / re-subscribe can be asserted without a broker.</summary>
private sealed class FakeTransport : IMqttSubscribeTransport
{
public List<IReadOnlyList<MqttTopicSubscription>> Calls { get; } = [];
/// <summary>Topics for which the fake SUBACK reports a rejection.</summary>
public Func<string, bool> Reject { get; set; } = _ => false;
/// <summary>When set, the whole subscribe call throws instead of answering.</summary>
public Exception? Throw { get; set; }
public Task<IReadOnlyList<MqttSubscribeOutcome>> SubscribeAsync(
IReadOnlyList<MqttTopicSubscription> filters, CancellationToken cancellationToken)
{
Calls.Add(filters);
if (Throw is not null) throw Throw;
IReadOnlyList<MqttSubscribeOutcome> outcomes =
[.. filters.Select(f => new MqttSubscribeOutcome(f.Topic, !Reject(f.Topic), Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1"))];
return Task.FromResult(outcomes);
}
}
}