using Google.Protobuf.WellKnownTypes; using Grpc.Core; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; namespace ZB.MOM.WW.MxGateway.Client.Tests; public sealed class GalaxyRepositoryClientTests { /// /// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag. /// /// A task that represents the asynchronous operation. [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")); } /// /// Verifies that TestConnectionAsync returns false when the server reports NotOk. /// /// A task that represents the asynchronous operation. [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); } /// /// Verifies that GetLastDeployTimeAsync returns null when the server reports not present. /// /// A task that represents the asynchronous operation. [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); } /// /// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present. /// /// A task that represents the asynchronous operation. [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); } /// /// Verifies that DiscoverHierarchyAsync returns the objects from the server reply. /// /// A task that represents the asynchronous operation. [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 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); } /// /// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport. /// /// A task that represents the asynchronous operation. [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); } /// /// Verifies that TestConnectionAsync retries on transient gRPC failures. /// /// A task that represents the asynchronous operation. [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( async () => await client.DiscoverHierarchyAsync()); Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal); } /// /// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request. /// /// A task that represents the asynchronous operation. [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); } /// /// Verifies that TestConnectionAsync retries on transient gRPC failures. /// /// A task that represents the asynchronous operation. [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); } /// /// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures. /// /// A task that represents the asynchronous operation. [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); } /// /// Verifies that WatchDeployEventsAsync delivers the bootstrap event. /// /// A task that represents the asynchronous operation. [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 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); } /// /// Verifies that WatchDeployEventsAsync delivers multiple events in order. /// /// A task that represents the asynchronous operation. [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 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()); } /// /// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled. /// /// A task that represents the asynchronous operation. [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 received = []; await Assert.ThrowsAnyAsync(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); } /// /// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed. /// /// A task that represents the asynchronous operation. [Fact] public async Task WatchDeployEventsAsync_ThrowsAfterDisposal() { FakeGalaxyRepositoryTransport transport = CreateTransport(); GalaxyRepositoryClient client = CreateClient(transport); await client.DisposeAsync(); Assert.Throws(() => client.WatchDeployEventsAsync()); } /// /// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed. /// /// A task that represents the asynchronous operation. [Fact] public async Task TestConnectionAsync_ThrowsAfterDisposal() { FakeGalaxyRepositoryTransport transport = CreateTransport(); GalaxyRepositoryClient client = CreateClient(transport); await client.DisposeAsync(); await Assert.ThrowsAsync(() => 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")); } }