Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added example Saga implementation
Browse files Browse the repository at this point in the history
oskardudycz committed Sep 3, 2024
1 parent 7dc4043 commit 4f57da6
Showing 32 changed files with 1,894 additions and 128 deletions.
Original file line number Diff line number Diff line change
@@ -9,12 +9,12 @@ public ValueTask Publish(object[] events, CancellationToken _)
{
foreach (var @event in events)
{
if (!eventHandlers.TryGetValue(@event.GetType(), out var handlers))
continue;

foreach (var middleware in middlewares)
middleware(@event);

if (!eventHandlers.TryGetValue(@event.GetType(), out var handlers))
continue;

foreach (var handler in handlers)
handler(@event);
}
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ namespace BusinessProcesses.Core;

public class CommandBus
{
public ValueTask Send(object[] commands, CancellationToken _)
public async ValueTask Send(object[] commands, CancellationToken ct)
{
foreach (var command in commands)
{
@@ -15,20 +15,18 @@ public ValueTask Send(object[] commands, CancellationToken _)
foreach (var middleware in middlewares)
middleware(command);

handler(command);
await handler(command, ct);
}

return ValueTask.CompletedTask;
}

public void Handle<T>(Action<T> eventHandler)
public void Handle<T>(Func<T, CancellationToken, ValueTask> eventHandler)
{
commandHandlers[typeof(T)] = x => eventHandler((T)x);
commandHandlers[typeof(T)] = (command, ct) => eventHandler((T)command, ct);
}

public void Use(Action<object> middleware) =>
middlewares.Add(middleware);

private readonly ConcurrentDictionary<Type, Action<object>> commandHandlers = new();
private readonly ConcurrentDictionary<Type, Func<object, CancellationToken, ValueTask>> commandHandlers = new();
private readonly List<Action<object>> middlewares = [];
}
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ namespace BusinessProcesses.Core;

public class EventBus
{
public ValueTask Publish(object[] events, CancellationToken _)
public async ValueTask Publish(object[] events, CancellationToken ct)
{
foreach (var @event in events)
{
@@ -15,15 +15,13 @@ public ValueTask Publish(object[] events, CancellationToken _)
middleware(@event);

foreach (var handler in handlers)
handler(@event);
await handler(@event, ct);
}

return ValueTask.CompletedTask;
}

public void Subscribe<T>(Action<T> eventHandler)
public void Subscribe<T>(Func<T, CancellationToken, ValueTask> eventHandler)
{
Action<object> handler = x => eventHandler((T)x);
Func<object, CancellationToken, ValueTask> handler = (@event, ct) => eventHandler((T)@event, ct);

eventHandlers.AddOrUpdate(
typeof(T),
@@ -39,6 +37,6 @@ public void Subscribe<T>(Action<T> eventHandler)
public void Use(Action<object> middleware) =>
middlewares.Add(middleware);

private readonly ConcurrentDictionary<Type, List<Action<object>>> eventHandlers = new();
private readonly ConcurrentDictionary<Type, List<Func<object, CancellationToken, ValueTask>>> eventHandlers = new();
private readonly List<Action<object>> middlewares = [];
}
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@

namespace BusinessProcesses.Core;

public class EventCatcher
public class MessageCatcher
{
public List<object> Published { get; } = [];

Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
using Bogus;
using BusinessProcesses.Core;
using BusinessProcesses.Version1_Aggregates.GroupCheckouts;
using BusinessProcesses.Version1_Aggregates.GuestStayAccounts;
using Xunit;
using Xunit.Abstractions;
using Database = BusinessProcesses.Core.Database;

namespace BusinessProcesses.Version1_Aggregates;

using static GuestStayAccountCommand;
using static GuestStayAccountEvent;
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 guestStayFacade.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 GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now),
new CheckOutGuest(guestStays[1], now, groupCheckoutId),
new GuestCheckedOut(guestStays[1], now, groupCheckoutId),
new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now),
new CheckOutGuest(guestStays[2], now, groupCheckoutId),
new GuestCheckedOut(guestStays[2], now, groupCheckoutId),
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 guestStayFacade.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 GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now),
new CheckOutGuest(guestStays[1], now, groupCheckoutId),
new GuestCheckedOut(guestStays[1], now, groupCheckoutId),
new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now),
new CheckOutGuest(guestStays[2], now, groupCheckoutId),
new GuestCheckedOut(guestStays[2], now, groupCheckoutId),
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 guestStayFacade.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 GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now),
new CheckOutGuest(guestStays[1], now, groupCheckoutId),
new GuestCheckoutFailed(guestStays[1], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now,
groupCheckoutId),
new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[1], now),
new CheckOutGuest(guestStays[2], now, groupCheckoutId),
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 guestStayFacade.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 GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[0], now),
new CheckOutGuest(guestStays[1], now, groupCheckoutId),
new GuestCheckoutFailed(guestStays[1], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now,
groupCheckoutId),
new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[1], now),
new CheckOutGuest(guestStays[2], now, groupCheckoutId),
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
),
]
);
}

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 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);

eventBus.Use(publishedMessages.Catch);
}
}
Loading

0 comments on commit 4f57da6

Please sign in to comment.