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