Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@

namespace OrderProcessing.Api.BackgroundJobs;

public sealed class OutboxBackgroundService
: BackgroundService
public sealed class OutboxBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly OutboxOptions _options;
Expand Down
18 changes: 6 additions & 12 deletions OrderProcessing.Api/Services/Messaging/RabbitMqTopology.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,21 @@ namespace OrderProcessing.Api.Services.Messaging;

public static class RabbitMqTopology
{
public const string OrderCreatedRoutingKey = "order.created";
public const string OrderCreatedRoutingKey = OrderEventRoutingKeys.Created;

public const string OrderCompletedRoutingKey = "order.completed";
public const string OrderCompletedRoutingKey = OrderEventRoutingKeys.Completed;

public const string OrderCancelledRoutingKey = "order.cancelled";
public const string OrderCancelledRoutingKey = OrderEventRoutingKeys.Cancelled;

public static string GetRoutingKey(string eventType)
{
return eventType switch
{
var type when type ==
typeof(OrderCreatedIntegrationEvent).FullName =>
OrderCreatedRoutingKey,
var type when type == typeof(OrderCreatedIntegrationEvent).FullName => OrderCreatedRoutingKey,

var type when type ==
typeof(OrderCompletedIntegrationEvent).FullName =>
OrderCompletedRoutingKey,
var type when type == typeof(OrderCompletedIntegrationEvent).FullName => OrderCompletedRoutingKey,

var type when type ==
typeof(OrderCancelledIntegrationEvent).FullName =>
OrderCancelledRoutingKey,
var type when type == typeof(OrderCancelledIntegrationEvent).FullName => OrderCancelledRoutingKey,

_ => throw new InvalidOperationException(
$"No RabbitMQ routing key is configured " +
Expand Down
6 changes: 3 additions & 3 deletions OrderProcessing.Api/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"RabbitMq": {
"Enabled": true
}
},
"RabbitMq": {
"Enabled": true
}
}
10 changes: 10 additions & 0 deletions OrderProcessing.Contracts/Orders/OrderEventRoutingKeys.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace OrderProcessing.Contracts.Orders;

public static class OrderEventRoutingKeys
{
public const string Created = "order.created";

public const string Completed = "order.completed";

public const string Cancelled = "order.cancelled";
}
100 changes: 100 additions & 0 deletions OrderProcessing.EmailWorker.Tests/OrderEventEmailHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using OrderProcessing.Contracts.Orders;
using OrderProcessing.EmailWorker.Emailing;
using OrderProcessing.EmailWorker.Messaging;

namespace OrderProcessing.EmailWorker.Tests;

public sealed class OrderEventEmailHandlerTests
{
[Fact]
public async Task HandleAsync_ForOrderCreatedEvent_SendsCreatedEmail()
{
// Arrange
var sender = new TestEmailSender();

var handler = new OrderEventEmailHandler(
sender,
NullLogger<OrderEventEmailHandler>.Instance);

var integrationEvent =
new OrderCreatedIntegrationEvent(
MessageId: Guid.NewGuid(),
OccurredAtUtc: DateTime.UtcNow,
OrderId: 1050,
CustomerId: 1001,
CustomerName: "John Smith",
CustomerEmail: "john.smith@example.com",
TotalAmount: 99.99m,
CreatedAtUtc: DateTime.UtcNow,
Items:
[
new OrderItemIntegrationModel(
ProductId: 2001,
ProductName: "Keyboard",
Quantity: 1,
UnitPrice: 99.99m,
LineTotal: 99.99m)
]);

var body = JsonSerializer.SerializeToUtf8Bytes(
integrationEvent,
new JsonSerializerOptions(
JsonSerializerDefaults.Web));

// Act
await handler.HandleAsync(
typeof(OrderCreatedIntegrationEvent).FullName!,
body,
CancellationToken.None);

// Assert
var email = Assert.Single(sender.Messages);

Assert.Equal(
"john.smith@example.com",
email.Recipient);

Assert.Contains(
"1050",
email.Subject);

Assert.Contains(
"created",
email.Subject,
StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task HandleAsync_ForUnknownEvent_ThrowsException()
{
// Arrange
var handler = new OrderEventEmailHandler(
new TestEmailSender(),
NullLogger<OrderEventEmailHandler>.Instance);

// Act
var action = () => handler.HandleAsync(
"UnknownIntegrationEvent",
[],
CancellationToken.None);

// Assert
await Assert.ThrowsAsync<
UnsupportedIntegrationEventException>(
action);
}

private sealed class TestEmailSender : IEmailSender
{
public List<EmailMessage> Messages { get; } = [];

public Task SendAsync(EmailMessage message, CancellationToken cancellationToken)
{
Messages.Add(message);

return Task.CompletedTask;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\OrderProcessing.Contracts\OrderProcessing.Contracts.csproj" />
<ProjectReference Include="..\OrderProcessing.EmailWorker\OrderProcessing.EmailWorker.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
26 changes: 26 additions & 0 deletions OrderProcessing.EmailWorker/Configuration/RabbitMqOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace OrderProcessing.EmailWorker.Configuration;

public sealed class RabbitMqOptions
{
public const string SectionName = "RabbitMq";

public string HostName { get; set; } = "localhost";

public int Port { get; set; } = 5672;

public string UserName { get; set; } = "guest";

public string Password { get; set; } = "guest";

public string VirtualHost { get; set; } = "/";

public string ExchangeName { get; set; } = "order-processing.events";

public string EmailQueueName { get; set; } = "order-processing.email";

public string ClientProvidedName { get; set; } = "order-processing-email-worker";

public int NetworkRecoveryIntervalSeconds { get; set; } = 5;

public ushort PrefetchCount { get; set; } = 1;
}
3 changes: 3 additions & 0 deletions OrderProcessing.EmailWorker/Emailing/EmailMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace OrderProcessing.EmailWorker.Emailing;

public sealed record EmailMessage(string Recipient, string Subject, string Body);
6 changes: 6 additions & 0 deletions OrderProcessing.EmailWorker/Emailing/IEmailSender.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace OrderProcessing.EmailWorker.Emailing;

public interface IEmailSender
{
Task SendAsync(EmailMessage message, CancellationToken cancellationToken);
}
24 changes: 24 additions & 0 deletions OrderProcessing.EmailWorker/Emailing/LoggingEmailSender.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace OrderProcessing.EmailWorker.Emailing;

public sealed class LoggingEmailSender : IEmailSender
{
private readonly ILogger<LoggingEmailSender> _logger;

public LoggingEmailSender(ILogger<LoggingEmailSender> logger)
{
_logger = logger;
}

public Task SendAsync(EmailMessage message, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

_logger.LogInformation(
"Simulated sending email to {Recipient} " +
"with subject {Subject}",
message.Recipient,
message.Subject);

return Task.CompletedTask;
}
}
130 changes: 130 additions & 0 deletions OrderProcessing.EmailWorker/Messaging/OrderEventEmailHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using System.Text.Json;
using OrderProcessing.Contracts.Orders;
using OrderProcessing.EmailWorker.Emailing;

namespace OrderProcessing.EmailWorker.Messaging;

public sealed class OrderEventEmailHandler
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);

private readonly IEmailSender _emailSender;
private readonly ILogger<OrderEventEmailHandler> _logger;

public OrderEventEmailHandler(IEmailSender emailSender, ILogger<OrderEventEmailHandler> logger)
{
_emailSender = emailSender;
_logger = logger;
}

public async Task HandleAsync(string eventType, byte[] body, CancellationToken cancellationToken)
{
switch (eventType)
{
case var type when type == typeof(OrderCreatedIntegrationEvent).FullName:

var createdEvent = Deserialize<OrderCreatedIntegrationEvent>(body);

await HandleCreatedAsync(createdEvent, cancellationToken);

break;

case var type when type == typeof(OrderCompletedIntegrationEvent).FullName:

var completedEvent = Deserialize<OrderCompletedIntegrationEvent>(body);

await HandleCompletedAsync(completedEvent, cancellationToken);

break;

case var type when type == typeof(OrderCancelledIntegrationEvent).FullName:

var cancelledEvent = Deserialize<OrderCancelledIntegrationEvent>(body);

await HandleCancelledAsync(cancelledEvent, cancellationToken);

break;

default:
throw new UnsupportedIntegrationEventException(eventType);
}
}

private async Task HandleCreatedAsync(OrderCreatedIntegrationEvent integrationEvent, CancellationToken cancellationToken)
{
var email = new EmailMessage(
Recipient: integrationEvent.CustomerEmail,
Subject:
$"Order #{integrationEvent.OrderId} created",
Body:
$"Hello {integrationEvent.CustomerName}, " +
$"your order #{integrationEvent.OrderId} " +
$"was created with a total of " +
$"{integrationEvent.TotalAmount:C}.");

await _emailSender.SendAsync(
email,
cancellationToken);

_logger.LogInformation(
"Handled order-created event {MessageId} " +
"for order {OrderId}",
integrationEvent.MessageId,
integrationEvent.OrderId);
}

private async Task HandleCompletedAsync(
OrderCompletedIntegrationEvent integrationEvent,
CancellationToken cancellationToken)
{
var email = new EmailMessage(
Recipient: integrationEvent.CustomerEmail,
Subject:
$"Order #{integrationEvent.OrderId} completed",
Body:
$"Hello {integrationEvent.CustomerName}, " +
$"your order #{integrationEvent.OrderId} " +
"has been completed.");

await _emailSender.SendAsync(
email,
cancellationToken);

_logger.LogInformation(
"Handled order-completed event {MessageId} " +
"for order {OrderId}",
integrationEvent.MessageId,
integrationEvent.OrderId);
}

private async Task HandleCancelledAsync(
OrderCancelledIntegrationEvent integrationEvent,
CancellationToken cancellationToken)
{
var email = new EmailMessage(
Recipient: integrationEvent.CustomerEmail,
Subject:
$"Order #{integrationEvent.OrderId} cancelled",
Body:
$"Hello {integrationEvent.CustomerName}, " +
$"your order #{integrationEvent.OrderId} " +
"has been cancelled.");

await _emailSender.SendAsync(
email,
cancellationToken);

_logger.LogInformation(
"Handled order-cancelled event {MessageId} " +
"for order {OrderId}",
integrationEvent.MessageId,
integrationEvent.OrderId);
}

private static TEvent Deserialize<TEvent>(byte[] body)
{
return JsonSerializer.Deserialize<TEvent>(body, SerializerOptions) ?? throw new JsonException(
$"Could not deserialize " +
$"{typeof(TEvent).Name}.");
}
}
Loading