diff --git a/src/ProjectOrigin.Chronicler.Server/IRegistryClientFactory.cs b/src/ProjectOrigin.Chronicler.Server/IRegistryClientFactory.cs new file mode 100644 index 0000000..992ef35 --- /dev/null +++ b/src/ProjectOrigin.Chronicler.Server/IRegistryClientFactory.cs @@ -0,0 +1,6 @@ +namespace ProjectOrigin.Chronicler.Server; + +public interface IRegistryClientFactory +{ + Registry.V1.RegistryService.RegistryServiceClient GetClient(string registryName); +} diff --git a/src/ProjectOrigin.Chronicler.Server/RegistryClientFactory.cs b/src/ProjectOrigin.Chronicler.Server/RegistryClientFactory.cs new file mode 100644 index 0000000..32d4f1f --- /dev/null +++ b/src/ProjectOrigin.Chronicler.Server/RegistryClientFactory.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; +using System.Net.Http; +using Grpc.Net.Client; +using Microsoft.Extensions.Options; +using ProjectOrigin.Chronicler.Server.Exceptions; +using ProjectOrigin.Chronicler.Server.Options; +using static ProjectOrigin.Registry.V1.RegistryService; + +namespace ProjectOrigin.Chronicler.Server; + +public class RegistryClientFactory : IRegistryClientFactory +{ + private readonly ConcurrentDictionary _registries = new ConcurrentDictionary(); + private readonly IOptionsMonitor _optionsMonitor; + + public RegistryClientFactory(IOptionsMonitor optionsMonitor) + { + _optionsMonitor = optionsMonitor; + _optionsMonitor.OnChange((options, _) => + { + foreach (var record in _registries) + { + if (options.RegistryUrls.TryGetValue(record.Key, out var uri)) + { + if (record.Value.Target != uri) + { + if (_registries.TryRemove(record.Key, out var channel)) + channel.ShutdownAsync().Wait(); + } + else + continue; + } + else + { + if (_registries.TryRemove(record.Key, out var channel)) + channel.ShutdownAsync().Wait(); + } + } + }); + } + + public RegistryServiceClient GetClient(string registryName) + { + var channel = _registries.GetOrAdd( + registryName, + (name) => + { + if (!_optionsMonitor.CurrentValue.RegistryUrls.TryGetValue(name, out var address)) + throw new RegistryNotKnownException($"Registry {name} not found in configuration"); + + return GrpcChannel.ForAddress(address, new GrpcChannelOptions + { + HttpHandler = new SocketsHttpHandler + { + EnableMultipleHttp2Connections = true, + } + }); + }); + + return new RegistryServiceClient(channel); + } +} diff --git a/src/ProjectOrigin.Chronicler.Server/RegistryService.cs b/src/ProjectOrigin.Chronicler.Server/RegistryService.cs index 9689d5a..8a1ca7e 100644 --- a/src/ProjectOrigin.Chronicler.Server/RegistryService.cs +++ b/src/ProjectOrigin.Chronicler.Server/RegistryService.cs @@ -1,71 +1,21 @@ - -using System.Collections.Concurrent; -using System.Collections.Generic; using System.Linq; -using System.Net.Http; using System.Threading.Tasks; -using Grpc.Net.Client; -using Microsoft.Extensions.Options; -using ProjectOrigin.Chronicler.Server.Exceptions; -using ProjectOrigin.Chronicler.Server.Options; -using static ProjectOrigin.Registry.V1.RegistryService; namespace ProjectOrigin.Chronicler.Server; public class RegistryService : IRegistryService { - private readonly ConcurrentDictionary _registries = new ConcurrentDictionary(); - private readonly IOptionsMonitor _optionsMonitor; - - public RegistryService(IOptionsMonitor optionsMonitor) - { - _optionsMonitor = optionsMonitor; - _optionsMonitor.OnChange((options, _) => - { - foreach (var record in _registries) - { - - if (options.RegistryUrls.TryGetValue(record.Key, out var uri)) - { - if (record.Value.Target != uri) - { - _registries.TryRemove(record.Key, out var _); - record.Value.ShutdownAsync().Wait(); - } - } - else - { - _registries.TryRemove(record.Key, out var _); - record.Value.ShutdownAsync().Wait(); - } - } - }); - } + private readonly IRegistryClientFactory _registryClientFactory; - private RegistryServiceClient CreateClient(string registryName) + public RegistryService(IRegistryClientFactory registryClientFactory) { - var channel = _registries.GetOrAdd( - registryName, - (name) => - { - if (!_optionsMonitor.CurrentValue.RegistryUrls.TryGetValue(name, out var address)) - throw new RegistryNotKnownException($"Registry {name} not found in configuration"); - - return GrpcChannel.ForAddress(address, new GrpcChannelOptions - { - HttpHandler = new SocketsHttpHandler - { - EnableMultipleHttp2Connections = true, - } - }); - }); - - return new RegistryServiceClient(channel); + _registryClientFactory = registryClientFactory; } public async Task GetNextBlock(string registryName, int previousBlockHeight) { - var client = CreateClient(registryName); + var client = _registryClientFactory.GetClient(registryName); + var response = await client.GetBlocksAsync(new Registry.V1.GetBlocksRequest { Skip = previousBlockHeight, diff --git a/src/ProjectOrigin.Chronicler.Server/Startup.cs b/src/ProjectOrigin.Chronicler.Server/Startup.cs index a670789..57e7fbf 100644 --- a/src/ProjectOrigin.Chronicler.Server/Startup.cs +++ b/src/ProjectOrigin.Chronicler.Server/Startup.cs @@ -33,7 +33,8 @@ public void ConfigureServices(IServiceCollection services) options.AddRepository(); }); - services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); services.AddOptions() .BindConfiguration(ChroniclerOptions.SectionPrefix) diff --git a/src/ProjectOrigin.Chronicler.Test/BlockReaderJobTests.cs b/src/ProjectOrigin.Chronicler.Test/BlockReaderJobTests.cs index 0d91121..09c131d 100644 --- a/src/ProjectOrigin.Chronicler.Test/BlockReaderJobTests.cs +++ b/src/ProjectOrigin.Chronicler.Test/BlockReaderJobTests.cs @@ -13,7 +13,6 @@ using ProjectOrigin.Chronicler.Server.Models; using AutoFixture; using Google.Protobuf.WellKnownTypes; -using System.Collections.Generic; namespace ProjectOrigin.Chronicler.Test; diff --git a/src/ProjectOrigin.Chronicler.Test/RegistryServiceTests.cs b/src/ProjectOrigin.Chronicler.Test/RegistryServiceTests.cs new file mode 100644 index 0000000..1571d9d --- /dev/null +++ b/src/ProjectOrigin.Chronicler.Test/RegistryServiceTests.cs @@ -0,0 +1,105 @@ +using System.Threading.Tasks; +using Xunit; +using Moq; +using ProjectOrigin.Chronicler.Server; +using Grpc.Core; +using FluentAssertions; + +namespace ProjectOrigin.Chronicler.Test; + +public class RegistryServiceTests +{ + private const string RegistryName = "someRegistry"; + private const string GridArea = "Narnia"; + private readonly Mock _factory; + + public RegistryServiceTests() + { + _factory = new Mock(); + } + + [Fact] + public async Task GetNextBlock_EmptyResponse_ReturnsNull() + { + // Arrange + var registryService = new RegistryService(_factory.Object); + var mockClient = new Mock(); + mockClient.Setup(x => x.GetBlocksAsync(It.IsAny(), null, null, default)) + .Returns(CreateAsyncUnaryCall(new Registry.V1.GetBlocksResponse + { + Blocks = { } + })); + _factory.Setup(x => x.GetClient(RegistryName)).Returns(mockClient.Object); + + // Act + var result = await registryService.GetNextBlock(RegistryName, 0); + + // Assert + _factory.Verify(x => x.GetClient(RegistryName), Times.Once); + result.Should().BeNull(); + } + + [Theory] + [InlineData(3)] + [InlineData(6)] + [InlineData(8)] + public async Task GetNextBlock_ContainsSingle_ReturnsSingle(int height) + { + // Arrange + var registryService = new RegistryService(_factory.Object); + var mockClient = new Mock(); + mockClient.Setup(x => x.GetBlocksAsync(It.IsAny(), null, null, default)) + .Returns(CreateAsyncUnaryCall(new Registry.V1.GetBlocksResponse + { + Blocks = { + new Registry.V1.Block { Height = height+1 } + } + })); + _factory.Setup(x => x.GetClient(RegistryName)).Returns(mockClient.Object); + + // Act + var result = await registryService.GetNextBlock(RegistryName, height); + + // Assert + _factory.Verify(x => x.GetClient(RegistryName), Times.Once); + mockClient.Verify(x => x.GetBlocksAsync(It.Is( + x => x.Skip == height + && x.Limit == 1 + && x.IncludeTransactions == true + ), null, null, default), Times.Once); + mockClient.VerifyNoOtherCalls(); + result.Should().NotBeNull(); + result!.Height.Should().Be(height + 1); + } + + [Fact] + public async Task GetNextBlock_ContainsMultiple_RaiseError() + { + // Arrange + var registryService = new RegistryService(_factory.Object); + var mockClient = new Mock(); + mockClient.Setup(x => x.GetBlocksAsync(It.IsAny(), null, null, default)) + .Returns(CreateAsyncUnaryCall(new Registry.V1.GetBlocksResponse + { + Blocks = { + new Registry.V1.Block { Height = 0 }, + new Registry.V1.Block { Height = 1 } + } + })); + _factory.Setup(x => x.GetClient(RegistryName)).Returns(mockClient.Object); + + // Assert + await Assert.ThrowsAsync(() => registryService.GetNextBlock(RegistryName, 0)); + } + + private static AsyncUnaryCall CreateAsyncUnaryCall(TResponse response) + { + return new AsyncUnaryCall( + Task.FromResult(response), + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => new Metadata(), + () => { }); + } + +}