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:
@@ -1,11 +1,67 @@
|
||||
using System.Buffers;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MQTTnet;
|
||||
using MQTTnet.Protocol;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Receives one inbound MQTT application message. Invoked on <b>MQTTnet's own dispatcher
|
||||
/// thread</b>: an implementation must not block, must not do I/O and must not throw, or it stalls
|
||||
/// the client's pump for every other subscription.
|
||||
/// </summary>
|
||||
/// <param name="topic">The concrete topic the message arrived on (never a filter).</param>
|
||||
/// <param name="payload">
|
||||
/// The message body. Only valid for the duration of the call — the underlying buffer belongs to
|
||||
/// MQTTnet, so anything an implementation wants to keep must be copied out.
|
||||
/// </param>
|
||||
/// <param name="retained">
|
||||
/// The message's MQTT <c>retain</c> flag as delivered to <i>this</i> client, i.e. <c>true</c> for
|
||||
/// the broker's stored last-known value replayed at subscribe time, and <c>false</c> for an
|
||||
/// ordinary live publish. This is the retained <b>seed</b> signal.
|
||||
/// </param>
|
||||
public delegate void MqttMessageObserver(string topic, ReadOnlySpan<byte> payload, bool retained);
|
||||
|
||||
/// <summary>One topic filter to SUBSCRIBE, as the subscription manager wants it established.</summary>
|
||||
/// <param name="Topic">The topic filter (concrete, or carrying MQTT wildcards).</param>
|
||||
/// <param name="Qos">Requested QoS, 0–2.</param>
|
||||
/// <param name="SeedRetained">
|
||||
/// Whether the broker should replay its retained message for this filter at subscribe time.
|
||||
/// Honoured natively on MQTT 5.0 (retain handling); on 3.1.1 the broker always replays and the
|
||||
/// manager drops the seed client-side instead.
|
||||
/// </param>
|
||||
public sealed record MqttTopicSubscription(string Topic, int Qos, bool SeedRetained);
|
||||
|
||||
/// <summary>The broker's SUBACK verdict for one requested filter.</summary>
|
||||
/// <param name="Topic">The filter this outcome answers.</param>
|
||||
/// <param name="Granted">Whether the broker granted the subscription.</param>
|
||||
/// <param name="Reason">The broker's reason code / string, for logs and diagnostics.</param>
|
||||
public sealed record MqttSubscribeOutcome(string Topic, bool Granted, string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// The one seam through which the subscription manager establishes MQTT subscriptions.
|
||||
/// <see cref="MqttConnection"/> is the production implementation; the interface exists so the
|
||||
/// manager's SUBACK handling (per-filter grant / rejection, total failure) is exercisable without
|
||||
/// a broker.
|
||||
/// </summary>
|
||||
public interface IMqttSubscribeTransport
|
||||
{
|
||||
/// <summary>
|
||||
/// Issues one SUBSCRIBE carrying every filter and returns the broker's per-filter verdict.
|
||||
/// Implementations must be bounded — a broker that accepts SUBSCRIBE and never SUBACKs must
|
||||
/// fail at a deadline, not hang.
|
||||
/// </summary>
|
||||
/// <param name="filters">The filters to establish; never empty.</param>
|
||||
/// <param name="cancellationToken">Caller cancellation, linked with the implementation's deadline.</param>
|
||||
/// <returns>One outcome per requested filter.</returns>
|
||||
Task<IReadOnlyList<MqttSubscribeOutcome>> SubscribeAsync(
|
||||
IReadOnlyList<MqttTopicSubscription> filters,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Lifecycle state of an <see cref="MqttConnection"/>.</summary>
|
||||
public enum MqttConnectionState
|
||||
{
|
||||
@@ -144,10 +200,10 @@ public enum MqttConnectionState
|
||||
/// socket no later dispose could ever reach. Now the losing side of that race disposes the
|
||||
/// client it created and reports <see cref="ObjectDisposedException"/>.
|
||||
/// </para>
|
||||
/// Subscription (Task 6) and Sparkplug rebirth-on-reconnect (Task 21, which hangs off
|
||||
/// <see cref="Reconnected"/>) are deliberately not implemented here.
|
||||
/// Sparkplug rebirth-on-reconnect (Task 21, which hangs off <see cref="Reconnected"/>) is
|
||||
/// deliberately not implemented here.
|
||||
/// </remarks>
|
||||
public sealed class MqttConnection : IAsyncDisposable
|
||||
public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
|
||||
{
|
||||
private readonly string _driverId;
|
||||
|
||||
@@ -217,6 +273,19 @@ public sealed class MqttConnection : IAsyncDisposable
|
||||
/// </remarks>
|
||||
public event Func<CancellationToken, Task>? Reconnected;
|
||||
|
||||
/// <summary>
|
||||
/// Raised for every inbound application message, on MQTTnet's own dispatcher thread. This
|
||||
/// connection does no routing, parsing or typing of its own — that is the subscription
|
||||
/// manager's job; here the message is only stamped onto <see cref="LastMessageUtc"/> and
|
||||
/// handed on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Handlers must not block, do I/O or throw. A throwing handler is caught and logged here
|
||||
/// rather than being allowed to escape into the library's pump — one bad tag must not stop
|
||||
/// delivery for every other subscriber.
|
||||
/// </remarks>
|
||||
public event MqttMessageObserver? MessageReceived;
|
||||
|
||||
/// <summary>Whether the underlying client currently holds an established MQTT session.</summary>
|
||||
public bool IsConnected => _client?.IsConnected ?? false;
|
||||
|
||||
@@ -572,20 +641,28 @@ public sealed class MqttConnection : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a connect failure onto this type's documented contract. MQTTnet wraps a cancelled
|
||||
/// connect in <c>MqttConnectingFailedException</c> rather than letting the
|
||||
/// Maps a bounded-operation failure onto this type's documented contract. MQTTnet wraps a
|
||||
/// cancelled connect in <c>MqttConnectingFailedException</c> rather than letting the
|
||||
/// <see cref="OperationCanceledException"/> surface, so the legs are told apart by which
|
||||
/// token fired, not by exception type. Precedence: teardown, then caller intent, then the
|
||||
/// deadline.
|
||||
/// </summary>
|
||||
private Exception Classify(Exception ex, CancellationToken cancellationToken, CancellationTokenSource deadline)
|
||||
/// <param name="ex">The failure to classify.</param>
|
||||
/// <param name="cancellationToken">The caller's token — cancelled means caller intent.</param>
|
||||
/// <param name="deadline">The operation's own deadline source.</param>
|
||||
/// <param name="operation">The operation name for the message ("connect", "subscribe").</param>
|
||||
private Exception Classify(
|
||||
Exception ex,
|
||||
CancellationToken cancellationToken,
|
||||
CancellationTokenSource deadline,
|
||||
string operation = "connect")
|
||||
{
|
||||
if (Disposed)
|
||||
{
|
||||
return new ObjectDisposedException(
|
||||
nameof(MqttConnection),
|
||||
new InvalidOperationException(
|
||||
$"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} was aborted because the "
|
||||
$"MQTT driver '{_driverId}': {operation} to {_options.Host}:{_options.Port} was aborted because the "
|
||||
+ "connection was disposed while the attempt was in flight.",
|
||||
ex));
|
||||
}
|
||||
@@ -593,7 +670,7 @@ public sealed class MqttConnection : IAsyncDisposable
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new OperationCanceledException(
|
||||
$"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} was cancelled.",
|
||||
$"MQTT driver '{_driverId}': {operation} to {_options.Host}:{_options.Port} was cancelled.",
|
||||
ex,
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -601,7 +678,7 @@ public sealed class MqttConnection : IAsyncDisposable
|
||||
if (deadline.IsCancellationRequested)
|
||||
{
|
||||
return new TimeoutException(
|
||||
$"MQTT driver '{_driverId}': connect to {_options.Host}:{_options.Port} did not complete within "
|
||||
$"MQTT driver '{_driverId}': {operation} to {_options.Host}:{_options.Port} did not complete within "
|
||||
+ $"{_options.ConnectTimeoutSeconds}s.",
|
||||
ex);
|
||||
}
|
||||
@@ -640,9 +717,139 @@ public sealed class MqttConnection : IAsyncDisposable
|
||||
private Task OnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs args)
|
||||
{
|
||||
Interlocked.Exchange(ref _lastMessageTicksUtc, DateTime.UtcNow.Ticks);
|
||||
|
||||
var observers = MessageReceived;
|
||||
if (observers is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var message = args.ApplicationMessage;
|
||||
var payload = message.Payload;
|
||||
|
||||
// Same idiom the browse session uses: the common single-segment case is served straight off
|
||||
// the library's buffer; a fragmented sequence is flattened once. Either way the span is only
|
||||
// valid for this call, which is exactly what MqttMessageObserver documents.
|
||||
ReadOnlySpan<byte> body = payload.IsSingleSegment ? payload.FirstSpan : payload.ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
observers(message.Topic, body, message.Retain);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// An exception escaping here surfaces inside MQTTnet's own pump. Contain it: a broken
|
||||
// observer must degrade its own tags, never stop delivery for every other subscription.
|
||||
_logger?.LogError(
|
||||
ex,
|
||||
"MQTT driver '{DriverId}': an inbound-message observer threw; the message was dropped.",
|
||||
_driverId);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues one SUBSCRIBE carrying every requested filter, bounded by
|
||||
/// <see cref="MqttDriverOptions.ConnectTimeoutSeconds"/> so a broker that accepts SUBSCRIBE and
|
||||
/// never answers SUBACK — the frozen-peer shape again — fails at the deadline instead of
|
||||
/// parking the caller (and, on the reconnect path, the supervisor) forever.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The deadline is this method's own linked source, deliberately <b>not</b>
|
||||
/// <see cref="MqttClientOptions.Timeout"/>: that knob already governs every MQTTnet operation
|
||||
/// and repurposing it would couple the subscribe budget to the connect budget in a way neither
|
||||
/// side could change independently.
|
||||
/// <para>
|
||||
/// A rejected filter is <b>not</b> an exception: the returned outcomes carry the broker's
|
||||
/// per-filter verdict so the caller can degrade exactly the affected references. Only a
|
||||
/// failure of the SUBSCRIBE itself (transport error, deadline, teardown) throws.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="filters">The filters to establish.</param>
|
||||
/// <param name="cancellationToken">Caller cancellation; linked with the subscribe deadline.</param>
|
||||
/// <returns>One outcome per requested filter, in request order.</returns>
|
||||
/// <exception cref="TimeoutException">The subscribe deadline elapsed.</exception>
|
||||
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
|
||||
/// <exception cref="ObjectDisposedException">The connection was disposed.</exception>
|
||||
/// <exception cref="InvalidOperationException">There is no established session to subscribe on.</exception>
|
||||
public async Task<IReadOnlyList<MqttSubscribeOutcome>> SubscribeAsync(
|
||||
IReadOnlyList<MqttTopicSubscription> filters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filters);
|
||||
ObjectDisposedException.ThrowIf(Disposed, this);
|
||||
|
||||
if (filters.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var client = _client
|
||||
?? throw new InvalidOperationException(
|
||||
$"MQTT driver '{_driverId}': cannot subscribe before a session to {_options.Host}:{_options.Port} "
|
||||
+ "has been established.");
|
||||
|
||||
var builder = new MqttClientSubscribeOptionsBuilder();
|
||||
foreach (var filter in filters)
|
||||
{
|
||||
builder = builder.WithTopicFilter(f => f
|
||||
.WithTopic(filter.Topic)
|
||||
.WithQualityOfServiceLevel(MapQos(filter.Qos))
|
||||
// Only meaningful on MQTT 5.0. On 3.1.1 the broker always replays its retained
|
||||
// message, so the manager also drops unwanted seeds client-side off the retain flag.
|
||||
.WithRetainHandling(filter.SeedRetained
|
||||
? MqttRetainHandling.SendAtSubscribe
|
||||
: MqttRetainHandling.DoNotSendOnSubscribe));
|
||||
}
|
||||
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds));
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
deadline.Token,
|
||||
_lifetimeCts.Token);
|
||||
|
||||
MqttClientSubscribeResult result;
|
||||
try
|
||||
{
|
||||
result = await client.SubscribeAsync(builder.Build(), linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw Classify(ex, cancellationToken, deadline, operation: "subscribe");
|
||||
}
|
||||
|
||||
// Pair each requested filter with its SUBACK item positionally: MQTT guarantees SUBACK reason
|
||||
// codes arrive in the order of the SUBSCRIBE's filters. A short/absent SUBACK (a
|
||||
// specification-violating broker) is reported as ungranted rather than silently assumed good.
|
||||
var items = result.Items as IList<MqttClientSubscribeResultItem> ?? [.. result.Items];
|
||||
var outcomes = new List<MqttSubscribeOutcome>(filters.Count);
|
||||
for (var i = 0; i < filters.Count; i++)
|
||||
{
|
||||
if (i >= items.Count)
|
||||
{
|
||||
outcomes.Add(new MqttSubscribeOutcome(filters[i].Topic, Granted: false, "NoSubAckReasonCode"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var code = items[i].ResultCode;
|
||||
var granted = code is MqttClientSubscribeResultCode.GrantedQoS0
|
||||
or MqttClientSubscribeResultCode.GrantedQoS1
|
||||
or MqttClientSubscribeResultCode.GrantedQoS2;
|
||||
outcomes.Add(new MqttSubscribeOutcome(filters[i].Topic, granted, code.ToString()));
|
||||
}
|
||||
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
/// <summary>Maps a configured QoS integer onto MQTTnet's enum, clamping an out-of-range value.</summary>
|
||||
private static MqttQualityOfServiceLevel MapQos(int qos) => qos switch
|
||||
{
|
||||
<= 0 => MqttQualityOfServiceLevel.AtMostOnce,
|
||||
1 => MqttQualityOfServiceLevel.AtLeastOnce,
|
||||
_ => MqttQualityOfServiceLevel.ExactlyOnce,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The reconnect loop MQTTnet v5 no longer provides. Started on the first successful connect
|
||||
/// and stopped by <see cref="DisposeAsync"/> cancelling the lifetime token; it owns every
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user