test: cover GalaxyBrowseProjector, GalaxyDeployNotifier, GalaxyHierarchyRefreshService (ported from mxaccessgw on adoption)
This commit is contained in:
+371
@@ -0,0 +1,371 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Direct coverage for <see cref="GalaxyBrowseProjector"/>. Validates parent
|
||||||
|
/// resolution (gobject id / tag name / contained path), paging across siblings,
|
||||||
|
/// filter parity with <see cref="GalaxyHierarchyProjector"/>, the
|
||||||
|
/// <c>child_has_children</c> hint, browse-subtree constraints, and the
|
||||||
|
/// attribute-skeleton mode.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyBrowseProjectorTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that an empty parent oneof returns the root area.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_NoParent_ReturnsRootArea()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest(),
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10);
|
||||||
|
|
||||||
|
Assert.Single(result.Children);
|
||||||
|
Assert.Equal("Plant", result.Children[0].TagName);
|
||||||
|
Assert.True(result.ChildHasChildren[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that resolving the parent by gobject id returns sorted direct children.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_ByParentGobjectId_ReturnsDirectChildren()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10);
|
||||||
|
|
||||||
|
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||||
|
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
|
||||||
|
Assert.Equal(new[] { true, false, false, false }, result.ChildHasChildren.ToArray());
|
||||||
|
Assert.Equal(4, result.TotalChildCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that resolving the parent by tag name returns the same direct children.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_ByParentTagName_ResolvesParent()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest { ParentTagName = "Plant" },
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10);
|
||||||
|
|
||||||
|
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||||
|
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that resolving the parent by contained path returns the same direct children.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_ByParentContainedPath_ResolvesParent()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest { ParentContainedPath = "Plant" },
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10);
|
||||||
|
|
||||||
|
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||||
|
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an unknown parent gobject id throws an RpcException with StatusCode.NotFound.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_UnknownParent_ThrowsNotFound()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||||
|
|
||||||
|
RpcException exception = Assert.Throws<RpcException>(() => GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest { ParentGobjectId = 999 },
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that paging across siblings returns every sibling exactly once.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_PagedAcrossSiblings_ReturnsEverySiblingOnce()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult first = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 2);
|
||||||
|
GalaxyBrowseChildrenResult second = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
offset: 2,
|
||||||
|
pageSize: 2);
|
||||||
|
|
||||||
|
List<string> collected = first.Children
|
||||||
|
.Concat(second.Children)
|
||||||
|
.Select(child => child.TagName)
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal(4, collected.Count);
|
||||||
|
Assert.Equal(collected.Count, collected.Distinct(StringComparer.Ordinal).Count());
|
||||||
|
Assert.Equal(
|
||||||
|
new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"Plant.Line_A",
|
||||||
|
"Plant.Mixer_001",
|
||||||
|
"Plant.Mixer_002",
|
||||||
|
"Plant.Pump_001",
|
||||||
|
},
|
||||||
|
new HashSet<string>(collected, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a tag-name glob filters direct children and clears the has-children hint.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_TagNameGlobFiltersChildren_AndUpdatesHasChildren()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest
|
||||||
|
{
|
||||||
|
ParentGobjectId = 1,
|
||||||
|
TagNameGlob = "*Mixer*",
|
||||||
|
},
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10);
|
||||||
|
|
||||||
|
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||||
|
Assert.Equal(new[] { "Plant.Mixer_001", "Plant.Mixer_002" }, names);
|
||||||
|
Assert.Equal(new[] { false, false }, result.ChildHasChildren.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that historized-only filtering also drives the has-children hint via descendants.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_HistorizedOnlyFiltersDescendants_HasChildrenReflectsFilter()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest
|
||||||
|
{
|
||||||
|
ParentGobjectId = 1,
|
||||||
|
HistorizedOnly = true,
|
||||||
|
},
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10);
|
||||||
|
|
||||||
|
// Line_A itself has no historized attributes, but its descendant Sensor_A1 does,
|
||||||
|
// so the subtree match keeps Line_A in the result with has-children = true.
|
||||||
|
// Mixer_001/Mixer_002/Pump_001 have no historized attributes themselves and
|
||||||
|
// no historized descendants -> filtered out entirely.
|
||||||
|
Assert.Single(result.Children);
|
||||||
|
Assert.Equal("Plant.Line_A", result.Children[0].TagName);
|
||||||
|
Assert.Equal(new[] { true }, result.ChildHasChildren.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that <c>IncludeAttributes=false</c> returns object skeletons.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_IncludeAttributesFalse_ReturnsSkeletons()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest
|
||||||
|
{
|
||||||
|
ParentGobjectId = 1,
|
||||||
|
IncludeAttributes = false,
|
||||||
|
},
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10);
|
||||||
|
|
||||||
|
GalaxyObject mixer = result.Children.Single(child => child.TagName == "Plant.Mixer_001");
|
||||||
|
Assert.Empty(mixer.Attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that browse-subtree globs constrain the returned children.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_BrowseSubtrees_ExcludesChildrenOutsideAllowedGlobs()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||||
|
browseSubtreeGlobs: new[] { "Plant/Line_*" },
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10);
|
||||||
|
|
||||||
|
Assert.Single(result.Children);
|
||||||
|
Assert.Equal("Plant.Line_A", result.Children[0].TagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="GalaxyBrowseProjector"/> terminates when the Galaxy data
|
||||||
|
/// contains a cyclic parent chain (A→B→C→A). Without the visited-id guard in
|
||||||
|
/// <c>HasMatchingDescendant</c>, the depth-first walk would loop forever; the
|
||||||
|
/// 5-second xUnit timeout asserts termination.
|
||||||
|
/// </summary>
|
||||||
|
[Fact(Timeout = 5000)]
|
||||||
|
public async Task Project_CyclicDescendants_DoesNotInfiniteLoop()
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
// Construct a 3-node cycle: A(10)→B(11)→C(12)→A. Each node's ParentGobjectId
|
||||||
|
// points to the next, so GalaxyHierarchyIndex.ChildrenByParent has
|
||||||
|
// [12] = [A], [10] = [B], [11] = [C].
|
||||||
|
// None of them are historized, so HistorizedOnly=true forces the projector to
|
||||||
|
// call HasMatchingDescendant on every direct child, exercising the cycle walk.
|
||||||
|
GalaxyObject a = new()
|
||||||
|
{
|
||||||
|
GobjectId = 10,
|
||||||
|
ParentGobjectId = 12,
|
||||||
|
ContainedName = "A",
|
||||||
|
BrowseName = "A",
|
||||||
|
TagName = "A",
|
||||||
|
};
|
||||||
|
GalaxyObject b = new()
|
||||||
|
{
|
||||||
|
GobjectId = 11,
|
||||||
|
ParentGobjectId = 10,
|
||||||
|
ContainedName = "B",
|
||||||
|
BrowseName = "B",
|
||||||
|
TagName = "B",
|
||||||
|
};
|
||||||
|
GalaxyObject c = new()
|
||||||
|
{
|
||||||
|
GobjectId = 12,
|
||||||
|
ParentGobjectId = 11,
|
||||||
|
ContainedName = "C",
|
||||||
|
BrowseName = "C",
|
||||||
|
TagName = "C",
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<GalaxyObject> objects = new[] { a, b, c };
|
||||||
|
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||||
|
{
|
||||||
|
Status = GalaxyCacheStatus.Healthy,
|
||||||
|
Sequence = 1,
|
||||||
|
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||||
|
Objects = objects,
|
||||||
|
Index = GalaxyHierarchyIndex.Build(objects),
|
||||||
|
ObjectCount = objects.Count,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Browse children of A (id=10). Its direct child B fails HistorizedOnly, so the
|
||||||
|
// projector falls back to HasMatchingDescendant(B), which walks B→C→A→B…
|
||||||
|
// without the visited-id guard. With the guard, the walk terminates and returns
|
||||||
|
// an empty page (no historized descendants exist anywhere in the cycle).
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
new BrowseChildrenRequest { ParentGobjectId = 10, HistorizedOnly = true },
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10);
|
||||||
|
|
||||||
|
Assert.Empty(result.Children);
|
||||||
|
Assert.Equal(0, result.TotalChildCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyHierarchyCacheEntry CreateEntry()
|
||||||
|
{
|
||||||
|
IReadOnlyList<GalaxyObject> objects = CreateObjects();
|
||||||
|
return GalaxyHierarchyCacheEntry.Empty with
|
||||||
|
{
|
||||||
|
Status = GalaxyCacheStatus.Healthy,
|
||||||
|
Sequence = 1,
|
||||||
|
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||||
|
Objects = objects,
|
||||||
|
Index = GalaxyHierarchyIndex.Build(objects),
|
||||||
|
ObjectCount = objects.Count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<GalaxyObject> CreateObjects()
|
||||||
|
{
|
||||||
|
GalaxyObject plant = new()
|
||||||
|
{
|
||||||
|
GobjectId = 1,
|
||||||
|
ParentGobjectId = 0,
|
||||||
|
IsArea = true,
|
||||||
|
ContainedName = "Plant",
|
||||||
|
BrowseName = "Plant",
|
||||||
|
TagName = "Plant",
|
||||||
|
};
|
||||||
|
GalaxyObject mixer001 = new()
|
||||||
|
{
|
||||||
|
GobjectId = 2,
|
||||||
|
ParentGobjectId = 1,
|
||||||
|
ContainedName = "Mixer_001",
|
||||||
|
BrowseName = "Mixer_001",
|
||||||
|
TagName = "Plant.Mixer_001",
|
||||||
|
};
|
||||||
|
mixer001.Attributes.Add(new GalaxyAttribute
|
||||||
|
{
|
||||||
|
AttributeName = "Speed",
|
||||||
|
FullTagReference = "Plant.Mixer_001.Speed",
|
||||||
|
});
|
||||||
|
GalaxyObject mixer002 = new()
|
||||||
|
{
|
||||||
|
GobjectId = 3,
|
||||||
|
ParentGobjectId = 1,
|
||||||
|
ContainedName = "Mixer_002",
|
||||||
|
BrowseName = "Mixer_002",
|
||||||
|
TagName = "Plant.Mixer_002",
|
||||||
|
};
|
||||||
|
GalaxyObject lineA = new()
|
||||||
|
{
|
||||||
|
GobjectId = 4,
|
||||||
|
ParentGobjectId = 1,
|
||||||
|
IsArea = true,
|
||||||
|
ContainedName = "Line_A",
|
||||||
|
BrowseName = "Line_A",
|
||||||
|
TagName = "Plant.Line_A",
|
||||||
|
};
|
||||||
|
GalaxyObject sensorA1 = new()
|
||||||
|
{
|
||||||
|
GobjectId = 5,
|
||||||
|
ParentGobjectId = 4,
|
||||||
|
ContainedName = "Sensor_A1",
|
||||||
|
BrowseName = "Sensor_A1",
|
||||||
|
TagName = "Plant.Line_A.Sensor_A1",
|
||||||
|
};
|
||||||
|
sensorA1.Attributes.Add(new GalaxyAttribute
|
||||||
|
{
|
||||||
|
AttributeName = "Value",
|
||||||
|
FullTagReference = "Plant.Line_A.Sensor_A1.Value",
|
||||||
|
IsHistorized = true,
|
||||||
|
});
|
||||||
|
GalaxyObject pump001 = new()
|
||||||
|
{
|
||||||
|
GobjectId = 6,
|
||||||
|
ParentGobjectId = 1,
|
||||||
|
ContainedName = "Pump_001",
|
||||||
|
BrowseName = "Pump_001",
|
||||||
|
TagName = "Plant.Pump_001",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new[] { plant, mixer001, mixer002, lineA, sensorA1, pump001 };
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-005 regression: the initial <c>RefreshAsync</c> call in
|
||||||
|
/// <see cref="GalaxyHierarchyRefreshService"/> must not let a transient,
|
||||||
|
/// non-cancellation first-load failure (e.g. a <see cref="TimeoutException"/>
|
||||||
|
/// or <see cref="System.ComponentModel.Win32Exception"/> from connection
|
||||||
|
/// establishment) escape and fault the host <c>BackgroundService</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyHierarchyRefreshServiceTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that the background service does not fault when the first refresh throws a non-cancellation exception.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFaultBackgroundService()
|
||||||
|
{
|
||||||
|
ThrowingCache cache = new(new TimeoutException("connection establishment timed out"));
|
||||||
|
GalaxyHierarchyRefreshService service = CreateService(cache);
|
||||||
|
|
||||||
|
using CancellationTokenSource cts = new();
|
||||||
|
|
||||||
|
await service.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
// Wait until the first RefreshAsync has actually been attempted (and
|
||||||
|
// thrown) before cancelling, so cancellation cannot race ahead of the
|
||||||
|
// first-load path under test — this is what made the test flaky under
|
||||||
|
// parallel load.
|
||||||
|
await cache.FirstRefreshAttempted.WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
await cts.CancelAsync();
|
||||||
|
|
||||||
|
// The background loop must have stopped cleanly: ExecuteTask reaches a
|
||||||
|
// terminal state that is not Faulted (RanToCompletion or Canceled)
|
||||||
|
// rather than faulting on the first refresh. WhenAny is used so a
|
||||||
|
// Canceled task does not rethrow before the IsFaulted assertion.
|
||||||
|
Task? executeTask = service.ExecuteTask;
|
||||||
|
Assert.NotNull(executeTask);
|
||||||
|
Task completed = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||||
|
Assert.Same(executeTask, completed);
|
||||||
|
Assert.False(executeTask.IsFaulted);
|
||||||
|
Assert.Equal(1, cache.RefreshCallCount);
|
||||||
|
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyHierarchyRefreshService CreateService(IGalaxyHierarchyCache cache)
|
||||||
|
{
|
||||||
|
GalaxyRepositoryOptions options = new()
|
||||||
|
{
|
||||||
|
DashboardRefreshIntervalSeconds = 3600,
|
||||||
|
};
|
||||||
|
return new GalaxyHierarchyRefreshService(
|
||||||
|
cache,
|
||||||
|
Options.Create(options),
|
||||||
|
NullLogger<GalaxyHierarchyRefreshService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingCache(Exception toThrow) : IGalaxyHierarchyCache
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource firstRefreshAttempted =
|
||||||
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
/// <summary>Gets the number of refresh calls.</summary>
|
||||||
|
public int RefreshCallCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Gets a task that completes once refresh has been invoked at least once.</summary>
|
||||||
|
public Task FirstRefreshAttempted => firstRefreshAttempted.Task;
|
||||||
|
|
||||||
|
/// <summary>Gets the current cache entry.</summary>
|
||||||
|
public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty;
|
||||||
|
|
||||||
|
/// <summary>Refreshes the cache asynchronously and throws the configured exception.</summary>
|
||||||
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
public Task RefreshAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
RefreshCallCount++;
|
||||||
|
firstRefreshAttempted.TrySetResult();
|
||||||
|
throw toThrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Waits for the first load and completes immediately.</summary>
|
||||||
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user