test(gateway): reconcile Galaxy tests to the shared library (delete upstream-owned, rebind host-specific)
This commit is contained in:
@@ -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<GalaxyHierarchyRow> 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<GalaxyAttributeRow> 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,
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Direct coverage for <see cref="GalaxyBrowseProjector"/>. Validates parent
|
||||
/// resolution (gobject id / tag name / contained path), paging across siblings,
|
||||
/// filter parity with <see cref="GalaxyHierarchyProjector"/>, the
|
||||
/// <c>child_has_children</c> hint, browse-subtree constraints, and the
|
||||
/// attribute-skeleton mode.
|
||||
/// </summary>
|
||||
public sealed class GalaxyBrowseProjectorTests
|
||||
{
|
||||
/// <summary>Verifies that an empty parent oneof returns the root area.</summary>
|
||||
[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]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving the parent by gobject id returns sorted direct children.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving the parent by tag name returns the same direct children.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving the parent by contained path returns the same direct children.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown parent gobject id throws an RpcException with StatusCode.NotFound.</summary>
|
||||
[Fact]
|
||||
public void Project_UnknownParent_ThrowsNotFound()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
RpcException exception = Assert.Throws<RpcException>(() => GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 999 },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10));
|
||||
|
||||
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that paging across siblings returns every sibling exactly once.</summary>
|
||||
[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<string> 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<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"Plant.Line_A",
|
||||
"Plant.Mixer_001",
|
||||
"Plant.Mixer_002",
|
||||
"Plant.Pump_001",
|
||||
},
|
||||
new HashSet<string>(collected, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a tag-name glob filters direct children and clears the has-children hint.</summary>
|
||||
[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());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that historized-only filtering also drives the has-children hint via descendants.</summary>
|
||||
[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());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that <c>IncludeAttributes=false</c> returns object skeletons.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that browse-subtree globs constrain the returned children.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="GalaxyBrowseProjector"/> terminates when the Galaxy data
|
||||
/// contains a cyclic parent chain (A→B→C→A). Without the visited-id guard in
|
||||
/// <c>HasMatchingDescendant</c>, the depth-first walk would loop forever; the
|
||||
/// 5-second xUnit timeout asserts termination.
|
||||
/// </summary>
|
||||
[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<GalaxyObject> 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<GalaxyObject> 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<GalaxyObject> 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 };
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
|
||||
|
||||
public sealed class GalaxyDeployNotifierTests
|
||||
{
|
||||
/// <summary>Verifies that a subscriber blocks until a deploy event is published.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_NoLatestEvent_BlocksUntilPublish()
|
||||
{
|
||||
GalaxyDeployNotifier notifier = new();
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
IAsyncEnumerator<GalaxyDeployEventInfo> enumerator = notifier
|
||||
.SubscribeAsync(cts.Token)
|
||||
.GetAsyncEnumerator(cts.Token);
|
||||
|
||||
ValueTask<bool> 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();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a subscriber immediately receives a cached latest deploy event.</summary>
|
||||
[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<GalaxyDeployEventInfo> 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();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that published events fan out to all active subscribers.</summary>
|
||||
[Fact]
|
||||
public async Task Publish_FansOutToAllSubscribers()
|
||||
{
|
||||
GalaxyDeployNotifier notifier = new();
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
await using IAsyncEnumerator<GalaxyDeployEventInfo> a = notifier
|
||||
.SubscribeAsync(cts.Token)
|
||||
.GetAsyncEnumerator(cts.Token);
|
||||
await using IAsyncEnumerator<GalaxyDeployEventInfo> 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();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Latest property tracks the most recently published event.</summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for finding Server-018: <see cref="GalaxyGlobMatcher"/>'s
|
||||
/// internal compiled-regex cache must stay bounded so a client cannot grow it
|
||||
/// without limit by submitting unique <c>TagNameGlob</c> values over the
|
||||
/// process lifetime. Feeding the matcher far more distinct globs than the cap
|
||||
/// must leave <c>CurrentCacheSize</c> at or below <c>RegexCacheCapacity</c>.
|
||||
/// Adversarial coverage for many distinct client-supplied globs: submitting a
|
||||
/// large number of unique <c>TagNameGlob</c> values (which the shared library's
|
||||
/// <see cref="GalaxyGlobMatcher"/> 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.
|
||||
/// <para>
|
||||
/// The matcher's internal compiled-regex cache and its bound are now owned by
|
||||
/// the <c>ZB.MOM.WW.GalaxyRepository</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -169,7 +172,8 @@ public sealed class GalaxyFilterInputSafetyTests
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest { TagNameGlob = glob });
|
||||
new DiscoverHierarchyRequest { TagNameGlob = glob },
|
||||
browseSubtreeGlobs: Array.Empty<string>());
|
||||
|
||||
// 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<RpcException>(
|
||||
() => GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest { RootTagName = rootTagName }));
|
||||
new DiscoverHierarchyRequest { RootTagName = rootTagName },
|
||||
browseSubtreeGlobs: Array.Empty<string>()));
|
||||
|
||||
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<string>());
|
||||
|
||||
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<GalaxyObject> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string> _tempPaths = [];
|
||||
|
||||
/// <summary>
|
||||
/// Verifies cache returns empty entry before any refresh occurs.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies HasData returns true for healthy cache entries.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HasData_OnHealthyEntry_IsTrue()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||
ObjectCount = 1,
|
||||
};
|
||||
|
||||
Assert.True(entry.HasData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies HasData returns false for unknown cache entries.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HasData_OnUnknownEntry_IsFalse()
|
||||
{
|
||||
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the hierarchy index builds paths and lookups without throwing on bad metadata.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a successful refresh writes the browse dataset to the on-disk
|
||||
/// snapshot store so a later cold start can restore it.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the Galaxy database is unreachable on first refresh but a
|
||||
/// snapshot exists on disk, the cache serves that data with <c>Stale</c> status
|
||||
/// rather than coming up empty.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the disk snapshot's deploy time still matches the live
|
||||
/// Galaxy database, the cache promotes the restored data to <c>Healthy</c>
|
||||
/// without re-running the heavy hierarchy and attribute queries.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with snapshot persistence disabled the cache does not
|
||||
/// restore from disk — an unreachable database leaves it Unavailable.
|
||||
/// Regression test for Server-037.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<GalaxyHierarchyCache> 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));
|
||||
}
|
||||
|
||||
/// <summary><see cref="IGalaxyRepository"/> whose deploy-time query blocks until released.</summary>
|
||||
private sealed class BlockingGalaxyRepository : IGalaxyRepository
|
||||
{
|
||||
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Releases the blocking task.</summary>
|
||||
public void Release() => _release.TrySetResult();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
await _release.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||
throw new InvalidOperationException("Galaxy repository unreachable");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("GetHierarchyAsync should not be reached");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("GetAttributesAsync should not be reached");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("GetAlarmAttributesAsync should not be reached");
|
||||
}
|
||||
|
||||
/// <summary>Snapshot store whose <see cref="SaveAsync"/> cancels the token mid-save.</summary>
|
||||
private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<GalaxyHierarchySnapshot?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
cts.Cancel();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal <see cref="ILogger{T}"/> that records every emitted log entry.</summary>
|
||||
private sealed class RecordingLogger<T> : ILogger<T>
|
||||
{
|
||||
/// <summary>Gets the list of recorded log entries.</summary>
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable BeginScope<TState>(TState state)
|
||||
where TState : notnull => NullScope.Instance;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>In-memory <see cref="IGalaxyRepository"/> that returns fixed rowsets.</summary>
|
||||
private sealed class StubGalaxyRepository(
|
||||
DateTime? deployTime,
|
||||
List<GalaxyHierarchyRow>? hierarchy = null,
|
||||
List<GalaxyAttributeRow>? attributes = null) : IGalaxyRepository
|
||||
{
|
||||
private readonly List<GalaxyHierarchyRow> _hierarchy = hierarchy ?? [];
|
||||
private readonly List<GalaxyAttributeRow> _attributes = attributes ?? [];
|
||||
|
||||
/// <summary>Gets the count of calls to <see cref="GetHierarchyAsync"/>.</summary>
|
||||
public int GetHierarchyCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the count of calls to <see cref="GetAttributesAsync"/>.</summary>
|
||||
public int GetAttributesCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
GetHierarchyCount++;
|
||||
return Task.FromResult(_hierarchy);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
GetAttributesCount++;
|
||||
return Task.FromResult(_attributes);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<GalaxyAlarmAttributeRow>());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
{
|
||||
/// <summary>Gets the number of times <see cref="GetLastDeployTimeAsync"/> was called.</summary>
|
||||
public int GetLastDeployTimeCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of times <see cref="GetHierarchyAsync"/> was called.</summary>
|
||||
public int GetHierarchyCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of times <see cref="GetAttributesAsync"/> was called.</summary>
|
||||
public int GetAttributesCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
GetLastDeployTimeCount++;
|
||||
throw toThrow;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
GetHierarchyCount++;
|
||||
throw toThrow;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
GetAttributesCount++;
|
||||
throw toThrow;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||
=> throw toThrow;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for <see cref="GalaxyHierarchyIndex.ChildrenByParent"/> — 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 <c>DashboardBrowseTreeBuilder</c>.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyIndexTests
|
||||
{
|
||||
/// <summary>Verifies roots (ParentGobjectId == 0) bucket under sentinel key 0.</summary>
|
||||
[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<GalaxyObjectView>? 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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a nested A→B→C chain links each parent to its single child bucket.</summary>
|
||||
[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<GalaxyObjectView>? underRoot));
|
||||
Assert.NotNull(underRoot);
|
||||
Assert.Single(underRoot!);
|
||||
Assert.Equal(1, underRoot![0].Object.GobjectId);
|
||||
|
||||
Assert.True(index.ChildrenByParent.TryGetValue(1, out IReadOnlyList<GalaxyObjectView>? underA));
|
||||
Assert.NotNull(underA);
|
||||
Assert.Single(underA!);
|
||||
Assert.Equal(2, underA![0].Object.GobjectId);
|
||||
|
||||
Assert.True(index.ChildrenByParent.TryGetValue(2, out IReadOnlyList<GalaxyObjectView>? underB));
|
||||
Assert.NotNull(underB);
|
||||
Assert.Single(underB!);
|
||||
Assert.Equal(3, underB![0].Object.GobjectId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a self-parented (corrupt) row appears under root, not under itself.</summary>
|
||||
[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<GalaxyObjectView>? 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<GalaxyObjectView>? underSelf))
|
||||
{
|
||||
Assert.Empty(underSelf!);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<GalaxyObjectView>? 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<GalaxyObjectView>? underOrphan));
|
||||
Assert.Single(underOrphan!);
|
||||
Assert.Equal(3, underOrphan![0].Object.GobjectId);
|
||||
|
||||
// Nothing buckets under the phantom parent id.
|
||||
Assert.False(index.ChildrenByParent.ContainsKey(5008));
|
||||
}
|
||||
|
||||
/// <summary>Verifies <see cref="GalaxyHierarchyIndex.ObjectViewsByTagName"/> is OrdinalIgnoreCase and supports O(1) lookups.</summary>
|
||||
[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"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies <see cref="GalaxyHierarchyIndex.ObjectViewsByContainedPath"/> is OrdinalIgnoreCase.</summary>
|
||||
[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"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies children sort areas-first, then by display name (case-insensitive).</summary>
|
||||
[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<GalaxyObjectView>? 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Direct coverage for <see cref="GalaxyHierarchyProjector"/> paging.
|
||||
/// <para>
|
||||
/// Regression guard for finding Server-007: the projector memoizes the filtered,
|
||||
/// ordered view list per <c>(cache entry, filter signature)</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyProjectorTests
|
||||
{
|
||||
/// <summary>Verifies that paging across a hierarchy returns every object exactly once.</summary>
|
||||
[Fact]
|
||||
public void Project_PagedAcrossEntireHierarchy_ReturnsEveryObjectExactlyOnce()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects(25));
|
||||
|
||||
List<string> 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]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that distinct filters on the same entry do not share memoized view list.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the same filter repeated returns identical totals.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that distinct cache entries project against their own data.</summary>
|
||||
[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<GalaxyObject> 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<GalaxyObject> CreateObjects(int count)
|
||||
{
|
||||
return Enumerable.Range(1, count)
|
||||
.Select(index => new GalaxyObject
|
||||
{
|
||||
GobjectId = index,
|
||||
TagName = $"Object_{index:000}",
|
||||
BrowseName = $"Object_{index:000}",
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Server-005 regression: the initial <c>RefreshAsync</c> call in
|
||||
/// <see cref="GalaxyHierarchyRefreshService"/> must not let a transient,
|
||||
/// non-cancellation first-load failure (e.g. a <see cref="TimeoutException"/>
|
||||
/// or <see cref="System.ComponentModel.Win32Exception"/> from connection
|
||||
/// establishment) escape and fault the host <c>BackgroundService</c>.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyRefreshServiceTests
|
||||
{
|
||||
/// <summary>Verifies that the background service does not fault when the first refresh throws a non-cancellation exception.</summary>
|
||||
[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<GalaxyHierarchyRefreshService>.Instance);
|
||||
}
|
||||
|
||||
private sealed class ThrowingCache(Exception toThrow) : IGalaxyHierarchyCache
|
||||
{
|
||||
private readonly TaskCompletionSource firstRefreshAttempted =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Gets the number of refresh calls.</summary>
|
||||
public int RefreshCallCount { get; private set; }
|
||||
|
||||
/// <summary>Gets a task that completes once refresh has been invoked at least once.</summary>
|
||||
public Task FirstRefreshAttempted => firstRefreshAttempted.Task;
|
||||
|
||||
/// <summary>Gets the current cache entry.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty;
|
||||
|
||||
/// <summary>Refreshes the cache asynchronously and throws the configured exception.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RefreshCallCount++;
|
||||
firstRefreshAttempted.TrySetResult();
|
||||
throw toThrow;
|
||||
}
|
||||
|
||||
/// <summary>Waits for the first load and completes immediately.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="GalaxyHierarchySnapshotStore"/>: the on-disk persistence
|
||||
/// that lets the Galaxy browse cache survive a cold start while the Galaxy
|
||||
/// database is unreachable.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _tempPaths = [];
|
||||
|
||||
/// <summary>Verifies that snapshots are correctly saved to and loaded from disk.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading returns null when no snapshot file exists.</summary>
|
||||
[Fact]
|
||||
public async Task TryLoadAsync_WhenNoFileExists_ReturnsNull()
|
||||
{
|
||||
GalaxyHierarchySnapshotStore store = CreateStore(CreateTempPath());
|
||||
|
||||
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that save writes nothing when persistence is disabled.</summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading returns null when the file contains invalid JSON.</summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that loading returns null when the schema version is unrecognized.</summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that saving overwrites an earlier snapshot.</summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>Verifies that mapping a galaxy attribute row preserves all scalar fields.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the array dimension present flag distinguishes null from zero.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null data type name becomes an empty string.</summary>
|
||||
[Fact]
|
||||
public void MapAttribute_NullDataTypeName_BecomesEmptyString()
|
||||
{
|
||||
GalaxyAttributeRow row = new() { DataTypeName = null };
|
||||
|
||||
GalaxyAttribute proto = GalaxyProtoMapper.MapAttribute(row);
|
||||
|
||||
Assert.Equal(string.Empty, proto.DataTypeName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that MapHierarchy groups attributes by GobjectId.</summary>
|
||||
[Fact]
|
||||
public void MapHierarchy_GroupsAttributesByGobjectId()
|
||||
{
|
||||
List<GalaxyHierarchyRow> 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<GalaxyAttributeRow> 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<GalaxyObject> 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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that MapObject copies the template chain.</summary>
|
||||
[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<int, List<GalaxyAttributeRow>>());
|
||||
|
||||
Assert.Equal(new[] { "EngineTpl", "AppEngineBase" }, proto.TemplateChain);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>Verifies that DiscoverHierarchy returns the requested page and totals.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DiscoverHierarchy with a page token returns remaining objects.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DiscoverHierarchy with invalid paging arguments returns InvalidArgument.</summary>
|
||||
/// <param name="pageToken">The page token to test.</param>
|
||||
/// <param name="pageSize">The page size to test.</param>
|
||||
[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<RpcException>(
|
||||
async () => await service.DiscoverHierarchy(
|
||||
new DiscoverHierarchyRequest
|
||||
{
|
||||
PageSize = pageSize,
|
||||
PageToken = pageToken,
|
||||
},
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DiscoverHierarchy with subtree root and depth filters descendants.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DiscoverHierarchy applies server-side filters and omits attributes.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DiscoverHierarchy with filtered paging returns post-filter total.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DiscoverHierarchy with mismatched filter token returns InvalidArgument.</summary>
|
||||
[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<RpcException>(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DiscoverHierarchy with missing root returns NotFound.</summary>
|
||||
[Fact]
|
||||
public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound()
|
||||
{
|
||||
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
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<GalaxyObject> 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<GalaxyObject> 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<GalaxyObject> 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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BrowseChildren returns root objects and the current cache sequence when called with no parent.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BrowseChildren returns Unavailable when the cache's first load never completes.</summary>
|
||||
[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<RpcException>(
|
||||
async () => await service.BrowseChildren(
|
||||
new BrowseChildrenRequest(),
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a page token bound to a stale cache sequence is rejected with InvalidArgument.</summary>
|
||||
[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<RpcException>(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that switching filters between paged BrowseChildren calls is rejected.</summary>
|
||||
[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<RpcException>(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an ApiKeyIdentity browse-subtrees constraint that matches nothing produces an empty child list.</summary>
|
||||
[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<string>(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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public GalaxyHierarchyCacheEntry Current { get; } =
|
||||
GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Unknown };
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) =>
|
||||
Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+61
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="GatewayBrowseScopeProvider"/>, the host seam that feeds the
|
||||
/// calling API key's <c>BrowseSubtrees</c> constraint into the shared library's Galaxy
|
||||
/// browse RPCs via <c>IGalaxyBrowseScopeProvider</c>.
|
||||
/// </summary>
|
||||
public sealed class GatewayBrowseScopeProviderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// With an ambient identity whose effective constraints carry a browse subtree,
|
||||
/// the provider returns exactly that subtree list to the library.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveBrowseSubtrees_WithCurrentIdentity_ReturnsConstraintBrowseSubtrees()
|
||||
{
|
||||
StubIdentityAccessor accessor = new(CreateIdentity(["AreaA"]));
|
||||
GatewayBrowseScopeProvider provider = new(accessor);
|
||||
|
||||
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
|
||||
|
||||
Assert.Equal(["AreaA"], result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveBrowseSubtrees_WithNoCurrentIdentity_ReturnsEmptyList()
|
||||
{
|
||||
StubIdentityAccessor accessor = new(current: null);
|
||||
GatewayBrowseScopeProvider provider = new(accessor);
|
||||
|
||||
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
private static ApiKeyIdentity CreateIdentity(IReadOnlyList<string> browseSubtrees) =>
|
||||
new(
|
||||
KeyId: "operator01",
|
||||
KeyPrefix: "mxgw_operator01",
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal),
|
||||
Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = browseSubtrees });
|
||||
|
||||
private sealed class StubIdentityAccessor(ApiKeyIdentity? current) : IGatewayRequestIdentityAccessor
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ApiKeyIdentity? Current { get; } = current;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable Push(ApiKeyIdentity identity) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user