using ZB.MOM.WW.MxGateway.Client; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests; /// /// Pure-construction + lifecycle coverage of . /// The session is internal; visibility comes via InternalsVisibleTo /// on the production project. /// /// Blocker: the hierarchy/expand/attribute paths all call into /// , which only ships an internal /// transport seam (IGalaxyRepositoryClientTransport) and an internal /// constructor — both keyed via InternalsVisibleTo on the vendored /// ZB.MOM.WW.MxGateway.Client assembly, and only granted to that repo's own /// ZB.MOM.WW.MxGateway.Client.Tests. We can't substitute a fake transport from /// here without changing the upstream repo, and the public Create /// factory always opens a real gRPC channel. So in-memory traversal coverage /// (RootAsync / ExpandAsync / AttributesAsync, including the SecurityClass /// mapping) is deferred to the integration suite (Task 17) and the manual /// smoke pass (Task 18) — both of which run the gateway for real. /// /// [Trait("Category", "Unit")] public sealed class GalaxyBrowseSessionTests { /// Builds a bound to an /// unreachable endpoint. No connection is opened — Create just builds the /// gRPC channel object — so this is safe to call without a fixture. private static GalaxyRepositoryClient NewClient() => GalaxyRepositoryClient.Create(new MxGatewayClientOptions { Endpoint = new Uri("http://127.0.0.1:1"), ApiKey = "test-key", UseTls = false, ConnectTimeout = TimeSpan.FromSeconds(1), DefaultCallTimeout = TimeSpan.FromSeconds(1), }); /// The internal ctor must reject a null client — the production caller /// (the factory in GalaxyDriverBrowser.OpenAsync) hands off ownership of a /// real client and never passes null, but defence-in-depth catches a future caller /// who skips that handoff. [Fact] public void Constructor_with_null_client_throws_ArgumentNullException() { Should.Throw(() => new GalaxyBrowseSession(null!)); } /// Each session must publish a distinct /// so the AdminUI registry can disambiguate concurrent browse sessions against the /// same driver config. [Fact] public async Task Token_is_unique_per_session() { await using var a = new GalaxyBrowseSession(NewClient()); await using var b = new GalaxyBrowseSession(NewClient()); a.Token.ShouldNotBe(b.Token); a.Token.ShouldNotBe(Guid.Empty); } /// is primed to the /// construction time so the registry reaper has a sensible baseline before the /// first Root/Expand/Attributes call lands. [Fact] public async Task LastUsedUtc_is_initialized_at_construction() { var before = DateTime.UtcNow; await using var session = new GalaxyBrowseSession(NewClient()); var after = DateTime.UtcNow; // Allow generous slop — the field is set inside the ctor body, both bookends // are wall-clock UtcNow, and we only care that it isn't default(DateTime). session.LastUsedUtc.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1)); session.LastUsedUtc.ShouldBeLessThanOrEqualTo(after.AddSeconds(1)); } /// is idempotent — the /// registry's reaper may race a client-initiated close, so the second call must /// no-op rather than throw or hit the /// already-disposed gRPC channel. [Fact] public async Task DisposeAsync_is_idempotent() { var session = new GalaxyBrowseSession(NewClient()); await session.DisposeAsync(); // Second call should silently no-op. await Should.NotThrowAsync(async () => await session.DisposeAsync()); } /// After disposal, any call /// must surface — not a downstream channel /// fault — so the AdminUI sees a clean "session closed" signal. [Fact] public async Task ExpandAsync_after_dispose_throws_ObjectDisposedException() { var session = new GalaxyBrowseSession(NewClient()); await session.DisposeAsync(); await Should.ThrowAsync( () => session.ExpandAsync("anything", TestContext.Current.CancellationToken)); } /// must reject a tag that /// hasn't been seen by a prior Root/Expand call — the cache is the source of /// truth, and silently returning [] would mask AdminUI bugs that browse with a /// stale path. [Fact] public async Task ExpandAsync_unknown_tag_throws_ArgumentException() { await using var session = new GalaxyBrowseSession(NewClient()); // No RootAsync call ⇒ cache is empty ⇒ any tag is unknown. await Should.ThrowAsync( () => session.ExpandAsync("Galaxy.Unknown", TestContext.Current.CancellationToken)); } }