using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; public class SiteStreamGrpcClientFactoryTests { private readonly ILoggerFactory _loggerFactory = NullLoggerFactory.Instance; [Fact] public void GetOrCreate_ReturnsSameClientForSameSite() { using var factory = new SiteStreamGrpcClientFactory(_loggerFactory); var client1 = factory.GetOrCreate("site-a", "http://localhost:5100"); var client2 = factory.GetOrCreate("site-a", "http://localhost:5100"); Assert.Same(client1, client2); } [Fact] public void GetOrCreate_ReturnsDifferentClientsForDifferentSites() { using var factory = new SiteStreamGrpcClientFactory(_loggerFactory); var client1 = factory.GetOrCreate("site-a", "http://localhost:5100"); var client2 = factory.GetOrCreate("site-b", "http://localhost:5200"); Assert.NotSame(client1, client2); } [Fact] public async Task RemoveSite_DisposesClient() { var factory = new SiteStreamGrpcClientFactory(_loggerFactory); var client1 = factory.GetOrCreate("site-a", "http://localhost:5100"); await factory.RemoveSiteAsync("site-a"); // After removal, GetOrCreate should return a new instance var client2 = factory.GetOrCreate("site-a", "http://localhost:5100"); Assert.NotSame(client1, client2); } [Fact] public async Task RemoveSite_NonExistent_DoesNotThrow() { var factory = new SiteStreamGrpcClientFactory(_loggerFactory); await factory.RemoveSiteAsync("does-not-exist"); // Should not throw } [Fact] public async Task DisposeAsync_DisposesAllClients() { var factory = new SiteStreamGrpcClientFactory(_loggerFactory); factory.GetOrCreate("site-a", "http://localhost:5100"); factory.GetOrCreate("site-b", "http://localhost:5200"); await factory.DisposeAsync(); // After dispose, creating new clients should work (new instances) // This tests that Dispose doesn't throw } [Fact] public void GetOrCreate_EndpointChanged_ReturnsClientBoundToNewEndpoint() { // Communication-012 regression: when the same site is requested with a // *different* endpoint (the NodeA→NodeB failover flip), the factory must // hand back a client bound to the new endpoint, not the stale cached one. using var factory = new TrackingEndpointFactory(); var nodeA = factory.GetOrCreate("site-a", "http://localhost:5100"); var nodeB = factory.GetOrCreate("site-a", "http://localhost:5200"); Assert.NotSame(nodeA, nodeB); Assert.Equal("http://localhost:5100", nodeA.Endpoint); Assert.Equal("http://localhost:5200", nodeB.Endpoint); } [Fact] public void GetOrCreate_DifferentEndpointSameSite_KeepsBothClientsAlive() { // Arch review 02 (High): the cache is keyed by (site, endpoint). Both node // channels for a site coexist, so a per-session NodeA→NodeB failover flip // must NOT dispose the other endpoint's channel — another debug session may // be using it. (Supersedes Communication-013's dispose-on-endpoint-change.) using var factory = new TrackingEndpointFactory(); var a = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100"); var b = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5200"); Assert.NotSame(a, b); Assert.False(a.Disposed, "the other endpoint's channel must stay alive"); Assert.Same(a, factory.GetOrCreate("site-a", "http://localhost:5100")); // still cached } [Fact] public void TryGet_ReturnsCachedClientOrNull_WithoutCreating() { using var factory = new TrackingEndpointFactory(); Assert.Null(factory.TryGet("site-a", "http://localhost:5100")); var a = factory.GetOrCreate("site-a", "http://localhost:5100"); Assert.Same(a, factory.TryGet("site-a", "http://localhost:5100")); Assert.Null(factory.TryGet("site-a", "http://localhost:5200")); // other endpoint not cached Assert.Equal(1, factory.CreatedCount); // TryGet never creates } [Fact] public async Task RemoveSiteAsync_DisposesAllEndpointsForTheSite_OnlyThatSite() { using var factory = new TrackingEndpointFactory(); var a = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100"); var b = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5200"); var other = (TrackingEndpointClient)factory.GetOrCreate("site-b", "http://localhost:5100"); await factory.RemoveSiteAsync("site-a"); Assert.True(a.Disposed); Assert.True(b.Disposed); Assert.False(other.Disposed); } [Fact] public void GetOrCreate_SameEndpoint_DoesNotDisposeOrRecreate() { // Endpoint unchanged → the cached client is reused untouched. using var factory = new TrackingEndpointFactory(); var first = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100"); var second = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100"); Assert.Same(first, second); Assert.False(first.Disposed); } /// Test client that records its endpoint and disposal (no real channel). private sealed class TrackingEndpointClient : SiteStreamGrpcClient { public TrackingEndpointClient(string endpoint) : base(endpoint) { } public bool Disposed { get; private set; } public override void Dispose() => Disposed = true; public override ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; } } /// Factory that hands out endpoint-tracking clients. private sealed class TrackingEndpointFactory : SiteStreamGrpcClientFactory { public TrackingEndpointFactory() : base(NullLoggerFactory.Instance) { } public int CreatedCount { get; private set; } protected override SiteStreamGrpcClient CreateClient(string grpcEndpoint) { CreatedCount++; return new TrackingEndpointClient(grpcEndpoint); } } }