Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewaySessionScopedHandleTests.cs
T

192 lines
7.9 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
/// <summary>
/// The adapter half of review 2026-10-21 02/C1 + 02/L1. The adapter instance is reused across
/// reconnects, but every reconnect opens a NEW gateway session whose item handles are plain ints
/// that restart (collide) per session. A subscription id handed out by a dead session must
/// therefore never reach the gateway again — it would unadvise whatever the live session gave that
/// number — and the routing maps must not keep dead-session entries alive.
/// </summary>
[Collection("DataConnectionManagerActor")]
public class MxGatewaySessionScopedHandleTests
{
private static Dictionary<string, string> Details() => new()
{
["Endpoint"] = "http://gw:5000",
["ApiKey"] = "key",
["ReadTimeoutMs"] = "2000",
};
private static MxGatewayDataConnection NewAdapter(FakeMxGatewayClient fake) =>
new(fake, NullLogger<MxGatewayDataConnection>.Instance);
/// <summary>
/// A batch release of a dead-session id never reaches the gateway, even when the new session
/// has handed the same handle number to a live tag — and that live tag keeps routing.
/// </summary>
[Fact]
public async Task DeadSessionId_IsNeverReleasedAtTheGateway_EvenWhenTheHandleWasReused()
{
var fake = new FakeMxGatewayClient { RestartHandlesOnConnect = true };
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
var dead = (await adapter.SubscribeBatchAsync(["Old.Tag"], (_, _) => { }))[0].SubscriptionId!;
await adapter.ConnectAsync(Details()); // reconnect: new session, handles restart at 1
var liveHits = 0;
var live = (await adapter.SubscribeBatchAsync(["Live.Tag"], (_, _) => liveHits++))[0].SubscriptionId!;
await adapter.UnsubscribeBatchAsync([dead]);
await adapter.UnsubscribeAsync(dead);
Assert.Empty(fake.Unsubscribed);
Assert.NotEqual(dead, live);
await WaitUntil(() => fake.OnUpdate is not null);
fake.OnUpdate!(new MxValueUpdate("Live.Tag", 2, QualityCode.Good, DateTimeOffset.UtcNow));
Assert.True(liveHits >= 1);
// A current-session id still releases normally.
await adapter.UnsubscribeBatchAsync([live]);
Assert.Single(fake.Unsubscribed);
}
/// <summary>
/// A reconnect drops the previous session's routing entries (L1): an update for a tag the
/// new session has not (re-)subscribed is not routed to the dead session's callback.
/// </summary>
[Fact]
public async Task Reconnect_ClearsTheDeadSessionsRoutingEntries()
{
var fake = new FakeMxGatewayClient();
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
var hits = 0;
await adapter.SubscribeBatchAsync(["A.Tag"], (_, _) => hits++);
await adapter.ConnectAsync(Details());
await WaitUntil(() => fake.OnUpdate is not null);
fake.OnUpdate!(new MxValueUpdate("A.Tag", 1, QualityCode.Good, DateTimeOffset.UtcNow));
Assert.Equal(0, hits);
}
/// <summary>
/// A disconnect drops the routing entries too, so a later update is not delivered to a
/// subscription that no longer exists at the gateway.
/// </summary>
[Fact]
public async Task Disconnect_ClearsRoutingEntries()
{
var fake = new FakeMxGatewayClient();
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
var hits = 0;
await adapter.SubscribeBatchAsync(["A.Tag"], (_, _) => hits++);
await WaitUntil(() => fake.OnUpdate is not null);
await adapter.DisconnectAsync();
fake.OnUpdate!(new MxValueUpdate("A.Tag", 1, QualityCode.Good, DateTimeOffset.UtcNow));
Assert.Equal(0, hits);
}
/// <summary>
/// Releasing an alarm subscription id the adapter does not know (a dead session's) must not
/// decrement the live feed's reference count — at zero the shared stream is torn down while
/// live subscribers still depend on it.
/// </summary>
[Fact]
public async Task UnknownAlarmSubscriptionId_DoesNotTearDownTheLiveStream()
{
var fake = new FakeMxGatewayClient();
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
var delivered = 0;
await adapter.SubscribeAlarmsAsync("Tank01", null, t =>
{
if (t.Kind == AlarmTransitionKind.SnapshotComplete)
Interlocked.Increment(ref delivered);
});
await WaitUntil(() => fake.AlarmCallback is not null);
await adapter.UnsubscribeAlarmsAsync(Guid.NewGuid().ToString());
fake.AlarmCallback!(MxGatewayAlarmMapper.SnapshotComplete());
Assert.Equal(1, Volatile.Read(ref delivered));
}
/// <summary>
/// Review 2026-10-21 02/P3: a burst of alarm subscribes (a staggered deploy batch of N
/// alarm-bound instances) is coalesced into ONE stream reopen on the union prefix instead of N
/// close/reopen cycles, each with its own connection-wide snapshot replay. Every source in the
/// burst is still covered by that one fresh snapshot (DC-5's standing-state guarantee).
/// </summary>
[Fact]
public async Task AlarmSubscribeBurst_IsCoalescedIntoOneStreamReopen()
{
var fake = new FakeMxGatewayClient();
var adapter = new MxGatewayDataConnection(
fake, NullLogger<MxGatewayDataConnection>.Instance,
alarmStreamReopenDebounce: TimeSpan.FromMilliseconds(300));
await adapter.ConnectAsync(Details());
// Spaced like actor-delivered subscribes, so an un-coalesced reopen really reaches the
// gateway each time (a reopen superseded before its task runs never would).
foreach (var source in new[] { "Area1.Tank1", "Area1.Tank2", "Area1.Tank3", "Area1.Tank4", "Area1.Tank5" })
{
await adapter.SubscribeAlarmsAsync(source, null, _ => { });
await Task.Delay(20);
}
await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count >= 1; });
await Task.Delay(400); // well past the window: no trailing reopens
lock (fake.AlarmStreamPrefixes)
{
Assert.Single(fake.AlarmStreamPrefixes);
Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[0]);
}
}
/// <summary>
/// A subscribe arriving after the coalesced reopen has fired still gets its own fresh
/// snapshot — coalescing never swallows a source.
/// </summary>
[Fact]
public async Task AlarmSubscribe_AfterTheWindow_StillReopens()
{
var fake = new FakeMxGatewayClient();
var adapter = new MxGatewayDataConnection(
fake, NullLogger<MxGatewayDataConnection>.Instance,
alarmStreamReopenDebounce: TimeSpan.FromMilliseconds(50));
await adapter.ConnectAsync(Details());
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { });
await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count == 1; });
await adapter.SubscribeAlarmsAsync("Area1.Tank2", null, _ => { });
await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count == 2; });
lock (fake.AlarmStreamPrefixes)
Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[1]);
}
private static async Task WaitUntil(Func<bool> condition, int timeoutMs = 30_000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!condition())
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException("Condition not met in time.");
await Task.Delay(10);
}
}
}