From 719a57f444bd02d70455140a6ee540bd0227aa46 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 12:12:03 -0400 Subject: [PATCH] test(gateway): reconcile Galaxy tests to the shared library (delete upstream-owned, rebind host-specific) --- .../Galaxy/GalaxyRepositoryLiveTests.cs | 12 +- .../LiveGalaxyRepositoryFactAttribute.cs | 2 +- .../Alarms/AlarmWatchListResolverTests.cs | 2 +- .../Dashboard/DashboardBrowseServiceTests.cs | 9 +- .../Galaxy/GalaxyBrowseProjectorTests.cs | 382 ------------- .../Galaxy/GalaxyDeployNotifierTests.cs | 94 --- .../Galaxy/GalaxyFilterInputSafetyTests.cs | 59 +- .../Galaxy/GalaxyHierarchyCacheTests.cs | 535 ------------------ .../Galaxy/GalaxyHierarchyIndexTests.cs | 166 ------ .../Galaxy/GalaxyHierarchyProjectorTests.cs | 140 ----- .../GalaxyHierarchyRefreshServiceTests.cs | 88 --- .../GalaxyHierarchySnapshotStoreTests.cs | 184 ------ .../Galaxy/GalaxyProtoMapperTests.cs | 114 ---- .../DashboardBrowseAndAlarmModelTests.cs | 2 +- .../DashboardSnapshotServiceTests.cs | 55 +- .../Grpc/GalaxyRepositoryGrpcServiceTests.cs | 486 ---------------- .../Sessions/ArrayAddressNormalizerTests.cs | 4 +- .../Sessions/GatewayArrayWriteWiringTests.cs | 4 +- .../Authorization/ConstraintEnforcerTests.cs | 6 +- .../GatewayBrowseScopeProviderTests.cs | 61 ++ .../GatewayGrpcScopeResolverTests.cs | 2 +- 21 files changed, 143 insertions(+), 2264 deletions(-) delete mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyProjectorTests.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyRefreshServiceTests.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayBrowseScopeProviderTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs index 9ffc4bd..83cc963 100644 --- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs @@ -1,4 +1,4 @@ -using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.GalaxyRepository; namespace ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy; @@ -10,7 +10,7 @@ public sealed class GalaxyRepositoryLiveTests [LiveGalaxyRepositoryFact] public async Task TestConnection_AgainstZb_Succeeds() { - GalaxyRepository repository = CreateRepository(); + ZB.MOM.WW.GalaxyRepository.GalaxyRepository repository = CreateRepository(); bool ok = await repository.TestConnectionAsync(CancellationToken.None); @@ -21,7 +21,7 @@ public sealed class GalaxyRepositoryLiveTests [LiveGalaxyRepositoryFact] public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp() { - GalaxyRepository repository = CreateRepository(); + ZB.MOM.WW.GalaxyRepository.GalaxyRepository repository = CreateRepository(); DateTime? lastDeploy = await repository.GetLastDeployTimeAsync(CancellationToken.None); @@ -32,7 +32,7 @@ public sealed class GalaxyRepositoryLiveTests [LiveGalaxyRepositoryFact] public async Task GetHierarchy_AgainstZb_ReturnsObjects() { - GalaxyRepository repository = CreateRepository(); + ZB.MOM.WW.GalaxyRepository.GalaxyRepository repository = CreateRepository(); List rows = await repository.GetHierarchyAsync(CancellationToken.None); @@ -49,7 +49,7 @@ public sealed class GalaxyRepositoryLiveTests [LiveGalaxyRepositoryFact] public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute() { - GalaxyRepository repository = CreateRepository(); + ZB.MOM.WW.GalaxyRepository.GalaxyRepository repository = CreateRepository(); List rows = await repository.GetAttributesAsync(CancellationToken.None); @@ -62,7 +62,7 @@ public sealed class GalaxyRepositoryLiveTests }); } - private static GalaxyRepository CreateRepository() => new(new GalaxyRepositoryOptions + private static ZB.MOM.WW.GalaxyRepository.GalaxyRepository CreateRepository() => new(new GalaxyRepositoryOptions { ConnectionString = LiveGalaxyRepositoryFactAttribute.ConnectionString, CommandTimeoutSeconds = 30, diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs index 3d2f5f4..08c2e3d 100644 --- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs @@ -1,4 +1,4 @@ -using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.GalaxyRepository; namespace ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs index d25dc53..d18edf0 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.GalaxyRepository; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Configuration; -using ZB.MOM.WW.MxGateway.Server.Galaxy; namespace ZB.MOM.WW.MxGateway.Tests.Alarms; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs index d749e08..7279215 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs @@ -1,6 +1,6 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Server.Dashboard; -using ZB.MOM.WW.MxGateway.Server.Galaxy; namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; @@ -133,11 +133,6 @@ public sealed class DashboardBrowseServiceTests LastSuccessAt = DateTimeOffset.UtcNow, Objects = objects, Index = GalaxyHierarchyIndex.Build(objects), - DashboardSummary = DashboardGalaxySummary.Unknown with - { - Status = DashboardGalaxyStatus.Healthy, - ObjectCount = objects.Count, - }, ObjectCount = objects.Count, }; } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs deleted file mode 100644 index 50a7d62..0000000 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs +++ /dev/null @@ -1,382 +0,0 @@ -using Grpc.Core; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Dashboard; -using ZB.MOM.WW.MxGateway.Server.Galaxy; - -namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; - -/// -/// Direct coverage for . Validates parent -/// resolution (gobject id / tag name / contained path), paging across siblings, -/// filter parity with , the -/// child_has_children hint, browse-subtree constraints, and the -/// attribute-skeleton mode. -/// -public sealed class GalaxyBrowseProjectorTests -{ - /// Verifies that an empty parent oneof returns the root area. - [Fact] - public void Project_NoParent_ReturnsRootArea() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(); - - GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest(), - browseSubtreeGlobs: null, - offset: 0, - pageSize: 10); - - Assert.Single(result.Children); - Assert.Equal("Plant", result.Children[0].TagName); - Assert.True(result.ChildHasChildren[0]); - } - - /// Verifies that resolving the parent by gobject id returns sorted direct children. - [Fact] - public void Project_ByParentGobjectId_ReturnsDirectChildren() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(); - - GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest { ParentGobjectId = 1 }, - browseSubtreeGlobs: null, - offset: 0, - pageSize: 10); - - string[] names = result.Children.Select(child => child.TagName).ToArray(); - Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names); - Assert.Equal(new[] { true, false, false, false }, result.ChildHasChildren.ToArray()); - Assert.Equal(4, result.TotalChildCount); - } - - /// Verifies that resolving the parent by tag name returns the same direct children. - [Fact] - public void Project_ByParentTagName_ResolvesParent() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(); - - GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest { ParentTagName = "Plant" }, - browseSubtreeGlobs: null, - offset: 0, - pageSize: 10); - - string[] names = result.Children.Select(child => child.TagName).ToArray(); - Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names); - } - - /// Verifies that resolving the parent by contained path returns the same direct children. - [Fact] - public void Project_ByParentContainedPath_ResolvesParent() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(); - - GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest { ParentContainedPath = "Plant" }, - browseSubtreeGlobs: null, - offset: 0, - pageSize: 10); - - string[] names = result.Children.Select(child => child.TagName).ToArray(); - Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names); - } - - /// Verifies that an unknown parent gobject id throws an RpcException with StatusCode.NotFound. - [Fact] - public void Project_UnknownParent_ThrowsNotFound() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(); - - RpcException exception = Assert.Throws(() => GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest { ParentGobjectId = 999 }, - browseSubtreeGlobs: null, - offset: 0, - pageSize: 10)); - - Assert.Equal(StatusCode.NotFound, exception.StatusCode); - } - - /// Verifies that paging across siblings returns every sibling exactly once. - [Fact] - public void Project_PagedAcrossSiblings_ReturnsEverySiblingOnce() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(); - - GalaxyBrowseChildrenResult first = GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest { ParentGobjectId = 1 }, - browseSubtreeGlobs: null, - offset: 0, - pageSize: 2); - GalaxyBrowseChildrenResult second = GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest { ParentGobjectId = 1 }, - browseSubtreeGlobs: null, - offset: 2, - pageSize: 2); - - List collected = first.Children - .Concat(second.Children) - .Select(child => child.TagName) - .ToList(); - Assert.Equal(4, collected.Count); - Assert.Equal(collected.Count, collected.Distinct(StringComparer.Ordinal).Count()); - Assert.Equal( - new HashSet(StringComparer.Ordinal) - { - "Plant.Line_A", - "Plant.Mixer_001", - "Plant.Mixer_002", - "Plant.Pump_001", - }, - new HashSet(collected, StringComparer.Ordinal)); - } - - /// Verifies that a tag-name glob filters direct children and clears the has-children hint. - [Fact] - public void Project_TagNameGlobFiltersChildren_AndUpdatesHasChildren() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(); - - GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest - { - ParentGobjectId = 1, - TagNameGlob = "*Mixer*", - }, - browseSubtreeGlobs: null, - offset: 0, - pageSize: 10); - - string[] names = result.Children.Select(child => child.TagName).ToArray(); - Assert.Equal(new[] { "Plant.Mixer_001", "Plant.Mixer_002" }, names); - Assert.Equal(new[] { false, false }, result.ChildHasChildren.ToArray()); - } - - /// Verifies that historized-only filtering also drives the has-children hint via descendants. - [Fact] - public void Project_HistorizedOnlyFiltersDescendants_HasChildrenReflectsFilter() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(); - - GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest - { - ParentGobjectId = 1, - HistorizedOnly = true, - }, - browseSubtreeGlobs: null, - offset: 0, - pageSize: 10); - - // Line_A itself has no historized attributes, but its descendant Sensor_A1 does, - // so the subtree match keeps Line_A in the result with has-children = true. - // Mixer_001/Mixer_002/Pump_001 have no historized attributes themselves and - // no historized descendants -> filtered out entirely. - Assert.Single(result.Children); - Assert.Equal("Plant.Line_A", result.Children[0].TagName); - Assert.Equal(new[] { true }, result.ChildHasChildren.ToArray()); - } - - /// Verifies that IncludeAttributes=false returns object skeletons. - [Fact] - public void Project_IncludeAttributesFalse_ReturnsSkeletons() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(); - - GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest - { - ParentGobjectId = 1, - IncludeAttributes = false, - }, - browseSubtreeGlobs: null, - offset: 0, - pageSize: 10); - - GalaxyObject mixer = result.Children.Single(child => child.TagName == "Plant.Mixer_001"); - Assert.Empty(mixer.Attributes); - } - - /// Verifies that browse-subtree globs constrain the returned children. - [Fact] - public void Project_BrowseSubtrees_ExcludesChildrenOutsideAllowedGlobs() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(); - - GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest { ParentGobjectId = 1 }, - browseSubtreeGlobs: new[] { "Plant/Line_*" }, - offset: 0, - pageSize: 10); - - Assert.Single(result.Children); - Assert.Equal("Plant.Line_A", result.Children[0].TagName); - } - - /// - /// Verifies terminates when the Galaxy data - /// contains a cyclic parent chain (A→B→C→A). Without the visited-id guard in - /// HasMatchingDescendant, the depth-first walk would loop forever; the - /// 5-second xUnit timeout asserts termination. - /// - [Fact(Timeout = 5000)] - public async Task Project_CyclicDescendants_DoesNotInfiniteLoop() - { - await Task.Yield(); - // Construct a 3-node cycle: A(10)→B(11)→C(12)→A. Each node's ParentGobjectId - // points to the next, so GalaxyHierarchyIndex.ChildrenByParent has - // [12] = [A], [10] = [B], [11] = [C]. - // None of them are historized, so HistorizedOnly=true forces the projector to - // call HasMatchingDescendant on every direct child, exercising the cycle walk. - GalaxyObject a = new() - { - GobjectId = 10, - ParentGobjectId = 12, - ContainedName = "A", - BrowseName = "A", - TagName = "A", - }; - GalaxyObject b = new() - { - GobjectId = 11, - ParentGobjectId = 10, - ContainedName = "B", - BrowseName = "B", - TagName = "B", - }; - GalaxyObject c = new() - { - GobjectId = 12, - ParentGobjectId = 11, - ContainedName = "C", - BrowseName = "C", - TagName = "C", - }; - - IReadOnlyList objects = new[] { a, b, c }; - GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with - { - Status = GalaxyCacheStatus.Healthy, - Sequence = 1, - LastSuccessAt = DateTimeOffset.UtcNow, - Objects = objects, - Index = GalaxyHierarchyIndex.Build(objects), - DashboardSummary = DashboardGalaxySummary.Unknown with - { - Status = DashboardGalaxyStatus.Healthy, - ObjectCount = objects.Count, - }, - ObjectCount = objects.Count, - }; - - // Browse children of A (id=10). Its direct child B fails HistorizedOnly, so the - // projector falls back to HasMatchingDescendant(B), which walks B→C→A→B… - // without the visited-id guard. With the guard, the walk terminates and returns - // an empty page (no historized descendants exist anywhere in the cycle). - GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( - entry, - new BrowseChildrenRequest { ParentGobjectId = 10, HistorizedOnly = true }, - browseSubtreeGlobs: null, - offset: 0, - pageSize: 10); - - Assert.Empty(result.Children); - Assert.Equal(0, result.TotalChildCount); - } - - private static GalaxyHierarchyCacheEntry CreateEntry() - { - IReadOnlyList objects = CreateObjects(); - return GalaxyHierarchyCacheEntry.Empty with - { - Status = GalaxyCacheStatus.Healthy, - Sequence = 1, - LastSuccessAt = DateTimeOffset.UtcNow, - Objects = objects, - Index = GalaxyHierarchyIndex.Build(objects), - DashboardSummary = DashboardGalaxySummary.Unknown with - { - Status = DashboardGalaxyStatus.Healthy, - ObjectCount = objects.Count, - }, - ObjectCount = objects.Count, - }; - } - - private static IReadOnlyList CreateObjects() - { - GalaxyObject plant = new() - { - GobjectId = 1, - ParentGobjectId = 0, - IsArea = true, - ContainedName = "Plant", - BrowseName = "Plant", - TagName = "Plant", - }; - GalaxyObject mixer001 = new() - { - GobjectId = 2, - ParentGobjectId = 1, - ContainedName = "Mixer_001", - BrowseName = "Mixer_001", - TagName = "Plant.Mixer_001", - }; - mixer001.Attributes.Add(new GalaxyAttribute - { - AttributeName = "Speed", - FullTagReference = "Plant.Mixer_001.Speed", - }); - GalaxyObject mixer002 = new() - { - GobjectId = 3, - ParentGobjectId = 1, - ContainedName = "Mixer_002", - BrowseName = "Mixer_002", - TagName = "Plant.Mixer_002", - }; - GalaxyObject lineA = new() - { - GobjectId = 4, - ParentGobjectId = 1, - IsArea = true, - ContainedName = "Line_A", - BrowseName = "Line_A", - TagName = "Plant.Line_A", - }; - GalaxyObject sensorA1 = new() - { - GobjectId = 5, - ParentGobjectId = 4, - ContainedName = "Sensor_A1", - BrowseName = "Sensor_A1", - TagName = "Plant.Line_A.Sensor_A1", - }; - sensorA1.Attributes.Add(new GalaxyAttribute - { - AttributeName = "Value", - FullTagReference = "Plant.Line_A.Sensor_A1.Value", - IsHistorized = true, - }); - GalaxyObject pump001 = new() - { - GobjectId = 6, - ParentGobjectId = 1, - ContainedName = "Pump_001", - BrowseName = "Pump_001", - TagName = "Plant.Pump_001", - }; - - return new[] { plant, mixer001, mixer002, lineA, sensorA1, pump001 }; - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs deleted file mode 100644 index 3324efa..0000000 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -using ZB.MOM.WW.MxGateway.Server.Galaxy; - -namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; - -public sealed class GalaxyDeployNotifierTests -{ - /// Verifies that a subscriber blocks until a deploy event is published. - [Fact] - public async Task SubscribeAsync_NoLatestEvent_BlocksUntilPublish() - { - GalaxyDeployNotifier notifier = new(); - using CancellationTokenSource cts = new(); - - IAsyncEnumerator enumerator = notifier - .SubscribeAsync(cts.Token) - .GetAsyncEnumerator(cts.Token); - - ValueTask moveNext = enumerator.MoveNextAsync(); - Assert.False(moveNext.IsCompleted); - - GalaxyDeployEventInfo published = new( - Sequence: 1, - ObservedAt: DateTimeOffset.UtcNow, - TimeOfLastDeploy: DateTimeOffset.UtcNow, - ObjectCount: 5, - AttributeCount: 25); - notifier.Publish(published); - - Assert.True(await moveNext.AsTask().WaitAsync(TimeSpan.FromSeconds(1))); - Assert.Same(published, enumerator.Current); - - await cts.CancelAsync(); - await enumerator.DisposeAsync(); - } - - /// Verifies that a subscriber immediately receives a cached latest deploy event. - [Fact] - public async Task SubscribeAsync_WithLatestEvent_BootstrapsImmediately() - { - GalaxyDeployNotifier notifier = new(); - GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 3, 9); - notifier.Publish(first); - - using CancellationTokenSource cts = new(); - await using IAsyncEnumerator enumerator = notifier - .SubscribeAsync(cts.Token) - .GetAsyncEnumerator(cts.Token); - - Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))); - Assert.Same(first, enumerator.Current); - - await cts.CancelAsync(); - } - - /// Verifies that published events fan out to all active subscribers. - [Fact] - public async Task Publish_FansOutToAllSubscribers() - { - GalaxyDeployNotifier notifier = new(); - using CancellationTokenSource cts = new(); - - await using IAsyncEnumerator a = notifier - .SubscribeAsync(cts.Token) - .GetAsyncEnumerator(cts.Token); - await using IAsyncEnumerator b = notifier - .SubscribeAsync(cts.Token) - .GetAsyncEnumerator(cts.Token); - - GalaxyDeployEventInfo info = new(1, DateTimeOffset.UtcNow, null, 0, 0); - notifier.Publish(info); - - Assert.True(await a.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))); - Assert.True(await b.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))); - Assert.Same(info, a.Current); - Assert.Same(info, b.Current); - - await cts.CancelAsync(); - } - - /// Verifies that the Latest property tracks the most recently published event. - [Fact] - public void Latest_TracksMostRecentPublish() - { - GalaxyDeployNotifier notifier = new(); - Assert.Null(notifier.Latest); - - GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, null, 0, 0); - GalaxyDeployEventInfo second = new(2, DateTimeOffset.UtcNow, null, 0, 0); - notifier.Publish(first); - notifier.Publish(second); - - Assert.Same(second, notifier.Latest); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyFilterInputSafetyTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyFilterInputSafetyTests.cs index f434fd6..1939e30 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyFilterInputSafetyTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyFilterInputSafetyTests.cs @@ -1,10 +1,7 @@ using System.Diagnostics; using Grpc.Core; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Dashboard; -using ZB.MOM.WW.MxGateway.Server.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Grpc; -using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Tests.TestSupport; namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; @@ -112,26 +109,32 @@ public sealed class GalaxyFilterInputSafetyTests } /// - /// Regression guard for finding Server-018: 's - /// internal compiled-regex cache must stay bounded so a client cannot grow it - /// without limit by submitting unique TagNameGlob values over the - /// process lifetime. Feeding the matcher far more distinct globs than the cap - /// must leave CurrentCacheSize at or below RegexCacheCapacity. + /// Adversarial coverage for many distinct client-supplied globs: submitting a + /// large number of unique TagNameGlob values (which the shared library's + /// caches internally) must keep returning the + /// correct literal-vs-wildcard result for every glob and never throw — a client + /// cannot poison the matcher by flooding it with distinct patterns. + /// + /// The matcher's internal compiled-regex cache and its bound are now owned by + /// the ZB.MOM.WW.GalaxyRepository library and covered by its own test + /// suite, so this host test asserts on observable behaviour rather than the + /// (no longer exposed) cache-size counters. + /// /// [Fact] - public void GlobMatcher_WithManyDistinctPatterns_CacheStaysBounded() + public void GlobMatcher_WithManyDistinctPatterns_StayCorrectAndDoNotThrow() { - // Submit well past the cap from a single thread to exercise the eviction path - // deterministically. The cap is internal; assert on it directly so the test - // tracks the source of truth. - int submissions = GalaxyGlobMatcher.RegexCacheCapacity * 4; + const int submissions = 2048; for (int i = 0; i < submissions; i++) { string uniqueGlob = $"client_supplied_{i}_*"; - GalaxyGlobMatcher.IsMatch($"client_supplied_{i}_thing", uniqueGlob); + Assert.True( + GalaxyGlobMatcher.IsMatch($"client_supplied_{i}_thing", uniqueGlob), + $"Each distinct glob must still match its own value: {uniqueGlob}"); + Assert.False( + GalaxyGlobMatcher.IsMatch("UnrelatedTagName", uniqueGlob), + $"A distinct glob must not wildcard-match unrelated text: {uniqueGlob}"); } - - Assert.InRange(GalaxyGlobMatcher.CurrentCacheSize, 0, GalaxyGlobMatcher.RegexCacheCapacity); } /// @@ -169,7 +172,8 @@ public sealed class GalaxyFilterInputSafetyTests GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project( entry, - new DiscoverHierarchyRequest { TagNameGlob = glob }); + new DiscoverHierarchyRequest { TagNameGlob = glob }, + browseSubtreeGlobs: Array.Empty()); // None of the seeded tag names equal an adversarial string, so a correctly // literal filter returns zero matches rather than the whole hierarchy. @@ -192,7 +196,8 @@ public sealed class GalaxyFilterInputSafetyTests RpcException exception = Assert.Throws( () => GalaxyHierarchyProjector.Project( entry, - new DiscoverHierarchyRequest { RootTagName = rootTagName })); + new DiscoverHierarchyRequest { RootTagName = rootTagName }, + browseSubtreeGlobs: Array.Empty())); Assert.Equal(StatusCode.NotFound, exception.StatusCode); } @@ -210,7 +215,10 @@ public sealed class GalaxyFilterInputSafetyTests DiscoverHierarchyRequest request = new(); request.TemplateChainContains.Add(filter); - GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request); + GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project( + entry, + request, + browseSubtreeGlobs: Array.Empty()); Assert.Equal(0, result.TotalObjectCount); } @@ -262,10 +270,10 @@ public sealed class GalaxyFilterInputSafetyTests ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;", }; return new GalaxyRepositoryGrpcService( - new ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options), + new ZB.MOM.WW.GalaxyRepository.GalaxyRepository(options), new StubGalaxyHierarchyCache(entry), new GalaxyDeployNotifier(), - new GatewayRequestIdentityAccessor()); + new NullGalaxyBrowseScopeProvider()); } private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList objects) @@ -277,11 +285,6 @@ public sealed class GalaxyFilterInputSafetyTests LastSuccessAt = DateTimeOffset.UtcNow, Objects = objects, Index = GalaxyHierarchyIndex.Build(objects), - DashboardSummary = DashboardGalaxySummary.Unknown with - { - Status = DashboardGalaxyStatus.Healthy, - ObjectCount = objects.Count, - }, ObjectCount = objects.Count, }; } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs deleted file mode 100644 index f7718b3..0000000 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs +++ /dev/null @@ -1,535 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using ZB.MOM.WW.MxGateway.Server.Galaxy; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Tests.TestSupport; - -namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; - -public sealed class GalaxyHierarchyCacheTests : IDisposable -{ - private readonly List _tempPaths = []; - - /// - /// Verifies cache returns empty entry before any refresh occurs. - /// - [Fact] - public void Current_BeforeAnyRefresh_ReturnsEmpty() - { - GalaxyDeployNotifier notifier = new(); - ThrowingGalaxyRepository repository = new(new InvalidOperationException("not invoked")); - GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider()); - - GalaxyHierarchyCacheEntry entry = cache.Current; - - Assert.Equal(GalaxyCacheStatus.Unknown, entry.Status); - Assert.False(entry.HasData); - Assert.Equal(0, entry.ObjectCount); - Assert.Empty(entry.Objects); - } - - /// - /// Verifies cache marks unavailable and does not publish when the repository - /// surface throws — the production trigger for this code path is a SQL - /// connection failure, but it is fully covered by the cache's exception - /// branch and does not require a real TCP probe from a unit test. - /// - [Fact] - public async Task RefreshAsync_WhenRepositoryThrows_MarksUnavailableAndDoesNotPublish() - { - GalaxyDeployNotifier notifier = new(); - ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); - ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); - GalaxyHierarchyCache cache = new(repository, notifier, clock); - - await cache.RefreshAsync(CancellationToken.None); - - Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); - Assert.Equal("Galaxy repository unreachable", cache.Current.LastError); - Assert.Null(notifier.Latest); - Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully); - Assert.Equal(1, repository.GetLastDeployTimeCount); - Assert.Equal(0, repository.GetHierarchyCount); - Assert.Equal(0, repository.GetAttributesCount); - } - - /// - /// Verifies HasData returns true for healthy cache entries. - /// - [Fact] - public void HasData_OnHealthyEntry_IsTrue() - { - GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with - { - Status = GalaxyCacheStatus.Healthy, - LastSuccessAt = DateTimeOffset.UtcNow, - ObjectCount = 1, - }; - - Assert.True(entry.HasData); - } - - /// - /// Verifies HasData returns false for unknown cache entries. - /// - [Fact] - public void HasData_OnUnknownEntry_IsFalse() - { - Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData); - } - - /// Verifies that the hierarchy index builds paths and lookups without throwing on bad metadata. - [Fact] - public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata() - { - GalaxyObject root = new() - { - GobjectId = 1, - TagName = "Area1", - ContainedName = "Area1", - }; - GalaxyObject duplicate = new() - { - GobjectId = 1, - TagName = "DuplicateArea", - ContainedName = "DuplicateArea", - }; - GalaxyObject child = new() - { - GobjectId = 2, - ParentGobjectId = 1, - TagName = "Pump_001", - ContainedName = "Pump", - Attributes = - { - new GalaxyAttribute - { - FullTagReference = "Pump_001.PV", - IsHistorized = true, - }, - }, - }; - GalaxyObject orphan = new() - { - GobjectId = 3, - ParentGobjectId = 99, - TagName = "Orphan_001", - ContainedName = "Orphan", - }; - - GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, duplicate, child, orphan]); - - Assert.Equal("Area1/Pump", index.ObjectViewsById[2].ContainedPath); - Assert.Equal("Orphan", index.ObjectViewsById[3].ContainedPath); - Assert.Same(child, index.TagsByAddress["Pump_001.PV"].Object); - Assert.NotNull(index.TagsByAddress["Pump_001.PV"].Attribute); - Assert.Same(root, index.ObjectViewsById[1].Object); - } - - /// - /// Verifies a successful refresh writes the browse dataset to the on-disk - /// snapshot store so a later cold start can restore it. - /// - [Fact] - public async Task RefreshAsync_WhenSuccessful_PersistsSnapshotToDisk() - { - GalaxyDeployNotifier notifier = new(); - StubGalaxyRepository repository = new( - deployTime: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc), - hierarchy: [SampleHierarchyRow()], - attributes: [SampleAttributeRow()]); - GalaxyHierarchySnapshotStore store = CreateStore(); - GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); - - await cache.RefreshAsync(CancellationToken.None); - - Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status); - GalaxyHierarchySnapshot? persisted = await store.TryLoadAsync(CancellationToken.None); - Assert.NotNull(persisted); - Assert.Equal(99, Assert.Single(persisted.Hierarchy).GobjectId); - Assert.Equal("PV", Assert.Single(persisted.Attributes).AttributeName); - } - - /// - /// Verifies that when the Galaxy database is unreachable on first refresh but a - /// snapshot exists on disk, the cache serves that data with Stale status - /// rather than coming up empty. - /// - [Fact] - public async Task RefreshAsync_WhenDatabaseUnreachableButSnapshotOnDisk_RestoresStaleData() - { - GalaxyHierarchySnapshotStore store = CreateStore(); - await store.SaveAsync( - new GalaxyHierarchySnapshot( - LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 0, 0, TimeSpan.Zero), - SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero), - Hierarchy: [SampleHierarchyRow()], - Attributes: [SampleAttributeRow()]), - CancellationToken.None); - - GalaxyDeployNotifier notifier = new(); - ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); - GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); - - await cache.RefreshAsync(CancellationToken.None); - - Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status); - Assert.True(cache.Current.HasData); - Assert.Equal(1, cache.Current.ObjectCount); - Assert.Equal(1, cache.Current.AttributeCount); - Assert.NotNull(notifier.Latest); - } - - /// - /// Verifies that when the disk snapshot's deploy time still matches the live - /// Galaxy database, the cache promotes the restored data to Healthy - /// without re-running the heavy hierarchy and attribute queries. - /// - [Fact] - public async Task RefreshAsync_WhenSnapshotDeployMatchesLive_PromotesToHealthyWithoutHeavyQuery() - { - DateTime deployTime = new(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc); - GalaxyHierarchySnapshotStore store = CreateStore(); - await store.SaveAsync( - new GalaxyHierarchySnapshot( - LastDeployTime: new DateTimeOffset(deployTime, TimeSpan.Zero), - SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero), - Hierarchy: [SampleHierarchyRow()], - Attributes: [SampleAttributeRow()]), - CancellationToken.None); - - GalaxyDeployNotifier notifier = new(); - StubGalaxyRepository repository = new(deployTime); - GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); - - await cache.RefreshAsync(CancellationToken.None); - - Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status); - Assert.Equal(1, cache.Current.ObjectCount); - Assert.Equal(0, repository.GetHierarchyCount); - Assert.Equal(0, repository.GetAttributesCount); - } - - /// - /// Verifies that a restored on-disk snapshot completes the first-load gate - /// immediately, so a browse call racing the first refresh is not blocked for - /// the full bootstrap budget while the live Galaxy query is still running. - /// Regression test for Server-033. - /// - [Fact] - public async Task RefreshAsync_RestoredSnapshotCompletesFirstLoadBeforeLiveQueryReturns() - { - GalaxyHierarchySnapshotStore store = CreateStore(); - await store.SaveAsync( - new GalaxyHierarchySnapshot( - LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 0, 0, TimeSpan.Zero), - SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero), - Hierarchy: [SampleHierarchyRow()], - Attributes: [SampleAttributeRow()]), - CancellationToken.None); - - GalaxyDeployNotifier notifier = new(); - BlockingGalaxyRepository repository = new(); - GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); - - Task refresh = cache.RefreshAsync(CancellationToken.None); - - // The live query is blocked inside the repository; first-load must still - // complete — from the restored snapshot — well within the wait budget. - await cache.WaitForFirstLoadAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); - Assert.True(cache.Current.HasData); - Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status); - - repository.Release(); - await refresh.WaitAsync(TimeSpan.FromSeconds(5)); - } - - /// - /// Verifies a corrupt on-disk snapshot does not crash startup: the cache - /// ignores the unreadable file and comes up Unavailable when the database is - /// also unreachable. Regression test for Server-037. - /// - [Fact] - public async Task RefreshAsync_WhenSnapshotFileCorrupt_ComesUpUnavailableWithoutThrowing() - { - string path = CreateTempPath(); - await File.WriteAllTextAsync(path, "{ this is not valid json"); - GalaxyHierarchySnapshotStore store = CreateStore(path); - - GalaxyDeployNotifier notifier = new(); - ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); - GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); - - await cache.RefreshAsync(CancellationToken.None); - - Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); - Assert.False(cache.Current.HasData); - } - - /// - /// Verifies that with snapshot persistence disabled the cache does not - /// restore from disk — an unreachable database leaves it Unavailable. - /// Regression test for Server-037. - /// - [Fact] - public async Task RefreshAsync_WhenPersistDisabled_DoesNotRestoreFromDisk() - { - GalaxyHierarchySnapshotStore store = CreateStore(CreateTempPath(), persist: false); - - GalaxyDeployNotifier notifier = new(); - ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); - GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); - - await cache.RefreshAsync(CancellationToken.None); - - Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); - Assert.False(cache.Current.HasData); - } - - /// - /// Verifies that a snapshot save aborted because the gateway is shutting down - /// (the refresh token is cancelled) is not logged as a persistence failure. - /// Regression test for Server-036. - /// - [Fact] - public async Task RefreshAsync_WhenSnapshotSaveCancelledAtShutdown_DoesNotLogPersistFailure() - { - using CancellationTokenSource cts = new(); - GalaxyDeployNotifier notifier = new(); - StubGalaxyRepository repository = new( - deployTime: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc), - hierarchy: [SampleHierarchyRow()], - attributes: [SampleAttributeRow()]); - CancellingSaveStore store = new(cts); - RecordingLogger logger = new(); - GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), logger, store); - - await cache.RefreshAsync(cts.Token); - - Assert.DoesNotContain( - logger.Entries, - entry => entry.Level == LogLevel.Warning - && entry.Message.Contains("persist", StringComparison.OrdinalIgnoreCase)); - } - - private static GalaxyHierarchyRow SampleHierarchyRow() => new() - { - GobjectId = 99, - TagName = "Pump_001", - ContainedName = "Pump", - BrowseName = "Pump", - CategoryId = 10, - TemplateChain = ["AppPump"], - }; - - private static GalaxyAttributeRow SampleAttributeRow() => new() - { - GobjectId = 99, - TagName = "Pump_001", - AttributeName = "PV", - FullTagReference = "Pump_001.PV", - MxDataType = 5, - DataTypeName = "Float", - }; - - private string CreateTempPath() - { - string path = Path.Combine( - Path.GetTempPath(), - $"mxgw-galaxy-cache-test-{Guid.NewGuid():N}.json"); - _tempPaths.Add(path); - return path; - } - - private GalaxyHierarchySnapshotStore CreateStore() => CreateStore(CreateTempPath()); - - private static GalaxyHierarchySnapshotStore CreateStore(string path, bool persist = true) - { - GalaxyRepositoryOptions options = new() - { - PersistSnapshot = persist, - SnapshotCachePath = path, - }; - return new GalaxyHierarchySnapshotStore(Options.Create(options)); - } - - /// whose deploy-time query blocks until released. - private sealed class BlockingGalaxyRepository : IGalaxyRepository - { - private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Releases the blocking task. - public void Release() => _release.TrySetResult(); - - /// - public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false); - - /// - public async Task GetLastDeployTimeAsync(CancellationToken ct = default) - { - await _release.Task.WaitAsync(ct).ConfigureAwait(false); - throw new InvalidOperationException("Galaxy repository unreachable"); - } - - /// - public Task> GetHierarchyAsync(CancellationToken ct = default) - => throw new InvalidOperationException("GetHierarchyAsync should not be reached"); - - /// - public Task> GetAttributesAsync(CancellationToken ct = default) - => throw new InvalidOperationException("GetAttributesAsync should not be reached"); - - /// - public Task> GetAlarmAttributesAsync(CancellationToken ct = default) - => throw new InvalidOperationException("GetAlarmAttributesAsync should not be reached"); - } - - /// Snapshot store whose cancels the token mid-save. - private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore - { - /// - public Task TryLoadAsync(CancellationToken cancellationToken) - => Task.FromResult(null); - - /// - public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken) - { - cts.Cancel(); - cancellationToken.ThrowIfCancellationRequested(); - return Task.CompletedTask; - } - } - - /// Minimal that records every emitted log entry. - private sealed class RecordingLogger : ILogger - { - /// Gets the list of recorded log entries. - public List<(LogLevel Level, string Message)> Entries { get; } = []; - - /// - public IDisposable BeginScope(TState state) - where TState : notnull => NullScope.Instance; - - /// - public bool IsEnabled(LogLevel logLevel) => true; - - /// - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - Entries.Add((logLevel, formatter(state, exception))); - } - - private sealed class NullScope : IDisposable - { - public static readonly NullScope Instance = new(); - - /// - public void Dispose() - { - } - } - } - - /// In-memory that returns fixed rowsets. - private sealed class StubGalaxyRepository( - DateTime? deployTime, - List? hierarchy = null, - List? attributes = null) : IGalaxyRepository - { - private readonly List _hierarchy = hierarchy ?? []; - private readonly List _attributes = attributes ?? []; - - /// Gets the count of calls to . - public int GetHierarchyCount { get; private set; } - - /// Gets the count of calls to . - public int GetAttributesCount { get; private set; } - - /// - public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); - - /// - public Task GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime); - - /// - public Task> GetHierarchyAsync(CancellationToken ct = default) - { - GetHierarchyCount++; - return Task.FromResult(_hierarchy); - } - - /// - public Task> GetAttributesAsync(CancellationToken ct = default) - { - GetAttributesCount++; - return Task.FromResult(_attributes); - } - - /// - public Task> GetAlarmAttributesAsync(CancellationToken ct = default) - => Task.FromResult(new List()); - } - - /// - public void Dispose() - { - foreach (string path in _tempPaths) - { - try - { - File.Delete(path); - File.Delete(path + ".tmp"); - } - catch (IOException) - { - // Best-effort cleanup of test scratch files. - } - } - } - - private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository - { - /// Gets the number of times was called. - public int GetLastDeployTimeCount { get; private set; } - - /// Gets the number of times was called. - public int GetHierarchyCount { get; private set; } - - /// Gets the number of times was called. - public int GetAttributesCount { get; private set; } - - /// - public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false); - - /// - public Task GetLastDeployTimeAsync(CancellationToken ct = default) - { - GetLastDeployTimeCount++; - throw toThrow; - } - - /// - public Task> GetHierarchyAsync(CancellationToken ct = default) - { - GetHierarchyCount++; - throw toThrow; - } - - /// - public Task> GetAttributesAsync(CancellationToken ct = default) - { - GetAttributesCount++; - throw toThrow; - } - - /// - public Task> GetAlarmAttributesAsync(CancellationToken ct = default) - => throw toThrow; - } - -} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs deleted file mode 100644 index 5da3dcc..0000000 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs +++ /dev/null @@ -1,166 +0,0 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Galaxy; - -namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; - -/// -/// Coverage for — the parent→children -/// index used by the lazy browse projector (Task 3). Verifies root grouping, nested -/// parent→child linkage, corrupt self-parented row handling, and the areas-first -/// ordering rule shared with DashboardBrowseTreeBuilder. -/// -public sealed class GalaxyHierarchyIndexTests -{ - /// Verifies roots (ParentGobjectId == 0) bucket under sentinel key 0. - [Fact] - public void ChildrenByParent_RootsUnderSentinelZero() - { - GalaxyObject root1 = new() { GobjectId = 1, ParentGobjectId = 0, ContainedName = "r1" }; - GalaxyObject root2 = new() { GobjectId = 2, ParentGobjectId = 0, ContainedName = "r2" }; - GalaxyObject root3 = new() { GobjectId = 3, ParentGobjectId = 0, ContainedName = "r3" }; - - GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root1, root2, root3]); - - Assert.True(index.ChildrenByParent.TryGetValue(0, out IReadOnlyList? roots)); - Assert.NotNull(roots); - Assert.Equal(3, roots!.Count); - Assert.Contains(roots, view => view.Object.GobjectId == 1); - Assert.Contains(roots, view => view.Object.GobjectId == 2); - Assert.Contains(roots, view => view.Object.GobjectId == 3); - } - - /// Verifies a nested A→B→C chain links each parent to its single child bucket. - [Fact] - public void ChildrenByParent_NestedHierarchy_LinksParentToChildren() - { - GalaxyObject areaA = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "A" }; - GalaxyObject objB = new() { GobjectId = 2, ParentGobjectId = 1, ContainedName = "B" }; - GalaxyObject objC = new() { GobjectId = 3, ParentGobjectId = 2, ContainedName = "C" }; - - GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([areaA, objB, objC]); - - Assert.True(index.ChildrenByParent.TryGetValue(0, out IReadOnlyList? underRoot)); - Assert.NotNull(underRoot); - Assert.Single(underRoot!); - Assert.Equal(1, underRoot![0].Object.GobjectId); - - Assert.True(index.ChildrenByParent.TryGetValue(1, out IReadOnlyList? underA)); - Assert.NotNull(underA); - Assert.Single(underA!); - Assert.Equal(2, underA![0].Object.GobjectId); - - Assert.True(index.ChildrenByParent.TryGetValue(2, out IReadOnlyList? underB)); - Assert.NotNull(underB); - Assert.Single(underB!); - Assert.Equal(3, underB![0].Object.GobjectId); - } - - /// Verifies a self-parented (corrupt) row appears under root, not under itself. - [Fact] - public void ChildrenByParent_SelfParentedObject_AppearsAsRoot() - { - GalaxyObject selfParented = new() { GobjectId = 5, ParentGobjectId = 5, ContainedName = "loop" }; - - GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([selfParented]); - - Assert.True(index.ChildrenByParent.TryGetValue(0, out IReadOnlyList? roots)); - Assert.NotNull(roots); - Assert.Single(roots!); - Assert.Equal(5, roots![0].Object.GobjectId); - - // The self-parented row must not appear as its own child — bucket either absent or empty. - if (index.ChildrenByParent.TryGetValue(5, out IReadOnlyList? underSelf)) - { - Assert.Empty(underSelf!); - } - } - - /// - /// Verifies an object whose parent is absent from the set (e.g. a deleted/undeployed - /// container area) is re-rooted under sentinel 0 rather than vanishing under a phantom - /// parent id that browse never reaches from the root. - /// - [Fact] - public void ChildrenByParent_OrphanWithMissingParent_AppearsAsRoot() - { - GalaxyObject realRoot = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "RealRoot" }; - GalaxyObject orphanArea = new() { GobjectId = 2, ParentGobjectId = 5008, IsArea = true, ContainedName = "Orphan" }; - GalaxyObject orphanChild = new() { GobjectId = 3, ParentGobjectId = 2, ContainedName = "Child" }; - - GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([realRoot, orphanArea, orphanChild]); - - // Both the real root and the orphan (its parent 5008 is absent) surface under root. - Assert.True(index.ChildrenByParent.TryGetValue(0, out IReadOnlyList? roots)); - Assert.NotNull(roots); - Assert.Contains(roots!, view => view.Object.GobjectId == 1); - Assert.Contains(roots!, view => view.Object.GobjectId == 2); - - // The orphan keeps its own deployed children nested beneath it. - Assert.True(index.ChildrenByParent.TryGetValue(2, out IReadOnlyList? underOrphan)); - Assert.Single(underOrphan!); - Assert.Equal(3, underOrphan![0].Object.GobjectId); - - // Nothing buckets under the phantom parent id. - Assert.False(index.ChildrenByParent.ContainsKey(5008)); - } - - /// Verifies is OrdinalIgnoreCase and supports O(1) lookups. - [Fact] - public void ObjectViewsByTagName_IsCaseInsensitive_AndLookupsAreO1() - { - GalaxyObject root = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "Plant", BrowseName = "Plant", TagName = "Plant" }; - GalaxyObject mixer = new() { GobjectId = 2, ParentGobjectId = 1, ContainedName = "Mixer_001", BrowseName = "Mixer_001", TagName = "Plant.Mixer_001" }; - - GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, mixer]); - - Assert.True(index.ObjectViewsByTagName.TryGetValue("Plant.Mixer_001", out GalaxyObjectView? exact)); - Assert.NotNull(exact); - Assert.Equal(2, exact!.Object.GobjectId); - - // Case-insensitive lookup must hit the same entry. - Assert.True(index.ObjectViewsByTagName.TryGetValue("plant.mixer_001", out GalaxyObjectView? lower)); - Assert.NotNull(lower); - Assert.Same(exact, lower); - - Assert.False(index.ObjectViewsByTagName.ContainsKey("Plant.Missing")); - } - - /// Verifies is OrdinalIgnoreCase. - [Fact] - public void ObjectViewsByContainedPath_IsCaseInsensitive() - { - GalaxyObject root = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "Plant", BrowseName = "Plant", TagName = "Plant" }; - GalaxyObject lineA = new() { GobjectId = 2, ParentGobjectId = 1, IsArea = true, ContainedName = "Line_A", BrowseName = "Line_A", TagName = "Plant.Line_A" }; - - GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, lineA]); - - Assert.True(index.ObjectViewsByContainedPath.TryGetValue("Plant/Line_A", out GalaxyObjectView? exact)); - Assert.NotNull(exact); - Assert.Equal(2, exact!.Object.GobjectId); - - Assert.True(index.ObjectViewsByContainedPath.TryGetValue("plant/line_a", out GalaxyObjectView? lower)); - Assert.NotNull(lower); - Assert.Same(exact, lower); - - Assert.False(index.ObjectViewsByContainedPath.ContainsKey("Plant/Missing")); - } - - /// Verifies children sort areas-first, then by display name (case-insensitive). - [Fact] - public void ChildrenByParent_SortsAreasFirstThenByDisplayName() - { - GalaxyObject parent = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "Root" }; - GalaxyObject zebraObj = new() { GobjectId = 10, ParentGobjectId = 1, IsArea = false, ContainedName = "zebra" }; - GalaxyObject alphaArea = new() { GobjectId = 11, ParentGobjectId = 1, IsArea = true, ContainedName = "alpha" }; - GalaxyObject betaArea = new() { GobjectId = 12, ParentGobjectId = 1, IsArea = true, ContainedName = "beta" }; - - GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([parent, zebraObj, alphaArea, betaArea]); - - Assert.True(index.ChildrenByParent.TryGetValue(1, out IReadOnlyList? children)); - Assert.NotNull(children); - Assert.Equal(3, children!.Count); - Assert.Equal(11, children[0].Object.GobjectId); - Assert.Equal(12, children[1].Object.GobjectId); - Assert.Equal(10, children[2].Object.GobjectId); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyProjectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyProjectorTests.cs deleted file mode 100644 index 907ac3e..0000000 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyProjectorTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Dashboard; -using ZB.MOM.WW.MxGateway.Server.Galaxy; - -namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; - -/// -/// Direct coverage for paging. -/// -/// Regression guard for finding Server-007: the projector memoizes the filtered, -/// ordered view list per (cache entry, filter signature) so paging is an -/// O(pageSize) slice rather than an O(total) re-scan per page. These tests confirm -/// the memo does not change paging results, does not bleed between distinct filter -/// signatures, and is scoped to a single cache-entry instance. -/// -/// -public sealed class GalaxyHierarchyProjectorTests -{ - /// Verifies that paging across a hierarchy returns every object exactly once. - [Fact] - public void Project_PagedAcrossEntireHierarchy_ReturnsEveryObjectExactlyOnce() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects(25)); - - List collected = []; - int totalReported = -1; - for (int offset = 0; offset < 25; offset += 4) - { - GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project( - entry, - new DiscoverHierarchyRequest(), - browseSubtreeGlobs: null, - offset, - pageSize: 4); - - totalReported = result.TotalObjectCount; - collected.AddRange(result.Objects.Select(obj => obj.TagName)); - } - - Assert.Equal(25, totalReported); - Assert.Equal(25, collected.Count); - Assert.Equal(collected.Count, collected.Distinct(StringComparer.Ordinal).Count()); - Assert.Equal("Object_001", collected[0]); - Assert.Equal("Object_025", collected[^1]); - } - - /// Verifies that distinct filters on the same entry do not share memoized view list. - [Fact] - public void Project_DistinctFiltersOnSameEntry_DoNotShareMemoizedViewList() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects(10)); - - GalaxyHierarchyQueryResult globbed = GalaxyHierarchyProjector.Project( - entry, - new DiscoverHierarchyRequest { TagNameGlob = "Object_00?" }); - GalaxyHierarchyQueryResult unfiltered = GalaxyHierarchyProjector.Project( - entry, - new DiscoverHierarchyRequest()); - - // Distinct filter signatures must each get their own filtered list. - Assert.Equal(9, globbed.TotalObjectCount); - Assert.Equal(10, unfiltered.TotalObjectCount); - } - - /// Verifies that the same filter repeated returns identical totals. - [Fact] - public void Project_SameFilterRepeated_ReturnsIdenticalTotals() - { - GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects(12)); - - GalaxyHierarchyQueryResult first = GalaxyHierarchyProjector.Project( - entry, - new DiscoverHierarchyRequest(), - browseSubtreeGlobs: null, - offset: 0, - pageSize: 5); - GalaxyHierarchyQueryResult second = GalaxyHierarchyProjector.Project( - entry, - new DiscoverHierarchyRequest(), - browseSubtreeGlobs: null, - offset: 5, - pageSize: 5); - - Assert.Equal(first.TotalObjectCount, second.TotalObjectCount); - Assert.Equal(first.FilterSignature, second.FilterSignature); - Assert.Equal(5, first.Objects.Count); - Assert.Equal(5, second.Objects.Count); - Assert.NotEqual(first.Objects[0].TagName, second.Objects[0].TagName); - } - - /// Verifies that distinct cache entries project against their own data. - [Fact] - public void Project_DistinctCacheEntries_ProjectAgainstTheirOwnData() - { - GalaxyHierarchyCacheEntry small = CreateEntry(CreateObjects(3)); - GalaxyHierarchyCacheEntry large = CreateEntry(CreateObjects(40)); - - GalaxyHierarchyQueryResult smallResult = GalaxyHierarchyProjector.Project( - small, - new DiscoverHierarchyRequest()); - GalaxyHierarchyQueryResult largeResult = GalaxyHierarchyProjector.Project( - large, - new DiscoverHierarchyRequest()); - - // Each entry instance keys its own memo; the second projection must not reuse the - // first entry's filtered view list. - Assert.Equal(3, smallResult.TotalObjectCount); - Assert.Equal(40, largeResult.TotalObjectCount); - } - - private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList objects) - { - return GalaxyHierarchyCacheEntry.Empty with - { - Status = GalaxyCacheStatus.Healthy, - Sequence = 1, - LastSuccessAt = DateTimeOffset.UtcNow, - Objects = objects, - Index = GalaxyHierarchyIndex.Build(objects), - DashboardSummary = DashboardGalaxySummary.Unknown with - { - Status = DashboardGalaxyStatus.Healthy, - ObjectCount = objects.Count, - }, - ObjectCount = objects.Count, - }; - } - - private static IReadOnlyList CreateObjects(int count) - { - return Enumerable.Range(1, count) - .Select(index => new GalaxyObject - { - GobjectId = index, - TagName = $"Object_{index:000}", - BrowseName = $"Object_{index:000}", - }) - .ToArray(); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyRefreshServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyRefreshServiceTests.cs deleted file mode 100644 index 7cf5045..0000000 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyRefreshServiceTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using ZB.MOM.WW.MxGateway.Server.Galaxy; - -namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; - -/// -/// Server-005 regression: the initial RefreshAsync call in -/// must not let a transient, -/// non-cancellation first-load failure (e.g. a -/// or from connection -/// establishment) escape and fault the host BackgroundService. -/// -public sealed class GalaxyHierarchyRefreshServiceTests -{ - /// Verifies that the background service does not fault when the first refresh throws a non-cancellation exception. - [Fact] - public async Task ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFaultBackgroundService() - { - ThrowingCache cache = new(new TimeoutException("connection establishment timed out")); - GalaxyHierarchyRefreshService service = CreateService(cache); - - using CancellationTokenSource cts = new(); - - await service.StartAsync(cts.Token); - - // Wait until the first RefreshAsync has actually been attempted (and - // thrown) before cancelling, so cancellation cannot race ahead of the - // first-load path under test — this is what made the test flaky under - // parallel load. - await cache.FirstRefreshAttempted.WaitAsync(TimeSpan.FromSeconds(10)); - - await cts.CancelAsync(); - - // The background loop must have stopped cleanly: ExecuteTask reaches a - // terminal state that is not Faulted (RanToCompletion or Canceled) - // rather than faulting on the first refresh. WhenAny is used so a - // Canceled task does not rethrow before the IsFaulted assertion. - Task? executeTask = service.ExecuteTask; - Assert.NotNull(executeTask); - Task completed = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromSeconds(10))); - Assert.Same(executeTask, completed); - Assert.False(executeTask.IsFaulted); - Assert.Equal(1, cache.RefreshCallCount); - - await service.StopAsync(CancellationToken.None); - } - - private static GalaxyHierarchyRefreshService CreateService(IGalaxyHierarchyCache cache) - { - GalaxyRepositoryOptions options = new() - { - DashboardRefreshIntervalSeconds = 3600, - }; - return new GalaxyHierarchyRefreshService( - cache, - Options.Create(options), - NullLogger.Instance); - } - - private sealed class ThrowingCache(Exception toThrow) : IGalaxyHierarchyCache - { - private readonly TaskCompletionSource firstRefreshAttempted = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Gets the number of refresh calls. - public int RefreshCallCount { get; private set; } - - /// Gets a task that completes once refresh has been invoked at least once. - public Task FirstRefreshAttempted => firstRefreshAttempted.Task; - - /// Gets the current cache entry. - public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty; - - /// Refreshes the cache asynchronously and throws the configured exception. - /// Token to observe for cancellation. - public Task RefreshAsync(CancellationToken cancellationToken) - { - RefreshCallCount++; - firstRefreshAttempted.TrySetResult(); - throw toThrow; - } - - /// Waits for the first load and completes immediately. - /// Token to observe for cancellation. - public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs deleted file mode 100644 index 6da3d34..0000000 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs +++ /dev/null @@ -1,184 +0,0 @@ -using Microsoft.Extensions.Options; -using ZB.MOM.WW.MxGateway.Server.Galaxy; - -namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; - -/// -/// Covers : the on-disk persistence -/// that lets the Galaxy browse cache survive a cold start while the Galaxy -/// database is unreachable. -/// -public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable -{ - private readonly List _tempPaths = []; - - /// Verifies that snapshots are correctly saved to and loaded from disk. - [Fact] - public async Task SaveAsync_ThenTryLoadAsync_RoundTripsRows() - { - string path = CreateTempPath(); - GalaxyHierarchySnapshotStore store = CreateStore(path); - GalaxyHierarchySnapshot snapshot = SampleSnapshot(); - - await store.SaveAsync(snapshot, CancellationToken.None); - GalaxyHierarchySnapshot? loaded = await store.TryLoadAsync(CancellationToken.None); - - Assert.NotNull(loaded); - Assert.Equal(snapshot.LastDeployTime, loaded.LastDeployTime); - Assert.Equal(snapshot.SavedAt, loaded.SavedAt); - - GalaxyHierarchyRow row = Assert.Single(loaded.Hierarchy); - Assert.Equal(7, row.GobjectId); - Assert.Equal("Pump_001", row.TagName); - Assert.Equal(["AppPump", "Pump"], row.TemplateChain); - - Assert.Equal(2, loaded.Attributes.Count); - GalaxyAttributeRow withDimension = loaded.Attributes[0]; - Assert.Equal("PV", withDimension.AttributeName); - Assert.Equal(8, withDimension.ArrayDimension); - Assert.True(withDimension.IsAlarm); - Assert.Null(loaded.Attributes[1].ArrayDimension); - } - - /// Verifies that loading returns null when no snapshot file exists. - [Fact] - public async Task TryLoadAsync_WhenNoFileExists_ReturnsNull() - { - GalaxyHierarchySnapshotStore store = CreateStore(CreateTempPath()); - - Assert.Null(await store.TryLoadAsync(CancellationToken.None)); - } - - /// Verifies that save writes nothing when persistence is disabled. - [Fact] - public async Task SaveAsync_WhenPersistenceDisabled_WritesNothing() - { - string path = CreateTempPath(); - GalaxyHierarchySnapshotStore store = CreateStore(path, persist: false); - - await store.SaveAsync(SampleSnapshot(), CancellationToken.None); - - Assert.False(File.Exists(path)); - Assert.Null(await store.TryLoadAsync(CancellationToken.None)); - } - - /// Verifies that loading returns null when the file contains invalid JSON. - [Fact] - public async Task TryLoadAsync_WhenFileIsCorruptJson_ReturnsNull() - { - string path = CreateTempPath(); - await File.WriteAllTextAsync(path, "{ this is not valid json"); - GalaxyHierarchySnapshotStore store = CreateStore(path); - - Assert.Null(await store.TryLoadAsync(CancellationToken.None)); - } - - /// Verifies that loading returns null when the schema version is unrecognized. - [Fact] - public async Task TryLoadAsync_WhenSchemaVersionUnrecognized_ReturnsNull() - { - string path = CreateTempPath(); - await File.WriteAllTextAsync(path, """{"SchemaVersion":999,"Snapshot":null}"""); - GalaxyHierarchySnapshotStore store = CreateStore(path); - - Assert.Null(await store.TryLoadAsync(CancellationToken.None)); - } - - /// Verifies that saving overwrites an earlier snapshot. - [Fact] - public async Task SaveAsync_OverwritesAnEarlierSnapshot() - { - string path = CreateTempPath(); - GalaxyHierarchySnapshotStore store = CreateStore(path); - - await store.SaveAsync(SampleSnapshot(), CancellationToken.None); - GalaxyHierarchySnapshot second = SampleSnapshot() with - { - Hierarchy = [], - Attributes = [], - }; - await store.SaveAsync(second, CancellationToken.None); - - GalaxyHierarchySnapshot? loaded = await store.TryLoadAsync(CancellationToken.None); - Assert.NotNull(loaded); - Assert.Empty(loaded.Hierarchy); - Assert.Empty(loaded.Attributes); - } - - private static GalaxyHierarchySnapshot SampleSnapshot() => new( - LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 30, 0, TimeSpan.Zero), - SavedAt: new DateTimeOffset(2026, 5, 20, 9, 31, 0, TimeSpan.Zero), - Hierarchy: - [ - new GalaxyHierarchyRow - { - GobjectId = 7, - TagName = "Pump_001", - ContainedName = "Pump", - BrowseName = "Pump", - CategoryId = 10, - TemplateChain = ["AppPump", "Pump"], - }, - ], - Attributes: - [ - new GalaxyAttributeRow - { - GobjectId = 7, - TagName = "Pump_001", - AttributeName = "PV", - FullTagReference = "Pump_001.PV[]", - MxDataType = 5, - DataTypeName = "Float", - IsArray = true, - ArrayDimension = 8, - IsAlarm = true, - }, - new GalaxyAttributeRow - { - GobjectId = 7, - TagName = "Pump_001", - AttributeName = "Mode", - FullTagReference = "Pump_001.Mode", - MxDataType = 3, - DataTypeName = "Integer", - ArrayDimension = null, - }, - ]); - - private static GalaxyHierarchySnapshotStore CreateStore(string path, bool persist = true) - { - GalaxyRepositoryOptions options = new() - { - PersistSnapshot = persist, - SnapshotCachePath = path, - }; - return new GalaxyHierarchySnapshotStore(Options.Create(options)); - } - - private string CreateTempPath() - { - string path = Path.Combine( - Path.GetTempPath(), - $"mxgw-galaxy-snapshot-{Guid.NewGuid():N}.json"); - _tempPaths.Add(path); - return path; - } - - /// - public void Dispose() - { - foreach (string path in _tempPaths) - { - try - { - File.Delete(path); - File.Delete(path + ".tmp"); - } - catch (IOException) - { - // Best-effort cleanup of test scratch files. - } - } - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs deleted file mode 100644 index 28d90f0..0000000 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Grpc; - -namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; - -public sealed class GalaxyProtoMapperTests -{ - /// Verifies that mapping a galaxy attribute row preserves all scalar fields. - [Fact] - public void MapAttribute_PreservesAllScalarFields() - { - GalaxyAttributeRow row = new() - { - GobjectId = 42, - TagName = "Pump_001", - AttributeName = "Speed", - FullTagReference = "Pump_001.Speed", - MxDataType = 3, - DataTypeName = "Float", - IsArray = false, - ArrayDimension = null, - MxAttributeCategory = 5, - SecurityClassification = 2, - IsHistorized = true, - IsAlarm = false, - }; - - GalaxyAttribute proto = GalaxyProtoMapper.MapAttribute(row); - - Assert.Equal("Speed", proto.AttributeName); - Assert.Equal("Pump_001.Speed", proto.FullTagReference); - Assert.Equal(3, proto.MxDataType); - Assert.Equal("Float", proto.DataTypeName); - Assert.False(proto.IsArray); - Assert.Equal(0, proto.ArrayDimension); - Assert.False(proto.ArrayDimensionPresent); - Assert.Equal(5, proto.MxAttributeCategory); - Assert.Equal(2, proto.SecurityClassification); - Assert.True(proto.IsHistorized); - Assert.False(proto.IsAlarm); - } - - /// Verifies that the array dimension present flag distinguishes null from zero. - [Fact] - public void MapAttribute_ArrayDimensionPresentFlag_DistinguishesNullFromZero() - { - GalaxyAttributeRow withDim = new() { ArrayDimension = 0, IsArray = true }; - GalaxyAttributeRow withoutDim = new() { ArrayDimension = null, IsArray = false }; - - Assert.True(GalaxyProtoMapper.MapAttribute(withDim).ArrayDimensionPresent); - Assert.Equal(0, GalaxyProtoMapper.MapAttribute(withDim).ArrayDimension); - - Assert.False(GalaxyProtoMapper.MapAttribute(withoutDim).ArrayDimensionPresent); - Assert.Equal(0, GalaxyProtoMapper.MapAttribute(withoutDim).ArrayDimension); - } - - /// Verifies that null data type name becomes an empty string. - [Fact] - public void MapAttribute_NullDataTypeName_BecomesEmptyString() - { - GalaxyAttributeRow row = new() { DataTypeName = null }; - - GalaxyAttribute proto = GalaxyProtoMapper.MapAttribute(row); - - Assert.Equal(string.Empty, proto.DataTypeName); - } - - /// Verifies that MapHierarchy groups attributes by GobjectId. - [Fact] - public void MapHierarchy_GroupsAttributesByGobjectId() - { - List hierarchy = - [ - new() { GobjectId = 1, TagName = "A", BrowseName = "A", TemplateChain = ["RootTpl"] }, - new() { GobjectId = 2, TagName = "B", BrowseName = "B", ParentGobjectId = 1 }, - new() { GobjectId = 3, TagName = "C", BrowseName = "C", ParentGobjectId = 1 }, - ]; - List attributes = - [ - new() { GobjectId = 1, AttributeName = "X", FullTagReference = "A.X" }, - new() { GobjectId = 2, AttributeName = "Y1", FullTagReference = "B.Y1" }, - new() { GobjectId = 2, AttributeName = "Y2", FullTagReference = "B.Y2" }, - ]; - - List result = GalaxyProtoMapper.MapHierarchy(hierarchy, attributes).ToList(); - - Assert.Equal(3, result.Count); - Assert.Single(result[0].Attributes); - Assert.Equal("X", result[0].Attributes[0].AttributeName); - Assert.Equal(2, result[1].Attributes.Count); - Assert.Empty(result[2].Attributes); - } - - /// Verifies that MapObject copies the template chain. - [Fact] - public void MapObject_CopiesTemplateChain() - { - GalaxyHierarchyRow row = new() - { - GobjectId = 5, - TagName = "Engine_001", - ContainedName = "Engine", - BrowseName = "Engine", - TemplateChain = ["EngineTpl", "AppEngineBase"], - }; - - GalaxyObject proto = GalaxyProtoMapper.MapObject( - row, - new Dictionary>()); - - Assert.Equal(new[] { "EngineTpl", "AppEngineBase" }, proto.TemplateChain); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs index a201e8b..f3efc6b 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs @@ -1,6 +1,6 @@ using Google.Protobuf.WellKnownTypes; +using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Contracts.Proto; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs index a2e15ef..e20bca4 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs @@ -1,10 +1,11 @@ using System.Globalization; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; -using ZB.MOM.WW.MxGateway.Server.Galaxy; using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; @@ -215,6 +216,35 @@ public sealed class DashboardSnapshotServiceTests [Fact] public void GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache() { + // The shared-library cache entry no longer carries a precomputed dashboard summary; + // DashboardSnapshotService derives templates/categories from the entry's objects via + // DashboardGalaxyProjector. Seed objects that yield $Pump x2 / $Area x1 templates and + // categories UserDefined(10) x2 / Area(13) x1, matching the asserted summary. + GalaxyObject[] objects = + [ + new GalaxyObject + { + GobjectId = 1, + BrowseName = "AreaA", + IsArea = true, + CategoryId = 13, + TemplateChain = { "$Area" }, + }, + new GalaxyObject + { + GobjectId = 2, + BrowseName = "Pump01", + CategoryId = 10, + TemplateChain = { "$Pump" }, + }, + new GalaxyObject + { + GobjectId = 3, + BrowseName = "Pump02", + CategoryId = 10, + TemplateChain = { "$Pump" }, + }, + ]; GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, @@ -222,27 +252,8 @@ public sealed class DashboardSnapshotServiceTests LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture), - DashboardSummary = new DashboardGalaxySummary( - DashboardGalaxyStatus.Healthy, - LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), - LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), - LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture), - LastError: null, - ObjectCount: 3, - AreaCount: 1, - AttributeCount: 2, - HistorizedAttributeCount: 1, - AlarmAttributeCount: 1, - TopTemplates: - [ - new DashboardGalaxyTemplateUsage("$Pump", 2), - new DashboardGalaxyTemplateUsage("$Area", 1), - ], - ObjectCategories: - [ - new DashboardGalaxyCategoryCount(10, "UserDefined", 2), - new DashboardGalaxyCategoryCount(13, "Area", 1), - ]), + Objects = objects, + Index = GalaxyHierarchyIndex.Build(objects), ObjectCount = 3, AreaCount = 1, AttributeCount = 2, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs deleted file mode 100644 index cb0b036..0000000 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs +++ /dev/null @@ -1,486 +0,0 @@ -using Grpc.Core; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Dashboard; -using ZB.MOM.WW.MxGateway.Server.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Grpc; -using ZB.MOM.WW.MxGateway.Server.Security.Authentication; -using ZB.MOM.WW.MxGateway.Server.Security.Authorization; -using ZB.MOM.WW.MxGateway.Tests.TestSupport; - -namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc; - -public sealed class GalaxyRepositoryGrpcServiceTests -{ - /// Verifies that DiscoverHierarchy returns the requested page and totals. - [Fact] - public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals() - { - GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); - - DiscoverHierarchyReply reply = await service.DiscoverHierarchy( - new DiscoverHierarchyRequest - { - PageSize = 2, - }, - new TestServerCallContext()); - - Assert.Equal(2, reply.Objects.Count); - Assert.Equal("Object_001", reply.Objects[0].TagName); - Assert.Equal("Object_002", reply.Objects[1].TagName); - Assert.StartsWith("7:", reply.NextPageToken, StringComparison.Ordinal); - Assert.EndsWith(":2", reply.NextPageToken, StringComparison.Ordinal); - Assert.Equal(3, reply.TotalObjectCount); - } - - /// Verifies that DiscoverHierarchy with a page token returns remaining objects. - [Fact] - public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects() - { - GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); - DiscoverHierarchyReply firstPage = await service.DiscoverHierarchy( - new DiscoverHierarchyRequest - { - PageSize = 2, - }, - new TestServerCallContext()); - - DiscoverHierarchyReply reply = await service.DiscoverHierarchy( - new DiscoverHierarchyRequest - { - PageSize = 2, - PageToken = firstPage.NextPageToken, - }, - new TestServerCallContext()); - - GalaxyObject item = Assert.Single(reply.Objects); - Assert.Equal("Object_003", item.TagName); - Assert.Equal("", reply.NextPageToken); - Assert.Equal(3, reply.TotalObjectCount); - } - - /// Verifies that DiscoverHierarchy with invalid paging arguments returns InvalidArgument. - /// The page token to test. - /// The page size to test. - [Theory] - [InlineData("-1", 1)] - [InlineData("not-an-offset", 1)] - [InlineData("7:4", 1)] - [InlineData("6:2", 1)] - [InlineData("", -1)] - public async Task DiscoverHierarchy_WithInvalidPagingArguments_ReturnsInvalidArgument( - string pageToken, - int pageSize) - { - GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); - - RpcException exception = await Assert.ThrowsAsync( - async () => await service.DiscoverHierarchy( - new DiscoverHierarchyRequest - { - PageSize = pageSize, - PageToken = pageToken, - }, - new TestServerCallContext())); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - } - - /// Verifies that DiscoverHierarchy with subtree root and depth filters descendants. - [Fact] - public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants() - { - GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); - - DiscoverHierarchyReply reply = await service.DiscoverHierarchy( - new DiscoverHierarchyRequest - { - RootContainedPath = "Area1/Line3", - MaxDepth = 1, - PageSize = 10, - }, - new TestServerCallContext()); - - Assert.Equal(["Line3", "Pump_001", "Valve_001"], reply.Objects.Select(obj => obj.TagName)); - Assert.Equal(3, reply.TotalObjectCount); - } - - /// Verifies that DiscoverHierarchy applies server-side filters and omits attributes. - [Fact] - public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes() - { - GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); - - DiscoverHierarchyReply reply = await service.DiscoverHierarchy( - new DiscoverHierarchyRequest - { - RootTagName = "Area1", - TagNameGlob = "Pump_*", - AlarmBearingOnly = true, - HistorizedOnly = true, - IncludeAttributes = false, - PageSize = 10, - CategoryIds = { 10 }, - TemplateChainContains = { "Pump" }, - }, - new TestServerCallContext()); - - GalaxyObject obj = Assert.Single(reply.Objects); - Assert.Equal("Pump_001", obj.TagName); - Assert.Empty(obj.Attributes); - Assert.Equal(1, reply.TotalObjectCount); - } - - /// Verifies that DiscoverHierarchy with filtered paging returns post-filter total. - [Fact] - public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal() - { - GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); - - DiscoverHierarchyReply first = await service.DiscoverHierarchy( - new DiscoverHierarchyRequest - { - RootGobjectId = 1, - PageSize = 1, - CategoryIds = { 10 }, - }, - new TestServerCallContext()); - - DiscoverHierarchyReply second = await service.DiscoverHierarchy( - new DiscoverHierarchyRequest - { - RootGobjectId = 1, - PageSize = 1, - PageToken = first.NextPageToken, - CategoryIds = { 10 }, - }, - new TestServerCallContext()); - - GalaxyObject firstObject = Assert.Single(first.Objects); - GalaxyObject secondObject = Assert.Single(second.Objects); - Assert.Equal(2, first.TotalObjectCount); - Assert.Equal(2, second.TotalObjectCount); - Assert.NotEqual(firstObject.TagName, secondObject.TagName); - } - - /// Verifies that DiscoverHierarchy with mismatched filter token returns InvalidArgument. - [Fact] - public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument() - { - GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); - DiscoverHierarchyReply first = await service.DiscoverHierarchy( - new DiscoverHierarchyRequest - { - PageSize = 1, - CategoryIds = { 10 }, - }, - new TestServerCallContext()); - - RpcException exception = await Assert.ThrowsAsync( - async () => await service.DiscoverHierarchy( - new DiscoverHierarchyRequest - { - PageSize = 1, - PageToken = first.NextPageToken, - CategoryIds = { 11 }, - }, - new TestServerCallContext())); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); - } - - /// Verifies that DiscoverHierarchy with missing root returns NotFound. - [Fact] - public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound() - { - GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); - - RpcException exception = await Assert.ThrowsAsync( - async () => await service.DiscoverHierarchy( - new DiscoverHierarchyRequest - { - RootTagName = "Missing", - }, - new TestServerCallContext())); - - Assert.Equal(StatusCode.NotFound, exception.StatusCode); - } - - private static GalaxyRepositoryGrpcService CreateService(GalaxyHierarchyCacheEntry entry) - { - GalaxyRepositoryOptions options = new() - { - ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;", - }; - return new GalaxyRepositoryGrpcService( - new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options), - new StubGalaxyHierarchyCache(entry), - new GalaxyDeployNotifier(), - new GatewayRequestIdentityAccessor()); - } - - private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList objects) - { - return GalaxyHierarchyCacheEntry.Empty with - { - Status = GalaxyCacheStatus.Healthy, - Sequence = 7, - LastSuccessAt = DateTimeOffset.UtcNow, - Objects = objects, - Index = GalaxyHierarchyIndex.Build(objects), - DashboardSummary = DashboardGalaxySummary.Unknown with - { - Status = DashboardGalaxyStatus.Healthy, - ObjectCount = objects.Count, - }, - ObjectCount = objects.Count, - }; - } - - private static IReadOnlyList CreateObjects(int count) - { - return Enumerable.Range(1, count) - .Select(index => new GalaxyObject - { - GobjectId = index, - TagName = $"Object_{index:000}", - BrowseName = $"Object_{index:000}", - }) - .ToArray(); - } - - private static IReadOnlyList CreateFilterObjects() - { - return - [ - new GalaxyObject - { - GobjectId = 1, - TagName = "Area1", - ContainedName = "Area1", - BrowseName = "Area1", - IsArea = true, - CategoryId = 13, - }, - new GalaxyObject - { - GobjectId = 2, - TagName = "Line3", - ContainedName = "Line3", - BrowseName = "Line3", - ParentGobjectId = 1, - CategoryId = 10, - TemplateChain = { "$Line", "$Base" }, - }, - new GalaxyObject - { - GobjectId = 3, - TagName = "Pump_001", - ContainedName = "Pump", - BrowseName = "Pump_001", - ParentGobjectId = 2, - CategoryId = 10, - TemplateChain = { "$Pump", "$Base" }, - Attributes = - { - new GalaxyAttribute - { - AttributeName = "PV", - FullTagReference = "Pump_001.PV", - IsAlarm = true, - IsHistorized = true, - SecurityClassification = 2, - }, - }, - }, - new GalaxyObject - { - GobjectId = 4, - TagName = "Valve_001", - ContainedName = "Valve", - BrowseName = "Valve_001", - ParentGobjectId = 2, - CategoryId = 11, - TemplateChain = { "$Valve" }, - Attributes = - { - new GalaxyAttribute - { - AttributeName = "PV", - FullTagReference = "Valve_001.PV", - }, - }, - }, - new GalaxyObject - { - GobjectId = 5, - TagName = "Other_001", - ContainedName = "Other", - BrowseName = "Other_001", - CategoryId = 10, - }, - ]; - } - - 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; - } - - /// Verifies that BrowseChildren returns root objects and the current cache sequence when called with no parent. - [Fact] - public async Task BrowseChildren_RootCall_ReturnsRootsWithCacheSequence() - { - GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); - - BrowseChildrenReply reply = await service.BrowseChildren( - new BrowseChildrenRequest(), - new TestServerCallContext()); - - Assert.Equal(2, reply.Children.Count); - Assert.Equal("Area1", reply.Children[0].TagName); - Assert.Equal("Other_001", reply.Children[1].TagName); - Assert.Equal(7UL, reply.CacheSequence); - Assert.Equal(2, reply.TotalChildCount); - Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count); - } - - /// Verifies that BrowseChildren returns Unavailable when the cache's first load never completes. - [Fact] - public async Task BrowseChildren_FirstLoadNotComplete_ReturnsUnavailable() - { - GalaxyRepositoryOptions options = new() - { - ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;", - }; - GalaxyRepositoryGrpcService service = new( - new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options), - new NeverLoadsHierarchyCache(), - new GalaxyDeployNotifier(), - new GatewayRequestIdentityAccessor()); - - // No caller-supplied CT so WaitForCacheBootstrap exits via its 5s internal budget - // (instead of re-throwing OperationCanceledException from the caller's CT). The - // handler then sees Status=Unknown and returns Unavailable. - RpcException exception = await Assert.ThrowsAsync( - async () => await service.BrowseChildren( - new BrowseChildrenRequest(), - new TestServerCallContext())); - - Assert.Equal(StatusCode.Unavailable, exception.StatusCode); - } - - /// Verifies that a page token bound to a stale cache sequence is rejected with InvalidArgument. - [Fact] - public async Task BrowseChildren_StaleToken_ReturnsInvalidArgument() - { - GalaxyRepositoryGrpcService firstService = CreateService(CreateEntry(CreateFilterObjects())); - BrowseChildrenReply firstReply = await firstService.BrowseChildren( - new BrowseChildrenRequest { PageSize = 1 }, - new TestServerCallContext()); - Assert.False(string.IsNullOrEmpty(firstReply.NextPageToken)); - - GalaxyHierarchyCacheEntry newerEntry = CreateEntry(CreateFilterObjects()) with { Sequence = 8 }; - GalaxyRepositoryGrpcService secondService = CreateService(newerEntry); - - RpcException exception = await Assert.ThrowsAsync( - async () => await secondService.BrowseChildren( - new BrowseChildrenRequest - { - PageSize = 1, - PageToken = firstReply.NextPageToken, - }, - new TestServerCallContext())); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Contains("stale", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); - } - - /// Verifies that switching filters between paged BrowseChildren calls is rejected. - [Fact] - public async Task BrowseChildren_FilterChangeBetweenPages_ReturnsInvalidArgument() - { - GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); - BrowseChildrenReply firstReply = await service.BrowseChildren( - new BrowseChildrenRequest - { - ParentGobjectId = 2, - PageSize = 1, - }, - new TestServerCallContext()); - Assert.False(string.IsNullOrEmpty(firstReply.NextPageToken)); - - RpcException exception = await Assert.ThrowsAsync( - async () => await service.BrowseChildren( - new BrowseChildrenRequest - { - ParentGobjectId = 2, - PageSize = 1, - PageToken = firstReply.NextPageToken, - TagNameGlob = "Pump*", - }, - new TestServerCallContext())); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); - } - - /// Verifies that an ApiKeyIdentity browse-subtrees constraint that matches nothing produces an empty child list. - [Fact] - public async Task BrowseChildren_BrowseSubtreesConstraint_FiltersChildren() - { - GalaxyRepositoryOptions options = new() - { - ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;", - }; - GatewayRequestIdentityAccessor identityAccessor = new(); - GalaxyRepositoryGrpcService service = new( - new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options), - new StubGalaxyHierarchyCache(CreateEntry(CreateFilterObjects())), - new GalaxyDeployNotifier(), - identityAccessor); - - // Sanity: with no identity pushed, both Pump and Valve come back under Line3 (id=2). - BrowseChildrenReply unconstrained = await service.BrowseChildren( - new BrowseChildrenRequest { ParentGobjectId = 2 }, - new TestServerCallContext()); - Assert.Equal(2, unconstrained.Children.Count); - - ApiKeyIdentity identity = new( - KeyId: "test-key", - KeyPrefix: "mxgw_test", - DisplayName: "constraint-only", - Scopes: new HashSet(StringComparer.Ordinal) { GatewayScopes.MetadataRead }, - Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = new[] { "NonExistent" } }); - - using (identityAccessor.Push(identity)) - { - BrowseChildrenReply constrained = await service.BrowseChildren( - new BrowseChildrenRequest { ParentGobjectId = 2 }, - new TestServerCallContext()); - Assert.Empty(constrained.Children); - Assert.Equal(0, constrained.TotalChildCount); - } - } - - private sealed class NeverLoadsHierarchyCache : IGalaxyHierarchyCache - { - /// - public GalaxyHierarchyCacheEntry Current { get; } = - GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Unknown }; - - /// - public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - /// - public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => - Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/ArrayAddressNormalizerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/ArrayAddressNormalizerTests.cs index ad34fcb..d90f2bb 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/ArrayAddressNormalizerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/ArrayAddressNormalizerTests.cs @@ -1,5 +1,5 @@ -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Server.Sessions; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewayArrayWriteWiringTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewayArrayWriteWiringTests.cs index 208e0e5..7991938 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewayArrayWriteWiringTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewayArrayWriteWiringTests.cs @@ -1,7 +1,7 @@ using System.Runtime.CompilerServices; +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Contracts.Proto; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; -using ZB.MOM.WW.MxGateway.Server.Galaxy; using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Server.Workers; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs index b79df1e..6d01ed4 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs @@ -1,9 +1,8 @@ using System.Text.Json; using ZB.MOM.WW.Audit; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Contracts.Proto; -using ZB.MOM.WW.MxGateway.Server.Dashboard; -using ZB.MOM.WW.MxGateway.Server.Galaxy; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Sessions; @@ -483,7 +482,6 @@ public sealed class ConstraintEnforcerTests Status = GalaxyCacheStatus.Healthy, Objects = objects, Index = GalaxyHierarchyIndex.Build(objects), - DashboardSummary = DashboardGalaxySummary.Unknown, }; } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayBrowseScopeProviderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayBrowseScopeProviderTests.cs new file mode 100644 index 0000000..9972670 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayBrowseScopeProviderTests.cs @@ -0,0 +1,61 @@ +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Server.Security.Authorization; +using ZB.MOM.WW.MxGateway.Tests.TestSupport; + +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization; + +/// +/// Unit tests for , the host seam that feeds the +/// calling API key's BrowseSubtrees constraint into the shared library's Galaxy +/// browse RPCs via IGalaxyBrowseScopeProvider. +/// +public sealed class GatewayBrowseScopeProviderTests +{ + /// + /// With an ambient identity whose effective constraints carry a browse subtree, + /// the provider returns exactly that subtree list to the library. + /// + [Fact] + public void ResolveBrowseSubtrees_WithCurrentIdentity_ReturnsConstraintBrowseSubtrees() + { + StubIdentityAccessor accessor = new(CreateIdentity(["AreaA"])); + GatewayBrowseScopeProvider provider = new(accessor); + + IReadOnlyList? result = provider.ResolveBrowseSubtrees(new TestServerCallContext()); + + Assert.Equal(["AreaA"], result); + } + + /// + /// With no ambient identity, the provider falls back to the empty browse-subtree + /// list, which the library treats as "no scoping" rather than denying everything. + /// + [Fact] + public void ResolveBrowseSubtrees_WithNoCurrentIdentity_ReturnsEmptyList() + { + StubIdentityAccessor accessor = new(current: null); + GatewayBrowseScopeProvider provider = new(accessor); + + IReadOnlyList? result = provider.ResolveBrowseSubtrees(new TestServerCallContext()); + + Assert.NotNull(result); + Assert.Empty(result); + } + + private static ApiKeyIdentity CreateIdentity(IReadOnlyList browseSubtrees) => + new( + KeyId: "operator01", + KeyPrefix: "mxgw_operator01", + DisplayName: "Operator", + Scopes: new HashSet(StringComparer.Ordinal), + Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = browseSubtrees }); + + private sealed class StubIdentityAccessor(ApiKeyIdentity? current) : IGatewayRequestIdentityAccessor + { + /// + public ApiKeyIdentity? Current { get; } = current; + + /// + public IDisposable Push(ApiKeyIdentity identity) => throw new NotSupportedException(); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs index 0c81950..f3f5d4d 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs @@ -1,5 +1,5 @@ +using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Contracts.Proto; -using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;