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, new FakeUniversalDriverBrowser()); private static BrowserSessionService NewService( BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) => new(browsers, registry, NullLogger.Instance, universal); [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 Open_BespokeBrowserWinsOverUniversal() { var registry = new BrowseSessionRegistry(); var bespokeSession = new FakeBrowseSession(); var bespoke = new FakeDriverBrowser("AbCip") { OpenHandler = (_, _) => Task.FromResult(bespokeSession), }; var universal = new FakeUniversalDriverBrowser { CanBrowsePredicate = (_, _) => true, // universal would also serve it OpenHandler = (_, _, _) => Task.FromResult(new FakeBrowseSession()), }; var service = NewService(registry, universal, bespoke); var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None); result.Ok.ShouldBeTrue(); result.Token.ShouldBe(bespokeSession.Token); // bespoke session, not the universal one } [Fact] public async Task Open_FallsBackToUniversal_WhenNoBespokeAndCanBrowse() { var registry = new BrowseSessionRegistry(); var universalSession = new FakeBrowseSession(); var universal = new FakeUniversalDriverBrowser { CanBrowsePredicate = (t, _) => t == "AbCip", OpenHandler = (_, _, _) => Task.FromResult(universalSession), }; var service = NewService(registry, universal); // no bespoke browsers var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None); result.Ok.ShouldBeTrue(); result.Token.ShouldBe(universalSession.Token); registry.TryGet(result.Token, out _).ShouldBeTrue(); } [Fact] public async Task Open_NoBespokeAndCannotBrowse_ReturnsNoBrowserMessage() { var registry = new BrowseSessionRegistry(); var service = NewService(registry, new FakeUniversalDriverBrowser()); // CanBrowse=false var result = await service.OpenAsync("Modbus", "{}", CancellationToken.None); result.Ok.ShouldBeFalse(); result.Token.ShouldBe(Guid.Empty); result.Message!.ShouldContain("Modbus"); } [Fact] public async Task Open_UniversalThrow_ReturnsOkFalseWithMessage() { var registry = new BrowseSessionRegistry(); var universal = new FakeUniversalDriverBrowser { CanBrowsePredicate = (_, _) => true, OpenHandler = (_, _, _) => throw new InvalidOperationException("universal-boom"), }; var service = NewService(registry, universal); var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message!.ShouldContain("universal-boom"); } [Fact] public void CanBrowse_TrueForBespokeType_TrueForUniversalType_FalseOtherwise() { var registry = new BrowseSessionRegistry(); var universal = new FakeUniversalDriverBrowser { CanBrowsePredicate = (t, _) => t == "AbCip" }; var service = NewService(registry, universal, new FakeDriverBrowser("Galaxy")); service.CanBrowse("Galaxy", "{}").ShouldBeTrue(); // bespoke registered service.CanBrowse("AbCip", "{}").ShouldBeTrue(); // universal can serve service.CanBrowse("Modbus", "{}").ShouldBeFalse(); // neither } [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())); } }