Files
scadalink-design/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs
Joseph Doherty 95168253fc feat(lmxproxy): replace subscribe/unsubscribe health probe with persistent subscription
The old probe did a subscribe-read-unsubscribe cycle every 5 seconds to
check connection health. This created unnecessary churn and didn't detect
the failure mode where long-lived subscriptions silently stop receiving
COM callbacks (e.g. stalled STA message pump). The new approach keeps a
persistent subscription on the health check tag and forces reconnect if
no value update arrives within a configurable threshold (ProbeStaleThresholdMs,
default 5s). Also adds STA message pump debug logging (5-min heartbeat with
message counters) and fixes log file path resolution for Windows services.

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

132 lines
5.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using Xunit;
using ZB.MOM.WW.LmxProxy.Host.Domain;
using ZB.MOM.WW.LmxProxy.Host.Health;
using HealthCheckService = ZB.MOM.WW.LmxProxy.Host.Health.HealthCheckService;
using ZB.MOM.WW.LmxProxy.Host.Metrics;
using ZB.MOM.WW.LmxProxy.Host.Status;
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Status
{
public class StatusReportServiceTests
{
private class FakeScadaClient : IScadaClient
{
public bool IsConnected { get; set; } = true;
public ConnectionState ConnectionState { get; set; } = ConnectionState.Connected;
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 FakeHandle());
public ValueTask DisposeAsync() => default;
internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!);
private class FakeHandle : IAsyncDisposable { public ValueTask DisposeAsync() => default; }
}
private (StatusReportService svc, PerformanceMetrics pm, SubscriptionManager sm) CreateService(
bool connected = true)
{
var client = new FakeScadaClient
{
IsConnected = connected,
ConnectionState = connected ? ConnectionState.Connected : ConnectionState.Disconnected
};
var sm = new SubscriptionManager(client);
var pm = new PerformanceMetrics();
var health = new HealthCheckService(client, sm, pm);
var detailed = new DetailedHealthCheckService(client);
var svc = new StatusReportService(client, sm, pm, health, detailed);
return (svc, pm, sm);
}
[Fact]
public async Task GenerateJsonReportAsync_ReturnsCamelCaseJson()
{
var (svc, pm, sm) = CreateService();
using (pm) using (sm)
{
var json = await svc.GenerateJsonReportAsync();
json.Should().Contain("\"serviceName\"");
json.Should().Contain("\"connection\"");
json.Should().Contain("\"isConnected\"");
}
}
[Fact]
public async Task GenerateHtmlReportAsync_ContainsAutoRefresh()
{
var (svc, pm, sm) = CreateService();
using (pm) using (sm)
{
var html = await svc.GenerateHtmlReportAsync();
html.Should().Contain("<meta http-equiv=\"refresh\" content=\"30\">");
}
}
[Fact]
public async Task IsHealthyAsync_ReturnsTrueWhenHealthy()
{
var (svc, pm, sm) = CreateService(connected: true);
using (pm) using (sm)
{
var result = await svc.IsHealthyAsync();
result.Should().BeTrue();
}
}
[Fact]
public async Task IsHealthyAsync_ReturnsFalseWhenUnhealthy()
{
var (svc, pm, sm) = CreateService(connected: false);
using (pm) using (sm)
{
var result = await svc.IsHealthyAsync();
result.Should().BeFalse();
}
}
[Fact]
public async Task GenerateJsonReportAsync_IncludesPerformanceMetrics()
{
var (svc, pm, sm) = CreateService();
using (pm) using (sm)
{
pm.RecordOperation("Read", TimeSpan.FromMilliseconds(15), true);
pm.RecordOperation("Write", TimeSpan.FromMilliseconds(25), true);
var json = await svc.GenerateJsonReportAsync();
var parsed = JObject.Parse(json);
var operations = parsed["performance"]?["operations"];
operations.Should().NotBeNull();
// Newtonsoft CamelCasePropertyNamesContractResolver camelCases dictionary keys
operations!["read"].Should().NotBeNull();
operations!["write"].Should().NotBeNull();
((long)operations!["read"]!["totalCount"]!).Should().Be(1);
}
}
}
}