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));
}
///
/// Verifies that each of the seven defined Galaxy security_classification codes
/// maps to the correct label string — matching the SecurityClassification
/// enum ordinals and the runtime SecurityMap in Driver.Galaxy.
/// (Regression for Driver.Galaxy.Browser-001: codes 2–6 were all wrong before
/// this fix.)
///
[Theory]
[InlineData(0, "FreeAccess")]
[InlineData(1, "Operate")]
[InlineData(2, "SecuredWrite")]
[InlineData(3, "VerifiedWrite")]
[InlineData(4, "Tune")]
[InlineData(5, "Configure")]
[InlineData(6, "ViewOnly")]
public void MapSecurityClass_maps_all_known_codes(int code, string expectedLabel)
{
GalaxyBrowseSession.MapSecurityClass(code).ShouldBe(expectedLabel);
}
///
/// An unrecognised Galaxy security_classification code must produce an
/// Unknown(N) label — not throw and not silently return a valid class.
///
[Theory]
[InlineData(7)]
[InlineData(99)]
[InlineData(-1)]
public void MapSecurityClass_unknown_code_returns_Unknown_label(int code)
{
var label = GalaxyBrowseSession.MapSecurityClass(code);
label.ShouldStartWith("Unknown(");
label.ShouldContain(code.ToString());
}
///
/// Two concurrent calls on the
/// same session must not throw. The registry reaper and a browser-side Close
/// can race in production.
/// (Regression for Driver.Galaxy.Browser-002: the second caller could throw
/// from SemaphoreSlim.Dispose().)
///
[Fact]
public async Task DisposeAsync_concurrent_calls_do_not_throw()
{
var session = new GalaxyBrowseSession(NewClient());
// Fire two concurrent dispose calls and await both — neither must throw.
await Should.NotThrowAsync(async () =>
{
await Task.WhenAll(session.DisposeAsync().AsTask(), session.DisposeAsync().AsTask());
});
}
}