using ZB.MOM.WW.GalaxyRepository; namespace ZB.MOM.WW.GalaxyRepository.Tests; /// /// Tests for first-load, deploy-gating, snapshot /// restore, persistence, and status-transition behavior. Uses an in-memory /// and snapshot store plus a fixed /// so no SQL is touched and no asserts are time-sensitive. /// public sealed class GalaxyHierarchyCacheTests { private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); private static readonly DateTime DeployTime = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static List SampleHierarchy() => [ new() { GobjectId = 1, TagName = "Area1", ContainedName = "Area1", BrowseName = "Area1", IsArea = true }, new() { GobjectId = 2, TagName = "Pump01", ContainedName = "Pump01", BrowseName = "Pump01", ParentGobjectId = 1 }, ]; private static List SampleAttributes() => [ new() { GobjectId = 2, AttributeName = "PV", FullTagReference = "Pump01.PV", IsHistorized = true, IsAlarm = true }, ]; [Fact] public async Task RefreshAsync_FirstLoad_PopulatesCurrentWithDataAndUnblocksWaitForFirstLoad() { FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime); RecordingDeployNotifier notifier = new(); using GalaxyHierarchyCache cache = new(repository, notifier, new StubTimeProvider(FixedNow)); // Before refresh, the gate is unset and there is no data. Assert.False(cache.Current.HasData); Assert.Equal(GalaxyCacheStatus.Unknown, cache.Current.Status); await cache.RefreshAsync(CancellationToken.None); // First load completes (does not hang) and Current now holds usable data. await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token); GalaxyHierarchyCacheEntry current = cache.Current; Assert.True(current.HasData); Assert.Equal(GalaxyCacheStatus.Healthy, current.Status); Assert.Equal(2, current.ObjectCount); Assert.Equal(1, current.AreaCount); Assert.Equal(1, current.AttributeCount); Assert.Equal(1, current.HistorizedAttributeCount); Assert.Equal(1, current.AlarmAttributeCount); // The heavy queries ran exactly once and a deploy event was published. Assert.Equal(1, repository.HierarchyReadCount); Assert.Equal(1, repository.AttributeReadCount); GalaxyDeployEventInfo published = Assert.Single(notifier.Published); Assert.Equal(2, published.ObjectCount); Assert.Equal(1, published.AttributeCount); } [Fact] public async Task RefreshAsync_NoDeployChange_SkipsHeavyQueriesOnSecondRefresh() { FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime); using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow)); await cache.RefreshAsync(CancellationToken.None); await cache.RefreshAsync(CancellationToken.None); // Deploy time unchanged => the heavy hierarchy/attribute reads happened only once. Assert.Equal(1, repository.HierarchyReadCount); Assert.Equal(1, repository.AttributeReadCount); Assert.True(cache.Current.HasData); Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status); } [Fact] public async Task RefreshAsync_DeployAdvances_RebuildsAndBumpsSequence() { FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime); RecordingDeployNotifier notifier = new(); using GalaxyHierarchyCache cache = new(repository, notifier, new StubTimeProvider(FixedNow)); await cache.RefreshAsync(CancellationToken.None); long firstSequence = cache.Current.Sequence; repository.DeployTime = DeployTime.AddHours(1); await cache.RefreshAsync(CancellationToken.None); Assert.Equal(2, repository.HierarchyReadCount); Assert.Equal(firstSequence + 1, cache.Current.Sequence); Assert.Equal(2, notifier.Published.Count); } [Fact] public async Task RefreshAsync_FirstQueryFailsNoPriorData_StatusUnavailableButFirstLoadStillCompletes() { FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime) { ThrowOnQuery = new TimeoutException("galaxy db unreachable"), }; using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow)); await cache.RefreshAsync(CancellationToken.None); // First load must complete so callers do not hang, even though the query failed. await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token); Assert.False(cache.Current.HasData); Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); Assert.Contains("unreachable", cache.Current.LastError); } [Fact] public async Task RefreshAsync_QueryFailsAfterPriorData_DegradesToStaleAndKeepsData() { FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime); using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow)); await cache.RefreshAsync(CancellationToken.None); Assert.True(cache.Current.HasData); // A later refresh fails: data is retained but flagged Stale. repository.DeployTime = DeployTime.AddHours(1); repository.ThrowOnQuery = new InvalidOperationException("transient"); await cache.RefreshAsync(CancellationToken.None); Assert.True(cache.Current.HasData); Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status); Assert.Equal(2, cache.Current.ObjectCount); } [Fact] public async Task Current_AfterStalenessThreshold_ProjectsHealthyToStale() { FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime); StubTimeProvider clock = new(FixedNow); using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), clock); await cache.RefreshAsync(CancellationToken.None); Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status); // Advance past the 5-minute staleness threshold with no successful refresh. clock.Advance(TimeSpan.FromMinutes(6)); Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status); // Data is still present — Stale means "old", not "gone". Assert.True(cache.Current.HasData); } [Fact] public async Task RefreshAsync_PersistsSnapshotAfterSuccessfulHeavyRefresh() { FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime); FakeSnapshotStore store = new(); using GalaxyHierarchyCache cache = new( repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow), logger: null, snapshotStore: store); await cache.RefreshAsync(CancellationToken.None); Assert.Equal(1, store.SaveCount); Assert.NotNull(store.Snapshot); Assert.Equal(2, store.Snapshot!.Hierarchy.Count); Assert.Single(store.Snapshot.Attributes); } [Fact] public async Task RefreshAsync_SnapshotRestore_ServesLastKnownDataAsStaleWhenDatabaseUnreachable() { // The snapshot store already holds a persisted dataset (last-known browse data). FakeSnapshotStore store = new() { Snapshot = new GalaxyHierarchySnapshot( LastDeployTime: DeployTime, SavedAt: FixedNow.AddMinutes(-1), Hierarchy: SampleHierarchy(), Attributes: SampleAttributes()), }; // The Galaxy database is unreachable on this cold start. FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime) { ThrowOnQuery = new TimeoutException("cold start, db down"), }; RecordingDeployNotifier notifier = new(); using GalaxyHierarchyCache cache = new( repository, notifier, new StubTimeProvider(FixedNow), logger: null, snapshotStore: store); await cache.RefreshAsync(CancellationToken.None); // First load is satisfied by the restored snapshot, not by SQL. await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token); Assert.Equal(1, store.LoadCount); GalaxyHierarchyCacheEntry current = cache.Current; Assert.True(current.HasData); // Restored data is "last-known", surfaced as Stale until the live DB confirms. Assert.Equal(GalaxyCacheStatus.Stale, current.Status); Assert.Equal(2, current.ObjectCount); Assert.Equal(DeployTime, current.LastDeployTime!.Value.UtcDateTime); // A deploy event was published for the restored data. Assert.Single(notifier.Published); } [Fact] public async Task RefreshAsync_SnapshotRestoreThenLiveQuery_PromotesRestoredDataToHealthy() { FakeSnapshotStore store = new() { Snapshot = new GalaxyHierarchySnapshot( LastDeployTime: DeployTime, SavedAt: FixedNow.AddMinutes(-1), Hierarchy: SampleHierarchy(), Attributes: SampleAttributes()), }; // DB is reachable and reports the SAME deploy time the snapshot was pulled at. FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime); using GalaxyHierarchyCache cache = new( repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow), logger: null, snapshotStore: store); await cache.RefreshAsync(CancellationToken.None); // Restore seeds Stale data; the same-deploy live query promotes it to Healthy // without re-running the heavy hierarchy/attribute reads. Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status); Assert.Equal(0, repository.HierarchyReadCount); Assert.True(cache.Current.HasData); } [Fact] public void Dispose_CanBeCalledWithoutHavingRefreshed() { FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime); GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow)); // Dispose must be safe even when no refresh ever ran (semaphore never entered). cache.Dispose(); } }