From e5a84610a713ac43fe02f159a48030cf3fa41faa Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 15 Aug 2024 09:31:22 +0200 Subject: [PATCH] Isolate type mapper from service result (#369) * Add type mapper interface * Don't throw in services if the type isn't mapped --- .../AggregateService/CommandService.cs | 8 +- .../FunctionalService/CommandService.cs | 19 ++-- src/Core/src/Eventuous.Application/Result.cs | 8 +- .../AppendEventsResult.cs | 3 +- .../DefaultEventSerializer.cs | 4 +- .../Eventuous.Shared/TypeMap/ITypeMapper.cs | 28 ++++++ .../Eventuous.Shared/TypeMap/TypeMapper.cs | 64 ++----------- .../TypeMap/TypeMapperExtensions.cs | 59 ++++++++++++ .../Handlers/EventHandler.cs | 6 +- .../BookingFuncService.cs | 16 ++-- .../CommandServiceTests.cs | 2 +- .../Eventuous.Tests.Application.csproj | 5 + .../FunctionalServiceTests.cs | 2 +- .../ServiceTestBase.Amendments.cs | 44 +++++++++ .../ServiceTestBase.OnAny.cs | 26 ++++++ .../ServiceTestBase.OnExisting.cs | 42 +++++++++ .../ServiceTestBase.OnNew.cs | 30 ++++++ .../ServiceTestBase.cs | 93 +------------------ .../test/Eventuous.Tests/StoringEvents.cs | 3 +- .../StoringEventsWithCustomStream.cs | 8 +- .../Eventuous.EventStore/EsdbEventStore.cs | 8 +- .../ProducerTracesTests.cs | 2 + .../Store/ElasticSerializer.cs | 4 +- .../src/Eventuous.Spyglass/SpyglassApi.cs | 6 +- ...mandsTests.MapEnrichedCommand.verified.txt | 2 +- ...ts.CallDiscoveredCommandRoute.verified.txt | 2 +- .../MongoProjector.cs | 8 +- .../Projections/PostgresProjector.cs | 2 +- .../Projections/SqlServerProjector.cs | 2 +- .../CommandServiceFixture.cs | 44 +++++---- .../Eventuous.Testing/InMemoryEventStore.cs | 2 +- test/Eventuous.Sut.App/BookingService.cs | 7 +- test/Eventuous.Sut.App/Commands.cs | 2 + test/Eventuous.Sut.Domain/Booking.cs | 2 + test/Eventuous.Sut.Domain/BookingEvents.cs | 22 +++-- 35 files changed, 358 insertions(+), 227 deletions(-) create mode 100644 src/Core/src/Eventuous.Shared/TypeMap/ITypeMapper.cs create mode 100644 src/Core/src/Eventuous.Shared/TypeMap/TypeMapperExtensions.cs create mode 100644 src/Core/test/Eventuous.Tests.Application/ServiceTestBase.Amendments.cs create mode 100644 src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnAny.cs create mode 100644 src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnExisting.cs create mode 100644 src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnNew.cs diff --git a/src/Core/src/Eventuous.Application/AggregateService/CommandService.cs b/src/Core/src/Eventuous.Application/AggregateService/CommandService.cs index 69610a0f..143811cc 100644 --- a/src/Core/src/Eventuous.Application/AggregateService/CommandService.cs +++ b/src/Core/src/Eventuous.Application/AggregateService/CommandService.cs @@ -17,7 +17,7 @@ public abstract partial class CommandService( IEventWriter? writer, AggregateFactoryRegistry? factoryRegistry = null, StreamNameMap? streamNameMap = null, - TypeMapper? typeMap = null, + ITypeMapper? typeMap = null, AmendEvent? amendEvent = null ) : ICommandService @@ -28,7 +28,7 @@ protected CommandService( IEventStore? store, AggregateFactoryRegistry? factoryRegistry = null, StreamNameMap? streamNameMap = null, - TypeMapper? typeMap = null, + ITypeMapper? typeMap = null, AmendEvent? amendEvent = null ) : this(store, store, factoryRegistry, streamNameMap, typeMap, amendEvent) { } @@ -40,7 +40,7 @@ protected CommandService( readonly HandlersMap _handlers = new(); readonly AggregateFactoryRegistry _factoryRegistry = factoryRegistry ?? AggregateFactoryRegistry.Instance; readonly StreamNameMap _streamNameMap = streamNameMap ?? new StreamNameMap(); - readonly TypeMapper _typeMap = typeMap ?? TypeMap.Instance; + readonly ITypeMapper _typeMap = typeMap ?? TypeMap.Instance; /// /// Returns the command handler builder for the specified command type. @@ -89,7 +89,7 @@ public async Task> Handle(TCommand command, Cancellatio var writer = registeredHandler.ResolveWriter(command); var storeResult = await writer.StoreAggregate(stream, result, Amend, cancellationToken).NoContext(); - var changes = result.Changes.Select(x => new Change(x, _typeMap.GetTypeName(x))); + var changes = result.Changes.Select(x => Change.FromEvent(x, _typeMap)); Log.CommandHandled(); return Result.FromSuccess(result.State, changes, storeResult.GlobalPosition); diff --git a/src/Core/src/Eventuous.Application/FunctionalService/CommandService.cs b/src/Core/src/Eventuous.Application/FunctionalService/CommandService.cs index f3f23aaf..9e5d3721 100644 --- a/src/Core/src/Eventuous.Application/FunctionalService/CommandService.cs +++ b/src/Core/src/Eventuous.Application/FunctionalService/CommandService.cs @@ -6,9 +6,9 @@ namespace Eventuous; using static Diagnostics.ApplicationEventSource; [Obsolete("Use CommandService")] -public abstract class FunctionalCommandService(IEventReader reader, IEventWriter writer, TypeMapper? typeMap = null, AmendEvent? amendEvent = null) +public abstract class FunctionalCommandService(IEventReader reader, IEventWriter writer, ITypeMapper? typeMap = null, AmendEvent? amendEvent = null) : CommandService(reader, writer, typeMap, amendEvent) where TState : State, new() { - protected FunctionalCommandService(IEventStore store, TypeMapper? typeMap = null, AmendEvent? amendEvent = null) + protected FunctionalCommandService(IEventStore store, ITypeMapper? typeMap = null, AmendEvent? amendEvent = null) : this(store, store, typeMap, amendEvent) { } [Obsolete("Use On().InState(ExpectedState.New).GetStream(...).Act(...) instead")] @@ -32,22 +32,22 @@ protected void OnAny(Func getStreamName, Func /// Event reader or event store /// Event writer or event store -/// instance or null to use the default type mapper +/// instance or null to use the default type mapper /// Optional function to add extra information to the event before it gets stored /// State object type -public abstract class CommandService(IEventReader reader, IEventWriter writer, TypeMapper? typeMap = null, AmendEvent? amendEvent = null) +public abstract class CommandService(IEventReader reader, IEventWriter writer, ITypeMapper? typeMap = null, AmendEvent? amendEvent = null) : ICommandService where TState : State, new() { - readonly TypeMapper _typeMap = typeMap ?? TypeMap.Instance; + readonly ITypeMapper _typeMap = typeMap ?? TypeMap.Instance; readonly HandlersMap _handlers = new(); /// /// Alternative constructor for the functional command service, which uses an instance for both reading and writing. /// /// Event store - /// instance or null to use the default type mapper + /// instance or null to use the default type mapper /// Optional function to add extra information to the event before it gets stored // ReSharper disable once UnusedMember.Global - protected CommandService(IEventStore store, TypeMapper? typeMap = null, AmendEvent? amendEvent = null) : this(store, store, typeMap, amendEvent) { } + protected CommandService(IEventStore store, ITypeMapper? typeMap = null, AmendEvent? amendEvent = null) : this(store, store, typeMap, amendEvent) { } /// /// Returns the command handler builder for the specified command type. @@ -96,7 +96,7 @@ public async Task> Handle(TCommand command, Cancellatio var storeResult = await resolvedWriter.Store(streamName, loadedState.StreamVersion, newEvents, Amend, cancellationToken) .NoContext(); - var changes = newEvents.Select(x => new Change(x, _typeMap.GetTypeName(x))); + var changes = newEvents.Select(x => Change.FromEvent(x, _typeMap)); Log.CommandHandled(); return Result.FromSuccess(newState, changes, storeResult.GlobalPosition); @@ -105,9 +105,10 @@ public async Task> Handle(TCommand command, Cancellatio return Result.FromError(e, $"Error handling command {typeof(TCommand).Name}"); } - + NewStreamEvent Amend(NewStreamEvent streamEvent) { var evt = registeredHandler.AmendEvent?.Invoke(streamEvent, command) ?? streamEvent; + return amendEvent?.Invoke(evt) ?? evt; } } diff --git a/src/Core/src/Eventuous.Application/Result.cs b/src/Core/src/Eventuous.Application/Result.cs index 1f474795..562684f6 100644 --- a/src/Core/src/Eventuous.Application/Result.cs +++ b/src/Core/src/Eventuous.Application/Result.cs @@ -9,7 +9,13 @@ namespace Eventuous; [StructLayout(LayoutKind.Auto)] -public record struct Change(object Event, string EventType); +public record struct Change(object Event, string EventType) { + internal static Change FromEvent(object evt, ITypeMapper typeMapper) { + var typeName = typeMapper.GetTypeName(evt); + + return new(evt, typeName != ITypeMapper.UnknownType ? typeName : evt.GetType().Name); + } +} /// /// Represents the command handling result, could be either success or error diff --git a/src/Core/src/Eventuous.Persistence/AppendEventsResult.cs b/src/Core/src/Eventuous.Persistence/AppendEventsResult.cs index fbea3a35..002bc8aa 100644 --- a/src/Core/src/Eventuous.Persistence/AppendEventsResult.cs +++ b/src/Core/src/Eventuous.Persistence/AppendEventsResult.cs @@ -3,7 +3,6 @@ namespace Eventuous; -[PublicAPI] public record AppendEventsResult(ulong GlobalPosition, long NextExpectedVersion) { public static readonly AppendEventsResult NoOp = new(0, -1); -} \ No newline at end of file +} diff --git a/src/Core/src/Eventuous.Serialization/DefaultEventSerializer.cs b/src/Core/src/Eventuous.Serialization/DefaultEventSerializer.cs index d75c3127..bf898e58 100644 --- a/src/Core/src/Eventuous.Serialization/DefaultEventSerializer.cs +++ b/src/Core/src/Eventuous.Serialization/DefaultEventSerializer.cs @@ -7,10 +7,10 @@ namespace Eventuous; [PublicAPI] -public class DefaultEventSerializer(JsonSerializerOptions options, TypeMapper? typeMapper = null) : IEventSerializer { +public class DefaultEventSerializer(JsonSerializerOptions options, ITypeMapper? typeMapper = null) : IEventSerializer { public static IEventSerializer Instance { get; private set; } = new DefaultEventSerializer(new(JsonSerializerDefaults.Web)); - readonly TypeMapper _typeMapper = typeMapper ?? TypeMap.Instance; + readonly ITypeMapper _typeMapper = typeMapper ?? TypeMap.Instance; public static void SetDefaultSerializer(IEventSerializer serializer) => Instance = serializer; diff --git a/src/Core/src/Eventuous.Shared/TypeMap/ITypeMapper.cs b/src/Core/src/Eventuous.Shared/TypeMap/ITypeMapper.cs new file mode 100644 index 00000000..8b48a879 --- /dev/null +++ b/src/Core/src/Eventuous.Shared/TypeMap/ITypeMapper.cs @@ -0,0 +1,28 @@ +// Copyright (C) Ubiquitous AS.All rights reserved +// Licensed under the Apache License, Version 2.0. + +namespace Eventuous; + +public interface ITypeMapper { + public const string UnknownType = "unknown"; + + /// + /// Try getting a type name for a given type + /// + /// Type for which the name is requested + /// Registered type name or null if the type isn't registered + /// True if the type is registered, false otherwise + bool TryGetTypeName(Type type, [NotNullWhen(true)] out string? typeName); + + /// + /// Try getting a registered type for a given name + /// + /// Known type name + /// Registered type for a given name or null if the type name isn't registered + /// True if the type is registered, false otherwise + bool TryGetType(string typeName, [NotNullWhen(true)] out Type? type); +} + +public interface ITypeMapperExt : ITypeMapper { + IEnumerable<(string TypeName, Type Type)> GetRegisteredTypes(); +} \ No newline at end of file diff --git a/src/Core/src/Eventuous.Shared/TypeMap/TypeMapper.cs b/src/Core/src/Eventuous.Shared/TypeMap/TypeMapper.cs index cb440753..fb3e8585 100644 --- a/src/Core/src/Eventuous.Shared/TypeMap/TypeMapper.cs +++ b/src/Core/src/Eventuous.Shared/TypeMap/TypeMapper.cs @@ -25,64 +25,28 @@ public static class TypeMap { /// /// Zero or more assemblies that contain event classes to scan. /// If omitted, all the assemblies of the current will be scanned. - public static void RegisterKnownEventTypes(params Assembly[] assemblies) => Instance.RegisterKnownEventTypes(assemblies); + public static void RegisterKnownEventTypes(params Assembly[] assemblies) { + Instance.RegisterKnownEventTypes(assemblies); + } } /// /// The actual mapper behind static . /// -public class TypeMapper { +public class TypeMapper : ITypeMapperExt { readonly Dictionary _reverseMap = new(); readonly Dictionary _map = new(); - // ReSharper disable once UnusedMember.Global - public IReadOnlyDictionary Map => _map; public IReadOnlyDictionary ReverseMap => _reverseMap; - [PublicAPI] - public string GetTypeName() { - if (!_map.TryGetValue(typeof(T), out var name)) { - Log.TypeNotMappedToName(typeof(T)); - - throw new UnregisteredTypeException(typeof(T)); - } - - return name; - } - - public string GetTypeName(object o, bool fail = true) { - if (_map.TryGetValue(o.GetType(), out var name)) return name; - - if (!fail) return "unknown"; - - Log.TypeNotMappedToName(o.GetType()); - - throw new UnregisteredTypeException(o.GetType()); - } - - [PublicAPI] - public string GetTypeNameByType(Type type) { - if (!_map.TryGetValue(type, out var name)) { - Log.TypeNotMappedToName(type); - - throw new UnregisteredTypeException(type); - } - - return name; - } - - public Type GetType(string typeName) { - if (!_reverseMap.TryGetValue(typeName, out var type)) { - Log.TypeNameNotMappedToType(typeName); - - throw new UnregisteredTypeException(typeName); - } - - return type; - } + /// > + public bool TryGetTypeName(Type type, [NotNullWhen(true)] out string? typeName) => _map.TryGetValue(type, out typeName); + /// > public bool TryGetType(string typeName, [NotNullWhen(true)] out Type? type) => _reverseMap.TryGetValue(typeName, out type); + public IEnumerable<(string TypeName, Type Type)> GetRegisteredTypes() => _reverseMap.Select(x => (x.Key, x.Value)); + /// /// Adds a message type to the map. /// @@ -125,14 +89,12 @@ public void AddType(Type type, string? name = null) { [MethodImpl(MethodImplOptions.Synchronized)] public void RemoveType() { - var name = GetTypeName(); + var name = this.GetTypeName(); _reverseMap.Remove(name); _map.Remove(typeof(T)); } - public bool IsTypeRegistered() => _map.ContainsKey(typeof(T)); - public void RegisterKnownEventTypes(params Assembly[] assembliesWithEvents) { var assembliesToScan = assembliesWithEvents.Length == 0 ? GetDefaultAssemblies() : assembliesWithEvents; @@ -173,12 +135,6 @@ void RegisterAssemblyEventTypes(Assembly assembly) { AddType(type, attr.EventType); } } - - public void EnsureTypesRegistered(IEnumerable types) { - foreach (var type in types) { - GetTypeNameByType(type); - } - } } [AttributeUsage(AttributeTargets.Class)] diff --git a/src/Core/src/Eventuous.Shared/TypeMap/TypeMapperExtensions.cs b/src/Core/src/Eventuous.Shared/TypeMap/TypeMapperExtensions.cs new file mode 100644 index 00000000..858f1372 --- /dev/null +++ b/src/Core/src/Eventuous.Shared/TypeMap/TypeMapperExtensions.cs @@ -0,0 +1,59 @@ +// Copyright (C) Ubiquitous AS.All rights reserved +// Licensed under the Apache License, Version 2.0. + +namespace Eventuous; + +using static TypeMapEventSource; + +public static class TypeMapperExtensions { + /// + /// Get the type name for a given type + /// + /// Type mapper instance + /// Object type for which the name needs to be retrieved + /// Indicates if exception should be thrown if the type is now registered + /// Type name from the map or "unknown" if the type isn't registered and fail is set to false + /// Thrown if the type isn't registered and fail is set to true + public static string GetTypeNameByType(this ITypeMapper typeMapper, Type type, bool fail = true) { + var typeKnown = typeMapper.TryGetTypeName(type, out var name); + + if (!typeKnown && fail) { + Log.TypeNotMappedToName(type); + + throw new UnregisteredTypeException(type); + } + + return name ?? ITypeMapper.UnknownType; + } + + public static string GetTypeName(this ITypeMapper typeMapper, object o, bool fail = true) => typeMapper.GetTypeNameByType(o.GetType(), fail); + + public static string GetTypeName(this ITypeMapper typeMapper, bool fail = true) => typeMapper.GetTypeNameByType(typeof(T), fail); + + public static bool TryGetTypeName(this ITypeMapper typeMapper, [NotNullWhen(true)] out string? typeName) => typeMapper.TryGetTypeName(typeof(T), out typeName); + + /// + /// Get the registered type for a given name + /// + /// Type mapper instance + /// Type name for which the type needs to be returned + /// Type that matches the given name + /// Thrown if the type isn't registered and fail is set to true + public static Type GetType(this ITypeMapper typeMapper, string typeName) { + var typeKnown = typeMapper.TryGetType(typeName, out var type); + + if (!typeKnown) { + Log.TypeNameNotMappedToType(typeName); + + throw new UnregisteredTypeException(typeName); + } + + return type!; + } + + public static void EnsureTypesRegistered(this ITypeMapper typeMapper, IEnumerable types) { + foreach (var type in types) { + typeMapper.GetTypeNameByType(type); + } + } +} diff --git a/src/Core/src/Eventuous.Subscriptions/Handlers/EventHandler.cs b/src/Core/src/Eventuous.Subscriptions/Handlers/EventHandler.cs index 7df35a4b..6f4d93b1 100644 --- a/src/Core/src/Eventuous.Subscriptions/Handlers/EventHandler.cs +++ b/src/Core/src/Eventuous.Subscriptions/Handlers/EventHandler.cs @@ -14,12 +14,12 @@ namespace Eventuous.Subscriptions; /// Base class for event handlers, which allows registering typed handlers for different event types /// [PublicAPI] -public abstract class EventHandler(TypeMapper? mapper = null) : BaseEventHandler { +public abstract class EventHandler(ITypeMapper? mapper = null) : BaseEventHandler { readonly Dictionary _handlersMap = new(); static readonly ValueTask Ignored = new(EventHandlingStatus.Ignored); - readonly TypeMapper _typeMapper = mapper ?? TypeMap.Instance; + readonly ITypeMapper _typeMapper = mapper ?? TypeMap.Instance; /// /// Register a handler for a particular event type @@ -32,7 +32,7 @@ protected void On(HandleTypedEvent handler) where T : class { throw new ArgumentException($"Type {typeof(T).Name} already has a handler"); } - if (!_typeMapper.IsTypeRegistered()) { + if (!_typeMapper.TryGetTypeName(out _)) { SubscriptionsEventSource.Log.MessageTypeNotRegistered(); } diff --git a/src/Core/test/Eventuous.Tests.Application/BookingFuncService.cs b/src/Core/test/Eventuous.Tests.Application/BookingFuncService.cs index 69b613a6..95eab640 100644 --- a/src/Core/test/Eventuous.Tests.Application/BookingFuncService.cs +++ b/src/Core/test/Eventuous.Tests.Application/BookingFuncService.cs @@ -8,11 +8,11 @@ namespace Eventuous.Tests.Application; using static Sut.Domain.BookingEvents; public class BookingFuncService : CommandService { - public BookingFuncService(IEventStore store, TypeMapper? typeMap = null, AmendEvent? amendEvent = null) : base(store, typeMap, amendEvent) { + public BookingFuncService(IEventStore store, ITypeMapper? typeMap = null, AmendEvent? amendEvent = null) : base(store, typeMap, amendEvent) { On() .InState(ExpectedState.New) .GetStream(cmd => GetStream(cmd.BookingId)) - .Act(BookRoom); + .Act(cmd => [new RoomBooked(cmd.RoomId, cmd.CheckIn, cmd.CheckOut, cmd.Price)]); On() .InState(ExpectedState.Existing) @@ -22,14 +22,14 @@ public BookingFuncService(IEventStore store, TypeMapper? typeMap = null, AmendEv On() .InState(ExpectedState.Any) .GetStream(cmd => GetStream(cmd.BookingId)) - .Act(ImportBooking); + .Act((_, _, cmd) => [new BookingImported(cmd.RoomId, cmd.Price, cmd.CheckIn, cmd.CheckOut)]); - return; - - static IEnumerable BookRoom(BookRoom cmd) => [new RoomBooked(cmd.RoomId, cmd.CheckIn, cmd.CheckOut, cmd.Price)]; + On() + .InState(ExpectedState.Any) + .GetStream(cmd => GetStream(cmd.BookingId)) + .Act((_, _, _) => [new BookingCancelled()]); - static IEnumerable ImportBooking(BookingState state, object[] events, ImportBooking cmd) - => [new BookingImported(cmd.RoomId, cmd.Price, cmd.CheckIn, cmd.CheckOut)]; + return; static IEnumerable RecordPayment(BookingState state, object[] originalEvents, RecordPayment cmd) { if (state.HasPayment(cmd.PaymentId)) yield break; diff --git a/src/Core/test/Eventuous.Tests.Application/CommandServiceTests.cs b/src/Core/test/Eventuous.Tests.Application/CommandServiceTests.cs index a2c7cec3..6b37ced9 100644 --- a/src/Core/test/Eventuous.Tests.Application/CommandServiceTests.cs +++ b/src/Core/test/Eventuous.Tests.Application/CommandServiceTests.cs @@ -14,7 +14,7 @@ protected override ICommandService CreateService( class ExtendedService : BookingService { public ExtendedService( IEventStore store, - TypeMapper typeMap, + ITypeMapper typeMap, AmendEvent? amendEvent = null, AmendEvent? amendAll = null ) : base(store, typeMapper: typeMap, amendEvent: amendAll) { diff --git a/src/Core/test/Eventuous.Tests.Application/Eventuous.Tests.Application.csproj b/src/Core/test/Eventuous.Tests.Application/Eventuous.Tests.Application.csproj index d2299396..c46c553b 100644 --- a/src/Core/test/Eventuous.Tests.Application/Eventuous.Tests.Application.csproj +++ b/src/Core/test/Eventuous.Tests.Application/Eventuous.Tests.Application.csproj @@ -6,4 +6,9 @@ + + + ServiceTestBase.cs + + diff --git a/src/Core/test/Eventuous.Tests.Application/FunctionalServiceTests.cs b/src/Core/test/Eventuous.Tests.Application/FunctionalServiceTests.cs index 491e0052..7462f9b8 100644 --- a/src/Core/test/Eventuous.Tests.Application/FunctionalServiceTests.cs +++ b/src/Core/test/Eventuous.Tests.Application/FunctionalServiceTests.cs @@ -15,7 +15,7 @@ protected override ICommandService CreateService( class ExtendedService : BookingFuncService { public ExtendedService( IEventStore store, - TypeMapper? typeMap, + ITypeMapper? typeMap, AmendEvent? amendEvent = null, AmendEvent? amendAll = null ) : base(store, typeMap, amendAll) { diff --git a/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.Amendments.cs b/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.Amendments.cs new file mode 100644 index 00000000..d51bb415 --- /dev/null +++ b/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.Amendments.cs @@ -0,0 +1,44 @@ +using Eventuous.Sut.Domain; +using Eventuous.Testing; + +namespace Eventuous.Tests.Application; + +public abstract partial class ServiceTestBase { + [Fact] + public async Task Should_amend_event_from_command() { + var service = CreateService(amendEvent: AmendEvent); + var cmd = CreateCommand(); + + await service.Handle(cmd, default); + + var stream = await Store.ReadStream(StreamName.For(cmd.BookingId), StreamReadPosition.Start); + stream[0].Metadata["userId"].Should().Be(cmd.ImportedBy); + } + + [Fact] + public async Task Should_amend_event_with_static_meta() { + var cmd = Helpers.GetBookRoom(); + + await CommandServiceFixture + .ForService(() => CreateService(amendAll: AddMeta), Store) + .Given(cmd.BookingId) + .When(cmd) + .Then(x => x.StreamIs(e => e[0].Metadata["foo"].Should().Be("bar"))); + } + + [Fact] + public async Task Should_combine_amendments() { + var service = CreateService(amendEvent: AmendEvent, amendAll: AddMeta); + var cmd = CreateCommand(); + + await service.Handle(cmd, default); + + var stream = await Store.ReadStream(StreamName.For(cmd.BookingId), StreamReadPosition.Start); + stream[0].Metadata["userId"].Should().Be(cmd.ImportedBy); + stream[0].Metadata["foo"].Should().Be("bar"); + } + + static NewStreamEvent AmendEvent(NewStreamEvent evt, ImportBooking cmd) => evt with { Metadata = evt.Metadata.With("userId", cmd.ImportedBy) }; + + static NewStreamEvent AddMeta(NewStreamEvent evt) => evt with { Metadata = evt.Metadata.With("foo", "bar") }; +} diff --git a/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnAny.cs b/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnAny.cs new file mode 100644 index 00000000..7c4890c4 --- /dev/null +++ b/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnAny.cs @@ -0,0 +1,26 @@ +using Eventuous.Sut.App; +using Eventuous.Testing; +using Shouldly; + +namespace Eventuous.Tests.Application; + +public abstract partial class ServiceTestBase { + [Fact] + public async Task Should_execute_on_any_no_stream() { + var bookRoom = Helpers.GetBookRoom(); + + var cmd = new Commands.ImportBooking { + BookingId = "dummy", + Price = bookRoom.Price, + CheckIn = bookRoom.CheckIn, + CheckOut = bookRoom.CheckOut, + RoomId = bookRoom.RoomId + }; + + await CommandServiceFixture + .ForService(() => CreateService(), Store) + .Given(cmd.BookingId) + .When(cmd) + .Then(result => result.ResultIsOk(x => x.Changes.Should().HaveCount(1)).StreamIs(x => x.Length.ShouldBe(1))); + } +} diff --git a/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnExisting.cs b/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnExisting.cs new file mode 100644 index 00000000..7a28f51c --- /dev/null +++ b/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnExisting.cs @@ -0,0 +1,42 @@ +using Eventuous.Sut.App; +using Eventuous.Sut.Domain; +using Eventuous.Testing; +using Shouldly; + +namespace Eventuous.Tests.Application; + +public abstract partial class ServiceTestBase { + [Fact] + public async Task Should_execute_on_existing_stream_exists() { + var seedCmd = Helpers.GetBookRoom(); + var seed = new BookingEvents.RoomBooked(seedCmd.RoomId, seedCmd.CheckIn, seedCmd.CheckOut, seedCmd.Price); + + var paymentTime = DateTimeOffset.Now; + var cmd = new Commands.RecordPayment(new(seedCmd.BookingId), "444", new(seedCmd.Price), paymentTime); + + var expectedResult = new object[] { + new BookingEvents.BookingPaymentRegistered(cmd.PaymentId, cmd.Amount.Amount), + new BookingEvents.BookingOutstandingAmountChanged(0), + new BookingEvents.BookingFullyPaid(paymentTime) + }; + + await CommandServiceFixture + .ForService(() => CreateService(), Store) + .Given(seedCmd.BookingId, seed) + .When(cmd) + .Then(result => result.ResultIsOk().NewStreamEventsAre(expectedResult)); + } + + [Fact] + public async Task Should_fail_on_existing_no_stream() { + var seedCmd = Helpers.GetBookRoom(); + var paymentTime = DateTimeOffset.Now; + var cmd = new Commands.RecordPayment(new(seedCmd.BookingId), "444", new(seedCmd.Price), paymentTime); + + await CommandServiceFixture + .ForService(() => CreateService(), Store) + .Given(seedCmd.BookingId) + .When(cmd) + .Then(result => result.ResultIsError().StreamIs(x => x.Length.ShouldBe(0))); + } +} diff --git a/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnNew.cs b/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnNew.cs new file mode 100644 index 00000000..c2ae6811 --- /dev/null +++ b/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.OnNew.cs @@ -0,0 +1,30 @@ +using Eventuous.Sut.Domain; +using Eventuous.Testing; + +namespace Eventuous.Tests.Application; + +public abstract partial class ServiceTestBase { + [Fact] + public async Task Should_run_on_new_no_stream() { + var cmd = Helpers.GetBookRoom(); + var expected = new BookingEvents.RoomBooked(cmd.RoomId, cmd.CheckIn, cmd.CheckOut, cmd.Price); + + await CommandServiceFixture + .ForService(() => CreateService(), Store) + .Given(cmd.BookingId) + .When(cmd) + .Then(result => result.ResultIsOk(x => x.Changes.Should().HaveCount(1)).FullStreamEventsAre(expected)); + } + + [Fact] + public async Task Should_fail_on_new_stream_exists() { + var cmd = Helpers.GetBookRoom(); + var seed = new BookingEvents.RoomBooked(cmd.RoomId, cmd.CheckIn, cmd.CheckOut, cmd.Price); + + await CommandServiceFixture + .ForService(() => CreateService(), Store) + .Given(cmd.BookingId, seed) + .When(cmd) + .Then(result => result.ResultIsError()); + } +} diff --git a/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.cs b/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.cs index ded0293b..2d2319a4 100644 --- a/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.cs +++ b/src/Core/test/Eventuous.Tests.Application/ServiceTestBase.cs @@ -3,64 +3,11 @@ using Eventuous.TestHelpers; using Eventuous.Testing; using NodaTime; -using Shouldly; using static Eventuous.Sut.Domain.BookingEvents; namespace Eventuous.Tests.Application; -public abstract class ServiceTestBase : IDisposable { - [Fact] - public async Task ExecuteOnNewStream() { - var cmd = Helpers.GetBookRoom(); - var expected = new RoomBooked(cmd.RoomId, cmd.CheckIn, cmd.CheckOut, cmd.Price); - - await CommandServiceFixture - .ForService(() => CreateService(), Store) - .Given(cmd.BookingId) - .When(cmd) - .Then(result => result.ResultIsOk(x => x.Changes.Should().HaveCount(1)).FullStreamEventsAre(expected)); - } - - [Fact] - public async Task ExecuteOnExistingStream() { - var seedCmd = Helpers.GetBookRoom(); - var seed = new RoomBooked(seedCmd.RoomId, seedCmd.CheckIn, seedCmd.CheckOut, seedCmd.Price); - - var paymentTime = DateTimeOffset.Now; - var cmd = new Commands.RecordPayment(new(seedCmd.BookingId), "444", new(seedCmd.Price), paymentTime); - - var expectedResult = new object[] { - new BookingPaymentRegistered(cmd.PaymentId, cmd.Amount.Amount), - new BookingOutstandingAmountChanged(0), - new BookingFullyPaid(paymentTime) - }; - - await CommandServiceFixture - .ForService(() => CreateService(), Store) - .Given(seedCmd.BookingId, seed) - .When(cmd) - .Then(result => result.ResultIsOk().NewStreamEventsAre(expectedResult)); - } - - [Fact] - public async Task ExecuteOnAnyForNewStream() { - var bookRoom = Helpers.GetBookRoom(); - - var cmd = new Commands.ImportBooking { - BookingId = "dummy", - Price = bookRoom.Price, - CheckIn = bookRoom.CheckIn, - CheckOut = bookRoom.CheckOut, - RoomId = bookRoom.RoomId - }; - - await CommandServiceFixture - .ForService(() => CreateService(), Store) - .Given(cmd.BookingId) - .When(cmd) - .Then(result => result.ResultIsOk(x => x.Changes.Should().HaveCount(1)).StreamIs(x => x.Length.ShouldBe(1))); - } - +public abstract partial class ServiceTestBase : IDisposable { [Fact] public async Task Ensure_builder_is_thread_safe() { const int threadCount = 3; @@ -82,44 +29,6 @@ public async Task Ensure_builder_is_thread_safe() { } } - [Fact] - public async Task Should_amend_event_from_command() { - var service = CreateService(amendEvent: AmendEvent); - var cmd = CreateCommand(); - - await service.Handle(cmd, default); - - var stream = await Store.ReadStream(StreamName.For(cmd.BookingId), StreamReadPosition.Start); - stream[0].Metadata["userId"].Should().Be(cmd.ImportedBy); - } - - [Fact] - public async Task Should_amend_event_with_static_meta() { - var cmd = Helpers.GetBookRoom(); - - await CommandServiceFixture - .ForService(() => CreateService(amendAll: AddMeta), Store) - .Given(cmd.BookingId) - .When(cmd) - .Then(x => x.StreamIs(e => e[0].Metadata["foo"].Should().Be("bar"))); - } - - [Fact] - public async Task Should_combine_amendments() { - var service = CreateService(amendEvent: AmendEvent, amendAll: AddMeta); - var cmd = CreateCommand(); - - await service.Handle(cmd, default); - - var stream = await Store.ReadStream(StreamName.For(cmd.BookingId), StreamReadPosition.Start); - stream[0].Metadata["userId"].Should().Be(cmd.ImportedBy); - stream[0].Metadata["foo"].Should().Be("bar"); - } - - static NewStreamEvent AmendEvent(NewStreamEvent evt, ImportBooking cmd) => evt with { Metadata = evt.Metadata.With("userId", cmd.ImportedBy) }; - - static NewStreamEvent AddMeta(NewStreamEvent evt) => evt with { Metadata = evt.Metadata.With("foo", "bar") }; - static ImportBooking CreateCommand() { var today = LocalDate.FromDateTime(DateTime.Today); diff --git a/src/Core/test/Eventuous.Tests/StoringEvents.cs b/src/Core/test/Eventuous.Tests/StoringEvents.cs index 34d06b4b..7c74c9bc 100644 --- a/src/Core/test/Eventuous.Tests/StoringEvents.cs +++ b/src/Core/test/Eventuous.Tests/StoringEvents.cs @@ -1,4 +1,5 @@ global using NodaTime; +using static Eventuous.Sut.Domain.BookingEvents; namespace Eventuous.Tests; @@ -24,7 +25,7 @@ public async Task StoreInitial() { Auto.Create() ); - Change[] expected = [new(new BookingEvents.RoomBooked(cmd.RoomId, cmd.CheckIn, cmd.CheckOut, cmd.Price), "RoomBooked")]; + Change[] expected = [new(new RoomBooked(cmd.RoomId, cmd.CheckIn, cmd.CheckOut, cmd.Price), TypeNames.RoomBooked)]; var result = await Service.Handle(cmd, default); diff --git a/src/Core/test/Eventuous.Tests/StoringEventsWithCustomStream.cs b/src/Core/test/Eventuous.Tests/StoringEventsWithCustomStream.cs index cfa46037..269207e5 100644 --- a/src/Core/test/Eventuous.Tests/StoringEventsWithCustomStream.cs +++ b/src/Core/test/Eventuous.Tests/StoringEventsWithCustomStream.cs @@ -20,7 +20,7 @@ public StoringEventsWithCustomStream() { public async Task TestOnNew() { var cmd = CreateBookRoomCommand(); - Change[] expected = [new(new RoomBooked(cmd.RoomId, cmd.CheckIn, cmd.CheckOut, cmd.Price), "RoomBooked")]; + Change[] expected = [new(new RoomBooked(cmd.RoomId, cmd.CheckIn, cmd.CheckOut, cmd.Price), TypeNames.RoomBooked)]; var result = await Service.Handle(cmd, default); @@ -41,9 +41,9 @@ public async Task TestOnExisting() { var secondCmd = new Commands.RecordPayment(new(cmd.BookingId), Auto.Create(), new(cmd.Price), DateTimeOffset.Now); var expected = new Change[] { - new(new BookingPaymentRegistered(secondCmd.PaymentId, secondCmd.Amount.Amount), "PaymentRegistered"), - new(new BookingOutstandingAmountChanged(0), "OutstandingAmountChanged"), - new(new BookingFullyPaid(secondCmd.PaidAt), "BookingFullyPaid") + new(new BookingPaymentRegistered(secondCmd.PaymentId, secondCmd.Amount.Amount), TypeNames.PaymentRegistered), + new(new BookingOutstandingAmountChanged(0), TypeNames.OutstandingAmountChanged), + new(new BookingFullyPaid(secondCmd.PaidAt), TypeNames.BookingFullyPaid) }; var result = await Service.Handle(secondCmd, default); diff --git a/src/EventStore/src/Eventuous.EventStore/EsdbEventStore.cs b/src/EventStore/src/Eventuous.EventStore/EsdbEventStore.cs index 8c60e84d..e7db5174 100644 --- a/src/EventStore/src/Eventuous.EventStore/EsdbEventStore.cs +++ b/src/EventStore/src/Eventuous.EventStore/EsdbEventStore.cs @@ -85,10 +85,7 @@ public Task AppendEvents( async () => { var result = await resultTask.NoContext(); - return new AppendEventsResult( - result.LogPosition.CommitPosition, - result.NextExpectedStreamRevision.ToInt64() - ); + return new AppendEventsResult(result.LogPosition.CommitPosition, result.NextExpectedStreamRevision.ToInt64()); }, stream, () => new("Unable to appends events to {Stream}", stream), @@ -256,8 +253,7 @@ StreamEvent AsStreamEvent(object payload) StreamEvent[] ToStreamEvents(ResolvedEvent[] resolvedEvents) => resolvedEvents .Where(x => !x.Event.EventType.StartsWith('$')) - // ReSharper disable once ConvertClosureToMethodGroup - .Select(e => ToStreamEvent(e)) + .Select(ToStreamEvent) .ToArray(); record ErrorInfo(string Message, params object[] Args); diff --git a/src/EventStore/test/Eventuous.Tests.EventStore/ProducerTracesTests.cs b/src/EventStore/test/Eventuous.Tests.EventStore/ProducerTracesTests.cs index 5c007048..0b1632bf 100644 --- a/src/EventStore/test/Eventuous.Tests.EventStore/ProducerTracesTests.cs +++ b/src/EventStore/test/Eventuous.Tests.EventStore/ProducerTracesTests.cs @@ -10,6 +10,8 @@ namespace Eventuous.Tests.EventStore; public class TracesTests : LegacySubscriptionFixture, IDisposable { readonly ActivityListener _listener; + static TracesTests() => TypeMap.Instance.AddType(TestEvent.TypeName); + public TracesTests(ITestOutputHelper output) : base(output, new(), false) { _listener = new() { ShouldListenTo = _ => true, diff --git a/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticSerializer.cs b/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticSerializer.cs index eb2c138b..ff291aea 100644 --- a/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticSerializer.cs +++ b/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticSerializer.cs @@ -5,10 +5,10 @@ namespace Eventuous.ElasticSearch.Store; -public class ElasticSerializer(IElasticsearchSerializer builtIn, JsonSerializerOptions? options, TypeMapper? typeMapper = null) +public class ElasticSerializer(IElasticsearchSerializer builtIn, JsonSerializerOptions? options, ITypeMapper? typeMapper = null) : IElasticsearchSerializer { readonly JsonSerializerOptions _options = options ?? new JsonSerializerOptions(JsonSerializerDefaults.Web); - readonly TypeMapper _typeMapper = typeMapper ?? TypeMap.Instance; + readonly ITypeMapper _typeMapper = typeMapper ?? TypeMap.Instance; public object Deserialize(Type type, Stream stream) { var reader = new BinaryReader(stream); diff --git a/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs b/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs index 4aa75661..6d283506 100644 --- a/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs +++ b/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs @@ -34,10 +34,12 @@ public static IEndpointRouteBuilder MapEventuousSpyglass(this IEndpointRouteBuil builder.MapGet( "/spyglass/events", - (HttpRequest request, [FromServices] TypeMapper? typeMapper) => { + (HttpRequest request, [FromServices] ITypeMapper? typeMapper) => { var typeMap = typeMapper ?? TypeMap.Instance; - return CheckAndReturn(request, () => typeMap.ReverseMap.Select(x => x.Key)); + return typeMap is not ITypeMapperExt typeMapExt + ? Results.Problem("Type mapper doesn't support listing registered types", statusCode: 500) + : CheckAndReturn(request, () => typeMapExt.GetRegisteredTypes()); } ) .ExcludeFromDescription(); diff --git a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/AggregateCommandsTests.MapEnrichedCommand.verified.txt b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/AggregateCommandsTests.MapEnrichedCommand.verified.txt index 5f6d0a71..ff1590fa 100644 --- a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/AggregateCommandsTests.MapEnrichedCommand.verified.txt +++ b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/AggregateCommandsTests.MapEnrichedCommand.verified.txt @@ -21,7 +21,7 @@ price: 100, guestId: test guest }, - eventType: RoomBooked + eventType: V1.RoomBooked } ], streamPosition: 0 diff --git a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/DiscoveredCommandsTests.CallDiscoveredCommandRoute.verified.txt b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/DiscoveredCommandsTests.CallDiscoveredCommandRoute.verified.txt index e5939bc1..23b3a78c 100644 --- a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/DiscoveredCommandsTests.CallDiscoveredCommandRoute.verified.txt +++ b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/DiscoveredCommandsTests.CallDiscoveredCommandRoute.verified.txt @@ -21,7 +21,7 @@ price: 100, guestId: guest }, - eventType: RoomBooked + eventType: V1.RoomBooked } ], streamPosition: 0 diff --git a/src/Mongo/src/Eventuous.Projections.MongoDB/MongoProjector.cs b/src/Mongo/src/Eventuous.Projections.MongoDB/MongoProjector.cs index 137e0e0e..f401c65b 100644 --- a/src/Mongo/src/Eventuous.Projections.MongoDB/MongoProjector.cs +++ b/src/Mongo/src/Eventuous.Projections.MongoDB/MongoProjector.cs @@ -11,7 +11,7 @@ namespace Eventuous.Projections.MongoDB; using Tools; [Obsolete("Use MongoProjector instead")] -public abstract class MongoProjection(IMongoDatabase database, TypeMapper? typeMap = null) : MongoProjector(database, typeMap) +public abstract class MongoProjection(IMongoDatabase database, ITypeMapper? typeMap = null) : MongoProjector(database, typeMap) where T : ProjectedDocument; /// @@ -19,12 +19,12 @@ public abstract class MongoProjection(IMongoDatabase database, TypeMapper? ty /// /// [UsedImplicitly] -public abstract class MongoProjector(IMongoDatabase database, TypeMapper? typeMap = null) : BaseEventHandler where T : ProjectedDocument { +public abstract class MongoProjector(IMongoDatabase database, ITypeMapper? typeMap = null) : BaseEventHandler where T : ProjectedDocument { [PublicAPI] protected IMongoCollection Collection { get; } = Ensure.NotNull(database).GetDocumentCollection(); readonly Dictionary _handlers = new(); - readonly TypeMapper _map = typeMap ?? TypeMap.Instance; + readonly ITypeMapper _map = typeMap ?? TypeMap.Instance; /// /// Register a handler for a particular event type @@ -38,7 +38,7 @@ protected void On(ProjectTypedEvent handler) where TEvent : c throw new ArgumentException($"Type {typeof(TEvent).Name} already has a handler"); } - if (!_map.IsTypeRegistered()) { + if (!_map.TryGetTypeName(out _)) { Log.MessageTypeNotRegistered(); } } diff --git a/src/Postgres/src/Eventuous.Postgresql/Projections/PostgresProjector.cs b/src/Postgres/src/Eventuous.Postgresql/Projections/PostgresProjector.cs index 1796290b..a2aa168f 100644 --- a/src/Postgres/src/Eventuous.Postgresql/Projections/PostgresProjector.cs +++ b/src/Postgres/src/Eventuous.Postgresql/Projections/PostgresProjector.cs @@ -10,7 +10,7 @@ namespace Eventuous.Postgresql.Projections; /// /// Base class for projectors that store read models in PostgreSQL. /// -public abstract class PostgresProjector(NpgsqlDataSource dataSource, TypeMapper? mapper = null) : EventHandler(mapper) { +public abstract class PostgresProjector(NpgsqlDataSource dataSource, ITypeMapper? mapper = null) : EventHandler(mapper) { protected void On(ProjectToPostgres handler) where T : class { base.On(async ctx => await Handle(ctx, GetCommand).NoContext()); diff --git a/src/SqlServer/src/Eventuous.SqlServer/Projections/SqlServerProjector.cs b/src/SqlServer/src/Eventuous.SqlServer/Projections/SqlServerProjector.cs index a8048991..fe1ce59b 100644 --- a/src/SqlServer/src/Eventuous.SqlServer/Projections/SqlServerProjector.cs +++ b/src/SqlServer/src/Eventuous.SqlServer/Projections/SqlServerProjector.cs @@ -9,7 +9,7 @@ namespace Eventuous.SqlServer.Projections; /// /// Base class for projectors that store read models in SQL Server. /// -public abstract class SqlServerProjector(SqlServerConnectionOptions options, TypeMapper? mapper = null) : EventHandler(mapper) { +public abstract class SqlServerProjector(SqlServerConnectionOptions options, ITypeMapper? mapper = null) : EventHandler(mapper) { readonly string _connectionString = Ensure.NotEmptyString(options.ConnectionString); /// diff --git a/src/Testing/src/Eventuous.Testing/CommandServiceFixture.cs b/src/Testing/src/Eventuous.Testing/CommandServiceFixture.cs index 29b692b1..b7f89a1e 100644 --- a/src/Testing/src/Eventuous.Testing/CommandServiceFixture.cs +++ b/src/Testing/src/Eventuous.Testing/CommandServiceFixture.cs @@ -13,7 +13,7 @@ public static class CommandServiceFixture { /// Event store used by the service /// State on which the service operates /// - public static IServiceFixtureGiven ForService(Func> serviceFactory, IEventStore store) where TState : State, new() + public static IServiceFixtureGiven ForService(Func> serviceFactory, IEventStore store) where TState : State, new() => new CommandServiceFixture(serviceFactory, store); } @@ -64,7 +64,7 @@ public class CommandServiceFixture : IServiceFixtureGiven, IServ Task>? _result; long _nextExpectedVersion = ExpectedStreamVersion.NoStream.Value; - public CommandServiceFixture(Func> serviceFactory) { + public CommandServiceFixture(Func> serviceFactory) { _store = new InMemoryEventStore(); TypeMapper typeMap = new(); _service = serviceFactory(_store, typeMap); @@ -135,27 +135,39 @@ internal FixtureResult(Result result, StreamEvent[] streamEvents, long v } /// - /// Asserts if the result is Ok + /// Asserts if the result is Ok and executes the provided assertions /// + /// Assertion function for successful result /// /// Thrown if the result is not ok - public FixtureResult ResultIsOk() { - if (Result) - return this; + public FixtureResult ResultIsOk(Action.Ok>? assert = null) { + if (!Result.TryGet(out var ok)) { + throw new ShouldAssertException("Expected the result to be Ok, but it was not"); + } - throw new ShouldAssertException($"Expected the result to be Ok, but it was {Result}"); + assert?.Invoke(ok); + + return this; } - /// - /// Asserts if the result is Ok and executes the provided assertions - /// - /// Assertion function for successful result - /// - /// Thrown if the result is not ok - public FixtureResult ResultIsOk(Action.Ok> assert) { - if (!Result.TryGet(out var ok)) throw new ShouldAssertException("Expected the result to be Ok, but it was not"); + public FixtureResult ResultIsError(Action.Error>? assert = null) { + if (!Result.TryGetError(out var error)) { + throw new ShouldAssertException("Expected the result to be Error, but it was Ok"); + } + + assert?.Invoke(error); + + return this; + } - assert(ok); + public FixtureResult ResultIsError(Action? assert = null) where T : Exception { + if (!Result.TryGetError(out var error)) { + throw new ShouldAssertException("Expected the result to be Error, but it was Ok"); + } + + error.Exception.ShouldBeOfType(); + + assert?.Invoke((T)error.Exception); return this; } diff --git a/src/Testing/src/Eventuous.Testing/InMemoryEventStore.cs b/src/Testing/src/Eventuous.Testing/InMemoryEventStore.cs index 97227b26..121cadb8 100644 --- a/src/Testing/src/Eventuous.Testing/InMemoryEventStore.cs +++ b/src/Testing/src/Eventuous.Testing/InMemoryEventStore.cs @@ -104,4 +104,4 @@ public void Truncate(ExpectedStreamVersion version, StreamTruncatePosition posit } } -class WrongVersion(ExpectedStreamVersion expected, int actual) : Exception($"Wrong stream version. Expected {expected.Value}, actual {actual}"); +public class WrongVersion(ExpectedStreamVersion expected, int actual) : Exception($"Wrong stream version. Expected {expected.Value}, actual {actual}"); diff --git a/test/Eventuous.Sut.App/BookingService.cs b/test/Eventuous.Sut.App/BookingService.cs index 563d0c08..478cdbbb 100644 --- a/test/Eventuous.Sut.App/BookingService.cs +++ b/test/Eventuous.Sut.App/BookingService.cs @@ -8,7 +8,7 @@ public class BookingService : CommandService { public BookingService( IEventStore eventStore, StreamNameMap? streamNameMap = null, - TypeMapper? typeMapper = null, + ITypeMapper? typeMapper = null, AmendEvent? amendEvent = null ) : base(eventStore, streamNameMap: streamNameMap, typeMap: typeMapper, amendEvent: amendEvent) { @@ -32,5 +32,10 @@ public BookingService( .InState(ExpectedState.Existing) .GetId(cmd => cmd.BookingId) .Act((booking, cmd) => booking.RecordPayment(cmd.PaymentId, cmd.Amount, cmd.PaidAt)); + + On() + .InState(ExpectedState.Any) + .GetId(cmd => cmd.BookingId) + .Act((booking, _) => booking.Cancel()); } } diff --git a/test/Eventuous.Sut.App/Commands.cs b/test/Eventuous.Sut.App/Commands.cs index 8833187b..7f3a361f 100644 --- a/test/Eventuous.Sut.App/Commands.cs +++ b/test/Eventuous.Sut.App/Commands.cs @@ -19,4 +19,6 @@ public record ImportBooking { public record RecordPayment(BookingId BookingId, string PaymentId, Money Amount, DateTimeOffset PaidAt) { public string? PaidBy { get; init; } }; + + public record CancelBooking(BookingId BookingId); } diff --git a/test/Eventuous.Sut.Domain/Booking.cs b/test/Eventuous.Sut.Domain/Booking.cs index ec175b79..892d17ac 100644 --- a/test/Eventuous.Sut.Domain/Booking.cs +++ b/test/Eventuous.Sut.Domain/Booking.cs @@ -15,6 +15,8 @@ public void Import(string roomId, StayPeriod period, Money price) { Apply(new BookingImported(roomId, price.Amount, period.CheckIn, period.CheckOut)); } + public void Cancel() => Apply(new BookingCancelled()); + public void RecordPayment(string paymentId, Money amount, DateTimeOffset paidAt) { EnsureExists(); diff --git a/test/Eventuous.Sut.Domain/BookingEvents.cs b/test/Eventuous.Sut.Domain/BookingEvents.cs index 6d36c3c4..2eb8713b 100644 --- a/test/Eventuous.Sut.Domain/BookingEvents.cs +++ b/test/Eventuous.Sut.Domain/BookingEvents.cs @@ -5,31 +5,35 @@ namespace Eventuous.Sut.Domain; public static class BookingEvents { - [EventType("RoomBooked")] + [EventType(TypeNames.RoomBooked)] public record RoomBooked(string RoomId, LocalDate CheckIn, LocalDate CheckOut, float Price, string? GuestId = null); - [EventType("PaymentRegistered")] + [EventType(TypeNames.PaymentRegistered)] public record BookingPaymentRegistered(string PaymentId, float AmountPaid); - [EventType("OutstandingAmountChanged")] + [EventType(TypeNames.OutstandingAmountChanged)] public record BookingOutstandingAmountChanged(float OutstandingAmount); - [EventType("BookingFullyPaid")] + [EventType(TypeNames.BookingFullyPaid)] public record BookingFullyPaid(DateTimeOffset PaidAt); - [EventType("BookingOverpaid")] + [EventType(TypeNames.BookingOverpaid)] public record BookingOverpaid(float OverpaidAmount); [EventType(TypeNames.BookingCancelled)] public record BookingCancelled; - [EventType("V1.BookingImported")] + [EventType(TypeNames.BookingImported)] public record BookingImported(string RoomId, float Price, LocalDate CheckIn, LocalDate CheckOut); // These constants are for test purpose, use inline names in real apps public static class TypeNames { - public const string BookingCancelled = "V1.BookingCancelled"; + public const string BookingCancelled = "V1.BookingCancelled"; + public const string BookingImported = "V1.BookingImported"; + public const string RoomBooked = "V1.RoomBooked"; + public const string PaymentRegistered = "V1.PaymentRegistered"; + public const string OutstandingAmountChanged = "V1.OutstandingAmountChanged"; + public const string BookingFullyPaid = "V1.BookingFullyPaid"; + public const string BookingOverpaid = "V1.BookingOverpaid"; } - - public static void MapBookingEvents() => TypeMap.RegisterKnownEventTypes(); }