diff --git a/src/Extensions/src/Eventuous.Extensions.DependencyInjection/Registrations/Stores.cs b/src/Extensions/src/Eventuous.Extensions.DependencyInjection/Registrations/Stores.cs index 068d593bd..6194a5d99 100644 --- a/src/Extensions/src/Eventuous.Extensions.DependencyInjection/Registrations/Stores.cs +++ b/src/Extensions/src/Eventuous.Extensions.DependencyInjection/Registrations/Stores.cs @@ -13,88 +13,93 @@ public static partial class ServiceCollectionExtensions { /// extension(IServiceCollection services) { /// - /// Registers the event reader + /// Registers a singleton event reader, preserving existing registrations. /// /// Implementation of /// public IServiceCollection AddEventReader<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() where T : class, IEventReader { + services.TryAddSingleton(); + if (EventuousDiagnostics.Enabled) { - services.TryAddSingleton(); services.TryAddSingleton(sp => TracedEventReader.Trace(sp.GetRequiredService())); } - else { services.TryAddSingleton(); } + else { services.TryAddSingleton(sp => sp.GetRequiredService()); } return services; } /// - /// Registers the event reader + /// Registers a singleton event reader, preserving existing registrations. /// /// Function to create an instance of /// Implementation of /// public IServiceCollection AddEventReader(Func getService) where T : class, IEventReader { + services.TryAddSingleton(getService); + if (EventuousDiagnostics.Enabled) { - services.TryAddSingleton(getService); services.TryAddSingleton(sp => TracedEventReader.Trace(sp.GetRequiredService())); } - else { services.TryAddSingleton(getService); } + else { services.TryAddSingleton(sp => sp.GetRequiredService()); } return services; } /// - /// Registers the event writer + /// Registers a singleton event writer, preserving existing registrations. /// /// Implementation of /// public IServiceCollection AddEventWriter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() where T : class, IEventWriter { + services.TryAddSingleton(); + if (EventuousDiagnostics.Enabled) { - services.TryAddSingleton(); services.TryAddSingleton(sp => TracedEventWriter.Trace(sp.GetRequiredService())); } - else { services.TryAddSingleton(); } + else { services.TryAddSingleton(sp => sp.GetRequiredService()); } return services; } /// - /// Registers the event writer + /// Registers a singleton event writer, preserving existing registrations. /// /// Function to create an instance of /// Implementation of /// public IServiceCollection AddEventWriter(Func getService) where T : class, IEventWriter { + services.TryAddSingleton(getService); + if (EventuousDiagnostics.Enabled) { - services.TryAddSingleton(getService); services.TryAddSingleton(sp => TracedEventWriter.Trace(sp.GetRequiredService())); } - else { services.TryAddSingleton(getService); } + else { services.TryAddSingleton(sp => sp.GetRequiredService()); } return services; } /// - /// Registers the event reader and writer + /// Registers a shared singleton event reader and writer, preserving existing registrations. /// /// Implementation of and /// public IServiceCollection AddEventReaderWriter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() where T : class, IEventWriter, IEventReader { + services.TryAddSingleton(); + if (EventuousDiagnostics.Enabled) { - services.TryAddSingleton(); services.TryAddSingleton(sp => TracedEventReader.Trace(sp.GetRequiredService())); services.TryAddSingleton(sp => TracedEventWriter.Trace(sp.GetRequiredService())); } else { - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); } return services; } /// - /// Registers the event reader and writer implemented by one class + /// Registers a shared singleton event reader and writer, preserving existing registrations. /// /// Function to create an instance of the class, /// which implements both and @@ -102,59 +107,62 @@ public IServiceCollection AddEventWriter(Func getService /// public IServiceCollection AddEventReaderWriter(Func getService) where T : class, IEventWriter, IEventReader { + services.TryAddSingleton(getService); + if (EventuousDiagnostics.Enabled) { - services.TryAddSingleton(getService); services.TryAddSingleton(sp => TracedEventReader.Trace(sp.GetRequiredService())); services.TryAddSingleton(sp => TracedEventWriter.Trace(sp.GetRequiredService())); } else { - services.TryAddSingleton(getService); - services.TryAddSingleton(getService); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); } return services; } /// - /// Registers an event store service as reader, writer, and event store + /// Registers one singleton as reader, writer, and event store, preserving existing registrations. /// /// Implementation of /// public IServiceCollection AddEventStore<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() where T : class, IEventStore { + services.TryAddSingleton(); + if (EventuousDiagnostics.Enabled) { - services.TryAddSingleton(); - services.AddSingleton(sp => new TracedEventStore(sp.GetRequiredService())); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => new TracedEventStore(sp.GetRequiredService())); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); } else { - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); } return services; } /// - /// Registers an event store service as reader, writer, and event store + /// Registers one singleton as reader, writer, and event store, preserving existing registrations. /// /// Function to create an instance of the class, which implements /// Implementation of /// public IServiceCollection AddEventStore(Func getService) where T : class, IEventStore { + services.TryAddSingleton(getService); + if (EventuousDiagnostics.Enabled) { - services.TryAddSingleton(getService); - services.AddSingleton(sp => new TracedEventStore(sp.GetRequiredService())); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => new TracedEventStore(sp.GetRequiredService())); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); } else { - services.TryAddSingleton(getService); - services.TryAddSingleton(getService); - services.TryAddSingleton(getService); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); } return services; diff --git a/src/Extensions/test/Eventuous.Tests.DependencyInjection/Eventuous.Tests.DependencyInjection.csproj b/src/Extensions/test/Eventuous.Tests.DependencyInjection/Eventuous.Tests.DependencyInjection.csproj index 252054daa..611de3d8c 100644 --- a/src/Extensions/test/Eventuous.Tests.DependencyInjection/Eventuous.Tests.DependencyInjection.csproj +++ b/src/Extensions/test/Eventuous.Tests.DependencyInjection/Eventuous.Tests.DependencyInjection.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Extensions/test/Eventuous.Tests.DependencyInjection/StoreRegistrationTests.cs b/src/Extensions/test/Eventuous.Tests.DependencyInjection/StoreRegistrationTests.cs new file mode 100644 index 000000000..4000e58e3 --- /dev/null +++ b/src/Extensions/test/Eventuous.Tests.DependencyInjection/StoreRegistrationTests.cs @@ -0,0 +1,180 @@ +using Eventuous.Diagnostics; +using Eventuous.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; + +namespace Eventuous.Tests.DependencyInjection; + +// Diagnostics is process-wide. No other tests may observe the temporary setting. +[NotInParallel] +public class StoreRegistrationTests { + [Test] + [Arguments(false, false, Registration.Store)] + [Arguments(false, true, Registration.Store)] + [Arguments(true, false, Registration.Store)] + [Arguments(true, true, Registration.Store)] + [Arguments(false, false, Registration.ReaderWriter)] + [Arguments(false, true, Registration.ReaderWriter)] + [Arguments(true, false, Registration.ReaderWriter)] + [Arguments(true, true, Registration.ReaderWriter)] + [Arguments(false, false, Registration.Separate)] + [Arguments(false, true, Registration.Separate)] + [Arguments(true, false, Registration.Separate)] + [Arguments(true, true, Registration.Separate)] + public async Task WrittenEventsAreVisibleThroughEveryRegisteredInterface(bool tracing, bool factory, Registration registration) { + using var diagnostics = new DiagnosticsSetting(tracing); + var factoryCalls = 0; + using var host = new HostBuilder().ConfigureServices(services => { + Register(services, registration, factory, _ => { factoryCalls++; return new(); }); + }).Build(); + await host.StartAsync(); + + var writer = host.Services.GetRequiredService(); + var reader = host.Services.GetRequiredService(); + var stream = new StreamName("shared-store"); + await Append(writer, stream, "first"); + (await Read(reader, stream)).ShouldBe(new[] { "first" }); + if (registration == Registration.Store) { + var store = host.Services.GetRequiredService(); + (await store.StreamExists(stream)).ShouldBeTrue(); + await Append(store, stream, "second", new(0)); + (await Read(reader, stream)).ShouldBe(new[] { "first", "second" }); + } + + using (var scope = host.Services.CreateScope()) { + scope.ServiceProvider.GetRequiredService().ShouldBeSameAs(reader); + scope.ServiceProvider.GetRequiredService().ShouldBeSameAs(writer); + } + if (factory) factoryCalls.ShouldBe(1); + await host.StopAsync(); + } + + [Test] + [Arguments(false, false, true)] + [Arguments(false, true, true)] + [Arguments(true, false, true)] + [Arguments(true, true, true)] + [Arguments(false, false, false)] + [Arguments(false, true, false)] + [Arguments(true, false, false)] + [Arguments(true, true, false)] + public async Task StandaloneRegistrationUsesTheExistingBackingStore(bool tracing, bool factory, bool readerOnly) { + using var diagnostics = new DiagnosticsSetting(tracing); + var backing = new InMemoryEventStore(); + var stream = new StreamName("standalone-store"); + var factoryCalls = 0; + using var host = new HostBuilder().ConfigureServices(services => { + services.AddSingleton(backing); + InMemoryEventStore Create(IServiceProvider _) { factoryCalls++; return new(); } + if (readerOnly) { + if (factory) services.AddEventReader(Create); + else services.AddEventReader(); + } + else { + if (factory) services.AddEventWriter(Create); + else services.AddEventWriter(); + } + }).Build(); + await host.StartAsync(); + + if (readerOnly) { + await Append(backing, stream, "seeded"); + (await Read(host.Services.GetRequiredService(), stream)).ShouldBe(new[] { "seeded" }); + } + else { + await Append(host.Services.GetRequiredService(), stream, "written"); + (await Read(backing, stream)).ShouldBe(new[] { "written" }); + } + factoryCalls.ShouldBe(0); + await host.StopAsync(); + } + + [Test] + [Arguments(false, false)] + [Arguments(false, true)] + [Arguments(true, false)] + [Arguments(true, true)] + public async Task RepeatedRegistrationKeepsOneServiceAndOneFactory(bool tracing, bool factory) { + using var diagnostics = new DiagnosticsSetting(tracing); + var firstCalls = 0; + var secondCalls = 0; + using var host = new HostBuilder().ConfigureServices(services => { + Register(services, Registration.Store, factory, _ => { firstCalls++; return new(); }); + Register(services, Registration.Store, factory, _ => { secondCalls++; return new(); }); + }).Build(); + await host.StartAsync(); + + var writer = host.Services.GetServices().ShouldHaveSingleItem(); + var reader = host.Services.GetServices().ShouldHaveSingleItem(); + var store = host.Services.GetServices().ShouldHaveSingleItem(); + var stream = new StreamName("repeated-store"); + await Append(writer, stream, "once"); + (await Read(reader, stream)).ShouldBe(new[] { "once" }); + (await store.StreamExists(stream)).ShouldBeTrue(); + firstCalls.ShouldBe(factory ? 1 : 0); + secondCalls.ShouldBe(0); + await host.StopAsync(); + } + + [Test] + [Arguments(false, false)] + [Arguments(false, true)] + [Arguments(true, false)] + [Arguments(true, true)] + public async Task RegistrationPreservesExplicitInterfaceOverrides(bool tracing, bool factory) { + using var diagnostics = new DiagnosticsSetting(tracing); + var existing = new InMemoryEventStore(); + var factoryCalls = 0; + using var host = new HostBuilder().ConfigureServices(services => { + services.AddSingleton(existing); + services.AddSingleton(existing); + services.AddSingleton(existing); + Register(services, Registration.Store, factory, _ => { factoryCalls++; return new(); }); + }).Build(); + await host.StartAsync(); + + var stream = new StreamName("explicit-store"); + await Append(host.Services.GetRequiredService(), stream, "preserved"); + (await Read(existing, stream)).ShouldBe(new[] { "preserved" }); + host.Services.GetRequiredService().ShouldBeSameAs(existing); + host.Services.GetRequiredService().ShouldBeSameAs(existing); + factoryCalls.ShouldBe(0); + await host.StopAsync(); + } + + static void Register(IServiceCollection services, Registration registration, bool factory, Func create) { + switch (registration) { + case Registration.Store: + if (factory) services.AddEventStore(create); + else services.AddEventStore(); + break; + case Registration.ReaderWriter: + if (factory) services.AddEventReaderWriter(create); + else services.AddEventReaderWriter(); + break; + case Registration.Separate: + if (factory) services.AddEventReader(create).AddEventWriter(create); + else services.AddEventReader().AddEventWriter(); + break; + } + } + + static Task Append(IEventWriter writer, StreamName stream, string value, ExpectedStreamVersion? version = null) + => writer.AppendEvents(stream, version ?? ExpectedStreamVersion.NoStream, new[] { new NewStreamEvent(Guid.NewGuid(), value, new()) }, default); + + static async Task Read(IEventReader reader, StreamName stream) + => (await reader.ReadEvents(stream, StreamReadPosition.Start, 10, true, default)).Select(x => (string)x.Payload!).ToArray(); + + public enum Registration { Store, ReaderWriter, Separate } + + sealed class DiagnosticsSetting : IDisposable { + readonly bool _previous = EventuousDiagnostics.Enabled; + public DiagnosticsSetting(bool enabled) => Set(enabled); + public void Dispose() => Set(_previous); + static void Set(bool enabled) { + if (enabled) EventuousDiagnostics.Enable(); + else EventuousDiagnostics.Disable(); + } + } +}