EOD Reports & Daily Close (order-service + Blazor UI): - GetEodReportQuery: Dapper query for revenue, orders, payment breakdown, top items, hourly chart - CloseDayCommand: check pending orders, generate final report - EodReport.razor: 6 KPI cards, donut/bar charts, top 10 table, close-day dialog - FluentValidation for both query and command - BFF proxy endpoints for reports Security Audit — Rate Limiting: - Tighten auth-ratelimit from 100 to 10 req/min (brute force protection) - Add payment-ratelimit (30/min), api-ratelimit (100/min), hub-ratelimit (500/min) - Apply rate limits to ALL Traefik routers (previously many had none) Security Audit — Input Sanitization (44 missing validators created): - iam-service: 14 validators (auth, user, role commands) - merchant-service: 11 validators (admin, attendance commands) - wallet-service: 7 validators (wallet, points commands) - fnb-engine: 7 validators (session, table, ticket, reservation) - catalog-service: 6 validators (product, category CRUD) - storage-service: 6 validators (upload, share, quota) - order-service: 2 validators (complete order/payment) Critical Path Unit Tests (30 new tests): - inventory-service: 12 tests (deduction, partial stock, idempotency) - wallet-service: 14 tests (create payment, process callback, domain events) - fnb-engine: 8 tests (kitchen-served event handler, inventory client integration) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
325 lines
13 KiB
C#
325 lines
13 KiB
C#
// EN: Unit tests for KitchenTicketServedDomainEventHandler - inventory deduction on served tickets.
|
|
// VI: Unit tests cho KitchenTicketServedDomainEventHandler - tru kho khi phieu da phuc vu.
|
|
|
|
using FluentAssertions;
|
|
using FnbEngine.API.Application.IntegrationEvents.EventHandlers;
|
|
using FnbEngine.Domain.AggregatesModel.KitchenAggregate;
|
|
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
|
using FnbEngine.Domain.AggregatesModel.SessionAggregate;
|
|
using FnbEngine.Domain.Events;
|
|
using FnbEngine.Infrastructure.ExternalServices;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace FnbEngine.UnitTests.Application.IntegrationEvents;
|
|
|
|
/// <summary>
|
|
/// EN: Unit tests for KitchenTicketServedDomainEventHandler.
|
|
/// VI: Unit tests cho KitchenTicketServedDomainEventHandler.
|
|
/// </summary>
|
|
public class KitchenTicketServedDomainEventHandlerTests
|
|
{
|
|
private readonly Mock<IRecipeRepository> _recipeRepositoryMock;
|
|
private readonly Mock<ISessionRepository> _sessionRepositoryMock;
|
|
private readonly Mock<IInventoryServiceClient> _inventoryClientMock;
|
|
private readonly Mock<ILogger<KitchenTicketServedDomainEventHandler>> _loggerMock;
|
|
private readonly KitchenTicketServedDomainEventHandler _handler;
|
|
|
|
private readonly Guid _shopId = Guid.NewGuid();
|
|
private readonly Guid _sessionId = Guid.NewGuid();
|
|
private readonly Guid _productId = Guid.NewGuid();
|
|
|
|
public KitchenTicketServedDomainEventHandlerTests()
|
|
{
|
|
_recipeRepositoryMock = new Mock<IRecipeRepository>();
|
|
_sessionRepositoryMock = new Mock<ISessionRepository>();
|
|
_inventoryClientMock = new Mock<IInventoryServiceClient>();
|
|
_loggerMock = new Mock<ILogger<KitchenTicketServedDomainEventHandler>>();
|
|
|
|
_handler = new KitchenTicketServedDomainEventHandler(
|
|
_recipeRepositoryMock.Object,
|
|
_sessionRepositoryMock.Object,
|
|
_inventoryClientMock.Object,
|
|
_loggerMock.Object);
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Helper to create a KitchenTicket for testing.
|
|
/// VI: Helper de tao KitchenTicket de test.
|
|
/// </summary>
|
|
private KitchenTicket CreateTicket(Guid? productId = null, int quantity = 1)
|
|
{
|
|
return new KitchenTicket(
|
|
_sessionId,
|
|
Guid.NewGuid(),
|
|
productId ?? _productId,
|
|
"Pho Bo",
|
|
quantity,
|
|
"Kitchen",
|
|
0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Helper to create a Session for testing.
|
|
/// VI: Helper de tao Session de test.
|
|
/// </summary>
|
|
private Session CreateSession()
|
|
{
|
|
return new Session(Guid.NewGuid(), _shopId, 2);
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Helper to create a Recipe with linked ingredients.
|
|
/// VI: Helper de tao Recipe voi nguyen lieu co lien ket.
|
|
/// </summary>
|
|
private Recipe CreateRecipeWithLinkedIngredients(int ingredientCount = 2)
|
|
{
|
|
var recipe = new Recipe(_shopId, _productId, "Pho Bo Recipe", "Boil broth", 30);
|
|
for (int i = 0; i < ingredientCount; i++)
|
|
{
|
|
recipe.AddIngredient(
|
|
$"Ingredient {i + 1}",
|
|
quantity: 100,
|
|
unit: "g",
|
|
costPerUnit: 10_000m,
|
|
inventoryItemId: Guid.NewGuid(),
|
|
quantityPerServing: 50m);
|
|
}
|
|
return recipe;
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Helper to create a Recipe with no linked inventory items.
|
|
/// VI: Helper de tao Recipe khong co lien ket den inventory items.
|
|
/// </summary>
|
|
private Recipe CreateRecipeWithoutLinkedIngredients()
|
|
{
|
|
var recipe = new Recipe(_shopId, _productId, "Simple Drink", "Pour and serve", 2);
|
|
recipe.AddIngredient("Water", 200, "ml", 0, inventoryItemId: null, quantityPerServing: 0);
|
|
return recipe;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Handle_WithValidEvent_ShouldCallInventoryClient()
|
|
{
|
|
// Arrange
|
|
var ticket = CreateTicket();
|
|
var session = CreateSession();
|
|
var recipe = CreateRecipeWithLinkedIngredients();
|
|
var notification = new KitchenTicketServedDomainEvent(ticket);
|
|
|
|
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(session);
|
|
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(recipe);
|
|
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(true);
|
|
|
|
// Act
|
|
await _handler.Handle(notification, CancellationToken.None);
|
|
|
|
// Assert
|
|
// EN: Should call inventory client exactly once with the correct deduction request.
|
|
// VI: Nen goi inventory client dung 1 lan voi request tru kho chinh xac.
|
|
_inventoryClientMock.Verify(
|
|
c => c.DeductInventoryAsync(
|
|
It.Is<DeductInventoryRequest>(req =>
|
|
req.ShopId == _shopId &&
|
|
req.ReferenceId == ticket.Id &&
|
|
req.ReferenceType == "KitchenTicket" &&
|
|
req.Items.Count == 2),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Handle_WithNoRecipeFound_ShouldSkipDeduction()
|
|
{
|
|
// Arrange
|
|
var ticket = CreateTicket();
|
|
var session = CreateSession();
|
|
var notification = new KitchenTicketServedDomainEvent(ticket);
|
|
|
|
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(session);
|
|
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((Recipe?)null);
|
|
|
|
// Act
|
|
await _handler.Handle(notification, CancellationToken.None);
|
|
|
|
// Assert
|
|
// EN: No recipe means no inventory deduction should be attempted.
|
|
// VI: Khong co cong thuc nghia la khong nen co gang tru kho.
|
|
_inventoryClientMock.Verify(
|
|
c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
|
|
Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Handle_WhenInventoryClientFails_ShouldLogErrorNotThrow()
|
|
{
|
|
// Arrange
|
|
var ticket = CreateTicket();
|
|
var session = CreateSession();
|
|
var recipe = CreateRecipeWithLinkedIngredients();
|
|
var notification = new KitchenTicketServedDomainEvent(ticket);
|
|
|
|
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(session);
|
|
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(recipe);
|
|
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new HttpRequestException("Inventory service unavailable"));
|
|
|
|
// Act
|
|
var action = () => _handler.Handle(notification, CancellationToken.None);
|
|
|
|
// Assert
|
|
// EN: Should NOT throw - inventory failure should not block kitchen workflow.
|
|
// VI: KHONG nen throw - loi tru kho khong nen chan luong bep.
|
|
await action.Should().NotThrowAsync();
|
|
|
|
_inventoryClientMock.Verify(
|
|
c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Handle_WithMultipleIngredients_ShouldDeductAll()
|
|
{
|
|
// Arrange
|
|
var ticket = CreateTicket(quantity: 3);
|
|
var session = CreateSession();
|
|
var recipe = CreateRecipeWithLinkedIngredients(ingredientCount: 4);
|
|
var notification = new KitchenTicketServedDomainEvent(ticket);
|
|
|
|
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(session);
|
|
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(recipe);
|
|
|
|
DeductInventoryRequest? capturedRequest = null;
|
|
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()))
|
|
.Callback<DeductInventoryRequest, CancellationToken>((req, _) => capturedRequest = req)
|
|
.ReturnsAsync(true);
|
|
|
|
// Act
|
|
await _handler.Handle(notification, CancellationToken.None);
|
|
|
|
// Assert
|
|
// EN: Should send all 4 ingredients in one deduction request.
|
|
// VI: Nen gui tat ca 4 nguyen lieu trong mot request tru kho.
|
|
capturedRequest.Should().NotBeNull();
|
|
capturedRequest!.Items.Should().HaveCount(4);
|
|
|
|
// EN: Each ingredient amount should be multiplied by ticket quantity (3).
|
|
// VI: So luong moi nguyen lieu nen duoc nhan voi so luong phieu (3).
|
|
capturedRequest.Items.Should().AllSatisfy(item =>
|
|
{
|
|
// EN: quantityPerServing=50, ticket.Quantity=3, so Math.Ceiling(50*3)=150
|
|
// VI: quantityPerServing=50, ticket.Quantity=3, nen Math.Ceiling(50*3)=150
|
|
item.Amount.Should().Be(150);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Handle_WithNoLinkedInventoryItems_ShouldSkipDeduction()
|
|
{
|
|
// Arrange
|
|
var ticket = CreateTicket();
|
|
var session = CreateSession();
|
|
var recipe = CreateRecipeWithoutLinkedIngredients();
|
|
var notification = new KitchenTicketServedDomainEvent(ticket);
|
|
|
|
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(session);
|
|
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(recipe);
|
|
|
|
// Act
|
|
await _handler.Handle(notification, CancellationToken.None);
|
|
|
|
// Assert
|
|
// EN: Recipe with no linked inventory items should not trigger deduction.
|
|
// VI: Cong thuc khong co nguyen lieu lien ket kho khong nen kich hoat tru kho.
|
|
_inventoryClientMock.Verify(
|
|
c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
|
|
Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Handle_WithSessionNotFound_ShouldSkipDeduction()
|
|
{
|
|
// Arrange
|
|
var ticket = CreateTicket();
|
|
var notification = new KitchenTicketServedDomainEvent(ticket);
|
|
|
|
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((Session?)null);
|
|
|
|
// Act
|
|
await _handler.Handle(notification, CancellationToken.None);
|
|
|
|
// Assert
|
|
// EN: Missing session means we cannot determine the shop, so skip deduction.
|
|
// VI: Thieu session nghia la khong the xac dinh shop, nen bo qua tru kho.
|
|
_recipeRepositoryMock.Verify(
|
|
r => r.GetByProductIdAndShopAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()),
|
|
Times.Never);
|
|
_inventoryClientMock.Verify(
|
|
c => c.DeductInventoryAsync(It.IsAny<DeductInventoryRequest>(), It.IsAny<CancellationToken>()),
|
|
Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Handle_WhenRepositoryThrows_ShouldNotThrow()
|
|
{
|
|
// Arrange
|
|
var ticket = CreateTicket();
|
|
var notification = new KitchenTicketServedDomainEvent(ticket);
|
|
|
|
_sessionRepositoryMock.Setup(r => r.GetByIdAsync(ticket.SessionId, It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("Database connection failed"));
|
|
|
|
// Act
|
|
var action = () => _handler.Handle(notification, CancellationToken.None);
|
|
|
|
// Assert
|
|
// EN: Repository failures should be caught and logged, not propagated.
|
|
// VI: Loi repository nen duoc bat va log, khong nen lan truyen.
|
|
await action.Should().NotThrowAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_WithNullRecipeRepository_ShouldThrowArgumentNullException()
|
|
{
|
|
// Act
|
|
var action = () => new KitchenTicketServedDomainEventHandler(
|
|
null!,
|
|
_sessionRepositoryMock.Object,
|
|
_inventoryClientMock.Object,
|
|
_loggerMock.Object);
|
|
|
|
// Assert
|
|
action.Should().Throw<ArgumentNullException>()
|
|
.And.ParamName.Should().Be("recipeRepository");
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_WithNullInventoryClient_ShouldThrowArgumentNullException()
|
|
{
|
|
// Act
|
|
var action = () => new KitchenTicketServedDomainEventHandler(
|
|
_recipeRepositoryMock.Object,
|
|
_sessionRepositoryMock.Object,
|
|
null!,
|
|
_loggerMock.Object);
|
|
|
|
// Assert
|
|
action.Should().Throw<ArgumentNullException>()
|
|
.And.ParamName.Should().Be("inventoryClient");
|
|
}
|
|
}
|