Files
scadalink-design/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Subscriptions/SubscriptionManagerTests.cs
Joseph Doherty 6df2cbdf90 fix(lmxproxy): support multiple subscriptions per session
Key subscriptions by unique subscriptionId instead of sessionId to prevent
overwrites when the same session calls Subscribe multiple times (e.g. DCL
StaleTagMonitor). Add session-to-subscription reverse lookup for cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:30:06 -04:00

198 lines
8.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Host.Domain;
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Subscriptions
{
public class SubscriptionManagerTests
{
/// <summary>Fake IScadaClient for testing (no COM dependency).</summary>
private class FakeScadaClient : IScadaClient
{
public bool IsConnected => true;
public ConnectionState ConnectionState => ConnectionState.Connected;
public DateTime ConnectedSince => DateTime.UtcNow;
public int ReconnectCount => 0;
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task<Vtq> ReadAsync(string address, CancellationToken ct = default) =>
Task.FromResult(Vtq.Good(42.0));
public Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyDictionary<string, Vtq>>(new Dictionary<string, Vtq>());
public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask;
public Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default) => Task.CompletedTask;
public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object> values, string flagTag, object flagValue,
int timeoutMs, int pollIntervalMs, CancellationToken ct = default) =>
Task.FromResult((false, 0));
public Task UnsubscribeByAddressAsync(IEnumerable<string> addresses) => Task.CompletedTask;
public Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback, CancellationToken ct = default) =>
Task.FromResult<IAsyncDisposable>(new FakeSubscriptionHandle());
public ValueTask DisposeAsync() => default;
// Suppress unused event warning
internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!);
private class FakeSubscriptionHandle : IAsyncDisposable
{
public ValueTask DisposeAsync() => default;
}
}
[Fact]
public async Task Subscribe_ReturnsChannelReader()
{
using var sm = new SubscriptionManager(new FakeScadaClient());
using var cts = new CancellationTokenSource();
var (reader, subscriptionId) = await sm.SubscribeAsync("client1", new[] { "Tag1", "Tag2" }, cts.Token);
reader.Should().NotBeNull();
subscriptionId.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task OnTagValueChanged_FansOutToSubscribedClients()
{
using var sm = new SubscriptionManager(new FakeScadaClient());
using var cts = new CancellationTokenSource();
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
var vtq = Vtq.Good(42.0);
sm.OnTagValueChanged("Motor.Speed", vtq);
var result = await reader.ReadAsync(cts.Token);
result.address.Should().Be("Motor.Speed");
result.vtq.Value.Should().Be(42.0);
result.vtq.Quality.Should().Be(Quality.Good);
}
[Fact]
public async Task OnTagValueChanged_MultipleClients_BothReceive()
{
using var sm = new SubscriptionManager(new FakeScadaClient());
using var cts = new CancellationTokenSource();
var (reader1, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
var (reader2, _) = await sm.SubscribeAsync("client2", new[] { "Motor.Speed" }, cts.Token);
sm.OnTagValueChanged("Motor.Speed", Vtq.Good(99.0));
var r1 = await reader1.ReadAsync(cts.Token);
var r2 = await reader2.ReadAsync(cts.Token);
r1.vtq.Value.Should().Be(99.0);
r2.vtq.Value.Should().Be(99.0);
}
[Fact]
public async Task OnTagValueChanged_NonSubscribedTag_NoDelivery()
{
using var sm = new SubscriptionManager(new FakeScadaClient());
using var cts = new CancellationTokenSource();
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
sm.OnTagValueChanged("Motor.Torque", Vtq.Good(10.0));
// Channel should be empty
reader.TryRead(out _).Should().BeFalse();
}
[Fact]
public async Task UnsubscribeSubscription_CompletesChannel()
{
using var sm = new SubscriptionManager(new FakeScadaClient());
using var cts = new CancellationTokenSource();
var (reader, subscriptionId) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
sm.UnsubscribeSubscription(subscriptionId);
// Channel should be completed
reader.Completion.IsCompleted.Should().BeTrue();
}
[Fact]
public async Task UnsubscribeSession_RemovesAllSubscriptions()
{
using var sm = new SubscriptionManager(new FakeScadaClient());
using var cts = new CancellationTokenSource();
await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
sm.UnsubscribeSession("client1");
var stats = sm.GetStats();
stats.TotalClients.Should().Be(0);
stats.TotalTags.Should().Be(0);
}
[Fact]
public async Task RefCounting_LastSubscriptionUnsubscribeRemovesTag()
{
using var sm = new SubscriptionManager(new FakeScadaClient());
using var cts = new CancellationTokenSource();
var (_, subId1) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
var (_, subId2) = await sm.SubscribeAsync("client2", new[] { "Motor.Speed" }, cts.Token);
sm.GetStats().TotalTags.Should().Be(1);
sm.UnsubscribeSubscription(subId1);
sm.GetStats().TotalTags.Should().Be(1); // client2 still subscribed
sm.UnsubscribeSubscription(subId2);
sm.GetStats().TotalTags.Should().Be(0); // last subscription gone
}
[Fact]
public async Task NotifyDisconnection_SendsBadQualityToAll()
{
using var sm = new SubscriptionManager(new FakeScadaClient());
using var cts = new CancellationTokenSource();
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed", "Motor.Torque" }, cts.Token);
sm.NotifyDisconnection();
// Should receive 2 bad quality messages
reader.TryRead(out var r1).Should().BeTrue();
r1.vtq.Quality.Should().Be(Quality.Bad_NotConnected);
reader.TryRead(out var r2).Should().BeTrue();
r2.vtq.Quality.Should().Be(Quality.Bad_NotConnected);
}
[Fact]
public async Task Backpressure_DropOldest_DropsWhenFull()
{
using var sm = new SubscriptionManager(new FakeScadaClient(), channelCapacity: 3);
using var cts = new CancellationTokenSource();
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
// Fill the channel beyond capacity
for (int i = 0; i < 10; i++)
{
sm.OnTagValueChanged("Motor.Speed", Vtq.Good((double)i));
}
// Should have exactly 3 messages (capacity limit)
int count = 0;
while (reader.TryRead(out _)) count++;
count.Should().Be(3);
}
[Fact]
public async Task GetStats_ReturnsCorrectCounts()
{
using var sm = new SubscriptionManager(new FakeScadaClient());
using var cts = new CancellationTokenSource();
var (_, _) = await sm.SubscribeAsync("c1", new[] { "Tag1", "Tag2" }, cts.Token);
var (_, _) = await sm.SubscribeAsync("c2", new[] { "Tag2", "Tag3" }, cts.Token);
var stats = sm.GetStats();
stats.TotalClients.Should().Be(2);
stats.TotalTags.Should().Be(3); // Tag1, Tag2, Tag3
stats.ActiveSubscriptions.Should().Be(4); // c1:Tag1, c1:Tag2, c2:Tag2, c2:Tag3
}
}
}