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 @@ -8,6 +8,7 @@
using Microsoft.Extensions.Hosting;
using Microsoft.VisualStudio.TestPlatform.TestHost;
using OrderProcessing.Api.Data;
using OrderProcessing.Api.Features.Orders.Queries.ReadModel;
using System.Data.Common;

namespace OrderProcessing.Api.Tests.Infrastructure;
Expand All @@ -22,22 +23,19 @@ public CustomWebApplicationFactory()
_connection.Open();
}

protected override void ConfigureWebHost(
IWebHostBuilder builder)
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");

builder.ConfigureServices(services =>
{
// Remove the SQL Server DbContext registration
// from the production application.
services.RemoveAll<
DbContextOptions<OrderProcessingDbContext>>();
services.RemoveAll<DbContextOptions<OrderProcessingDbContext>>();

services.RemoveAll<OrderProcessingDbContext>();

services.RemoveAll<
IDbContextOptionsConfiguration<OrderProcessingDbContext>>();
services.RemoveAll<IDbContextOptionsConfiguration<OrderProcessingDbContext>>();

// All test DbContext instances use the same open
// SQLite connection.
Expand All @@ -51,6 +49,10 @@ protected override void ConfigureWebHost(

options.UseSqlite(connection);
});

services.RemoveAll<IOrderReadModelReader>();

services.AddSingleton<IOrderReadModelReader, TestOrderReadModelReader>();
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using OrderProcessing.Api.Features.Orders.Queries.ReadModel;
using OrderProcessing.ReadModels.Orders;

namespace OrderProcessing.Api.Tests.Infrastructure;

public sealed class TestOrderReadModelReader : IOrderReadModelReader
{
public const int ExistingOrderId = 88001;

public Task<OrderReadModel?> GetByIdAsync(int orderId, CancellationToken cancellationToken)
{
if (orderId != ExistingOrderId)
{
return Task.FromResult<OrderReadModel?>(null);
}

return Task.FromResult<OrderReadModel?>(new OrderReadModel
{
OrderId = ExistingOrderId,
CustomerId =
TestDataSeeder.CustomerId,
CustomerName =
"Integration Test Customer",
Status = "Pending",
TotalAmount = 24.99m,
CreatedAtUtc =
new DateTime(
2026,
8,
1,
10,
0,
0,
DateTimeKind.Utc),

LastUpdatedAtUtc =
new DateTime(
2026,
8,
1,
10,
0,
0,
DateTimeKind.Utc),

Items =
[
new OrderItemReadModel
{
ProductId =
TestDataSeeder.ProductId,
ProductName =
"Integration Test Product",
Quantity = 1,
UnitPrice = 24.99m,
LineTotal = 24.99m
}
]
});
}
}
53 changes: 24 additions & 29 deletions OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,35 +347,6 @@ private async Task<int> GetProductStockAsync(
.SingleAsync();
}

//private async Task<OrderResponse> CreateTestOrderAsync(
//int quantity = 1,
//int customerId = TestDataSeeder.CustomerId)
//{
// var request = new CreateOrderRequest
// {
// CustomerId = customerId,
// Items =
// [
// new CreateOrderItemRequest
// {
// ProductId = TestDataSeeder.ProductId,
// Quantity = quantity
// }
// ]
// };

// var response = await Client.PostAsJsonAsync(
// "/api/orders",
// request);

// response.EnsureSuccessStatusCode();

// return await response.Content
// .ReadFromJsonAsync<OrderResponse>()
// ?? throw new InvalidOperationException(
// "The create-order response was empty.");
//}


[Fact]
public async Task CancelOrder_CreatesCancellationAuditLog()
Expand Down Expand Up @@ -698,4 +669,28 @@ public async Task CancelOrder_PersistsOrderCancelledEventInOutbox()
default,
integrationEvent.CancelledAtUtc);
}

[Fact]
public async Task GetOrderById_WhenReadModelExists_ReturnsOrder()
{
var response = await Client.GetAsync(
$"/api/orders/" +
$"{TestOrderReadModelReader.ExistingOrderId}");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var order = await response.Content.ReadFromJsonAsync<OrderResponse>();

Assert.NotNull(order);

Assert.Equal(TestOrderReadModelReader.ExistingOrderId, order.Id);
}

