diff --git a/OrderProcessing.Api.Tests/Infrastructure/TestOrderReadModelReader.cs b/OrderProcessing.Api.Tests/Infrastructure/TestOrderReadModelReader.cs index ed525a8..b88a7ee 100644 --- a/OrderProcessing.Api.Tests/Infrastructure/TestOrderReadModelReader.cs +++ b/OrderProcessing.Api.Tests/Infrastructure/TestOrderReadModelReader.cs @@ -1,4 +1,5 @@ -using OrderProcessing.Api.Features.Orders.Queries.ReadModel; +using OrderProcessing.Api.DTOs.Orders; +using OrderProcessing.Api.Features.Orders.Queries.ReadModel; using OrderProcessing.ReadModels.Orders; namespace OrderProcessing.Api.Tests.Infrastructure; @@ -9,53 +10,188 @@ public sealed class TestOrderReadModelReader : IOrderReadModelReader public Task GetByIdAsync(int orderId, CancellationToken cancellationToken) { - if (orderId != ExistingOrderId) + cancellationToken.ThrowIfCancellationRequested(); + + var order = CreateOrders().SingleOrDefault(order => order.OrderId == orderId); + return Task.FromResult(order); + } + + public Task GetPageAsync(OrderQueryParameters parameters, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + IEnumerable query = CreateOrders(); + + query = ApplyFilters(query, parameters); + + var totalCount = query.Count(); + + query = ApplySorting(query, parameters); + + var orders = query + .Skip((parameters.Page - 1) * parameters.PageSize) + .Take(parameters.PageSize) + .ToList(); + + return Task.FromResult( + new OrderReadModelPage(orders, totalCount)); + } + + private static IEnumerable ApplyFilters(IEnumerable query, OrderQueryParameters parameters) + { + if (parameters.CustomerId.HasValue) + { + query = query.Where(order => order.CustomerId == parameters.CustomerId.Value); + } + + if (parameters.Status.HasValue) + { + var status = parameters.Status.Value.ToString(); + + query = query.Where(order => + string.Equals( + order.Status, + status, + StringComparison.OrdinalIgnoreCase)); + } + + if (parameters.CreatedFromUtc.HasValue) + { + query = query.Where(order => order.CreatedAtUtc >= parameters.CreatedFromUtc.Value); + } + + if (parameters.CreatedToUtc.HasValue) { - return Task.FromResult(null); + query = query.Where(order => order.CreatedAtUtc <= parameters.CreatedToUtc.Value); } - return Task.FromResult(new OrderReadModel + return query; + } + + private static IEnumerable ApplySorting(IEnumerable query, OrderQueryParameters parameters) + { + return (parameters.SortBy, parameters.SortDirection) switch + { + (OrderSortBy.Id, SortDirection.Ascending) => + query.OrderBy(order => order.OrderId), + + (OrderSortBy.Id, SortDirection.Descending) => + query.OrderByDescending(order => order.OrderId), + + (OrderSortBy.TotalAmount, SortDirection.Ascending) => + query + .OrderBy(order => order.TotalAmount) + .ThenBy(order => order.OrderId), + + (OrderSortBy.TotalAmount, SortDirection.Descending) => + query + .OrderByDescending(order => order.TotalAmount) + .ThenByDescending(order => order.OrderId), + + (OrderSortBy.CreatedAtUtc, SortDirection.Ascending) => + query + .OrderBy(order => order.CreatedAtUtc) + .ThenBy(order => order.OrderId), + + _ => + query + .OrderByDescending(order => order.CreatedAtUtc) + .ThenByDescending(order => order.OrderId) + }; + } + + private static IReadOnlyList CreateOrders() + { + return + [ + CreateOrder( + orderId: ExistingOrderId, + customerId: TestDataSeeder.CustomerId, + customerName: "Integration Test Customer", + status: "Pending", + quantity: 1, + createdAtUtc: new DateTime( + 2026, + 8, + 1, + 10, + 0, + 0, + DateTimeKind.Utc)), + + CreateOrder( + orderId: 88002, + customerId: TestDataSeeder.CustomerId, + customerName: "Integration Test Customer", + status: "Completed", + quantity: 2, + createdAtUtc: new DateTime( + 2026, + 8, + 2, + 10, + 0, + 0, + DateTimeKind.Utc), + completedAtUtc: new DateTime( + 2026, + 8, + 2, + 11, + 0, + 0, + DateTimeKind.Utc)), + + CreateOrder( + orderId: 88003, + customerId: TestDataSeeder.SecondCustomerId, + customerName: "Second Integration Test Customer", + status: "Pending", + quantity: 3, + createdAtUtc: new DateTime( + 2026, + 8, + 3, + 10, + 0, + 0, + DateTimeKind.Utc)) + ]; + } + + private static OrderReadModel CreateOrder( + int orderId, + int customerId, + string customerName, + string status, + int quantity, + DateTime createdAtUtc, + DateTime? completedAtUtc = null) + { + const decimal unitPrice = 24.99m; + + return new OrderReadModel + { + OrderId = orderId, + CustomerId = customerId, + CustomerName = customerName, + Status = status, + TotalAmount = unitPrice * quantity, + CreatedAtUtc = createdAtUtc, + CompletedAtUtc = completedAtUtc, + LastUpdatedAtUtc = + completedAtUtc ?? createdAtUtc, + Items = + [ + new OrderItemReadModel { - 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 - } - ] - }); + ProductId = TestDataSeeder.ProductId, + ProductName = "Integration Test Product", + Quantity = quantity, + UnitPrice = unitPrice, + LineTotal = unitPrice * quantity + } + ] + }; } } \ No newline at end of file diff --git a/OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs b/OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs index 26b284b..ece1c34 100644 --- a/OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs +++ b/OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs @@ -69,11 +69,6 @@ public async Task CancelOrder_RestoresProductStock() [Fact] public async Task GetOrders_ReturnsPagedResponse() { - // Arrange - await CreateTestOrderAsync(); - await CreateTestOrderAsync(); - await CreateTestOrderAsync(); - // Act var response = await Client.GetAsync( "/api/orders?page=1&pageSize=2"); @@ -97,13 +92,6 @@ public async Task GetOrders_ReturnsPagedResponse() [Fact] public async Task GetOrders_WhenCustomerFilterProvided_ReturnsOnlyMatchingOrders() { - // Arrange - await CreateTestOrderAsync( - customerId: TestDataSeeder.CustomerId); - - await CreateTestOrderAsync( - customerId: TestDataSeeder.SecondCustomerId); - // Act var response = await Client.GetAsync( $"/api/orders?customerId={TestDataSeeder.SecondCustomerId}"); diff --git a/OrderProcessing.Api.Tests/Unit/GetOrderByIdQueryHadlerTests.cs b/OrderProcessing.Api.Tests/Unit/GetOrderByIdQueryHadlerTests.cs index 85eaadb..3c7e955 100644 --- a/OrderProcessing.Api.Tests/Unit/GetOrderByIdQueryHadlerTests.cs +++ b/OrderProcessing.Api.Tests/Unit/GetOrderByIdQueryHadlerTests.cs @@ -1,4 +1,5 @@ -using OrderProcessing.Api.Entities; +using OrderProcessing.Api.DTOs.Orders; +using OrderProcessing.Api.Entities; using OrderProcessing.Api.Exceptions; using OrderProcessing.Api.Features.Orders.Queries.GetOrderById; using OrderProcessing.Api.Features.Orders.Queries.ReadModel; @@ -84,6 +85,11 @@ public FakeOrderReadModelReader(OrderReadModel? order) { return Task.FromResult(_order?.OrderId == orderId ? _order : null); } + + public Task GetPageAsync(OrderQueryParameters parameters, CancellationToken cancellationToken) + { + return Task.FromResult(new OrderReadModelPage([], 0)); + } } } } diff --git a/OrderProcessing.Api.Tests/Unit/GetOrdersQueryHandlerTests.cs b/OrderProcessing.Api.Tests/Unit/GetOrdersQueryHandlerTests.cs new file mode 100644 index 0000000..ca0ae44 --- /dev/null +++ b/OrderProcessing.Api.Tests/Unit/GetOrdersQueryHandlerTests.cs @@ -0,0 +1,117 @@ +using OrderProcessing.Api.DTOs.Orders; +using OrderProcessing.Api.Entities; +using OrderProcessing.Api.Exceptions; +using OrderProcessing.Api.Features.Orders.Queries.GetOrders; +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 GetOrdersQueryHandlerTests + { + [Fact] + public async Task Handle_WhenOrdersExist_ReturnsMappedPagedResponse() + { + var parameters = new OrderQueryParameters + { + Page = 2, + PageSize = 5 + }; + + 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( + new OrderReadModelPage([readModel], 8)); + + var handler = new GetOrdersQueryHandler(reader); + + var result = await handler.Handle( + new GetOrdersQuery(parameters), + CancellationToken.None); + + Assert.Equal(2, result.Page); + Assert.Equal(5, result.PageSize); + Assert.Equal(8, result.TotalCount); + Assert.Equal(2, result.TotalPages); + + var order = Assert.Single(result.Items); + + Assert.Equal(123, order.Id); + Assert.Equal("John Smith", order.CustomerName); + Assert.Equal(OrderStatus.Completed, order.Status); + Assert.Single(order.Items); + + Assert.Same(parameters, reader.ReceivedParameters); + } + + private sealed class FakeOrderReadModelReader : IOrderReadModelReader + { + private readonly OrderReadModelPage _page; + + public FakeOrderReadModelReader(OrderReadModelPage page) + { + _page = page; + } + + public OrderQueryParameters? ReceivedParameters { get; private set; } + + public Task GetByIdAsync(int orderId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task GetPageAsync(OrderQueryParameters parameters, CancellationToken cancellationToken) + { + ReceivedParameters = parameters; + + return Task.FromResult(_page); + } + } + + [Fact] + public async Task Handle_WhenDateRangeIsInvalid_ThrowsBadRequestException() + { + var reader = new FakeOrderReadModelReader(new OrderReadModelPage([], 0)); + + var handler = new GetOrdersQueryHandler(reader); + + var parameters = new OrderQueryParameters + { + CreatedFromUtc = new DateTime(2026, 8, 15), + CreatedToUtc = new DateTime(2026, 8, 14) + }; + + var action = () => handler.Handle( new GetOrdersQuery(parameters), CancellationToken.None); + + await Assert.ThrowsAsync(action); + + Assert.Null(reader.ReceivedParameters); + } + + } + +} diff --git a/OrderProcessing.Api/Features/Orders/Queries/GetOrders/GetOrdersQueryHandler.cs b/OrderProcessing.Api/Features/Orders/Queries/GetOrders/GetOrdersQueryHandler.cs index b170858..ea816e0 100644 --- a/OrderProcessing.Api/Features/Orders/Queries/GetOrders/GetOrdersQueryHandler.cs +++ b/OrderProcessing.Api/Features/Orders/Queries/GetOrders/GetOrdersQueryHandler.cs @@ -1,152 +1,76 @@ using MediatR; -using Microsoft.EntityFrameworkCore; -using OrderProcessing.Api.Data; using OrderProcessing.Api.DTOs.Common; using OrderProcessing.Api.DTOs.Orders; using OrderProcessing.Api.Entities; using OrderProcessing.Api.Exceptions; +using OrderProcessing.Api.Features.Orders.Queries.ReadModel; +using OrderProcessing.ReadModels.Orders; namespace OrderProcessing.Api.Features.Orders.Queries.GetOrders; public sealed class GetOrdersQueryHandler : IRequestHandler> { - private readonly OrderProcessingDbContext _dbContext; + private readonly IOrderReadModelReader _readModelReader; - public GetOrdersQueryHandler( - OrderProcessingDbContext dbContext) + public GetOrdersQueryHandler(IOrderReadModelReader readModelReader) { - _dbContext = dbContext; + _readModelReader = readModelReader; } - public async Task> Handle( - GetOrdersQuery request, - CancellationToken cancellationToken) + public async Task> Handle(GetOrdersQuery request, CancellationToken cancellationToken) { var parameters = request.Parameters; ValidateDateRange(parameters); - var query = _dbContext.Orders - .AsNoTracking() - .AsQueryable(); + var page = await _readModelReader.GetPageAsync(parameters, cancellationToken); - query = ApplyFilters(query, parameters); - - var totalCount = await query.CountAsync( - cancellationToken); - - query = ApplySorting(query, parameters); - - var orders = await query - .Skip((parameters.Page - 1) * parameters.PageSize) - .Take(parameters.PageSize) - .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 - { - ProductId = item.ProductId, - ProductName = item.ProductName, - Quantity = item.Quantity, - UnitPrice = item.UnitPrice, - LineTotal = item.LineTotal - }) - .ToList() - }) - .ToListAsync(cancellationToken); + var orders = page.Items + .Select(MapToResponse) + .ToList(); return new PagedResponse { Items = orders, Page = parameters.Page, PageSize = parameters.PageSize, - TotalCount = totalCount + TotalCount = page.TotalCount }; } - private static IQueryable ApplyFilters( - IQueryable query, - OrderQueryParameters parameters) + private static OrderResponse MapToResponse(OrderReadModel order) { - if (parameters.CustomerId.HasValue) - { - query = query.Where(order => - order.CustomerId == parameters.CustomerId.Value); - } - - if (parameters.Status.HasValue) - { - query = query.Where(order => - order.Status == parameters.Status.Value); - } - - if (parameters.CreatedFromUtc.HasValue) - { - query = query.Where(order => - order.CreatedAtUtc >= parameters.CreatedFromUtc.Value); - } - - if (parameters.CreatedToUtc.HasValue) + return new OrderResponse { - query = query.Where(order => - order.CreatedAtUtc <= parameters.CreatedToUtc.Value); - } - - return query; - } - - private static IQueryable ApplySorting( - IQueryable query, - OrderQueryParameters parameters) - { - return (parameters.SortBy, parameters.SortDirection) switch - { - (OrderSortBy.Id, SortDirection.Ascending) => - query.OrderBy(order => order.Id), - - (OrderSortBy.Id, SortDirection.Descending) => - query.OrderByDescending(order => order.Id), - - (OrderSortBy.TotalAmount, SortDirection.Ascending) => - query - .OrderBy(order => order.TotalAmount) - .ThenBy(order => order.Id), - - (OrderSortBy.TotalAmount, SortDirection.Descending) => - query - .OrderByDescending(order => order.TotalAmount) - .ThenByDescending(order => order.Id), - - (OrderSortBy.CreatedAtUtc, SortDirection.Ascending) => - query - .OrderBy(order => order.CreatedAtUtc) - .ThenBy(order => order.Id), - - _ => - query - .OrderByDescending(order => order.CreatedAtUtc) - .ThenByDescending(order => order.Id) + Id = order.OrderId, + CustomerId = order.CustomerId, + CustomerName = order.CustomerName, + Status = Enum.Parse( + order.Status, + ignoreCase: true), + TotalAmount = order.TotalAmount, + CreatedAtUtc = order.CreatedAtUtc, + CompletedAtUtc = order.CompletedAtUtc, + CancelledAtUtc = order.CancelledAtUtc, + Items = order.Items + .Select(item => new OrderItemResponse + { + ProductId = item.ProductId, + ProductName = item.ProductName, + Quantity = item.Quantity, + UnitPrice = item.UnitPrice, + LineTotal = item.LineTotal + }) + .ToList() }; } - private static void ValidateDateRange( - OrderQueryParameters parameters) + private static void ValidateDateRange(OrderQueryParameters parameters) { if (parameters.CreatedFromUtc.HasValue && - parameters.CreatedToUtc.HasValue && - parameters.CreatedFromUtc > parameters.CreatedToUtc) + parameters.CreatedToUtc.HasValue && + parameters.CreatedFromUtc > parameters.CreatedToUtc) { throw new BadRequestException( "CreatedFromUtc cannot be later than CreatedToUtc.", diff --git a/OrderProcessing.Api/Features/Orders/Queries/ReadModel/IOrderReadModelReader.cs b/OrderProcessing.Api/Features/Orders/Queries/ReadModel/IOrderReadModelReader.cs index 1647e9c..401b987 100644 --- a/OrderProcessing.Api/Features/Orders/Queries/ReadModel/IOrderReadModelReader.cs +++ b/OrderProcessing.Api/Features/Orders/Queries/ReadModel/IOrderReadModelReader.cs @@ -1,8 +1,11 @@ -using OrderProcessing.ReadModels.Orders; +using OrderProcessing.Api.DTOs.Orders; +using OrderProcessing.ReadModels.Orders; namespace OrderProcessing.Api.Features.Orders.Queries.ReadModel; public interface IOrderReadModelReader { Task GetByIdAsync(int orderId, CancellationToken cancellationToken); + + Task GetPageAsync(OrderQueryParameters parameters, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/OrderProcessing.Api/Features/Orders/Queries/ReadModel/MongoOrderReadModelReader.cs b/OrderProcessing.Api/Features/Orders/Queries/ReadModel/MongoOrderReadModelReader.cs index 8f58504..ef0c8f7 100644 --- a/OrderProcessing.Api/Features/Orders/Queries/ReadModel/MongoOrderReadModelReader.cs +++ b/OrderProcessing.Api/Features/Orders/Queries/ReadModel/MongoOrderReadModelReader.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.Options; using MongoDB.Driver; using OrderProcessing.Api.Configuration; +using OrderProcessing.Api.DTOs.Orders; using OrderProcessing.ReadModels.Orders; +using SortDirection = OrderProcessing.Api.DTOs.Orders.SortDirection; namespace OrderProcessing.Api.Features.Orders.Queries.ReadModel; @@ -13,14 +15,106 @@ public MongoOrderReadModelReader(IMongoClient mongoClient, IOptions(mongoOptions.OrdersCollectionName); + _orders = database.GetCollection( + mongoOptions.OrdersCollectionName); } public Task GetByIdAsync(int orderId, CancellationToken cancellationToken) { - return _orders.Find(order => order.OrderId == orderId) - .FirstOrDefaultAsync(cancellationToken); + return _orders.Find(order => order.OrderId == orderId).FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetPageAsync(OrderQueryParameters parameters, CancellationToken cancellationToken) + { + var filter = BuildFilter(parameters); + var sort = BuildSort(parameters); + + var totalCount = await _orders.CountDocumentsAsync( + filter, + cancellationToken: cancellationToken); + + var skip = (parameters.Page - 1) * parameters.PageSize; + + var orders = await _orders + .Find(filter) + .Sort(sort) + .Skip(skip) + .Limit(parameters.PageSize) + .ToListAsync(cancellationToken); + + return new OrderReadModelPage(orders, checked((int)totalCount)); + } + + private static FilterDefinition BuildFilter(OrderQueryParameters parameters) + { + var builder = Builders.Filter; + var filters = new List>(); + + if (parameters.CustomerId.HasValue) + { + filters.Add(builder.Eq( + order => order.CustomerId, + parameters.CustomerId.Value)); + } + + if (parameters.Status.HasValue) + { + filters.Add(builder.Eq( + order => order.Status, + parameters.Status.Value.ToString())); + } + + if (parameters.CreatedFromUtc.HasValue) + { + filters.Add(builder.Gte( + order => order.CreatedAtUtc, + parameters.CreatedFromUtc.Value)); + } + + if (parameters.CreatedToUtc.HasValue) + { + filters.Add(builder.Lte( + order => order.CreatedAtUtc, + parameters.CreatedToUtc.Value)); + } + + return filters.Count == 0 + ? builder.Empty + : builder.And(filters); + } + + private static SortDefinition BuildSort(OrderQueryParameters parameters) + { + var builder = Builders.Sort; + + return (parameters.SortBy, parameters.SortDirection) switch + { + (OrderSortBy.Id, SortDirection.Ascending) => builder.Ascending(order => order.OrderId), + + (OrderSortBy.Id, SortDirection.Descending) => builder.Descending(order => order.OrderId), + + (OrderSortBy.TotalAmount, SortDirection.Ascending) => + builder.Combine( + builder.Ascending(order => order.TotalAmount), + builder.Ascending(order => order.OrderId)), + + (OrderSortBy.TotalAmount, SortDirection.Descending) => + builder.Combine( + builder.Descending(order => order.TotalAmount), + builder.Descending(order => order.OrderId)), + + (OrderSortBy.CreatedAtUtc, SortDirection.Ascending) => + builder.Combine( + builder.Ascending(order => order.CreatedAtUtc), + builder.Ascending(order => order.OrderId)), + + _ => + builder.Combine( + builder.Descending(order => order.CreatedAtUtc), + builder.Descending(order => order.OrderId)) + }; } } \ No newline at end of file diff --git a/OrderProcessing.Api/Features/Orders/Queries/ReadModel/OrderReadModelPage.cs b/OrderProcessing.Api/Features/Orders/Queries/ReadModel/OrderReadModelPage.cs new file mode 100644 index 0000000..f71f0d5 --- /dev/null +++ b/OrderProcessing.Api/Features/Orders/Queries/ReadModel/OrderReadModelPage.cs @@ -0,0 +1,5 @@ +using OrderProcessing.ReadModels.Orders; + +namespace OrderProcessing.Api.Features.Orders.Queries.ReadModel; + +public sealed record OrderReadModelPage(IReadOnlyList Items, int TotalCount); \ No newline at end of file