rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.
External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths
Also fixes two tests that were not rename-related but became visible
while validating the rename:
- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
gateway service correctly maps to RpcException(Cancelled) per gRPC
convention was being misclassified as a stream fault. Added a sibling
catch on RpcException with StatusCode.Cancelled.
- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
and made it accept either a .git marker OR a .sln/.slnx next to src/
so the worker-exe walker works in non-git working copies.
clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.
Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
Tests: 472/472 pass
Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
IntegrationTests: 18/18 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using System.Diagnostics;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
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.MxGateway.Tests.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Adversarial-input coverage for the Galaxy Repository browse filter layer.
|
||||
/// <para>
|
||||
/// Re-triage note (finding Tests-002): the Galaxy Repository's SQL surface
|
||||
/// (<c>HierarchySql</c>, <c>AttributesSql</c>, <c>SELECT 1</c>,
|
||||
/// <c>SELECT time_of_last_deploy FROM galaxy</c>) is entirely constant — no
|
||||
/// <see cref="DiscoverHierarchyRequest"/> field is ever concatenated into a SQL
|
||||
/// string. All filters (<c>TagNameGlob</c>, <c>RootTagName</c>, category ids,
|
||||
/// template-chain filters, contained-path roots) are applied in memory by
|
||||
/// <see cref="GalaxyHierarchyProjector"/> against the cached snapshot, so there is
|
||||
/// no SQL-injection surface and no <c>LIKE</c>-escaping helper to test.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The genuine, testable concern is that adversarial filter strings — SQL
|
||||
/// metacharacters (<c>'</c>, <c>;</c>) and <c>LIKE</c>-wildcards (<c>%</c>,
|
||||
/// <c>_</c>) — are treated as opaque literals by the in-memory filter layer:
|
||||
/// they must never act as wildcards, never throw, and never trigger catastrophic
|
||||
/// regex backtracking in <see cref="GalaxyGlobMatcher"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GalaxyFilterInputSafetyTests
|
||||
{
|
||||
private static readonly string[] AdversarialInputs =
|
||||
[
|
||||
"'",
|
||||
"' OR '1'='1",
|
||||
"'; DROP TABLE gobject;--",
|
||||
"%",
|
||||
"_",
|
||||
"100%_off",
|
||||
"[abc]",
|
||||
"Pump'001",
|
||||
];
|
||||
|
||||
public static TheoryData<string> AdversarialInputCases()
|
||||
{
|
||||
TheoryData<string> data = [];
|
||||
foreach (string input in AdversarialInputs)
|
||||
{
|
||||
data.Add(input);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="GalaxyGlobMatcher"/> treats SQL metacharacters and
|
||||
/// <c>LIKE</c>-wildcards as literals — a glob equal to the literal value matches,
|
||||
/// and the same glob does not spuriously match an unrelated value.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(AdversarialInputCases))]
|
||||
public void GlobMatcher_TreatsSqlMetacharactersAsLiterals(string input)
|
||||
{
|
||||
Assert.True(
|
||||
GalaxyGlobMatcher.IsMatch(input, input),
|
||||
$"A glob equal to the literal value should match: {input}");
|
||||
Assert.False(
|
||||
GalaxyGlobMatcher.IsMatch("UnrelatedTagName", input),
|
||||
$"Adversarial glob must not behave as a wildcard against unrelated text: {input}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the SQL <c>LIKE</c> wildcards <c>%</c> and <c>_</c> are NOT treated as
|
||||
/// wildcards by the glob matcher; only <c>*</c> and <c>?</c> are glob wildcards.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GlobMatcher_DoesNotTreatLikeWildcardsAsWildcards()
|
||||
{
|
||||
// '%' would match anything if interpreted as a SQL LIKE wildcard.
|
||||
Assert.False(GalaxyGlobMatcher.IsMatch("Pump_001", "%"));
|
||||
// '_' would match a single character if interpreted as a SQL LIKE wildcard.
|
||||
Assert.False(GalaxyGlobMatcher.IsMatch("A", "_"));
|
||||
Assert.True(GalaxyGlobMatcher.IsMatch("_", "_"));
|
||||
// '*' and '?' remain glob wildcards.
|
||||
Assert.True(GalaxyGlobMatcher.IsMatch("Pump_001", "Pump*"));
|
||||
Assert.True(GalaxyGlobMatcher.IsMatch("Pump_001", "Pump_00?"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for finding Server-008: <see cref="GalaxyGlobMatcher"/> caches
|
||||
/// the compiled regex per glob pattern. Repeated calls with the same pattern, and
|
||||
/// interleaved calls with different patterns, must keep returning the correct
|
||||
/// literal-vs-wildcard result rather than a stale cached match.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GlobMatcher_RepeatedAndInterleavedPatterns_StayCorrect()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
Assert.True(GalaxyGlobMatcher.IsMatch("Pump_001", "Pump_*"));
|
||||
Assert.False(GalaxyGlobMatcher.IsMatch("Valve_001", "Pump_*"));
|
||||
Assert.True(GalaxyGlobMatcher.IsMatch("Valve_001", "Valve_00?"));
|
||||
Assert.False(GalaxyGlobMatcher.IsMatch("Pump_001", "Valve_00?"));
|
||||
// A glob equal to a SQL metacharacter still matches only its literal.
|
||||
Assert.True(GalaxyGlobMatcher.IsMatch("%", "%"));
|
||||
Assert.False(GalaxyGlobMatcher.IsMatch("anything", "%"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GlobMatcher_WithManyDistinctPatterns_CacheStaysBounded()
|
||||
{
|
||||
// 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;
|
||||
for (int i = 0; i < submissions; i++)
|
||||
{
|
||||
string uniqueGlob = $"client_supplied_{i}_*";
|
||||
GalaxyGlobMatcher.IsMatch($"client_supplied_{i}_thing", uniqueGlob);
|
||||
}
|
||||
|
||||
Assert.InRange(GalaxyGlobMatcher.CurrentCacheSize, 0, GalaxyGlobMatcher.RegexCacheCapacity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a pathological glob does not cause catastrophic regex backtracking —
|
||||
/// <see cref="GalaxyGlobMatcher"/> escapes every literal character and applies a
|
||||
/// 100 ms regex timeout, so a long adversarial input completes promptly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GlobMatcher_WithPathologicalInput_DoesNotHang()
|
||||
{
|
||||
string pathologicalGlob = new string('a', 5000) + "!";
|
||||
string pathologicalValue = new string('a', 5000);
|
||||
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
bool matched = GalaxyGlobMatcher.IsMatch(pathologicalValue, pathologicalGlob);
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.False(matched);
|
||||
Assert.True(
|
||||
stopwatch.Elapsed < TimeSpan.FromSeconds(2),
|
||||
$"Glob matching took {stopwatch.ElapsedMilliseconds} ms — expected sub-second.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the <see cref="GalaxyHierarchyProjector"/> <c>TagNameGlob</c> filter
|
||||
/// treats an adversarial glob as a literal: it never wildcard-matches the whole
|
||||
/// hierarchy and never throws.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(AdversarialInputCases))]
|
||||
public void Projector_TagNameGlob_WithAdversarialInput_DoesNotMatchEverything(string glob)
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects());
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest { TagNameGlob = glob });
|
||||
|
||||
// None of the seeded tag names equal an adversarial string, so a correctly
|
||||
// literal filter returns zero matches rather than the whole hierarchy.
|
||||
Assert.Equal(0, result.TotalObjectCount);
|
||||
Assert.Empty(result.Objects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an adversarial <c>RootTagName</c> resolves through the projector as a
|
||||
/// literal — an exact-match lookup that finds nothing and surfaces NotFound,
|
||||
/// never matching unrelated objects or throwing an unexpected exception.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(AdversarialInputCases))]
|
||||
public void Projector_RootTagName_WithAdversarialInput_ThrowsNotFound(string rootTagName)
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects());
|
||||
|
||||
RpcException exception = Assert.Throws<RpcException>(
|
||||
() => GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest { RootTagName = rootTagName }));
|
||||
|
||||
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an adversarial <c>TemplateChainContains</c> filter is a literal
|
||||
/// substring test — it never matches unrelated template chains and never throws.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(AdversarialInputCases))]
|
||||
public void Projector_TemplateChainContains_WithAdversarialInput_MatchesNothing(string filter)
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects());
|
||||
DiscoverHierarchyRequest request = new();
|
||||
request.TemplateChainContains.Add(filter);
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||
|
||||
Assert.Equal(0, result.TotalObjectCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the <see cref="GalaxyRepositoryGrpcService.DiscoverHierarchy"/> RPC
|
||||
/// handles an adversarial <c>TagNameGlob</c> end-to-end: the request succeeds with
|
||||
/// zero matches rather than returning the whole hierarchy or faulting.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(AdversarialInputCases))]
|
||||
public async Task DiscoverHierarchy_WithAdversarialTagNameGlob_ReturnsZeroMatches(string glob)
|
||||
{
|
||||
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects()));
|
||||
|
||||
DiscoverHierarchyReply reply = await service.DiscoverHierarchy(
|
||||
new DiscoverHierarchyRequest { TagNameGlob = glob, PageSize = 100 },
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(0, reply.TotalObjectCount);
|
||||
Assert.Empty(reply.Objects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the <see cref="GalaxyRepositoryGrpcService.DiscoverHierarchy"/> RPC
|
||||
/// maps an adversarial <c>RootTagName</c> to NotFound rather than executing it as
|
||||
/// a query fragment or matching unrelated objects.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(AdversarialInputCases))]
|
||||
public async Task DiscoverHierarchy_WithAdversarialRootTagName_ReturnsNotFound(string rootTagName)
|
||||
{
|
||||
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects()));
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await service.DiscoverHierarchy(
|
||||
new DiscoverHierarchyRequest { RootTagName = rootTagName, PageSize = 100 },
|
||||
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 ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options),
|
||||
new StubGalaxyHierarchyCache(entry),
|
||||
new GalaxyDeployNotifier(),
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
NullLogger<GalaxyRepositoryGrpcService>.Instance);
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
return
|
||||
[
|
||||
new GalaxyObject
|
||||
{
|
||||
GobjectId = 1,
|
||||
TagName = "Area1",
|
||||
ContainedName = "Area1",
|
||||
BrowseName = "Area1",
|
||||
IsArea = true,
|
||||
CategoryId = 13,
|
||||
},
|
||||
new GalaxyObject
|
||||
{
|
||||
GobjectId = 2,
|
||||
TagName = "Pump_001",
|
||||
ContainedName = "Pump",
|
||||
BrowseName = "Pump_001",
|
||||
ParentGobjectId = 1,
|
||||
CategoryId = 10,
|
||||
TemplateChain = { "$Pump", "$Base" },
|
||||
},
|
||||
new GalaxyObject
|
||||
{
|
||||
GobjectId = 3,
|
||||
TagName = "Valve_001",
|
||||
ContainedName = "Valve",
|
||||
BrowseName = "Valve_001",
|
||||
ParentGobjectId = 1,
|
||||
CategoryId = 11,
|
||||
TemplateChain = { "$Valve" },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
|
||||
{
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
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);
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
public void Release() => _release.TrySetResult();
|
||||
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);
|
||||
|
||||
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
await _release.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||
throw new InvalidOperationException("Galaxy repository unreachable");
|
||||
}
|
||||
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("GetHierarchyAsync should not be reached");
|
||||
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("GetAttributesAsync should not be reached");
|
||||
}
|
||||
|
||||
/// <summary>Snapshot store whose <see cref="SaveAsync"/> cancels the token mid-save.</summary>
|
||||
private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore
|
||||
{
|
||||
public Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<GalaxyHierarchySnapshot?>(null);
|
||||
|
||||
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>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state)
|
||||
where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
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();
|
||||
|
||||
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 ?? [];
|
||||
|
||||
public int GetHierarchyCount { get; private set; }
|
||||
|
||||
public int GetAttributesCount { get; private set; }
|
||||
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
|
||||
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime);
|
||||
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
GetHierarchyCount++;
|
||||
return Task.FromResult(_hierarchy);
|
||||
}
|
||||
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
GetAttributesCount++;
|
||||
return Task.FromResult(_attributes);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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
|
||||
{
|
||||
[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]);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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
|
||||
{
|
||||
[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);
|
||||
|
||||
public int RefreshCallCount { get; private set; }
|
||||
|
||||
/// <summary>Completes once <see cref="RefreshAsync"/> has been invoked at least once.</summary>
|
||||
public Task FirstRefreshAttempted => firstRefreshAttempted.Task;
|
||||
|
||||
public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty;
|
||||
|
||||
public Task RefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RefreshCallCount++;
|
||||
firstRefreshAttempted.TrySetResult();
|
||||
throw toThrow;
|
||||
}
|
||||
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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 = [];
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryLoadAsync_WhenNoFileExists_ReturnsNull()
|
||||
{
|
||||
GalaxyHierarchySnapshotStore store = CreateStore(CreateTempPath());
|
||||
|
||||
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_OverwritesAnEarlierSnapshot()
|
||||
{
|
||||
string path = CreateTempPath();
|
||||
GalaxyHierarchySnapshotStore store = CreateStore(path);
|
||||
|
||||
await store.SaveAsync(SampleSnapshot(), CancellationToken.None);
|
||||
GalaxyHierarchySnapshot second = SampleSnapshot() with
|
||||
{
|
||||
Hierarchy = [],
|
||||
Attributes = [],
|
||||
};
|
||||
await store.SaveAsync(second, CancellationToken.None);
|
||||
|
||||
GalaxyHierarchySnapshot? loaded = await store.TryLoadAsync(CancellationToken.None);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Empty(loaded.Hierarchy);
|
||||
Assert.Empty(loaded.Attributes);
|
||||
}
|
||||
|
||||
private static GalaxyHierarchySnapshot SampleSnapshot() => new(
|
||||
LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 30, 0, TimeSpan.Zero),
|
||||
SavedAt: new DateTimeOffset(2026, 5, 20, 9, 31, 0, TimeSpan.Zero),
|
||||
Hierarchy:
|
||||
[
|
||||
new GalaxyHierarchyRow
|
||||
{
|
||||
GobjectId = 7,
|
||||
TagName = "Pump_001",
|
||||
ContainedName = "Pump",
|
||||
BrowseName = "Pump",
|
||||
CategoryId = 10,
|
||||
TemplateChain = ["AppPump", "Pump"],
|
||||
},
|
||||
],
|
||||
Attributes:
|
||||
[
|
||||
new GalaxyAttributeRow
|
||||
{
|
||||
GobjectId = 7,
|
||||
TagName = "Pump_001",
|
||||
AttributeName = "PV",
|
||||
FullTagReference = "Pump_001.PV[]",
|
||||
MxDataType = 5,
|
||||
DataTypeName = "Float",
|
||||
IsArray = true,
|
||||
ArrayDimension = 8,
|
||||
IsAlarm = true,
|
||||
},
|
||||
new GalaxyAttributeRow
|
||||
{
|
||||
GobjectId = 7,
|
||||
TagName = "Pump_001",
|
||||
AttributeName = "Mode",
|
||||
FullTagReference = "Pump_001.Mode",
|
||||
MxDataType = 3,
|
||||
DataTypeName = "Integer",
|
||||
ArrayDimension = null,
|
||||
},
|
||||
]);
|
||||
|
||||
private static GalaxyHierarchySnapshotStore CreateStore(string path, bool persist = true)
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
{
|
||||
PersistSnapshot = persist,
|
||||
SnapshotCachePath = path,
|
||||
};
|
||||
return new GalaxyHierarchySnapshotStore(Options.Create(options));
|
||||
}
|
||||
|
||||
private string CreateTempPath()
|
||||
{
|
||||
string path = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"mxgw-galaxy-snapshot-{Guid.NewGuid():N}.json");
|
||||
_tempPaths.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string path in _tempPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
File.Delete(path + ".tmp");
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort cleanup of test scratch files.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user