using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Server.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Tests.TestSupport; namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; public sealed class GalaxyHierarchyCacheTests : IDisposable { private readonly List _tempPaths = []; /// /// Verifies cache returns empty entry before any refresh occurs. /// [Fact] public void Current_BeforeAnyRefresh_ReturnsEmpty() { GalaxyDeployNotifier notifier = new(); ThrowingGalaxyRepository repository = new(new InvalidOperationException("not invoked")); GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider()); GalaxyHierarchyCacheEntry entry = cache.Current; Assert.Equal(GalaxyCacheStatus.Unknown, entry.Status); Assert.False(entry.HasData); Assert.Equal(0, entry.ObjectCount); Assert.Empty(entry.Objects); } /// /// Verifies cache marks unavailable and does not publish when the repository /// surface throws — the production trigger for this code path is a SQL /// connection failure, but it is fully covered by the cache's exception /// branch and does not require a real TCP probe from a unit test. /// [Fact] public async Task RefreshAsync_WhenRepositoryThrows_MarksUnavailableAndDoesNotPublish() { GalaxyDeployNotifier notifier = new(); ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); GalaxyHierarchyCache cache = new(repository, notifier, clock); await cache.RefreshAsync(CancellationToken.None); Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); Assert.Equal("Galaxy repository unreachable", cache.Current.LastError); Assert.Null(notifier.Latest); Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully); Assert.Equal(1, repository.GetLastDeployTimeCount); Assert.Equal(0, repository.GetHierarchyCount); Assert.Equal(0, repository.GetAttributesCount); } /// /// Verifies HasData returns true for healthy cache entries. /// [Fact] public void HasData_OnHealthyEntry_IsTrue() { GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, LastSuccessAt = DateTimeOffset.UtcNow, ObjectCount = 1, }; Assert.True(entry.HasData); } /// /// Verifies HasData returns false for unknown cache entries. /// [Fact] public void HasData_OnUnknownEntry_IsFalse() { Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData); } [Fact] public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata() { GalaxyObject root = new() { GobjectId = 1, TagName = "Area1", ContainedName = "Area1", }; GalaxyObject duplicate = new() { GobjectId = 1, TagName = "DuplicateArea", ContainedName = "DuplicateArea", }; GalaxyObject child = new() { GobjectId = 2, ParentGobjectId = 1, TagName = "Pump_001", ContainedName = "Pump", Attributes = { new GalaxyAttribute { FullTagReference = "Pump_001.PV", IsHistorized = true, }, }, }; GalaxyObject orphan = new() { GobjectId = 3, ParentGobjectId = 99, TagName = "Orphan_001", ContainedName = "Orphan", }; GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, duplicate, child, orphan]); Assert.Equal("Area1/Pump", index.ObjectViewsById[2].ContainedPath); Assert.Equal("Orphan", index.ObjectViewsById[3].ContainedPath); Assert.Same(child, index.TagsByAddress["Pump_001.PV"].Object); Assert.NotNull(index.TagsByAddress["Pump_001.PV"].Attribute); Assert.Same(root, index.ObjectViewsById[1].Object); } /// /// Verifies a successful refresh writes the browse dataset to the on-disk /// snapshot store so a later cold start can restore it. /// [Fact] public async Task RefreshAsync_WhenSuccessful_PersistsSnapshotToDisk() { GalaxyDeployNotifier notifier = new(); StubGalaxyRepository repository = new( deployTime: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc), hierarchy: [SampleHierarchyRow()], attributes: [SampleAttributeRow()]); GalaxyHierarchySnapshotStore store = CreateStore(); GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); await cache.RefreshAsync(CancellationToken.None); Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status); GalaxyHierarchySnapshot? persisted = await store.TryLoadAsync(CancellationToken.None); Assert.NotNull(persisted); Assert.Equal(99, Assert.Single(persisted.Hierarchy).GobjectId); Assert.Equal("PV", Assert.Single(persisted.Attributes).AttributeName); } /// /// Verifies that when the Galaxy database is unreachable on first refresh but a /// snapshot exists on disk, the cache serves that data with Stale status /// rather than coming up empty. /// [Fact] public async Task RefreshAsync_WhenDatabaseUnreachableButSnapshotOnDisk_RestoresStaleData() { GalaxyHierarchySnapshotStore store = CreateStore(); await store.SaveAsync( new GalaxyHierarchySnapshot( LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 0, 0, TimeSpan.Zero), SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero), Hierarchy: [SampleHierarchyRow()], Attributes: [SampleAttributeRow()]), CancellationToken.None); GalaxyDeployNotifier notifier = new(); ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); await cache.RefreshAsync(CancellationToken.None); Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status); Assert.True(cache.Current.HasData); Assert.Equal(1, cache.Current.ObjectCount); Assert.Equal(1, cache.Current.AttributeCount); Assert.NotNull(notifier.Latest); } /// /// Verifies that when the disk snapshot's deploy time still matches the live /// Galaxy database, the cache promotes the restored data to Healthy /// without re-running the heavy hierarchy and attribute queries. /// [Fact] public async Task RefreshAsync_WhenSnapshotDeployMatchesLive_PromotesToHealthyWithoutHeavyQuery() { DateTime deployTime = new(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc); GalaxyHierarchySnapshotStore store = CreateStore(); await store.SaveAsync( new GalaxyHierarchySnapshot( LastDeployTime: new DateTimeOffset(deployTime, TimeSpan.Zero), SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero), Hierarchy: [SampleHierarchyRow()], Attributes: [SampleAttributeRow()]), CancellationToken.None); GalaxyDeployNotifier notifier = new(); StubGalaxyRepository repository = new(deployTime); GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); await cache.RefreshAsync(CancellationToken.None); Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status); Assert.Equal(1, cache.Current.ObjectCount); Assert.Equal(0, repository.GetHierarchyCount); Assert.Equal(0, repository.GetAttributesCount); } /// /// Verifies that a restored on-disk snapshot completes the first-load gate /// immediately, so a browse call racing the first refresh is not blocked for /// the full bootstrap budget while the live Galaxy query is still running. /// Regression test for Server-033. /// [Fact] public async Task RefreshAsync_RestoredSnapshotCompletesFirstLoadBeforeLiveQueryReturns() { GalaxyHierarchySnapshotStore store = CreateStore(); await store.SaveAsync( new GalaxyHierarchySnapshot( LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 0, 0, TimeSpan.Zero), SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero), Hierarchy: [SampleHierarchyRow()], Attributes: [SampleAttributeRow()]), CancellationToken.None); GalaxyDeployNotifier notifier = new(); BlockingGalaxyRepository repository = new(); GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); Task refresh = cache.RefreshAsync(CancellationToken.None); // The live query is blocked inside the repository; first-load must still // complete — from the restored snapshot — well within the wait budget. await cache.WaitForFirstLoadAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); Assert.True(cache.Current.HasData); Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status); repository.Release(); await refresh.WaitAsync(TimeSpan.FromSeconds(5)); } /// /// Verifies a corrupt on-disk snapshot does not crash startup: the cache /// ignores the unreadable file and comes up Unavailable when the database is /// also unreachable. Regression test for Server-037. /// [Fact] public async Task RefreshAsync_WhenSnapshotFileCorrupt_ComesUpUnavailableWithoutThrowing() { string path = CreateTempPath(); await File.WriteAllTextAsync(path, "{ this is not valid json"); GalaxyHierarchySnapshotStore store = CreateStore(path); GalaxyDeployNotifier notifier = new(); ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); await cache.RefreshAsync(CancellationToken.None); Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); Assert.False(cache.Current.HasData); } /// /// Verifies that with snapshot persistence disabled the cache does not /// restore from disk — an unreachable database leaves it Unavailable. /// Regression test for Server-037. /// [Fact] public async Task RefreshAsync_WhenPersistDisabled_DoesNotRestoreFromDisk() { GalaxyHierarchySnapshotStore store = CreateStore(CreateTempPath(), persist: false); GalaxyDeployNotifier notifier = new(); ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable")); GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store); await cache.RefreshAsync(CancellationToken.None); Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); Assert.False(cache.Current.HasData); } /// /// Verifies that a snapshot save aborted because the gateway is shutting down /// (the refresh token is cancelled) is not logged as a persistence failure. /// Regression test for Server-036. /// [Fact] public async Task RefreshAsync_WhenSnapshotSaveCancelledAtShutdown_DoesNotLogPersistFailure() { using CancellationTokenSource cts = new(); GalaxyDeployNotifier notifier = new(); StubGalaxyRepository repository = new( deployTime: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc), hierarchy: [SampleHierarchyRow()], attributes: [SampleAttributeRow()]); CancellingSaveStore store = new(cts); RecordingLogger logger = new(); GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), logger, store); await cache.RefreshAsync(cts.Token); Assert.DoesNotContain( logger.Entries, entry => entry.Level == LogLevel.Warning && entry.Message.Contains("persist", StringComparison.OrdinalIgnoreCase)); } private static GalaxyHierarchyRow SampleHierarchyRow() => new() { GobjectId = 99, TagName = "Pump_001", ContainedName = "Pump", BrowseName = "Pump", CategoryId = 10, TemplateChain = ["AppPump"], }; private static GalaxyAttributeRow SampleAttributeRow() => new() { GobjectId = 99, TagName = "Pump_001", AttributeName = "PV", FullTagReference = "Pump_001.PV", MxDataType = 5, DataTypeName = "Float", }; private string CreateTempPath() { string path = Path.Combine( Path.GetTempPath(), $"mxgw-galaxy-cache-test-{Guid.NewGuid():N}.json"); _tempPaths.Add(path); return path; } private GalaxyHierarchySnapshotStore CreateStore() => CreateStore(CreateTempPath()); private static GalaxyHierarchySnapshotStore CreateStore(string path, bool persist = true) { GalaxyRepositoryOptions options = new() { PersistSnapshot = persist, SnapshotCachePath = path, }; return new GalaxyHierarchySnapshotStore(Options.Create(options)); } /// whose deploy-time query blocks until released. private sealed class BlockingGalaxyRepository : IGalaxyRepository { private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); public void Release() => _release.TrySetResult(); public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false); public async Task GetLastDeployTimeAsync(CancellationToken ct = default) { await _release.Task.WaitAsync(ct).ConfigureAwait(false); throw new InvalidOperationException("Galaxy repository unreachable"); } public Task> GetHierarchyAsync(CancellationToken ct = default) => throw new InvalidOperationException("GetHierarchyAsync should not be reached"); public Task> GetAttributesAsync(CancellationToken ct = default) => throw new InvalidOperationException("GetAttributesAsync should not be reached"); } /// Snapshot store whose cancels the token mid-save. private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore { public Task TryLoadAsync(CancellationToken cancellationToken) => Task.FromResult(null); public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken) { cts.Cancel(); cancellationToken.ThrowIfCancellationRequested(); return Task.CompletedTask; } } /// Minimal that records every emitted log entry. private sealed class RecordingLogger : ILogger { public List<(LogLevel Level, string Message)> Entries { get; } = []; public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Entries.Add((logLevel, formatter(state, exception))); } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } /// In-memory that returns fixed rowsets. private sealed class StubGalaxyRepository( DateTime? deployTime, List? hierarchy = null, List? attributes = null) : IGalaxyRepository { private readonly List _hierarchy = hierarchy ?? []; private readonly List _attributes = attributes ?? []; public int GetHierarchyCount { get; private set; } public int GetAttributesCount { get; private set; } public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); public Task GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime); public Task> GetHierarchyAsync(CancellationToken ct = default) { GetHierarchyCount++; return Task.FromResult(_hierarchy); } public Task> GetAttributesAsync(CancellationToken ct = default) { GetAttributesCount++; return Task.FromResult(_attributes); } } public void Dispose() { foreach (string path in _tempPaths) { try { File.Delete(path); File.Delete(path + ".tmp"); } catch (IOException) { // Best-effort cleanup of test scratch files. } } } private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository { /// Gets the number of times was called. public int GetLastDeployTimeCount { get; private set; } /// Gets the number of times was called. public int GetHierarchyCount { get; private set; } /// Gets the number of times was called. public int GetAttributesCount { get; private set; } /// public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false); /// public Task GetLastDeployTimeAsync(CancellationToken ct = default) { GetLastDeployTimeCount++; throw toThrow; } /// public Task> GetHierarchyAsync(CancellationToken ct = default) { GetHierarchyCount++; throw toThrow; } /// public Task> GetAttributesAsync(CancellationToken ct = default) { GetAttributesCount++; throw toThrow; } } }