test(adminui): browse session registry, reaper, service

This commit is contained in:
Joseph Doherty
2026-05-28 15:44:20 -04:00
parent d605d0b20d
commit dc8a2dd52c
6 changed files with 366 additions and 0 deletions
@@ -11,6 +11,10 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.AdminUI.Tests"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
@@ -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;
/// <summary>Unit tests for <see cref="BrowseSessionReaper"/>. We drive the reaper
/// directly through the internal <c>ReapOnceAsync</c> entry point and skew
/// <see cref="FakeBrowseSession.LastUsedUtc"/> to simulate the passage of time —
/// no real wall-clock waits.</summary>
public sealed class BrowseSessionReaperTests
{
private static BrowseSessionReaper NewReaper(BrowseSessionRegistry registry) =>
new(registry, NullLogger<BrowseSessionReaper>.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();
}
}
@@ -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;
/// <summary>Unit tests for <see cref="BrowseSessionRegistry"/>'s lookup, removal and
/// concurrent-registration behaviour.</summary>
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);
}
}
@@ -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;
/// <summary>Unit tests for <see cref="BrowserSessionService"/> — driver-type dispatch
/// on open, NotFound semantics on unknown tokens, exception swallowing, and per-call
/// timeout enforcement.</summary>
public sealed class BrowserSessionServiceTests
{
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
new(browsers, registry, NullLogger<BrowserSessionService>.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<IBrowseSession>(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<BrowseSessionNotFoundException>(
() => service.RootAsync(Guid.NewGuid(), CancellationToken.None));
}
[Fact]
public async Task RootAsync_invokes_session_Root()
{
var registry = new BrowseSessionRegistry();
IReadOnlyList<BrowseNode> 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<IReadOnlyList<BrowseNode>>(_ => Array.Empty<BrowseNode>(), ct),
};
registry.Register(session);
var service = NewService(registry);
var sw = System.Diagnostics.Stopwatch.StartNew();
await Should.ThrowAsync<OperationCanceledException>(
() => 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<IBrowseSession>(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()));
}
}
@@ -0,0 +1,50 @@
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>Test double for <see cref="IBrowseSession"/> used by the registry, reaper,
/// and service tests. All three operations delegate to caller-supplied handlers so each
/// test can shape behaviour; <see cref="DisposeAsync"/> records that it ran and can be
/// instructed to throw via <see cref="ThrowOnDispose"/>.</summary>
internal sealed class FakeBrowseSession : IBrowseSession
{
/// <inheritdoc />
public Guid Token { get; } = Guid.NewGuid();
/// <summary>Mutable so tests can rewind the timestamp into the reaper's eviction window.</summary>
public DateTime LastUsedUtc { get; set; } = DateTime.UtcNow;
/// <summary>True once <see cref="DisposeAsync"/> has run to completion.</summary>
public bool Disposed { get; private set; }
/// <summary>When true, <see cref="DisposeAsync"/> throws to exercise the reaper's
/// best-effort dispose path.</summary>
public bool ThrowOnDispose { get; set; }
// Suppress CS0649: handlers are test seams — some tests leave them null intentionally.
#pragma warning disable CS0649
public Func<CancellationToken, Task<IReadOnlyList<BrowseNode>>>? RootHandler;
public Func<string, CancellationToken, Task<IReadOnlyList<BrowseNode>>>? ExpandHandler;
public Func<string, CancellationToken, Task<IReadOnlyList<AttributeInfo>>>? AttributesHandler;
#pragma warning restore CS0649
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct)
=> RootHandler?.Invoke(ct) ?? Task.FromResult<IReadOnlyList<BrowseNode>>(Array.Empty<BrowseNode>());
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken ct)
=> ExpandHandler?.Invoke(nodeId, ct) ?? Task.FromResult<IReadOnlyList<BrowseNode>>(Array.Empty<BrowseNode>());
/// <inheritdoc />
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct)
=> AttributesHandler?.Invoke(nodeId, ct) ?? Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
/// <inheritdoc />
public ValueTask DisposeAsync()
{
if (ThrowOnDispose) throw new InvalidOperationException("dispose-failed");
Disposed = true;
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,22 @@
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>Test double for <see cref="IDriverBrowser"/>. The constructor sets
/// <see cref="DriverType"/>; <see cref="OpenAsync"/> delegates to the caller-supplied
/// <see cref="OpenHandler"/> or returns a fresh <see cref="FakeBrowseSession"/>.</summary>
internal sealed class FakeDriverBrowser(string driverType) : IDriverBrowser
{
/// <inheritdoc />
public string DriverType { get; } = driverType;
/// <summary>Override for <see cref="OpenAsync"/>; if null, a fresh
/// <see cref="FakeBrowseSession"/> is returned.</summary>
#pragma warning disable CS0649
public Func<string, CancellationToken, Task<IBrowseSession>>? OpenHandler;
#pragma warning restore CS0649
/// <inheritdoc />
public Task<IBrowseSession> OpenAsync(string configJson, CancellationToken ct)
=> OpenHandler?.Invoke(configJson, ct) ?? Task.FromResult<IBrowseSession>(new FakeBrowseSession());
}