using Grpc.Core; using ZB.MOM.WW.GalaxyRepository; using ZB.MOM.WW.GalaxyRepository.Grpc; namespace ZB.MOM.WW.GalaxyRepository.Tests; /// /// Verifies that scopes browse/discover /// results through the injected . The default /// (null-returning) provider must preserve full-hierarchy behavior, and a provider /// returning a glob that matches nothing must filter the result to empty. /// public sealed class GalaxyRepositoryGrpcServiceScopeTests { /// /// A scope provider built with a result behaves like the /// default : DiscoverHierarchy returns /// the full hierarchy. /// [Fact] public async Task DiscoverHierarchy_DefaultScope_ReturnsFullHierarchy() { GalaxyRepositoryGrpcService service = CreateService( BuildSampleEntry(), new FakeBrowseScopeProvider(subtrees: null)); DiscoverHierarchyReply reply = await service.DiscoverHierarchy( new DiscoverHierarchyRequest { PageSize = 100 }, new TestServerCallContext()); // The sample hierarchy has six objects; with no scoping all are returned. Assert.Equal(6, reply.TotalObjectCount); Assert.Equal(6, reply.Objects.Count); } /// /// A scope provider returning a glob that matches no contained path filters the /// children to empty, mirroring mxaccessgw's browse-subtree constraint behavior. /// [Fact] public async Task BrowseChildren_ScopedProvider_FiltersChildren() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); // Sanity: with the default (unscoped) provider, LineA(2) has two children. GalaxyRepositoryGrpcService unscopedService = CreateService( entry, new FakeBrowseScopeProvider(subtrees: null)); BrowseChildrenReply unscoped = await unscopedService.BrowseChildren( new BrowseChildrenRequest { ParentGobjectId = 2 }, new TestServerCallContext()); Assert.Equal(2, unscoped.Children.Count); // A glob matching nothing scopes the result to empty. GalaxyRepositoryGrpcService scopedService = CreateService( entry, new FakeBrowseScopeProvider(subtrees: ["NonExistent"])); BrowseChildrenReply scoped = await scopedService.BrowseChildren( new BrowseChildrenRequest { ParentGobjectId = 2 }, new TestServerCallContext()); Assert.Empty(scoped.Children); Assert.Equal(0, scoped.TotalChildCount); } private static GalaxyRepositoryGrpcService CreateService( GalaxyHierarchyCacheEntry entry, IGalaxyBrowseScopeProvider scope) { GalaxyRepositoryOptions options = new() { ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;", }; return new GalaxyRepositoryGrpcService( new GalaxyRepository(options), new StubGalaxyHierarchyCache(entry), new RecordingDeployNotifier(), scope); } // A small but representative galaxy, materialized through the real cache refresh path // so the projectors run against a real GalaxyHierarchyIndex: // PlantArea (area, id 1) // ├─ LineA (area, id 2) // │ ├─ Pump01 (id 10) // │ └─ Valve01 (id 11) // └─ Mixer01 (id 12) // StandaloneTank (id 20, root) private static GalaxyHierarchyCacheEntry BuildSampleEntry() { List hierarchy = [ Hierarchy(1, "PlantArea", parent: 0, isArea: true, category: 100), Hierarchy(2, "LineA", parent: 1, isArea: true, category: 100), Hierarchy(10, "Pump01", parent: 2, category: 200, templates: ["$Pump"]), Hierarchy(11, "Valve01", parent: 2, category: 201, templates: ["$Valve"]), Hierarchy(12, "Mixer01", parent: 1, category: 202, templates: ["$Mixer"]), Hierarchy(20, "StandaloneTank", parent: 0, category: 203, templates: ["$Tank"]), ]; List attributes = [ Attribute(10, "Pump01.PV"), Attribute(11, "Valve01.Cmd"), Attribute(12, "Mixer01.Fault"), Attribute(20, "StandaloneTank.Level"), ]; FakeGalaxyRepository repository = new( hierarchy, attributes, deployTime: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier()); cache.RefreshAsync(CancellationToken.None).GetAwaiter().GetResult(); GalaxyHierarchyCacheEntry entry = cache.Current; Assert.True(entry.HasData); return entry; } private static GalaxyHierarchyRow Hierarchy( int id, string tagName, int parent, bool isArea = false, int category = 0, IReadOnlyList? templates = null) => new() { GobjectId = id, TagName = tagName, ContainedName = tagName, BrowseName = tagName, ParentGobjectId = parent, IsArea = isArea, CategoryId = category, TemplateChain = templates ?? Array.Empty(), }; private static GalaxyAttributeRow Attribute(int gobjectId, string fullTagReference) => new() { GobjectId = gobjectId, AttributeName = fullTagReference.Split('.')[^1], FullTagReference = fullTagReference, }; /// An that returns a fixed glob list. private sealed class FakeBrowseScopeProvider(IReadOnlyList? subtrees) : IGalaxyBrowseScopeProvider { public IReadOnlyList? ResolveBrowseSubtrees(ServerCallContext context) => subtrees; } /// Serves a fixed cache entry; never blocks on first load. private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache { public GalaxyHierarchyCacheEntry Current { get; } = current; public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } /// Minimal in-memory for direct service unit tests. private sealed class TestServerCallContext : ServerCallContext { private readonly Metadata _requestHeaders = []; private readonly Metadata _responseTrailers = []; private readonly Dictionary _userState = []; private Status _status; private WriteOptions? _writeOptions; protected override string MethodCore => "/zb.galaxy.v1.GalaxyRepository/Test"; protected override string HostCore => "localhost"; protected override string PeerCore => "ipv4:127.0.0.1:5000"; protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); protected override Metadata RequestHeadersCore => _requestHeaders; protected override CancellationToken CancellationTokenCore => CancellationToken.None; protected override Metadata ResponseTrailersCore => _responseTrailers; protected override Status StatusCore { get => _status; set => _status = value; } protected override WriteOptions? WriteOptionsCore { get => _writeOptions; set => _writeOptions = value; } protected override AuthContext AuthContextCore { get; } = new( string.Empty, new Dictionary>(StringComparer.Ordinal)); protected override IDictionary UserStateCore => _userState; protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => throw new NotSupportedException(); } }