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())); } }