diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/05-Consistency.csproj b/Workshops/EventDrivenArchitecture/05-Consistency/05-Consistency.csproj new file mode 100644 index 00000000..d966d3e3 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/05-Consistency.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + Consistency + enable + enable + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Core/CommandBus.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Core/CommandBus.cs new file mode 100644 index 00000000..94e2aed7 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Core/CommandBus.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; + +namespace Consistency.Core; + +public class CommandBus +{ + public async ValueTask Send(object[] commands, CancellationToken ct) + { + foreach (var command in commands) + { + if (!commandHandlers.TryGetValue(command.GetType(), out var handler)) + continue; + + foreach (var middleware in middlewares) + middleware(command); + + await handler(command, ct); + } + } + + public CommandBus Handle(Func eventHandler) + { + commandHandlers[typeof(T)] = (command, ct) => eventHandler((T)command, ct); + + return this; + } + + public void Use(Action middleware) => + middlewares.Add(middleware); + + private readonly ConcurrentDictionary> commandHandlers = new(); + private readonly List> middlewares = []; +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Core/Database.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Core/Database.cs new file mode 100644 index 00000000..9f0821a4 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Core/Database.cs @@ -0,0 +1,66 @@ +using System.Text.Json; + +namespace Consistency.Core; + +public class Database +{ + private Dictionary storage = new(); + + public ValueTask Store(Guid id, T obj, CancellationToken _) where T : class + { + Monitor.Enter(storage); + try + { + if (DateTimeOffset.Now.Ticks % 2 == 0) + storage[GetId(id)] = obj; + + return ValueTask.CompletedTask; + } + finally + { + Monitor.Exit(storage); + } + } + + public ValueTask Delete(Guid id, CancellationToken _) + { + Monitor.Enter(storage); + try + { + if (DateTimeOffset.Now.Ticks % 2 == 0) + storage.Remove(GetId(id)); + + return ValueTask.CompletedTask; + } + finally + { + Monitor.Exit(storage); + } + } + + public ValueTask Get(Guid id, CancellationToken _) where T : class => + ValueTask.FromResult( + storage.TryGetValue(GetId(id), out var result) + ? + // Clone to simulate getting new instance on loading + JsonSerializer.Deserialize(JsonSerializer.Serialize((T)result)) + : null + ); + + public async ValueTask Transaction(Func action) + { + Monitor.Enter(storage); + try + { + var serialisedDatabase = new Database(); + + await action(serialisedDatabase); + } + finally + { + Monitor.Exit(storage); + } + } + + private static string GetId(Guid id) => $"{typeof(T).Name}-{id}"; +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Core/EventBus.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Core/EventBus.cs new file mode 100644 index 00000000..1230aff5 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Core/EventBus.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; + +namespace Consistency.Core; + +public class EventBus +{ + public async ValueTask Publish(object[] events, CancellationToken ct) + { + if (DateTimeOffset.Now.Ticks % 2 == 0) + return; + + foreach (var @event in events) + { + foreach (var middleware in middlewares) + middleware(@event); + + if (!eventHandlers.TryGetValue(@event.GetType(), out var handlers)) + continue; + + foreach (var handler in handlers) + await handler(@event, ct); + } + } + + public EventBus Subscribe(Func eventHandler) + { + Func handler = (@event, ct) => eventHandler((T)@event, ct); + + eventHandlers.AddOrUpdate( + typeof(T), + _ => [handler], + (_, handlers) => + { + handlers.Add(handler); + return handlers; + } + ); + + return this; + } + + public void Use(Action middleware) => + middlewares.Add(middleware); + + private readonly ConcurrentDictionary>> eventHandlers = new(); + private readonly List> middlewares = []; +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Core/MessageCatcher.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Core/MessageCatcher.cs new file mode 100644 index 00000000..830bc9ed --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Core/MessageCatcher.cs @@ -0,0 +1,26 @@ +using FluentAssertions; + +namespace Consistency.Core; + +public class MessageCatcher +{ + public List Published { get; } = []; + + public void Catch(object message) => + Published.Add(message); + + public void Reset() => Published.Clear(); + + public void ShouldNotReceiveAnyMessage() => + Published.Should().BeEmpty(); + + public void ShouldReceiveSingleMessage(T message) + { + Published.Should().HaveCount(1); + Published.OfType().Should().HaveCount(1); + Published.Single().Should().Be(message); + } + + public void ShouldReceiveMessages(object[] messages) => + Published.Should().BeEquivalentTo(messages); +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/BusinessProcessTests.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/BusinessProcessTests.cs new file mode 100644 index 00000000..1ac57373 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/BusinessProcessTests.cs @@ -0,0 +1,245 @@ +using Bogus; +using Consistency.Core; +using Consistency.Sagas.Version1_Aggregates.GroupCheckouts; +using Consistency.Sagas.Version1_Aggregates.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = Consistency.Core.Database; +using GroupCheckoutCommand = Consistency.Sagas.Version1_Aggregates.GroupCheckouts.GroupCheckoutCommand; + +namespace Consistency.Sagas.Version1_Aggregates; + +using static GuestStayAccountEvent; +using static GuestStayAccountCommand; +using static GroupCheckoutsConfig; +using static GuestStayAccountsConfig; +using static GroupCheckoutCommand; + +public class BusinessProcessTests +{ + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithoutPaymentsAndCharges_ShouldComplete() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckedOut(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckedOut(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutCompleted(groupCheckoutId, guestStays, now), + ] + ); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithAllStaysSettled_ShouldComplete() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[0], now.AddHours(-1))); + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[1], amounts[1], now.AddHours(-2))); + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckedOut(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckedOut(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutCompleted(groupCheckoutId, guestStays, now), + ] + ); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithOneSettledAndRestUnsettled_ShouldFail() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + // 🟢 settled + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[0], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[1], now), + new GuestCheckOutFailed(guestStays[1], GuestCheckOutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckOutFailed(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[2], now), + new GuestCheckOutFailed(guestStays[2], GuestCheckOutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckOutFailed(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutFailed( + groupCheckoutId, + [guestStays[0]], + [guestStays[1], guestStays[2]], + now + ), + ] + ); + } + + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithAllUnsettled_ShouldFail() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + // 🛑 charge without payment + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckOutFailed(guestStays[0], GuestCheckOutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckOutFailed(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckOutFailed(guestStays[1], GuestCheckOutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckOutFailed(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckOutFailed(guestStays[2], GuestCheckOutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckOutFailed(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutFailed( + groupCheckoutId, + [], + [guestStays[0], guestStays[1], guestStays[2]], + now + ), + ] + ); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly CommandBus commandBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly GroupCheckOutFacade groupCheckoutFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public BusinessProcessTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + groupCheckoutFacade = new GroupCheckOutFacade(database, eventBus); + + eventBus.Use(publishedMessages.Catch); + commandBus.Use(publishedMessages.Catch); + + ConfigureGroupCheckouts(eventBus, commandBus, groupCheckoutFacade); + ConfigureGuestStayAccounts(commandBus, guestStayFacade); + } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/Core/Aggregate.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/Core/Aggregate.cs new file mode 100644 index 00000000..c17e2990 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/Core/Aggregate.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Consistency.Sagas.Version1_Aggregates.Core; + +public abstract class Aggregate + where TEvent : class + where TId : notnull +{ + [JsonInclude] public TId Id { get; protected set; } = default!; + + [NonSerialized] private readonly Queue uncommittedEvents = new(); + + public virtual void Apply(TEvent @event) { } + + public object[] DequeueUncommittedEvents() + { + var dequeuedEvents = uncommittedEvents.Cast().ToArray();; + + uncommittedEvents.Clear(); + + return dequeuedEvents; + } + + protected void Enqueue(TEvent @event) + { + uncommittedEvents.Enqueue(@event); + Apply(@event); + } +} + diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/EntityDefinitionTests.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/EntityDefinitionTests.cs new file mode 100644 index 00000000..521117df --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/EntityDefinitionTests.cs @@ -0,0 +1,172 @@ +using Bogus; +using Consistency.Core; +using Consistency.Sagas.Version1_Aggregates.GroupCheckouts; +using Consistency.Sagas.Version1_Aggregates.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = Consistency.Core.Database; + +namespace Consistency.Sagas.Version1_Aggregates; + +using static GuestStayAccountEvent; +using static GuestStayAccountCommand; +using static GroupCheckoutCommand; + +public class EntityDefinitionTests +{ + [Fact] + public async Task CheckingInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + var command = new CheckInGuest(guestStayId, now); + publishedMessages.Reset(); + + // When + await guestStayFacade.CheckInGuest(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedIn(guestStayId, now)); + } + + [Fact] + public async Task RecordingChargeForCheckedInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordCharge(guestStayId, amount, now); + + // When + await guestStayFacade.RecordCharge(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new ChargeRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task RecordingPaymentForCheckedInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordPayment(guestStayId, amount, now); + + // When + await guestStayFacade.RecordPayment(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task RecordingPaymentForCheckedInGuestWithCharge_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, generate.Finance.Amount(), now.AddHours(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordPayment(guestStayId, amount, now); + + // When + await guestStayFacade.RecordPayment(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task CheckingOutGuestWithSettledBalance_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + + var amount = generate.Finance.Amount(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount, now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var command = new CheckOutGuest(guestStayId, now); + + // When + await guestStayFacade.CheckOutGuest(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedOut(guestStayId, now)); + } + + [Fact] + public async Task CheckingOutGuestWithSettledBalance_FailsWithGuestCheckoutFailed() + { + // Given + var guestStayId = Guid.NewGuid(); + + var amount = generate.Finance.Amount(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount + 10, now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var command = new CheckOutGuest(guestStayId, now); + + // When + try + { + await guestStayFacade.CheckOutGuest(command); + } + catch (Exception exc) + { + testOutputHelper.WriteLine(exc.Message); + } + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckOutFailed(guestStayId, GuestCheckOutFailed.FailureReason.BalanceNotSettled, now)); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStay_ShouldBeInitiated() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await guestStayFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now)); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public EntityDefinitionTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + eventBus.Use(publishedMessages.Catch); + } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckout.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckout.cs new file mode 100644 index 00000000..ced175b3 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckout.cs @@ -0,0 +1,149 @@ +using System.Text.Json.Serialization; +using Consistency.Sagas.Version1_Aggregates.Core; + +namespace Consistency.Sagas.Version1_Aggregates.GroupCheckouts; + +using static GroupCheckoutEvent; + +public abstract record GroupCheckoutEvent +{ + public record GroupCheckoutInitiated( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset InitiatedAt + ): GroupCheckoutEvent; + + public record GuestCheckoutCompleted( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt + ): GroupCheckoutEvent; + + public record GuestCheckOutFailed( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset FailedAt + ): GroupCheckoutEvent; + + public record GroupCheckoutCompleted( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + DateTimeOffset CompletedAt + ): GroupCheckoutEvent; + + public record GroupCheckoutFailed( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + Guid[] FailedCheckouts, + DateTimeOffset FailedAt + ): GroupCheckoutEvent; + + private GroupCheckoutEvent() { } +} + +public class GroupCheckOut: Aggregate +{ + [JsonInclude] private readonly Dictionary guestStayCheckouts = new(); + [JsonInclude] private CheckoutStatus status; + + [JsonConstructor] + private GroupCheckOut( + Guid id, + Dictionary guestStayCheckouts, + CheckoutStatus status + ) + { + Id = id; + this.guestStayCheckouts = guestStayCheckouts; + this.status = status; + } + + public static GroupCheckOut Initiate( + Guid groupCheckoutId, + Guid clerkId, + Guid[] guestStayIds, + DateTimeOffset initiatedAt + ) + { + var checkOut = new GroupCheckOut( + groupCheckoutId, + guestStayIds.ToDictionary(id => id, _ => CheckoutStatus.Initiated), + CheckoutStatus.Initiated + ); + checkOut.Enqueue(new GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStayIds, initiatedAt)); + + return checkOut; + } + + + public void RecordGuestCheckoutCompletion( + Guid guestStayId, + DateTimeOffset now + ) + { + if (status != CheckoutStatus.Initiated || this.guestStayCheckouts[guestStayId] == CheckoutStatus.Completed) + return; + + var guestCheckoutCompleted = new GuestCheckoutCompleted(Id, guestStayId, now); + + Enqueue(guestCheckoutCompleted); + + guestStayCheckouts[guestStayId] = CheckoutStatus.Completed; + + if (!AreAnyOngoingCheckouts()) + Enqueue(Finalize(now)); + } + + public void RecordGuestCheckoutFailure( + Guid guestStayId, + DateTimeOffset now + ) + { + if (status != CheckoutStatus.Initiated || this.guestStayCheckouts[guestStayId] == CheckoutStatus.Failed) + return; + + var guestCheckoutFailed = new GuestCheckOutFailed(Id, guestStayId, now); + + Enqueue(guestCheckoutFailed); + + guestStayCheckouts[guestStayId] = CheckoutStatus.Failed; + + if (!AreAnyOngoingCheckouts()) + Enqueue(Finalize(now)); + } + + private GroupCheckoutEvent Finalize(DateTimeOffset now) => + !AreAnyFailedCheckouts() + ? new GroupCheckoutCompleted( + Id, + CheckoutsWith(CheckoutStatus.Completed), + now + ) + : new GroupCheckoutFailed + ( + Id, + CheckoutsWith(CheckoutStatus.Completed), + CheckoutsWith(CheckoutStatus.Failed), + now + ); + + private bool AreAnyOngoingCheckouts() => + guestStayCheckouts.Values.Any(guestStayStatus => guestStayStatus is CheckoutStatus.Initiated); + + private bool AreAnyFailedCheckouts() => + guestStayCheckouts.Values.Any(guestStayStatus => guestStayStatus is CheckoutStatus.Failed); + + private Guid[] CheckoutsWith(CheckoutStatus guestStayStatus) => + guestStayCheckouts + .Where(pair => pair.Value == guestStayStatus) + .Select(pair => pair.Key) + .ToArray(); +} + +public enum CheckoutStatus +{ + Initiated, + Completed, + Failed +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckoutFacade.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckoutFacade.cs new file mode 100644 index 00000000..b28edc9f --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckoutFacade.cs @@ -0,0 +1,78 @@ +using Consistency.Core; +using Database = Consistency.Core.Database; + +namespace Consistency.Sagas.Version1_Aggregates.GroupCheckouts; + +using static GroupCheckoutCommand; + +public class GroupCheckOutFacade(Database database, EventBus eventBus) +{ + public async ValueTask InitiateGroupCheckout(InitiateGroupCheckout command, CancellationToken ct = default) + { + if (await database.Get(command.GroupCheckoutId, ct) != null) + return; + + var groupCheckout = + GroupCheckOut.Initiate( + command.GroupCheckoutId, + command.ClerkId, + command.GuestStayIds, + command.Now + ); + + await database.Store(command.GroupCheckoutId, groupCheckout, ct); + await eventBus.Publish(groupCheckout.DequeueUncommittedEvents(), ct); + } + + public async ValueTask RecordGuestCheckoutCompletion( + RecordGuestCheckoutCompletion command, + CancellationToken ct = default + ) + { + var groupCheckout = await database.Get(command.GroupCheckoutId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + groupCheckout.RecordGuestCheckoutCompletion(command.GuestStayId, command.CompletedAt); + + await database.Store(command.GroupCheckoutId, groupCheckout, ct); + await eventBus.Publish(groupCheckout.DequeueUncommittedEvents(), ct); + } + + public async ValueTask RecordGuestCheckoutFailure( + RecordGuestCheckoutFailure command, + CancellationToken ct = default + ) + { + var groupCheckout = await database.Get(command.GroupCheckoutId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + groupCheckout.RecordGuestCheckoutFailure(command.GuestStayId, command.FailedAt); + + await database.Store(command.GroupCheckoutId, groupCheckout, ct); + await eventBus.Publish(groupCheckout.DequeueUncommittedEvents(), ct); + } +} + +public abstract record GroupCheckoutCommand +{ + public record InitiateGroupCheckout( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset Now + ): GroupCheckoutCommand; + + public record RecordGuestCheckoutCompletion( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt + ): GroupCheckoutCommand; + + public record RecordGuestCheckoutFailure( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset FailedAt + ): GroupCheckoutCommand; + + private GroupCheckoutCommand() { } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckoutSaga.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckoutSaga.cs new file mode 100644 index 00000000..a1d59cf3 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckoutSaga.cs @@ -0,0 +1,60 @@ +using Consistency.Sagas.Version1_Aggregates.GuestStayAccounts; + +namespace Consistency.Sagas.Version1_Aggregates.GroupCheckouts; + +using static GroupCheckoutCommand; +using static GroupCheckoutEvent; +using static GuestStayAccountCommand; +using static GuestStayAccountEvent; +using static SagaResult; + +public static class GroupCheckoutSaga +{ + public static Command[] Handle(GroupCheckoutInitiated @event) => + @event.GuestStayIds.Select(guestAccountId => + Send(new CheckOutGuest(guestAccountId, @event.InitiatedAt, @event.GroupCheckoutId)) + ).ToArray(); + + public static SagaResult Handle(GuestCheckedOut @event) + { + if (!@event.GroupCheckOutId.HasValue) + return Ignore; + + return Send( + new RecordGuestCheckoutCompletion( + @event.GroupCheckOutId.Value, + @event.GuestStayId, + @event.CheckedOutAt + ) + ); + } + + public static SagaResult Handle(GuestStayAccountEvent.GuestCheckOutFailed @event) + { + if (!@event.GroupCheckOutId.HasValue) + return Ignore; + + return Send( + new RecordGuestCheckoutFailure( + @event.GroupCheckOutId.Value, + @event.GuestStayId, + @event.FailedAt + ) + ); + } +}; + +public abstract record SagaResult +{ + public record Command(T Message): SagaResult; + + public record Event(T Message): SagaResult; + + public record None: SagaResult; + + public static Command Send(T command) => new(command); + + public static Event Publish(T @event) => new(@event); + + public static readonly None Ignore = new(); +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckoutsConfig.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckoutsConfig.cs new file mode 100644 index 00000000..9d5244e8 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GroupCheckouts/GroupCheckoutsConfig.cs @@ -0,0 +1,36 @@ +using Consistency.Core; +using Consistency.Sagas.Version1_Aggregates.GuestStayAccounts; + +namespace Consistency.Sagas.Version1_Aggregates.GroupCheckouts; + +using static GuestStayAccountEvent; +using static GroupCheckoutCommand; +using static SagaResult; + +public static class GroupCheckoutsConfig +{ + public static void ConfigureGroupCheckouts( + EventBus eventBus, + CommandBus commandBus, + GroupCheckOutFacade groupCheckoutFacade + ) + { + eventBus + .Subscribe((@event, ct) => + commandBus.Send(GroupCheckoutSaga.Handle(@event).Select(c => c.Message).ToArray(), ct) + ) + .Subscribe((@event, ct) => + GroupCheckoutSaga.Handle(@event) is Command(var command) + ? commandBus.Send([command], ct) + : ValueTask.CompletedTask + ) + .Subscribe((@event, ct) => + GroupCheckoutSaga.Handle(@event) is Command(var command) + ? commandBus.Send([command], ct) + : ValueTask.CompletedTask + ); + + commandBus.Handle(groupCheckoutFacade.RecordGuestCheckoutCompletion); + commandBus.Handle(groupCheckoutFacade.RecordGuestCheckoutFailure); + } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GuestStayAccounts/GroupCheckoutsConfig.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GuestStayAccounts/GroupCheckoutsConfig.cs new file mode 100644 index 00000000..e481ef77 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GuestStayAccounts/GroupCheckoutsConfig.cs @@ -0,0 +1,16 @@ + using Consistency.Core; + + namespace Consistency.Sagas.Version1_Aggregates.GuestStayAccounts; + +using static GuestStayAccountCommand; + +public static class GuestStayAccountsConfig +{ + public static void ConfigureGuestStayAccounts( + CommandBus commandBus, + GuestStayFacade guestStayFacade + ) + { + commandBus.Handle(guestStayFacade.CheckOutGuest); + } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GuestStayAccounts/GuestStayAccount.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GuestStayAccounts/GuestStayAccount.cs new file mode 100644 index 00000000..e10b9af6 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GuestStayAccounts/GuestStayAccount.cs @@ -0,0 +1,128 @@ +using System.Text.Json.Serialization; +using Consistency.Sagas.Version1_Aggregates.Core; + +namespace Consistency.Sagas.Version1_Aggregates.GuestStayAccounts; + +using static GuestStayAccountEvent; + +public abstract record GuestStayAccountEvent +{ + public record GuestCheckedIn( + Guid GuestStayId, + DateTimeOffset CheckedInAt + ): GuestStayAccountEvent; + + public record ChargeRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt + ): GuestStayAccountEvent; + + public record PaymentRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt + ): GuestStayAccountEvent; + + public record GuestCheckedOut( + Guid GuestStayId, + DateTimeOffset CheckedOutAt, + Guid? GroupCheckOutId = null + ): GuestStayAccountEvent; + + public record GuestCheckOutFailed( + Guid GuestStayId, + GuestCheckOutFailed.FailureReason Reason, + DateTimeOffset FailedAt, + Guid? GroupCheckOutId = null + ): GuestStayAccountEvent + { + public enum FailureReason + { + NotOpened, + BalanceNotSettled + } + } + + private GuestStayAccountEvent() { } +} + +public class GuestStayAccount: Aggregate +{ + [JsonInclude] private decimal balance; + [JsonInclude] private GuestStayAccountStatus status; + private bool IsSettled => balance == 0; + + [JsonConstructor] + private GuestStayAccount( + Guid id, + decimal balance, + GuestStayAccountStatus status + ) + { + Id = id; + this.balance = balance; + this.status = status; + } + + public static GuestStayAccount CheckIn(Guid guestStayId, DateTimeOffset now) + { + var guestStay = new GuestStayAccount(guestStayId, 0, GuestStayAccountStatus.Opened); + + guestStay.Enqueue(new GuestCheckedIn(guestStayId, now)); + + return guestStay; + } + + public void RecordCharge(decimal amount, DateTimeOffset now) + { + if (status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + balance -= amount; + + Enqueue(new ChargeRecorded(Id, amount, now)); + } + + public void RecordPayment(decimal amount, DateTimeOffset now) + { + if (status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + balance += amount; + + Enqueue(new PaymentRecorded(Id, amount, now)); + } + + public void CheckOut(DateTimeOffset now, Guid? groupCheckoutId = null) + { + if (status != GuestStayAccountStatus.Opened || !IsSettled) + { + Enqueue(new GuestCheckOutFailed( + Id, + status != GuestStayAccountStatus.Opened + ? GuestCheckOutFailed.FailureReason.NotOpened + : GuestCheckOutFailed.FailureReason.BalanceNotSettled, + now, + groupCheckoutId + )); + return; + } + + status = GuestStayAccountStatus.CheckedOut; + + Enqueue( + new GuestCheckedOut( + Id, + now, + groupCheckoutId + ) + ); + } +} + +public enum GuestStayAccountStatus +{ + Opened = 1, + CheckedOut = 2 +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GuestStayAccounts/GuestStayFacade.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GuestStayAccounts/GuestStayFacade.cs new file mode 100644 index 00000000..f69ce9b9 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/GuestStayAccounts/GuestStayFacade.cs @@ -0,0 +1,88 @@ +using Consistency.Core; +using Consistency.Sagas.Version1_Aggregates.GroupCheckouts; + +namespace Consistency.Sagas.Version1_Aggregates.GuestStayAccounts; + +using static GuestStayAccountCommand; +using static GroupCheckoutCommand; + +public class GuestStayFacade(Database database, EventBus eventBus) +{ + public async ValueTask CheckInGuest(CheckInGuest command, CancellationToken ct = default) + { + var account = GuestStayAccount.CheckIn(command.GuestStayId, command.Now); + + await database.Store(command.GuestStayId, account, ct); + await eventBus.Publish(account.DequeueUncommittedEvents(), ct); + } + + public async ValueTask RecordCharge(RecordCharge command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + account.RecordCharge(command.Amount, command.Now); + + await database.Store(command.GuestStayId, account, ct); + await eventBus.Publish(account.DequeueUncommittedEvents(), ct); + } + + public async ValueTask RecordPayment(RecordPayment command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + account.RecordPayment(command.Amount, command.Now); + + await database.Store(command.GuestStayId, account, ct); + await eventBus.Publish(account.DequeueUncommittedEvents(), ct); + } + + public async ValueTask CheckOutGuest(CheckOutGuest command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + account.CheckOut(command.Now, command.GroupCheckOutId); + + await database.Store(command.GuestStayId, account, ct); + await eventBus.Publish(account.DequeueUncommittedEvents(), ct); + } + + public ValueTask InitiateGroupCheckout(InitiateGroupCheckout command, CancellationToken ct = default) => + eventBus.Publish([ + new GroupCheckoutEvent.GroupCheckoutInitiated( + command.GroupCheckoutId, + command.ClerkId, + command.GuestStayIds, + command.Now + ) + ], ct); +} + +public abstract record GuestStayAccountCommand +{ + public record CheckInGuest( + Guid GuestStayId, + DateTimeOffset Now + ): GuestStayAccountCommand; + + public record RecordCharge( + Guid GuestStayId, + decimal Amount, + DateTimeOffset Now + ): GuestStayAccountCommand; + + public record RecordPayment( + Guid GuestStayId, + decimal Amount, + DateTimeOffset Now + ): GuestStayAccountCommand; + + public record CheckOutGuest( + Guid GuestStayId, + DateTimeOffset Now, + Guid? GroupCheckOutId = null + ): GuestStayAccountCommand; +} + diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/IdempotencyTests.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/IdempotencyTests.cs new file mode 100644 index 00000000..bc90c33d --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version1-Aggregates/IdempotencyTests.cs @@ -0,0 +1,104 @@ +using Bogus; +using Consistency.Core; +using Consistency.Sagas.Version1_Aggregates.GroupCheckouts; +using Consistency.Sagas.Version1_Aggregates.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = Consistency.Core.Database; +using GroupCheckoutCommand = Consistency.Sagas.Version1_Aggregates.GroupCheckouts.GroupCheckoutCommand; + +namespace Consistency.Sagas.Version1_Aggregates; + +using static GroupCheckoutCommand; +using static GroupCheckoutEvent; + +public class IdempotencyTests +{ + [Fact] + public async Task GroupCheckoutIsInitiatedOnceForTheSameId() + { + // Given + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + // And + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + + publishedMessages.ShouldReceiveSingleMessage(new GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, + now)); + } + + [Fact] + public async Task GroupCheckoutRecordsOnceGuestCheckoutCompletion() + { + // Given + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + // And + await groupCheckoutFacade.InitiateGroupCheckout(new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, + now.AddDays(-1))); + publishedMessages.Reset(); + + + var command = new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now); + await groupCheckoutFacade.RecordGuestCheckoutCompletion(command); + + // When + await groupCheckoutFacade.RecordGuestCheckoutCompletion(command); + + // Then + + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now)); + } + + [Fact] + public async Task GroupCheckoutRecordsOnceGuestCheckoutFailure() + { + // Given + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + // And + await groupCheckoutFacade.InitiateGroupCheckout(new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, + now.AddDays(-1))); + publishedMessages.Reset(); + + + var command = new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[0], now); + await groupCheckoutFacade.RecordGuestCheckoutFailure(command); + + // When + await groupCheckoutFacade.RecordGuestCheckoutFailure(command); + + // Then + + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckOutFailed(groupCheckoutId, guestStays[0], now)); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly CommandBus commandBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly GroupCheckOutFacade groupCheckoutFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public IdempotencyTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + groupCheckoutFacade = new GroupCheckOutFacade(database, eventBus); + + eventBus.Use(publishedMessages.Catch); + commandBus.Use(publishedMessages.Catch); + } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/BusinessProcessTests.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/BusinessProcessTests.cs new file mode 100644 index 00000000..760b00df --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/BusinessProcessTests.cs @@ -0,0 +1,244 @@ +using Bogus; +using Consistency.Core; +using Consistency.Sagas.Version2_ImmutableEntities.GroupCheckouts; +using Consistency.Sagas.Version2_ImmutableEntities.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = Consistency.Core.Database; + +namespace Consistency.Sagas.Version2_ImmutableEntities; + +using static GuestStayAccountEvent; +using static GuestStayAccountCommand; +using static GroupCheckoutCommand; +using static GroupCheckoutsConfig; +using static GuestStayAccountsConfig; + +public class BusinessProcessTests +{ + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithoutPaymentsAndCharges_ShouldComplete() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckedOut(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckedOut(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutCompleted(groupCheckoutId, guestStays, now), + ] + ); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithAllStaysSettled_ShouldComplete() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[0], now.AddHours(-1))); + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[1], amounts[1], now.AddHours(-2))); + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckedOut(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckedOut(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutCompleted(groupCheckoutId, guestStays, now), + ] + ); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithOneSettledAndRestUnsettled_ShouldFail() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + // 🟢 settled + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[0], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[1], now), + new GuestCheckOutFailed(guestStays[1], GuestCheckOutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckOutFailed(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[2], now), + new GuestCheckOutFailed(guestStays[2], GuestCheckOutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckOutFailed(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutFailed( + groupCheckoutId, + [guestStays[0]], + [guestStays[1], guestStays[2]], + now + ), + ] + ); + } + + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithAllUnsettled_ShouldFail() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + // 🛑 charge without payment + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckOutFailed(guestStays[0], GuestCheckOutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckOutFailed(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckOutFailed(guestStays[1], GuestCheckOutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckOutFailed(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckOutFailed(guestStays[2], GuestCheckOutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckOutFailed(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutFailed( + groupCheckoutId, + [], + [guestStays[0], guestStays[1], guestStays[2]], + now + ), + ] + ); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly CommandBus commandBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly GroupCheckOutFacade groupCheckoutFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public BusinessProcessTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + groupCheckoutFacade = new GroupCheckOutFacade(database, eventBus); + + eventBus.Use(publishedMessages.Catch); + commandBus.Use(publishedMessages.Catch); + + ConfigureGroupCheckouts(eventBus, commandBus, groupCheckoutFacade); + ConfigureGuestStayAccounts(commandBus, guestStayFacade); + } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/Core/DictionaryExtensions.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/Core/DictionaryExtensions.cs new file mode 100644 index 00000000..2e846900 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/Core/DictionaryExtensions.cs @@ -0,0 +1,17 @@ +namespace Consistency.Sagas.Version2_ImmutableEntities.Core; + +public static class DictionaryExtensions +{ + public static Dictionary With( + this Dictionary first, + TKey key, + TValue value + ) where TKey : notnull + { + var newDictionary = first.ToDictionary(ks => ks.Key, vs => vs.Value); + + newDictionary[key] = value; + + return newDictionary; + } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/EntityDefinitionTests.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/EntityDefinitionTests.cs new file mode 100644 index 00000000..a0fa9da0 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/EntityDefinitionTests.cs @@ -0,0 +1,148 @@ +using Bogus; +using Consistency.Core; +using Consistency.Sagas.Version2_ImmutableEntities.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = Consistency.Core.Database; + +namespace Consistency.Sagas.Version2_ImmutableEntities; + +using static GuestStayAccountEvent; +using static GuestStayAccountCommand; + +public class EntityDefinitionTests +{ + [Fact] + public async Task CheckingInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + var command = new CheckInGuest(guestStayId, now); + publishedMessages.Reset(); + + // When + await guestStayFacade.CheckInGuest(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedIn(guestStayId, now)); + } + + [Fact] + public async Task RecordingChargeForCheckedInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordCharge(guestStayId, amount, now); + + // When + await guestStayFacade.RecordCharge(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new ChargeRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task RecordingPaymentForCheckedInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordPayment(guestStayId, amount, now); + + // When + await guestStayFacade.RecordPayment(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task RecordingPaymentForCheckedInGuestWithCharge_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, generate.Finance.Amount(), now.AddHours(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordPayment(guestStayId, amount, now); + + // When + await guestStayFacade.RecordPayment(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task CheckingOutGuestWithSettledBalance_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + + var amount = generate.Finance.Amount(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount, now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var command = new CheckOutGuest(guestStayId, now); + + // When + await guestStayFacade.CheckOutGuest(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedOut(guestStayId, now)); + } + + [Fact] + public async Task CheckingOutGuestWithSettledBalance_FailsWithGuestCheckoutFailed() + { + // Given + var guestStayId = Guid.NewGuid(); + + var amount = generate.Finance.Amount(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount + 10, now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var command = new CheckOutGuest(guestStayId, now); + + // When + try + { + await guestStayFacade.CheckOutGuest(command); + } + catch (Exception exc) + { + testOutputHelper.WriteLine(exc.Message); + } + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckOutFailed(guestStayId, GuestCheckOutFailed.FailureReason.BalanceNotSettled, now)); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public EntityDefinitionTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + eventBus.Use(publishedMessages.Catch); + } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckout.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckout.cs new file mode 100644 index 00000000..7ae041b4 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckout.cs @@ -0,0 +1,156 @@ +using Consistency.Sagas.Version2_ImmutableEntities.Core; + +namespace Consistency.Sagas.Version2_ImmutableEntities.GroupCheckouts; + +using static GroupCheckoutEvent; + +public abstract record GroupCheckoutEvent +{ + public record GroupCheckoutInitiated( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset InitiatedAt + ): GroupCheckoutEvent; + + public record GuestCheckoutCompleted( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt + ): GroupCheckoutEvent; + + public record GuestCheckOutFailed( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset FailedAt + ): GroupCheckoutEvent; + + public record GroupCheckoutCompleted( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + DateTimeOffset CompletedAt + ): GroupCheckoutEvent; + + public record GroupCheckoutFailed( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + Guid[] FailedCheckouts, + DateTimeOffset FailedAt + ): GroupCheckoutEvent; + + private GroupCheckoutEvent() { } +} + +public record GroupCheckOut( + Guid Id, + Dictionary GuestStayCheckouts, + CheckoutStatus Status = CheckoutStatus.Initiated +) +{ + public static GroupCheckoutInitiated Initiate(Guid groupCheckoutId, Guid clerkId, Guid[] guestStayIds, + DateTimeOffset initiatedAt) => + new(groupCheckoutId, clerkId, guestStayIds, initiatedAt); + + public GroupCheckoutEvent[] RecordGuestCheckoutCompletion( + Guid guestStayId, + DateTimeOffset now + ) + { + if (Status != CheckoutStatus.Initiated || GuestStayCheckouts[guestStayId] == CheckoutStatus.Completed) + return []; + + var guestCheckoutCompleted = new GuestCheckoutCompleted(Id, guestStayId, now); + + var guestStayCheckouts = GuestStayCheckouts.With(guestStayId, CheckoutStatus.Completed); + + return AreAnyOngoingCheckouts(guestStayCheckouts) + ? [guestCheckoutCompleted] + : [guestCheckoutCompleted, Finalize(guestStayCheckouts, now)]; + } + + public GroupCheckoutEvent[] RecordGuestCheckoutFailure( + Guid guestStayId, + DateTimeOffset now + ) + { + if (Status != CheckoutStatus.Initiated || GuestStayCheckouts[guestStayId] == CheckoutStatus.Failed) + return []; + + var guestCheckoutFailed = new GuestCheckOutFailed(Id, guestStayId, now); + + var guestStayCheckouts = GuestStayCheckouts.With(guestStayId, CheckoutStatus.Failed); + + return AreAnyOngoingCheckouts(guestStayCheckouts) + ? [guestCheckoutFailed] + : [guestCheckoutFailed, Finalize(guestStayCheckouts, now)]; + } + + private GroupCheckoutEvent Finalize( + Dictionary guestStayCheckouts, + DateTimeOffset now + ) => + !AreAnyFailedCheckouts(guestStayCheckouts) + ? new GroupCheckoutCompleted + ( + Id, + CheckoutsWith(guestStayCheckouts, CheckoutStatus.Completed), + now + ) + : new GroupCheckoutFailed + ( + Id, + CheckoutsWith(guestStayCheckouts, CheckoutStatus.Completed), + CheckoutsWith(guestStayCheckouts, CheckoutStatus.Failed), + now + ); + + private static bool AreAnyOngoingCheckouts(Dictionary guestStayCheckouts) => + guestStayCheckouts.Values.Any(status => status is CheckoutStatus.Initiated); + + private static bool AreAnyFailedCheckouts(Dictionary guestStayCheckouts) => + guestStayCheckouts.Values.Any(status => status is CheckoutStatus.Failed); + + private static Guid[] CheckoutsWith(Dictionary guestStayCheckouts, CheckoutStatus status) => + guestStayCheckouts + .Where(pair => pair.Value == status) + .Select(pair => pair.Key) + .ToArray(); + + + public GroupCheckOut Evolve(GroupCheckoutEvent @event) => + @event switch + { + GroupCheckoutInitiated initiated => this with + { + Id = initiated.GroupCheckoutId, + GuestStayCheckouts = initiated.GuestStayIds.ToDictionary(id => id, _ => CheckoutStatus.Initiated), + Status = CheckoutStatus.Initiated + }, + GuestCheckoutCompleted guestCheckedOut => this with + { + GuestStayCheckouts = GuestStayCheckouts.ToDictionary( + ks => ks.Key, + vs => vs.Key == guestCheckedOut.GuestStayId ? CheckoutStatus.Completed : vs.Value + ) + }, + GuestCheckOutFailed guestCheckedOutFailed => this with + { + GuestStayCheckouts = GuestStayCheckouts.ToDictionary( + ks => ks.Key, + vs => vs.Key == guestCheckedOutFailed.GuestStayId ? CheckoutStatus.Failed : vs.Value + ) + }, + GroupCheckoutCompleted => this with { Status = CheckoutStatus.Completed }, + GroupCheckoutFailed => this with { Status = CheckoutStatus.Failed }, + _ => this + }; + + public static GroupCheckOut Initial = new(default, [], default); +} + +public enum CheckoutStatus +{ + Initiated, + Completed, + Failed +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckoutFacade.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckoutFacade.cs new file mode 100644 index 00000000..7aa98e36 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckoutFacade.cs @@ -0,0 +1,88 @@ +using Consistency.Core; +using Database = Consistency.Core.Database; + +namespace Consistency.Sagas.Version2_ImmutableEntities.GroupCheckouts; + +using static GroupCheckoutCommand; + +public class GroupCheckOutFacade(Database database, EventBus eventBus) +{ + public async ValueTask InitiateGroupCheckout(InitiateGroupCheckout command, CancellationToken ct = default) + { + if (await database.Get(command.GroupCheckoutId, ct) != null) + return; + + var @event = GroupCheckOut.Initiate( + command.GroupCheckoutId, + command.ClerkId, + command.GuestStayIds, + command.Now + ); + + await database.Store(command.GroupCheckoutId, GroupCheckOut.Initial.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask RecordGuestCheckoutCompletion( + RecordGuestCheckoutCompletion command, + CancellationToken ct = default + ) + { + var groupCheckout = await database.Get(command.GroupCheckoutId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var events = groupCheckout.RecordGuestCheckoutCompletion(command.GuestStayId, command.CompletedAt); + + if (events.Length == 0) + return; + + await database.Store(command.GroupCheckoutId, + events.Aggregate(groupCheckout, (state, @event) => state.Evolve(@event)), ct); + + await eventBus.Publish(events.Cast().ToArray(), ct); + } + + public async ValueTask RecordGuestCheckoutFailure( + RecordGuestCheckoutFailure command, + CancellationToken ct = default + ) + { + var groupCheckout = await database.Get(command.GroupCheckoutId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var events = groupCheckout.RecordGuestCheckoutFailure(command.GuestStayId, command.FailedAt); + + if (events.Length == 0) + return; + + var newState = events.Aggregate(groupCheckout, (state, @event) => state.Evolve(@event)); + + await database.Store(command.GroupCheckoutId, newState, ct); + + await eventBus.Publish(events.Cast().ToArray(), ct); + } +} + +public abstract record GroupCheckoutCommand +{ + public record InitiateGroupCheckout( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset Now + ): GroupCheckoutCommand; + + public record RecordGuestCheckoutCompletion( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt + ): GroupCheckoutCommand; + + public record RecordGuestCheckoutFailure( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset FailedAt + ): GroupCheckoutCommand; + + private GroupCheckoutCommand() { } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckoutSaga.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckoutSaga.cs new file mode 100644 index 00000000..effb5518 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckoutSaga.cs @@ -0,0 +1,60 @@ +using Consistency.Sagas.Version2_ImmutableEntities.GuestStayAccounts; + +namespace Consistency.Sagas.Version2_ImmutableEntities.GroupCheckouts; + +using static GroupCheckoutCommand; +using static GroupCheckoutEvent; +using static GuestStayAccountCommand; +using static GuestStayAccountEvent; +using static SagaResult; + +public static class GroupCheckoutSaga +{ + public static Command[] Handle(GroupCheckoutInitiated @event) => + @event.GuestStayIds.Select(guestAccountId => + Send(new CheckOutGuest(guestAccountId, @event.InitiatedAt, @event.GroupCheckoutId)) + ).ToArray(); + + public static SagaResult Handle(GuestCheckedOut @event) + { + if (!@event.GroupCheckOutId.HasValue) + return Ignore; + + return Send( + new RecordGuestCheckoutCompletion( + @event.GroupCheckOutId.Value, + @event.GuestStayId, + @event.CheckedOutAt + ) + ); + } + + public static SagaResult Handle(GuestStayAccountEvent.GuestCheckOutFailed @event) + { + if (!@event.GroupCheckOutId.HasValue) + return Ignore; + + return Send( + new RecordGuestCheckoutFailure( + @event.GroupCheckOutId.Value, + @event.GuestStayId, + @event.FailedAt + ) + ); + } +}; + +public abstract record SagaResult +{ + public record Command(T Message): SagaResult; + + public record Event(T Message): SagaResult; + + public record None: SagaResult; + + public static Command Send(T command) => new(command); + + public static Event Publish(T @event) => new(@event); + + public static readonly None Ignore = new(); +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckoutsConfig.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckoutsConfig.cs new file mode 100644 index 00000000..085edebd --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GroupCheckouts/GroupCheckoutsConfig.cs @@ -0,0 +1,36 @@ +using Consistency.Core; +using Consistency.Sagas.Version2_ImmutableEntities.GuestStayAccounts; + +namespace Consistency.Sagas.Version2_ImmutableEntities.GroupCheckouts; + +using static GuestStayAccountEvent; +using static GroupCheckoutCommand; +using static SagaResult; + +public static class GroupCheckoutsConfig +{ + public static void ConfigureGroupCheckouts( + EventBus eventBus, + CommandBus commandBus, + GroupCheckOutFacade groupCheckoutFacade + ) + { + eventBus + .Subscribe((@event, ct) => + commandBus.Send(GroupCheckoutSaga.Handle(@event).Select(c => c.Message).ToArray(), ct) + ) + .Subscribe((@event, ct) => + GroupCheckoutSaga.Handle(@event) is Command(var command) + ? commandBus.Send([command], ct) + : ValueTask.CompletedTask + ) + .Subscribe((@event, ct) => + GroupCheckoutSaga.Handle(@event) is Command(var command) + ? commandBus.Send([command], ct) + : ValueTask.CompletedTask + ); + + commandBus.Handle(groupCheckoutFacade.RecordGuestCheckoutCompletion); + commandBus.Handle(groupCheckoutFacade.RecordGuestCheckoutFailure); + } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GuestStayAccounts/GroupCheckoutsConfig.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GuestStayAccounts/GroupCheckoutsConfig.cs new file mode 100644 index 00000000..46bc1c4c --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GuestStayAccounts/GroupCheckoutsConfig.cs @@ -0,0 +1,16 @@ +using Consistency.Core; + +namespace Consistency.Sagas.Version2_ImmutableEntities.GuestStayAccounts; + +using static GuestStayAccountCommand; + +public static class GuestStayAccountsConfig +{ + public static void ConfigureGuestStayAccounts( + CommandBus commandBus, + GuestStayFacade guestStayFacade + ) + { + commandBus.Handle(guestStayFacade.CheckOutGuest); + } +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GuestStayAccounts/GuestStayAccount.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GuestStayAccounts/GuestStayAccount.cs new file mode 100644 index 00000000..d7c82b38 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GuestStayAccounts/GuestStayAccount.cs @@ -0,0 +1,120 @@ +namespace Consistency.Sagas.Version2_ImmutableEntities.GuestStayAccounts; + +using static GuestStayAccountEvent; + +public abstract record GuestStayAccountEvent +{ + public record GuestCheckedIn( + Guid GuestStayId, + DateTimeOffset CheckedInAt + ): GuestStayAccountEvent; + + public record ChargeRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt + ): GuestStayAccountEvent; + + public record PaymentRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt + ): GuestStayAccountEvent; + + public record GuestCheckedOut( + Guid GuestStayId, + DateTimeOffset CheckedOutAt, + Guid? GroupCheckOutId = null + ): GuestStayAccountEvent; + + public record GuestCheckOutFailed( + Guid GuestStayId, + GuestCheckOutFailed.FailureReason Reason, + DateTimeOffset FailedAt, + Guid? GroupCheckOutId = null + ): GuestStayAccountEvent + { + public enum FailureReason + { + NotOpened, + BalanceNotSettled + } + } + + private GuestStayAccountEvent() { } +} + +public record GuestStayAccount( + Guid Id, + decimal Balance = 0, + GuestStayAccountStatus Status = GuestStayAccountStatus.Opened +) +{ + public bool IsSettled => Balance == 0; + + public static GuestCheckedIn CheckIn(Guid guestStayId, DateTimeOffset now) => new(guestStayId, now); + + public ChargeRecorded RecordCharge(decimal amount, DateTimeOffset now) + { + if (Status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + return new ChargeRecorded(Id, amount, now); + } + + public PaymentRecorded RecordPayment(decimal amount, DateTimeOffset now) + { + if (Status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + return new PaymentRecorded(Id, amount, now); + } + + public GuestStayAccountEvent CheckOut(DateTimeOffset now, Guid? groupCheckoutId = null) + { + if (Status != GuestStayAccountStatus.Opened) + return new GuestCheckOutFailed( + Id, + GuestCheckOutFailed.FailureReason.NotOpened, + now, + groupCheckoutId + ); + + return IsSettled + ? new GuestCheckedOut( + Id, + now, + groupCheckoutId + ) + : new GuestCheckOutFailed( + Id, + GuestCheckOutFailed.FailureReason.BalanceNotSettled, + now, + groupCheckoutId + ); + } + + // This method can be used to build state from events + // You can ignore it if you're not into Event Sourcing + public GuestStayAccount Evolve(GuestStayAccountEvent @event) => + @event switch + { + GuestCheckedIn checkedIn => this with + { + Id = checkedIn.GuestStayId, Status = GuestStayAccountStatus.Opened + }, + ChargeRecorded charge => this with { Balance = Balance - charge.Amount }, + PaymentRecorded payment => this with { Balance = Balance + payment.Amount }, + GuestCheckedOut => this with { Status = GuestStayAccountStatus.CheckedOut }, + GuestCheckOutFailed => this, + _ => this + }; + + public static readonly GuestStayAccount Initial = new(default, default, default); +} + +public enum GuestStayAccountStatus +{ + Opened = 1, + CheckedOut = 2 +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GuestStayAccounts/GuestStayFacade.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GuestStayAccounts/GuestStayFacade.cs new file mode 100644 index 00000000..fa0220fb --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/GuestStayAccounts/GuestStayFacade.cs @@ -0,0 +1,86 @@ +using Consistency.Core; + +namespace Consistency.Sagas.Version2_ImmutableEntities.GuestStayAccounts; + +using static GuestStayAccountCommand; +using static GuestStayAccountEvent; + +public class GuestStayFacade(Database database, EventBus eventBus) +{ + public async ValueTask CheckInGuest(CheckInGuest command, CancellationToken ct = default) + { + var @event = GuestStayAccount.CheckIn(command.GuestStayId, command.Now); + + await database.Store(command.GuestStayId, GuestStayAccount.Initial.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask RecordCharge(RecordCharge command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var @event = account.RecordCharge(command.Amount, command.Now); + + await database.Store(command.GuestStayId, account.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask RecordPayment(RecordPayment command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var @event = account.RecordPayment(command.Amount, command.Now); + + await database.Store(command.GuestStayId, account.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask CheckOutGuest(CheckOutGuest command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + switch (account.CheckOut(command.Now, command.GroupCheckOutId)) + { + case GuestCheckedOut checkedOut: + { + await database.Store(command.GuestStayId, account.Evolve(checkedOut), ct); + await eventBus.Publish([checkedOut], ct); + return; + } + case GuestCheckOutFailed checkOutFailed: + { + await eventBus.Publish([checkOutFailed], ct); + return; + } + } + } +} + +public abstract record GuestStayAccountCommand +{ + public record CheckInGuest( + Guid GuestStayId, + DateTimeOffset Now + ): GuestStayAccountCommand; + + public record RecordCharge( + Guid GuestStayId, + decimal Amount, + DateTimeOffset Now + ): GuestStayAccountCommand; + + public record RecordPayment( + Guid GuestStayId, + decimal Amount, + DateTimeOffset Now + ): GuestStayAccountCommand; + + public record CheckOutGuest( + Guid GuestStayId, + DateTimeOffset Now, + Guid? GroupCheckOutId = null + ): GuestStayAccountCommand; +} diff --git a/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/IdempotencyTests.cs b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/IdempotencyTests.cs new file mode 100644 index 00000000..6b0a485d --- /dev/null +++ b/Workshops/EventDrivenArchitecture/05-Consistency/Sagas/Version2-ImmutableEntities/IdempotencyTests.cs @@ -0,0 +1,104 @@ +using Bogus; +using Consistency.Core; +using Consistency.Sagas.Version2_ImmutableEntities.GroupCheckouts; +using Consistency.Sagas.Version2_ImmutableEntities.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = Consistency.Core.Database; +using GroupCheckoutCommand = Consistency.Sagas.Version2_ImmutableEntities.GroupCheckouts.GroupCheckoutCommand; + +namespace Consistency.Sagas.Version2_ImmutableEntities; + +using static GroupCheckoutCommand; +using static GroupCheckoutEvent; + +public class IdempotencyTests +{ + [Fact] + public async Task GroupCheckoutIsInitiatedOnceForTheSameId() + { + // Given + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + // And + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + + publishedMessages.ShouldReceiveSingleMessage(new GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, + now)); + } + + [Fact] + public async Task GroupCheckoutRecordsOnceGuestCheckoutCompletion() + { + // Given + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + // And + await groupCheckoutFacade.InitiateGroupCheckout(new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, + now.AddDays(-1))); + publishedMessages.Reset(); + + + var command = new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now); + await groupCheckoutFacade.RecordGuestCheckoutCompletion(command); + + // When + await groupCheckoutFacade.RecordGuestCheckoutCompletion(command); + + // Then + + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now)); + } + + [Fact] + public async Task GroupCheckoutRecordsOnceGuestCheckoutFailure() + { + // Given + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + // And + await groupCheckoutFacade.InitiateGroupCheckout(new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, + now.AddDays(-1))); + publishedMessages.Reset(); + + + var command = new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[0], now); + await groupCheckoutFacade.RecordGuestCheckoutFailure(command); + + // When + await groupCheckoutFacade.RecordGuestCheckoutFailure(command); + + // Then + + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckOutFailed(groupCheckoutId, guestStays[0], now)); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly CommandBus commandBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly GroupCheckOutFacade groupCheckoutFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public IdempotencyTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + groupCheckoutFacade = new GroupCheckOutFacade(database, eventBus); + + eventBus.Use(publishedMessages.Catch); + commandBus.Use(publishedMessages.Catch); + } +} diff --git a/Workshops/EventDrivenArchitecture/EventDrivenArchitecture.sln b/Workshops/EventDrivenArchitecture/EventDrivenArchitecture.sln index b24e8274..1f1a41a6 100644 --- a/Workshops/EventDrivenArchitecture/EventDrivenArchitecture.sln +++ b/Workshops/EventDrivenArchitecture/EventDrivenArchitecture.sln @@ -20,6 +20,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "04-Idempotency", "04-Idempo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "04-Idempotency", "Solutions\04-Idempotency\04-Idempotency.csproj", "{669B0F34-5D92-4047-969B-6F07F1DEAAC0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "05-Consistency", "05-Consistency\05-Consistency.csproj", "{1D6AEF2B-58FE-4775-9C51-1CE16C2404A6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -62,6 +64,10 @@ Global {669B0F34-5D92-4047-969B-6F07F1DEAAC0}.Debug|Any CPU.Build.0 = Debug|Any CPU {669B0F34-5D92-4047-969B-6F07F1DEAAC0}.Release|Any CPU.ActiveCfg = Release|Any CPU {669B0F34-5D92-4047-969B-6F07F1DEAAC0}.Release|Any CPU.Build.0 = Release|Any CPU + {1D6AEF2B-58FE-4775-9C51-1CE16C2404A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D6AEF2B-58FE-4775-9C51-1CE16C2404A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D6AEF2B-58FE-4775-9C51-1CE16C2404A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D6AEF2B-58FE-4775-9C51-1CE16C2404A6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {7312A099-6FAD-462A-8D94-FB817B531A27} = {96700308-E324-4D7A-854D-9ED17ECEE9B7}