Files
pos-system/services/fnb-engine-net/tests/FnbEngine.UnitTests/Application/IntegrationEvents/KitchenTicketServedDomainEventHandlerTests.cs
Ho Ngoc Hai a7a753bf38 feat: EOD reports, security audit (rate limiting + 44 validators), and 30 critical path tests
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>
2026-03-06 16:33:39 +07:00

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");
}
}