test(galaxyrepo): projector + cache tests; dispose semaphores; pack 0.1.0
This commit is contained in:
+236
@@ -0,0 +1,236 @@
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="GalaxyHierarchyCache"/> first-load, deploy-gating, snapshot
|
||||
/// restore, persistence, and status-transition behavior. Uses an in-memory
|
||||
/// <see cref="IGalaxyRepository"/> and snapshot store plus a fixed
|
||||
/// <see cref="StubTimeProvider"/> so no SQL is touched and no asserts are time-sensitive.
|
||||
/// </summary>
|
||||
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<GalaxyHierarchyRow> 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<GalaxyAttributeRow> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user