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); } /// /// When the scope provider returns a non-empty glob, the deploy event's /// object/attribute counts are re-projected against the scoped subtree and override /// the raw counts the notifier published. /// [Fact] public async Task WatchDeployEvents_ScopedProvider_EmitsFilteredCounts() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); // Sanity: the full hierarchy projects to six objects / four attributes. GalaxyHierarchyQueryResult full = GalaxyHierarchyProjector.Project( entry, new DiscoverHierarchyRequest()); Assert.Equal(6, full.TotalObjectCount); Assert.Equal(4, full.Objects.Sum(obj => obj.Attributes.Count)); // The glob selects only LineA's two leaf objects (Pump01, Valve01), each with one // attribute. That scoped projection (2 objects / 2 attributes) is a non-empty subset // distinct from both the full count and the raw notifier values below. GalaxyHierarchyQueryResult scopedProjection = GalaxyHierarchyProjector.Project( entry, new DiscoverHierarchyRequest(), browseSubtreeGlobs: ["PlantArea/LineA/*"]); Assert.Equal(2, scopedProjection.TotalObjectCount); Assert.Equal(2, scopedProjection.Objects.Sum(obj => obj.Attributes.Count)); // Publish a deploy event whose RAW counts differ from both full and scoped, so an // assertion on the scoped values proves the override actually happened. RecordingDeployNotifier notifier = new(); notifier.Publish(new GalaxyDeployEventInfo( Sequence: 42, ObservedAt: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), TimeOfLastDeploy: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), ObjectCount: 999, AttributeCount: 888)); GalaxyRepositoryGrpcService service = CreateService( entry, new FakeBrowseScopeProvider(subtrees: ["PlantArea/LineA/*"]), notifier); // RecordingDeployNotifier yields the latest event then completes, so the stream // ends after the single event without needing cancellation. CapturingStreamWriter responseStream = new(); await service.WatchDeployEvents( new WatchDeployEventsRequest(), responseStream, new TestServerCallContext()); DeployEvent emitted = Assert.Single(responseStream.Written); Assert.Equal(scopedProjection.TotalObjectCount, emitted.ObjectCount); Assert.Equal(2, emitted.AttributeCount); // The raw notifier values were overridden by the scoped re-projection. Assert.NotEqual(999, emitted.ObjectCount); Assert.NotEqual(888, emitted.AttributeCount); } private static GalaxyRepositoryGrpcService CreateService( GalaxyHierarchyCacheEntry entry, IGalaxyBrowseScopeProvider scope, IGalaxyDeployNotifier? notifier = null) { // No test here calls TestConnection, so a fake repository (no real SQL) is enough // and removes any latent localhost-connection risk. return new GalaxyRepositoryGrpcService( new FakeGalaxyRepository( Array.Empty(), Array.Empty(), deployTime: null), new StubGalaxyHierarchyCache(entry), notifier ?? 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; } /// Records every the service streams. private sealed class CapturingStreamWriter : IServerStreamWriter { public List Written { get; } = []; public WriteOptions? WriteOptions { get; set; } public Task WriteAsync(DeployEvent message) { Written.Add(message); return 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(); } }