From dc8a2dd52c5fd1d01036060a452383354bbd6b61 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 15:44:20 -0400 Subject: [PATCH] test(adminui): browse session registry, reaper, service --- .../ZB.MOM.WW.OtOpcUa.AdminUI.csproj | 4 + .../Browsing/BrowseSessionReaperTests.cs | 86 +++++++++++ .../Browsing/BrowseSessionRegistryTests.cs | 62 ++++++++ .../Browsing/BrowserSessionServiceTests.cs | 142 ++++++++++++++++++ .../Browsing/FakeBrowseSession.cs | 50 ++++++ .../Browsing/FakeDriverBrowser.cs | 22 +++ 6 files changed, 366 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionReaperTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionRegistryTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeBrowseSession.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeDriverBrowser.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index 288e541d..742c6cad 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -11,6 +11,10 @@ + + + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionReaperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionReaperTests.cs new file mode 100644 index 00000000..076aa933 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionReaperTests.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing; + +/// Unit tests for . We drive the reaper +/// directly through the internal ReapOnceAsync entry point and skew +/// to simulate the passage of time — +/// no real wall-clock waits. +public sealed class BrowseSessionReaperTests +{ + private static BrowseSessionReaper NewReaper(BrowseSessionRegistry registry) => + new(registry, NullLogger.Instance); + + [Fact] + public async Task ReapOnceAsync_evicts_idle_session() + { + var registry = new BrowseSessionRegistry(); + var session = new FakeBrowseSession + { + LastUsedUtc = DateTime.UtcNow - TimeSpan.FromMinutes(3), + }; + registry.Register(session); + var reaper = NewReaper(registry); + + await reaper.ReapOnceAsync(CancellationToken.None); + + registry.TryGet(session.Token, out _).ShouldBeFalse(); + session.Disposed.ShouldBeTrue(); + } + + [Fact] + public async Task ReapOnceAsync_preserves_recent_session() + { + var registry = new BrowseSessionRegistry(); + var session = new FakeBrowseSession { LastUsedUtc = DateTime.UtcNow }; + registry.Register(session); + var reaper = NewReaper(registry); + + await reaper.ReapOnceAsync(CancellationToken.None); + + registry.TryGet(session.Token, out _).ShouldBeTrue(); + session.Disposed.ShouldBeFalse(); + } + + [Fact] + public async Task ReapOnceAsync_handles_already_removed_session() + { + var registry = new BrowseSessionRegistry(); + var session = new FakeBrowseSession + { + LastUsedUtc = DateTime.UtcNow - TimeSpan.FromMinutes(3), + }; + registry.Register(session); + // Race the reaper: pull the entry out before the loop calls TryRemove. + registry.TryRemove(session.Token, out _).ShouldBeTrue(); + var reaper = NewReaper(registry); + + // No exception, no double dispose. Because we removed the session ourselves + // without disposing it, Disposed should still be false. + await reaper.ReapOnceAsync(CancellationToken.None); + + session.Disposed.ShouldBeFalse(); + } + + [Fact] + public async Task ReapOnceAsync_continues_when_one_session_dispose_throws() + { + var registry = new BrowseSessionRegistry(); + var staleAt = DateTime.UtcNow - TimeSpan.FromMinutes(3); + var bad = new FakeBrowseSession { LastUsedUtc = staleAt, ThrowOnDispose = true }; + var good = new FakeBrowseSession { LastUsedUtc = staleAt }; + registry.Register(bad); + registry.Register(good); + var reaper = NewReaper(registry); + + // The dispose exception is swallowed; the second session must still be reaped + disposed. + await Should.NotThrowAsync(() => reaper.ReapOnceAsync(CancellationToken.None)); + + registry.TryGet(bad.Token, out _).ShouldBeFalse(); + registry.TryGet(good.Token, out _).ShouldBeFalse(); + good.Disposed.ShouldBeTrue(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionRegistryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionRegistryTests.cs new file mode 100644 index 00000000..2e2dca9b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionRegistryTests.cs @@ -0,0 +1,62 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing; + +/// Unit tests for 's lookup, removal and +/// concurrent-registration behaviour. +public sealed class BrowseSessionRegistryTests +{ + [Fact] + public void Register_then_TryGet_returns_session() + { + var registry = new BrowseSessionRegistry(); + var session = new FakeBrowseSession(); + + registry.Register(session); + var found = registry.TryGet(session.Token, out var got); + + found.ShouldBeTrue(); + got.ShouldBeSameAs((IBrowseSession)session); + } + + [Fact] + public void TryGet_unknown_returns_false() + { + var registry = new BrowseSessionRegistry(); + + registry.TryGet(Guid.NewGuid(), out var got).ShouldBeFalse(); + got.ShouldBeNull(); + } + + [Fact] + public void TryRemove_then_TryGet_returns_false() + { + var registry = new BrowseSessionRegistry(); + var session = new FakeBrowseSession(); + registry.Register(session); + + registry.TryRemove(session.Token, out var removed).ShouldBeTrue(); + removed.ShouldBeSameAs((IBrowseSession)session); + registry.TryGet(session.Token, out _).ShouldBeFalse(); + } + + [Fact] + public async Task Concurrent_Register_from_many_tasks_all_visible_in_Snapshot() + { + var registry = new BrowseSessionRegistry(); + const int count = 50; + var sessions = Enumerable.Range(0, count).Select(_ => new FakeBrowseSession()).ToArray(); + + var tasks = sessions.Select(s => Task.Run(() => registry.Register(s))).ToArray(); + await Task.WhenAll(tasks); + + var snapshot = registry.Snapshot(); + snapshot.Count.ShouldBe(count); + var snapshotTokens = snapshot.Select(x => x.Token).OrderBy(g => g).ToArray(); + var expectedTokens = sessions.Select(s => s.Token).OrderBy(g => g).ToArray(); + snapshotTokens.ShouldBe(expectedTokens); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs new file mode 100644 index 00000000..d69d8322 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs @@ -0,0 +1,142 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing; + +/// Unit tests for — driver-type dispatch +/// on open, NotFound semantics on unknown tokens, exception swallowing, and per-call +/// timeout enforcement. +public sealed class BrowserSessionServiceTests +{ + private static BrowserSessionService NewService( + BrowseSessionRegistry registry, params IDriverBrowser[] browsers) => + new(browsers, registry, NullLogger.Instance); + + [Fact] + public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message() + { + var registry = new BrowseSessionRegistry(); + var service = NewService(registry, new FakeDriverBrowser("Known")); + + var result = await service.OpenAsync("Unknown", "{}", CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Token.ShouldBe(Guid.Empty); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldContain("Unknown"); + } + + [Fact] + public async Task OpenAsync_happy_path_returns_token_and_registers() + { + var registry = new BrowseSessionRegistry(); + var session = new FakeBrowseSession(); + var browser = new FakeDriverBrowser("Galaxy") + { + OpenHandler = (_, _) => Task.FromResult(session), + }; + var service = NewService(registry, browser); + + var result = await service.OpenAsync("Galaxy", "{}", CancellationToken.None); + + result.Ok.ShouldBeTrue(); + result.Message.ShouldBeNull(); + result.Token.ShouldNotBe(Guid.Empty); + result.Token.ShouldBe(session.Token); + registry.TryGet(result.Token, out var registered).ShouldBeTrue(); + registered.ShouldBeSameAs((IBrowseSession)session); + } + + [Fact] + public async Task OpenAsync_swallows_driver_throws_returns_Ok_false() + { + var registry = new BrowseSessionRegistry(); + var browser = new FakeDriverBrowser("Galaxy") + { + OpenHandler = (_, _) => throw new InvalidOperationException("boom"), + }; + var service = NewService(registry, browser); + + var result = await service.OpenAsync("Galaxy", "{}", CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Token.ShouldBe(Guid.Empty); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldContain("boom"); + } + + [Fact] + public async Task RootAsync_unknown_token_throws_BrowseSessionNotFoundException() + { + var registry = new BrowseSessionRegistry(); + var service = NewService(registry); + + await Should.ThrowAsync( + () => service.RootAsync(Guid.NewGuid(), CancellationToken.None)); + } + + [Fact] + public async Task RootAsync_invokes_session_Root() + { + var registry = new BrowseSessionRegistry(); + IReadOnlyList expected = new[] + { + new BrowseNode("ns=2;s=A", "A", BrowseNodeKind.Folder, true), + new BrowseNode("ns=2;s=B", "B", BrowseNodeKind.Leaf, false), + }; + var session = new FakeBrowseSession { RootHandler = _ => Task.FromResult(expected) }; + registry.Register(session); + var service = NewService(registry); + + var actual = await service.RootAsync(session.Token, CancellationToken.None); + + actual.ShouldBe(expected); + } + + [Fact] + public async Task RootAsync_enforces_PerCallTimeout() + { + var registry = new BrowseSessionRegistry(); + var session = new FakeBrowseSession + { + // Awaits the linked CTS — the service must cancel it via PerCallTimeout (20s). + RootHandler = ct => Task.Delay(TimeSpan.FromSeconds(40), ct) + .ContinueWith>(_ => Array.Empty(), ct), + }; + registry.Register(session); + var service = NewService(registry); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + await Should.ThrowAsync( + () => service.RootAsync(session.Token, CancellationToken.None)); + sw.Stop(); + + // Real timeout is 20s; allow generous slack but cap well below the 40s task delay. + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(35)); + } + + [Fact] + public async Task CloseAsync_removes_and_disposes_session() + { + var registry = new BrowseSessionRegistry(); + var session = new FakeBrowseSession(); + var browser = new FakeDriverBrowser("Galaxy") + { + OpenHandler = (_, _) => Task.FromResult(session), + }; + var service = NewService(registry, browser); + var opened = await service.OpenAsync("Galaxy", "{}", CancellationToken.None); + opened.Ok.ShouldBeTrue(); + + await service.CloseAsync(opened.Token); + + registry.TryGet(opened.Token, out _).ShouldBeFalse(); + session.Disposed.ShouldBeTrue(); + + // Unknown token is a no-op. + await Should.NotThrowAsync(() => service.CloseAsync(Guid.NewGuid())); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeBrowseSession.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeBrowseSession.cs new file mode 100644 index 00000000..1930dc41 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeBrowseSession.cs @@ -0,0 +1,50 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing; + +/// Test double for used by the registry, reaper, +/// and service tests. All three operations delegate to caller-supplied handlers so each +/// test can shape behaviour; records that it ran and can be +/// instructed to throw via . +internal sealed class FakeBrowseSession : IBrowseSession +{ + /// + public Guid Token { get; } = Guid.NewGuid(); + + /// Mutable so tests can rewind the timestamp into the reaper's eviction window. + public DateTime LastUsedUtc { get; set; } = DateTime.UtcNow; + + /// True once has run to completion. + public bool Disposed { get; private set; } + + /// When true, throws to exercise the reaper's + /// best-effort dispose path. + public bool ThrowOnDispose { get; set; } + + // Suppress CS0649: handlers are test seams — some tests leave them null intentionally. +#pragma warning disable CS0649 + public Func>>? RootHandler; + public Func>>? ExpandHandler; + public Func>>? AttributesHandler; +#pragma warning restore CS0649 + + /// + public Task> RootAsync(CancellationToken ct) + => RootHandler?.Invoke(ct) ?? Task.FromResult>(Array.Empty()); + + /// + public Task> ExpandAsync(string nodeId, CancellationToken ct) + => ExpandHandler?.Invoke(nodeId, ct) ?? Task.FromResult>(Array.Empty()); + + /// + public Task> AttributesAsync(string nodeId, CancellationToken ct) + => AttributesHandler?.Invoke(nodeId, ct) ?? Task.FromResult>(Array.Empty()); + + /// + public ValueTask DisposeAsync() + { + if (ThrowOnDispose) throw new InvalidOperationException("dispose-failed"); + Disposed = true; + return ValueTask.CompletedTask; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeDriverBrowser.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeDriverBrowser.cs new file mode 100644 index 00000000..c9795793 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeDriverBrowser.cs @@ -0,0 +1,22 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing; + +/// Test double for . The constructor sets +/// ; delegates to the caller-supplied +/// or returns a fresh . +internal sealed class FakeDriverBrowser(string driverType) : IDriverBrowser +{ + /// + public string DriverType { get; } = driverType; + + /// Override for ; if null, a fresh + /// is returned. +#pragma warning disable CS0649 + public Func>? OpenHandler; +#pragma warning restore CS0649 + + /// + public Task OpenAsync(string configJson, CancellationToken ct) + => OpenHandler?.Invoke(configJson, ct) ?? Task.FromResult(new FakeBrowseSession()); +}