diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 72d70a3c..b07550cc 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -82,6 +82,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj index 0239fc03..d7ccba1e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj @@ -9,6 +9,9 @@ + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyBrowseSessionTests.cs new file mode 100644 index 00000000..d5d88d0c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyBrowseSessionTests.cs @@ -0,0 +1,115 @@ +using 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 +/// MxGateway.Client assembly, and only granted to that repo's own +/// 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)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs new file mode 100644 index 00000000..ab917ef5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs @@ -0,0 +1,55 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests; + +/// +/// Unit-only coverage of 's pre-connect validation. +/// These tests do not require a live mxaccessgw endpoint and are safe to run without +/// the gateway fixture — they exercise the JSON deserialization + validation paths +/// that run before GalaxyRepositoryClient.Create + TestConnectionAsync. +/// The factory's transport-construction path is covered by the integration suite +/// (Task 17) and manual smoke (Task 18) since both require a real gateway. +/// +[Trait("Category", "Unit")] +public sealed class GalaxyDriverBrowserTests +{ + private readonly GalaxyDriverBrowser _sut = new(); + + /// The DriverType key must match the AdminUI's persisted "Galaxy" value + /// so the factory wire-up picks the right browser implementation. + [Fact] + public void DriverType_is_Galaxy() => _sut.DriverType.ShouldBe("Galaxy"); + + /// An empty Gateway.Endpoint must fail fast with a clear, endpoint-mentioning + /// message rather than surfacing a downstream gRPC URI parse error. + [Fact] + public async Task OpenAsync_with_empty_endpoint_throws_InvalidOperationException() + { + var json = """{"Gateway":{"Endpoint":"","ApiKeySecretRef":"dev:k"},"MxAccess":{"ClientName":"X"},"Repository":{},"Reconnect":{}}"""; + var ex = await Should.ThrowAsync( + () => _sut.OpenAsync(json, TestContext.Current.CancellationToken)); + ex.Message.ShouldContain("Endpoint"); + } + + /// An empty MxAccess.ClientName must fail fast — refused so the gateway + /// side doesn't see anonymous browse sessions during triage. + [Fact] + public async Task OpenAsync_with_empty_clientName_throws_InvalidOperationException() + { + var json = """{"Gateway":{"Endpoint":"http://127.0.0.1:1","ApiKeySecretRef":"dev:k"},"MxAccess":{"ClientName":""},"Repository":{},"Reconnect":{}}"""; + var ex = await Should.ThrowAsync( + () => _sut.OpenAsync(json, TestContext.Current.CancellationToken)); + ex.Message.ShouldContain("ClientName"); + } + + /// A JSON literal that deserializes to null must fail fast with a + /// "deserialized to null" message — never a downstream NRE. + [Fact] + public async Task OpenAsync_with_null_json_throws_InvalidOperationException() + { + var ex = await Should.ThrowAsync( + () => _sut.OpenAsync("null", TestContext.Current.CancellationToken)); + ex.Message.ShouldContain("null"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj new file mode 100644 index 00000000..d45df637 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj @@ -0,0 +1,46 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + ..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\libs\MxGateway.Client.dll + true + + + ..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\libs\MxGateway.Contracts.dll + true + + + +