using FluentAssertions; using Microsoft.Extensions.Time.Testing; using NSubstitute; using NSubstitute.ExceptionExtensions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Audit; using SIGCM2.Application.Products.Update; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; namespace SIGCM2.Application.Tests.Products.Update; /// /// PRD-002 — UpdateProductCommandHandler tests. /// public class UpdateProductCommandHandlerTests { private readonly IProductRepository _repo = Substitute.For(); private readonly IProductTypeRepository _ptRepo = Substitute.For(); private readonly IRubroRepository _rubroRepo = Substitute.For(); private readonly IAuditLogger _audit = Substitute.For(); private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero)); private readonly UpdateProductCommandHandler _handler; private static readonly ProductType _activePtNoFlags = new( id: 2, nombre: "Clasificado", hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false, allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null, isActive: true, fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); private static readonly ProductType _activePtRequiresCategory = new( id: 3, nombre: "Con Rubro", hasDuration: false, requiresText: false, requiresCategory: true, isBundle: false, allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null, isActive: true, fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); private static Product AProduct(int id = 1, int productTypeId = 2) => new( id: id, nombre: "Clasificado Estándar", medioId: 1, productTypeId: productTypeId, rubroId: null, basePrice: 100.50m, priceDurationDays: null, isActive: true, fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); public UpdateProductCommandHandlerTests() { _repo.GetByIdAsync(1, Arg.Any()).Returns(AProduct(1)); _ptRepo.GetByIdAsync(2, Arg.Any()).Returns(_activePtNoFlags); _repo.ExistsByNombreAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); _handler = new UpdateProductCommandHandler(_repo, _ptRepo, _rubroRepo, _audit, _time); } private static UpdateProductCommand ValidCmd() => new( Id: 1, Nombre: "Nuevo Nombre", RubroId: null, BasePrice: 200m, PriceDurationDays: null); // ── Happy path ─────────────────────────────────────────────────────────── [Fact] public async Task Handle_ValidCommand_UpdatesAndReturnsDto() { var result = await _handler.Handle(ValidCmd()); result.Id.Should().Be(1); result.Nombre.Should().Be("Nuevo Nombre"); result.BasePrice.Should().Be(200m); } [Fact] public async Task Handle_ValidCommand_CallsUpdateAsync() { await _handler.Handle(ValidCmd()); await _repo.Received(1).UpdateAsync( Arg.Is(p => p.Nombre == "Nuevo Nombre"), Arg.Any()); } [Fact] public async Task Handle_ValidCommand_LogsAuditEvent_ProductoUpdated() { await _handler.Handle(ValidCmd()); await _audit.Received(1).LogAsync( action: "producto.updated", targetType: "Product", targetId: "1", metadata: Arg.Any(), ct: Arg.Any()); } // ── Not found ──────────────────────────────────────────────────────────── [Fact] public async Task Handle_NotFound_ThrowsProductNotFoundException() { _repo.GetByIdAsync(99, Arg.Any()).Returns((Product?)null); var act = async () => await _handler.Handle(ValidCmd() with { Id = 99 }); await act.Should().ThrowAsync(); } // ── Flags coherence ─────────────────────────────────────────────────────── [Fact] public async Task Handle_RequiresCategoryTrue_RubroIdNull_ThrowsFlagsException() { _repo.GetByIdAsync(1, Arg.Any()).Returns(AProduct(1, productTypeId: 3)); _ptRepo.GetByIdAsync(3, Arg.Any()).Returns(_activePtRequiresCategory); var act = async () => await _handler.Handle(ValidCmd() with { RubroId = null }); await act.Should().ThrowAsync(); } // ── Duplicate nombre ────────────────────────────────────────────────────── [Fact] public async Task Handle_NombreDuplicado_ThrowsProductNombreDuplicadoException() { _repo.ExistsByNombreAsync("Nuevo Nombre", 1, 2, 1, Arg.Any()).Returns(true); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync(); } // ── Rollback ────────────────────────────────────────────────────────────── [Fact] public async Task Handle_RepoThrows_AuditNotCalled() { _repo.UpdateAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("DB error")); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync(); await _audit.DidNotReceive().LogAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } }