test(adminui): browse session registry, reaper, service
This commit is contained in:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user