using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
///
/// The push-model edge behaviours lacked (archreview
/// 05/STAB-18): an initial push of the cached snapshot on every (re)subscribe, and a
/// connection-loss stale to BadNoCommunication with the value retained.
///
/// Why both, and why the identical payload is load-bearing. The manager's steady-state
/// dedup suppresses a message whose value AND status equal the cached one, and that cache is
/// driver-owned — it survives a reconnect and a redeploy. So a broker that replays its retained
/// message unchanged after an outage raised nothing, and every node the host had latched Bad stayed
/// Bad until the plant value happened to change. A recovery test that replays a different
/// payload passes against the pre-fix code and proves nothing; every recovery assertion here
/// therefore replays the identical payload.
///
/// The steady-state suppression itself must survive — it is what keeps a 10 Hz topic from
/// re-triggering the whole downstream fan-out per message — so each recovery case is paired with a
/// control that asserts an unchanged republish is still suppressed.
///
public sealed class MqttSubscriptionManagerRecoveryTests
{
// OPC UA status codes, verified against Opc.Ua.StatusCodes rather than recalled.
private const uint Good = 0x00000000u;
private const uint BadNoCommunication = 0x80310000u;
private const uint BadWaitingForInitialData = 0x80320000u;
private const string TempPath = "Plant/Mqtt/broker1/OvenTemp";
private const string PressPath = "Plant/Mqtt/broker1/OvenPressure";
private const string TempTopic = "f/oven/temp";
private const string PressTopic = "f/oven/press";
private static RawTagEntry Tag(string rawPath, string topic) =>
new(rawPath, $$"""{"topic":"{{topic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""",
WriteIdempotent: false);
private static MqttSubscriptionManager NewManager()
{
var mgr = new MqttSubscriptionManager(new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1");
mgr.Register([Tag(TempPath, TempTopic), Tag(PressPath, PressTopic)]);
return mgr;
}
private sealed record Raised(string Reference, object? Value, uint StatusCode, ISubscriptionHandle Handle);
private static List Record(MqttSubscriptionManager mgr)
{
var raised = new List();
mgr.OnDataChange += (_, e) =>
raised.Add(new Raised(e.FullReference, e.Snapshot.Value, e.Snapshot.StatusCode, e.SubscriptionHandle));
return raised;
}
// ---------------------------------------------------------------------------------
// (A) Initial push on (re)subscribe — 05/STAB-18 shape (b)
// ---------------------------------------------------------------------------------
///
/// A ref observed before the subscription existed is pushed once, with its cached snapshot,
/// attributed to the new handle. This is the push-model equivalent of the poll engine's
/// forceRaise: true on the first poll of a new subscription, and it is what refills a
/// node the address-space rebuild reseeded BadWaitingForInitialData on a quiet topic.
///
[Fact]
public async Task SubscribeAsync_pushes_the_cached_snapshot_for_an_observed_ref()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false); // cached; no handle yet
var raised = Record(mgr);
var handle = await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
raised.Count.ShouldBe(1);
raised[0].Reference.ShouldBe(TempPath);
raised[0].Value.ShouldBe(21.5);
raised[0].StatusCode.ShouldBe(Good);
raised[0].Handle.ShouldBe(handle);
}
///
/// A ref that has never been observed carries only the materialiser's
/// BadWaitingForInitialData placeholder — pushing that would claim an observation the
/// driver has not made, so the initial push skips it.
///
[Fact]
public async Task SubscribeAsync_does_not_push_a_never_observed_ref()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
var raised = Record(mgr);
await mgr.SubscribeAsync([TempPath, PressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
raised.Select(r => r.Reference).ShouldBe([TempPath]);
mgr.Values.Read(PressPath).StatusCode.ShouldBe(BadWaitingForInitialData);
}
///
/// Control. The initial push must not weaken steady-state suppression: an identical
/// message arriving after the subscribe is still swallowed. Without this, "recovery works"
/// could simply mean "nothing is ever deduped any more".
///
[Fact]
public async Task An_identical_message_after_the_initial_push_is_still_suppressed()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
var raised = Record(mgr);
await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
raised.Count.ShouldBe(1); // the initial push only
}
///
/// The rebuild shape: unsubscribe and subscribe again — as every deploy does through
/// DriverInstanceActor's unsubscribe-then-subscribe — and the cached value is pushed
/// again onto the new handle rather than leaving the rebuilt node waiting for the plant.
///
[Fact]
public async Task A_resubscribe_pushes_again()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
var first = await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await mgr.UnsubscribeAsync(first, TestContext.Current.CancellationToken);
var raised = Record(mgr);
var second = await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
raised.Count.ShouldBe(1);
raised[0].Value.ShouldBe(21.5);
raised[0].Handle.ShouldBe(second);
}
}