Skip to content

Commit

Permalink
Added example Saga implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
oskardudycz committed Sep 3, 2024
1 parent 7dc4043 commit 4f57da6
Show file tree
Hide file tree
Showing 32 changed files with 1,894 additions and 128 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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
Expand Up @@ -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)
{
Expand All @@ -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),
Expand All @@ -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
Expand Up @@ -2,7 +2,7 @@

namespace BusinessProcesses.Core;

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

Expand Down
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.