ddad573b75
- Resolve 14 conflicts from popping local stash on top of origin'seed1e88+8d3352fdoc-comment additions (11 mechanical, plus version.rs, DashboardAuthenticatorTests.cs, DashboardGalaxyProjector.cs) - Fix 4 test files that used AGENTS.md as the repo-root sentinel (now use CLAUDE.md, since AGENTS.md was removed in4731ab5) - Redirect 10 doc citations from AGENTS.md to the matching gateway.md sections (Value Model, Status Model, Security, STA Worker Thread Model, gRPC Layer rule, cancellation rule) Verified: solution build clean, x86 worker build clean, 266/266 gateway tests passing, 121/121 worker tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
411 lines
16 KiB
C#
411 lines
16 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using Grpc.Core;
|
|
using MxGateway.Contracts.Proto.Galaxy;
|
|
|
|
namespace MxGateway.Client.Tests;
|
|
|
|
public sealed class GalaxyRepositoryClientTests
|
|
{
|
|
/// <summary>
|
|
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
|
|
{
|
|
using CancellationTokenSource cancellation = new();
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
bool ok = await client.TestConnectionAsync(cancellation.Token);
|
|
|
|
Assert.True(ok);
|
|
var call = Assert.Single(transport.TestConnectionCalls);
|
|
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that TestConnectionAsync returns false when the server reports NotOk.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
transport.TestConnectionReply = new TestConnectionReply { Ok = false };
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
bool ok = await client.TestConnectionAsync();
|
|
|
|
Assert.False(ok);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply { Present = false };
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
DateTime? deployTime = await client.GetLastDeployTimeAsync();
|
|
|
|
Assert.Null(deployTime);
|
|
Assert.Single(transport.GetLastDeployTimeCalls);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
|
|
{
|
|
DateTime expected = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply
|
|
{
|
|
Present = true,
|
|
TimeOfLastDeploy = Timestamp.FromDateTime(expected),
|
|
};
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
DateTime? deployTime = await client.GetLastDeployTimeAsync();
|
|
|
|
Assert.NotNull(deployTime);
|
|
Assert.Equal(expected, deployTime!.Value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
{
|
|
NextPageToken = "page-2",
|
|
TotalObjectCount = 2,
|
|
Objects =
|
|
{
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 12,
|
|
TagName = "DelmiaReceiver_001",
|
|
ContainedName = "DelmiaReceiver",
|
|
BrowseName = "TestMachine_001/DelmiaReceiver",
|
|
ParentGobjectId = 5,
|
|
Attributes =
|
|
{
|
|
new GalaxyAttribute
|
|
{
|
|
AttributeName = "DownloadPath",
|
|
FullTagReference = "DelmiaReceiver_001.DownloadPath",
|
|
MxDataType = 8,
|
|
DataTypeName = "MxString",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
{
|
|
TotalObjectCount = 2,
|
|
Objects =
|
|
{
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 13,
|
|
TagName = "DelmiaReceiver_002",
|
|
},
|
|
},
|
|
});
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
IReadOnlyList<GalaxyObject> objects = await client.DiscoverHierarchyAsync();
|
|
|
|
Assert.Equal(2, objects.Count);
|
|
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
|
|
Assert.Equal(5000, transport.DiscoverHierarchyCalls[0].Request.PageSize);
|
|
Assert.Equal("", transport.DiscoverHierarchyCalls[0].Request.PageToken);
|
|
Assert.Equal("page-2", transport.DiscoverHierarchyCalls[1].Request.PageToken);
|
|
GalaxyObject obj = objects[0];
|
|
Assert.Equal(12, obj.GobjectId);
|
|
Assert.Equal("DelmiaReceiver_001", obj.TagName);
|
|
GalaxyAttribute attribute = Assert.Single(obj.Attributes);
|
|
Assert.Equal("DownloadPath", attribute.AttributeName);
|
|
Assert.Equal("DelmiaReceiver_001.DownloadPath", attribute.FullTagReference);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
|
|
{
|
|
using CancellationTokenSource cancellation = new();
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
await client.DiscoverHierarchyAsync(cancellation.Token);
|
|
|
|
var call = Assert.Single(transport.DiscoverHierarchyCalls);
|
|
// The retry pipeline links the caller token with a per-call timeout token,
|
|
// so the transport sees the linked token rather than the caller's directly.
|
|
// Verify the link relationship by cancelling the caller and checking the
|
|
// call-side token reflects it.
|
|
Assert.False(call.CallOptions.CancellationToken.IsCancellationRequested);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
{
|
|
NextPageToken = "7:1",
|
|
});
|
|
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
{
|
|
NextPageToken = "7:1",
|
|
});
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
|
|
async () => await client.DiscoverHierarchyAsync());
|
|
|
|
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
await client.DiscoverHierarchyAsync(new DiscoverHierarchyOptions
|
|
{
|
|
RootContainedPath = "Area1/Line3",
|
|
MaxDepth = 2,
|
|
CategoryIds = [10, 13],
|
|
TemplateChainContains = ["Pump"],
|
|
TagNameGlob = "Pump_*",
|
|
IncludeAttributes = false,
|
|
AlarmBearingOnly = true,
|
|
HistorizedOnly = true,
|
|
});
|
|
|
|
DiscoverHierarchyRequest request = Assert.Single(transport.DiscoverHierarchyCalls).Request;
|
|
Assert.Equal(DiscoverHierarchyRequest.RootOneofCase.RootContainedPath, request.RootCase);
|
|
Assert.Equal("Area1/Line3", request.RootContainedPath);
|
|
Assert.Equal(2, request.MaxDepth);
|
|
Assert.Equal([10, 13], request.CategoryIds);
|
|
Assert.Equal(["Pump"], request.TemplateChainContains);
|
|
Assert.Equal("Pump_*", request.TagNameGlob);
|
|
Assert.True(request.HasIncludeAttributes);
|
|
Assert.False(request.IncludeAttributes);
|
|
Assert.True(request.AlarmBearingOnly);
|
|
Assert.True(request.HistorizedOnly);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
transport.TestConnectionExceptions.Enqueue(CreateTransientRpcException());
|
|
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
bool ok = await client.TestConnectionAsync();
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(2, transport.TestConnectionCalls.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
transport.DiscoverHierarchyExceptions.Enqueue(CreateTransientRpcException());
|
|
transport.DiscoverHierarchyReply = new DiscoverHierarchyReply();
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
await client.DiscoverHierarchyAsync();
|
|
|
|
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
DateTime deployTime = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
|
|
transport.WatchDeployEvents.Add(new DeployEvent
|
|
{
|
|
Sequence = 1,
|
|
ObservedAt = Timestamp.FromDateTime(deployTime),
|
|
TimeOfLastDeploy = Timestamp.FromDateTime(deployTime),
|
|
TimeOfLastDeployPresent = true,
|
|
ObjectCount = 7,
|
|
AttributeCount = 42,
|
|
});
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
List<DeployEvent> received = [];
|
|
await foreach (DeployEvent evt in client.WatchDeployEventsAsync())
|
|
{
|
|
received.Add(evt);
|
|
}
|
|
|
|
DeployEvent only = Assert.Single(received);
|
|
Assert.Equal(1ul, only.Sequence);
|
|
Assert.Equal(7, only.ObjectCount);
|
|
Assert.Equal(42, only.AttributeCount);
|
|
Assert.True(only.TimeOfLastDeployPresent);
|
|
var call = Assert.Single(transport.WatchDeployEventsCalls);
|
|
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
|
// No last_seen_deploy_time supplied → request leaves the field unset.
|
|
Assert.Null(call.Request.LastSeenDeployTime);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that WatchDeployEventsAsync delivers multiple events in order.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
DateTime t0 = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
|
|
for (int index = 1; index <= 3; index++)
|
|
{
|
|
transport.WatchDeployEvents.Add(new DeployEvent
|
|
{
|
|
Sequence = (ulong)index,
|
|
ObservedAt = Timestamp.FromDateTime(t0.AddSeconds(index)),
|
|
TimeOfLastDeploy = Timestamp.FromDateTime(t0.AddSeconds(index)),
|
|
TimeOfLastDeployPresent = true,
|
|
ObjectCount = 10 + index,
|
|
AttributeCount = 100 + index,
|
|
});
|
|
}
|
|
|
|
DateTimeOffset lastSeen = new(t0, TimeSpan.Zero);
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
List<DeployEvent> received = [];
|
|
await foreach (DeployEvent evt in client.WatchDeployEventsAsync(lastSeen))
|
|
{
|
|
received.Add(evt);
|
|
}
|
|
|
|
Assert.Equal(3, received.Count);
|
|
Assert.Equal(new ulong[] { 1, 2, 3 }, received.Select(e => e.Sequence).ToArray());
|
|
Assert.Equal(new[] { 11, 12, 13 }, received.Select(e => e.ObjectCount).ToArray());
|
|
var call = Assert.Single(transport.WatchDeployEventsCalls);
|
|
Assert.NotNull(call.Request.LastSeenDeployTime);
|
|
Assert.Equal(t0, call.Request.LastSeenDeployTime!.ToDateTime());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
// Add many events; the test will cancel after the first.
|
|
for (int index = 1; index <= 10; index++)
|
|
{
|
|
transport.WatchDeployEvents.Add(new DeployEvent { Sequence = (ulong)index });
|
|
}
|
|
|
|
using CancellationTokenSource cancellation = new();
|
|
// Cancel before the second yield by wiring the fake's pre-yield hook.
|
|
int yields = 0;
|
|
transport.WatchDeployEventsBeforeYield = _ =>
|
|
{
|
|
yields++;
|
|
if (yields >= 2)
|
|
{
|
|
cancellation.Cancel();
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
};
|
|
|
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
List<DeployEvent> received = [];
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
|
{
|
|
await foreach (DeployEvent evt in client
|
|
.WatchDeployEventsAsync(cancellationToken: cancellation.Token))
|
|
{
|
|
received.Add(evt);
|
|
}
|
|
});
|
|
|
|
// The first event yields before cancellation triggers on the second pass.
|
|
Assert.Single(received);
|
|
Assert.Equal(1ul, received[0].Sequence);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
await client.DisposeAsync();
|
|
|
|
Assert.Throws<ObjectDisposedException>(() =>
|
|
client.WatchDeployEventsAsync());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task TestConnectionAsync_ThrowsAfterDisposal()
|
|
{
|
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
GalaxyRepositoryClient client = CreateClient(transport);
|
|
|
|
await client.DisposeAsync();
|
|
|
|
await Assert.ThrowsAsync<ObjectDisposedException>(() => client.TestConnectionAsync());
|
|
}
|
|
|
|
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
|
|
{
|
|
return new GalaxyRepositoryClient(transport.Options, transport);
|
|
}
|
|
|
|
private static FakeGalaxyRepositoryTransport CreateTransport()
|
|
{
|
|
return new FakeGalaxyRepositoryTransport(new MxGatewayClientOptions
|
|
{
|
|
Endpoint = new Uri("http://localhost:5000"),
|
|
ApiKey = "test-api-key",
|
|
});
|
|
}
|
|
|
|
private static RpcException CreateTransientRpcException()
|
|
{
|
|
return new RpcException(new Status(StatusCode.Unavailable, "gateway unavailable"));
|
|
}
|
|
}
|