[Fact]
public async Task GetOrderById_WhenReadModelDoesNotExist_ReturnsNotFound()
{
var response = await Client.GetAsync("/api/orders/999999");

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
89 changes: 89 additions & 0 deletions OrderProcessing.Api.Tests/Unit/GetOrderByIdQueryHadlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using OrderProcessing.Api.Entities;
using OrderProcessing.Api.Exceptions;
using OrderProcessing.Api.Features.Orders.Queries.GetOrderById;
using OrderProcessing.Api.Features.Orders.Queries.ReadModel;
using OrderProcessing.ReadModels.Orders;
using System;
using System.Collections.Generic;
using System.Text;

namespace OrderProcessing.Api.Tests.Unit
{
public class GetOrderByIdQueryHadlerTests
{

[Fact]
public async Task Handle_WhenOrderExists_ReturnsMappedResponse()
{
// Arrange
var readModel = new OrderReadModel
{
OrderId = 123,
CustomerId = 456,
CustomerName = "John Smith",
Status = "Completed",
TotalAmount = 99.99m,
CreatedAtUtc = DateTime.UtcNow.AddMinutes(-10),
CompletedAtUtc = DateTime.UtcNow,
LastUpdatedAtUtc = DateTime.UtcNow,

Items =
[
new OrderItemReadModel
{
ProductId = 10,
ProductName = "Keyboard",
Quantity = 1,
UnitPrice = 99.99m,
LineTotal = 99.99m
}
]
};

var reader = new FakeOrderReadModelReader(readModel);

var handler = new GetOrderByIdQueryHandler(reader);

// Act
var result = await handler.Handle(new GetOrderByIdQuery(123), CancellationToken.None);

// Assert
Assert.Equal(123, result.Id);
Assert.Equal(456, result.CustomerId);
Assert.Equal("John Smith", result.CustomerName);
Assert.Equal(OrderStatus.Completed, result.Status);
Assert.Equal(99.99m, result.TotalAmount);
Assert.Single(result.Items);
}

[Fact]
public async Task Handle_WhenOrderDoesNotExist_ThrowsNotFoundException()
{
var handler =
new GetOrderByIdQueryHandler(
new FakeOrderReadModelReader(null));

var action = () => handler.Handle(
new GetOrderByIdQuery(999),
CancellationToken.None);

await Assert.ThrowsAsync<NotFoundException>(
action);
}

private sealed class FakeOrderReadModelReader : IOrderReadModelReader
{
private readonly OrderReadModel? _order;

public FakeOrderReadModelReader(OrderReadModel? order)
{
_order = order;
}

public Task<OrderReadModel?> GetByIdAsync(int orderId, CancellationToken cancellationToken)
{
return Task.FromResult(_order?.OrderId == orderId ? _order : null);
}
}
}
}
12 changes: 12 additions & 0 deletions OrderProcessing.Api/Configuration/MongoDbOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace OrderProcessing.Api.Configuration;

public sealed class MongoDbOptions
{
public const string SectionName = "MongoDb";

public string ConnectionString { get; set; } = "mongodb://localhost:27017";

public string DatabaseName { get; set; } = "OrderProcessingReadDb";

public string OrdersCollectionName { get; set; } = "orders";
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,58 @@
using Microsoft.EntityFrameworkCore;
using OrderProcessing.Api.Data;
using OrderProcessing.Api.DTOs.Orders;
using OrderProcessing.Api.Entities;
using OrderProcessing.Api.Exceptions;
using OrderProcessing.Api.Features.Orders.Queries.ReadModel;

namespace OrderProcessing.Api.Features.Orders.Queries.GetOrderById;

public sealed class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderResponse>
{
private readonly OrderProcessingDbContext _dbContext;

public GetOrderByIdQueryHandler(OrderProcessingDbContext dbContext)
private readonly IOrderReadModelReader _reader;
public GetOrderByIdQueryHandler(IOrderReadModelReader reader)
{
_dbContext = dbContext;
_reader = reader;
}

public async Task<OrderResponse> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken)
{
return await _dbContext.Orders
.AsNoTracking()
.Where(order => order.Id == request.OrderId)
.Select(order => new OrderResponse
{
Id = order.Id,
CustomerId = order.CustomerId,
CustomerName =
order.Customer.FirstName + " " +
order.Customer.LastName,
Status = order.Status,
TotalAmount = order.TotalAmount,
CreatedAtUtc = order.CreatedAtUtc,
CompletedAtUtc = order.CompletedAtUtc,
CancelledAtUtc = order.CancelledAtUtc,
Items = order.Items
.OrderBy(item => item.Id)
.Select(item => new OrderItemResponse
var readModel = await _reader.GetByIdAsync(request.OrderId, cancellationToken);

if (readModel is null)
{
throw new NotFoundException($"Order with id {request.OrderId} was not found.");
}

if (!Enum.TryParse<OrderStatus>(readModel.Status, ignoreCase: true, out var status))
{
throw new InvalidOperationException(
$"Order read model {readModel.OrderId} " +
$"contains invalid status '{readModel.Status}'.");
}

return new OrderResponse
{
Id = readModel.OrderId,
CustomerId = readModel.CustomerId,
CustomerName = readModel.CustomerName,
Status = status,
TotalAmount = readModel.TotalAmount,
CreatedAtUtc = readModel.CreatedAtUtc,
CompletedAtUtc = readModel.CompletedAtUtc,
CancelledAtUtc = readModel.CancelledAtUtc,

Items = readModel.Items
.Select(item =>
new OrderItemResponse
{
ProductId = item.ProductId,
ProductName = item.ProductName,
Quantity = item.Quantity,
UnitPrice = item.UnitPrice,
LineTotal = item.LineTotal
})
.ToList()
})
.FirstOrDefaultAsync(cancellationToken)
?? throw new NotFoundException(
$"Order with id {request.OrderId} was not found.");
.ToList()
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using OrderProcessing.ReadModels.Orders;

namespace OrderProcessing.Api.Features.Orders.Queries.ReadModel;

public interface IOrderReadModelReader
{
Task<OrderReadModel?> GetByIdAsync(int orderId, CancellationToken cancellationToken);
}
Loading