2b4b3fcbbf
Findings from the per-module review of Driver.Mqtt at b990adae
(code-reviews/Driver.Mqtt/findings.md records all ten; these are the fixed
ones).
Driver.Mqtt-001 (High) — MqttSubscriptionManager._subscribedTopics is a claim
about the broker session it established filters on, and AttachTo never cleared
it, but the manager OUTLIVES a session rebuild (AdoptOptions keeps it whenever
the ingest identity is unchanged). After a driver-host reconnect
(ShutdownAsync + InitializeAsync, the #527 watchdog's retry) or a
ReinitializeAsync that only changed the endpoint/credentials, the host's
re-subscribe resolved an empty pending set, sent no SUBSCRIBE at all, and left
the driver Connected/Healthy on a session carrying ZERO subscriptions — no
error, no Bad status, values frozen until the new session happened to drop.
AttachTo now forgets them, mirroring SparkplugIngestor.AttachTo's reset.
Driver.Mqtt-002 (High) — InitializeCoreAsync published _connection and
_primaryHost over whatever the driver already held, so a second initialize
stranded a connected, self-supervising broker session (plus a second Sparkplug
primary-host claim) that no later dispose could reach. Reachable without any
teardown of ours via DriverInstanceActor.AdoptConfigDuringInit. Now tears down
first, under the gate both callers already hold.
Driver.Mqtt-003 (Medium) — connectTimeoutSeconds is [Range]-annotated but
unvalidated on the runtime path; 0 makes every deadline fire immediately and a
negative value throws ArgumentOutOfRangeException out of ShutdownAsync and
DisposeAsync. Normalized once, at the seam every options instance passes.
Also: ShutdownAsync keeps the live last-message instant (-004), three usings
left behind by #512 removed (-005), stale remarks corrected (-009, -010), and
docs/drivers/Mqtt.md now distinguishes the two kinds of reconnect.
Tests 708 -> 713. All three regression tests were confirmed to fail against
the un-fixed code, with two controls that pass in either state.
912 lines
43 KiB
C#
912 lines
43 KiB
C#
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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// C1 — wildcard-authored tags on the paths that reconstruct state from a FILTER string.
|
|
// Delivery matches an incoming topic (concrete); reconnect + degrade key off the subscribed
|
|
// filter, which for a wildcard tag is its PATTERN. Reading the concrete-only index there
|
|
// silently drops the tag: no exception, no log, last Good value frozen forever.
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task OnReconnectedAsync_WildcardAuthoredTopic_IsReSubscribed()
|
|
{
|
|
var transport = new FakeTransport();
|
|
var mgr = NewManager(transport);
|
|
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
|
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
|
transport.Calls.Count.ShouldBe(1);
|
|
|
|
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
|
|
|
transport.Calls.Count.ShouldBe(2);
|
|
transport.Calls[1].Select(f => f.Topic).ShouldBe(["f/+/temp"]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The end-to-end shape of the defect: after a reconnect the wildcard tag must still receive
|
|
/// data. A dropped re-subscribe is invisible in every other assertion — the cache keeps its last
|
|
/// Good value, so nothing turns Bad.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OnReconnectedAsync_WildcardAuthoredTopic_KeepsDeliveringAfterwards()
|
|
{
|
|
var transport = new FakeTransport();
|
|
var mgr = NewManager(transport);
|
|
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
|
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
|
|
|
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
|
|
|
// The broker only forwards what it was re-subscribed to; model that by asserting the filter is
|
|
// live, then that a matching publish still lands.
|
|
transport.Calls[^1].Select(f => f.Topic).ShouldContain("f/+/temp");
|
|
|
|
object? got = null;
|
|
mgr.OnDataChange += (_, e) => got = e.Snapshot.Value;
|
|
mgr.HandleMessage("f/oven/temp", "9.5"u8.ToArray(), retained: false);
|
|
got.ShouldBe(9.5);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A total re-subscribe failure must throw for a wildcard-only driver too. Before the fix the
|
|
/// wildcard tag never reached the filter set, so <c>filters.Count == 0</c> took a silent early
|
|
/// return and the caller was told everything was fine.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OnReconnectedAsync_WildcardOnly_TotalFailure_StillThrows()
|
|
{
|
|
var transport = new FakeTransport();
|
|
var mgr = NewManager(transport);
|
|
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","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));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubscribeAsync_WildcardAuthoredTopic_SubackRejection_DegradesItsRefs()
|
|
{
|
|
var transport = new FakeTransport { Reject = _ => true };
|
|
var mgr = NewManager(transport);
|
|
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
|
|
|
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
|
|
|
// Before the fix this stayed at BadWaitingForInitialData — the broker refused the subscription
|
|
// and the tag never learned it was unreachable.
|
|
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OnReconnectedAsync_WildcardAuthoredTopic_PartialRejection_DegradesTheWildcardRefs()
|
|
{
|
|
var transport = new FakeTransport();
|
|
var mgr = NewManager(transport);
|
|
mgr.Register(
|
|
[
|
|
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
|
Tag(OvenPressPath, """{"topic":"f/+/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/+/press";
|
|
|
|
await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
|
|
|
mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(BadCommunicationError);
|
|
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// I2 — bounded decode on the dispatcher thread
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task HandleMessage_PayloadOverTheCeiling_IsRefusedBeforeDecode()
|
|
{
|
|
var mgr = new MqttSubscriptionManager(
|
|
new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1", maxPayloadBytes: 16);
|
|
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"String"}""")]);
|
|
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
|
|
|
DataValueSnapshot? snap = null;
|
|
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
|
|
|
mgr.HandleMessage(OvenTopic, new byte[17], retained: false);
|
|
snap!.StatusCode.ShouldBe(BadDecodingError);
|
|
|
|
// …and a body at the ceiling still flows.
|
|
mgr.HandleMessage(OvenTopic, "0123456789ABCDEF"u8.ToArray(), retained: false);
|
|
snap.StatusCode.ShouldBe(Good);
|
|
}
|
|
|
|
[Fact]
|
|
public void MaxPayloadBytes_NonPositive_FallsBackToTheDefault() =>
|
|
new MqttSubscriptionManager(new MqttDriverOptions(), "d", maxPayloadBytes: 0)
|
|
.MaxPayloadBytes.ShouldBe(MqttSubscriptionManager.DefaultMaxPayloadBytes);
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// I3 — integral-valued reals against integer tags
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("Int32", """{"value":5.0}""", 5)]
|
|
[InlineData("Int32", """{"value":5}""", 5)]
|
|
[InlineData("Int64", """{"value":5.0}""", 5L)]
|
|
[InlineData("Int16", """{"value":5.0}""", (short)5)]
|
|
[InlineData("UInt32", """{"value":5.0}""", 5u)]
|
|
public async Task HandleMessage_Json_IntegralValuedReal_CoercesToIntegerTag(
|
|
string dataType, string payload, object expected)
|
|
{
|
|
var mgr = NewManager();
|
|
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","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>A genuinely fractional value is still refused — never rounded into a wrong value.</summary>
|
|
[Fact]
|
|
public async Task HandleMessage_Json_FractionalReal_StillRefusedByIntegerTag()
|
|
{
|
|
var mgr = NewManager();
|
|
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int32"}""")]);
|
|
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
|
|
|
DataValueSnapshot? snap = null;
|
|
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
|
|
|
mgr.HandleMessage(OvenTopic, """{"value":5.5}"""u8.ToArray(), retained: false);
|
|
|
|
snap!.StatusCode.ShouldBe(BadTypeMismatch);
|
|
snap.Value.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleMessage_Json_OutOfRangeIntegralReal_IsRefused()
|
|
{
|
|
var mgr = NewManager();
|
|
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int16"}""")]);
|
|
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
|
|
|
DataValueSnapshot? snap = null;
|
|
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
|
|
|
mgr.HandleMessage(OvenTopic, """{"value":99999.0}"""u8.ToArray(), retained: false);
|
|
|
|
snap!.StatusCode.ShouldBe(BadTypeMismatch);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleMessage_Scalar_IntegralValuedReal_CoercesToIntegerTag()
|
|
{
|
|
var mgr = NewManager();
|
|
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Int32"}""")]);
|
|
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
|
|
|
DataValueSnapshot? snap = null;
|
|
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
|
|
|
|
mgr.HandleMessage(OvenTopic, "5.0"u8.ToArray(), retained: false);
|
|
|
|
snap!.StatusCode.ShouldBe(Good);
|
|
snap.Value.ShouldBe(5);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// I5 — a JSON string holding a number, against a numeric tag (documented as supported)
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("Float64", """{"value":"21.5"}""", 21.5)]
|
|
[InlineData("Int32", """{"value":"5"}""", 5)]
|
|
[InlineData("Boolean", """{"value":"true"}""", true)]
|
|
public async Task HandleMessage_Json_StringHoldingANumber_CoercesToTheNumericTag(
|
|
string dataType, string payload, object expected)
|
|
{
|
|
var mgr = NewManager();
|
|
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Stale-state pruning on redeploy
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Register_DroppingATag_StopsItsTopicBeingReSubscribedForever()
|
|
{
|
|
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);
|
|
|
|
mgr.Register([]); // redeploy drops the tag
|
|
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
|
|
|
transport.Calls.Count.ShouldBe(1); // the initial subscribe only — no zombie re-subscribe
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Session rebind — this manager OUTLIVES the broker session it established filters on
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// <b>Driver.Mqtt-001.</b> A driver-host reconnect (ShutdownAsync + InitializeAsync, the #527
|
|
/// watchdog's path) builds a brand-new <see cref="MqttConnection"/> but keeps <i>this</i> manager
|
|
/// — <c>MqttDriver.AdoptOptions</c> only replaces it when the ingest identity changed. The new
|
|
/// session holds none of the old one's subscriptions, so the host's post-reconnect re-subscribe
|
|
/// must actually reach the broker. While <c>_subscribedTopics</c> survived the rebind it resolved
|
|
/// an empty pending set, sent no SUBSCRIBE at all, and left the driver Connected/Healthy and
|
|
/// permanently deaf.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The rebound connection is deliberately a real, <b>unconnected</b> one: its
|
|
/// <c>SubscribeAsync</c> refuses ("no established session"), which <c>EstablishAsync</c> catches
|
|
/// and turns into <c>BadCommunicationError</c> on the affected references. That Bad IS the proof
|
|
/// the subscribe was attempted — with the bug the tag keeps the Good value it was last delivered,
|
|
/// because nothing is attempted at all.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AttachTo_ForgetsTheFiltersEstablishedOnThePreviousSession()
|
|
{
|
|
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);
|
|
mgr.HandleMessage(OvenTopic, "21.5"u8.ToArray(), retained: false);
|
|
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
|
|
|
|
// The session underneath is rebuilt and this manager is re-attached to it.
|
|
await using var rebound = new MqttConnection(
|
|
new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false }, "mqtt-1");
|
|
mgr.AttachTo(rebound);
|
|
|
|
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
|
|
|
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(
|
|
BadCommunicationError,
|
|
"the re-subscribe must be issued against the NEW session, not skipped as already-established");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The falsifiability control for the test above: within ONE session the dedupe must still hold,
|
|
/// or the fix would simply be "re-SUBSCRIBE everything on every subscribe" — which re-triggers
|
|
/// every topic's retained seed and is the behaviour <c>_subscribedTopics</c> exists to prevent.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SubscribeAsync_TwiceOnTheSameSession_StillIssuesOneSubscribe()
|
|
{
|
|
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.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
|
|
|
transport.Calls.Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|
|
}
|