// 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;
///
/// EN: Unit tests for KitchenTicketServedDomainEventHandler.
/// VI: Unit tests cho KitchenTicketServedDomainEventHandler.
///
public class KitchenTicketServedDomainEventHandlerTests
{
private readonly Mock _recipeRepositoryMock;
private readonly Mock _sessionRepositoryMock;
private readonly Mock _inventoryClientMock;
private readonly Mock> _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();
_sessionRepositoryMock = new Mock();
_inventoryClientMock = new Mock();
_loggerMock = new Mock>();
_handler = new KitchenTicketServedDomainEventHandler(
_recipeRepositoryMock.Object,
_sessionRepositoryMock.Object,
_inventoryClientMock.Object,
_loggerMock.Object);
}
///
/// EN: Helper to create a KitchenTicket for testing.
/// VI: Helper de tao KitchenTicket de test.
///
private KitchenTicket CreateTicket(Guid? productId = null, int quantity = 1)
{
return new KitchenTicket(
_sessionId,
Guid.NewGuid(),
productId ?? _productId,
"Pho Bo",
quantity,
"Kitchen",
0);
}
///
/// EN: Helper to create a Session for testing.
/// VI: Helper de tao Session de test.
///
private Session CreateSession()
{
return new Session(Guid.NewGuid(), _shopId, 2);
}
///
/// EN: Helper to create a Recipe with linked ingredients.
/// VI: Helper de tao Recipe voi nguyen lieu co lien ket.
///
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;
}
///
/// EN: Helper to create a Recipe with no linked inventory items.
/// VI: Helper de tao Recipe khong co lien ket den inventory items.
///
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()))
.ReturnsAsync(session);
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny()))
.ReturnsAsync(recipe);
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()))
.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(req =>
req.ShopId == _shopId &&
req.ReferenceId == ticket.Id &&
req.ReferenceType == "KitchenTicket" &&
req.Items.Count == 2),
It.IsAny()),
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()))
.ReturnsAsync(session);
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny()))
.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(), It.IsAny()),
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()))
.ReturnsAsync(session);
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny()))
.ReturnsAsync(recipe);
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()))
.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(), It.IsAny()),
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()))
.ReturnsAsync(session);
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny()))
.ReturnsAsync(recipe);
DeductInventoryRequest? capturedRequest = null;
_inventoryClientMock.Setup(c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()))
.Callback((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()))
.ReturnsAsync(session);
_recipeRepositoryMock.Setup(r => r.GetByProductIdAndShopAsync(_productId, _shopId, It.IsAny()))
.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(), It.IsAny()),
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()))
.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(), It.IsAny(), It.IsAny()),
Times.Never);
_inventoryClientMock.Verify(
c => c.DeductInventoryAsync(It.IsAny(), It.IsAny()),
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()))
.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()
.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()
.And.ParamName.Should().Be("inventoryClient");
}
}