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.Create; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; namespace SIGCM2.Application.Tests.Products.Create; /// /// PRD-002 — CreateProductCommandHandler tests. /// Covers: happy path, flags coherence, duplicate nombre, inactive Medio/ProductType/Rubro, /// audit, immutability, rollback. /// public class CreateProductCommandHandlerTests { private readonly IProductRepository _repo = Substitute.For(); private readonly IProductTypeRepository _ptRepo = Substitute.For(); private readonly IMedioRepository _medioRepo = 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 CreateProductCommandHandler _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 readonly ProductType _activePtHasDuration = new( id: 4, nombre: "Con Duración", hasDuration: true, 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 _inactivePt = new( id: 5, nombre: "Inactivo", hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false, allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null, isActive: false, fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); private static Medio ActiveMedio(int id = 1) => new( id: id, codigo: "ELD", nombre: "El Día", tipo: TipoMedio.Diario, plataformaEmpresaId: null, activo: true, fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); private static Medio InactiveMedio(int id = 1) => new( id: id, codigo: "ELD", nombre: "El Día", tipo: TipoMedio.Diario, plataformaEmpresaId: null, activo: false, fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); private static Rubro ActiveRubro(int id = 10) => new( id: id, parentId: null, nombre: "Clasificados", orden: 1, activo: true, tarifarioBaseId: null, fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); private static Rubro InactiveRubro(int id = 10) => new( id: id, parentId: null, nombre: "Clasificados", orden: 1, activo: false, tarifarioBaseId: null, fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); public CreateProductCommandHandlerTests() { _medioRepo.GetByIdAsync(1, Arg.Any()).Returns(ActiveMedio(1)); _ptRepo.GetByIdAsync(2, Arg.Any()).Returns(_activePtNoFlags); _repo.ExistsByNombreAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(1); _handler = new CreateProductCommandHandler(_repo, _ptRepo, _medioRepo, _rubroRepo, _audit, _time); } private static CreateProductCommand ValidCmd(int productTypeId = 2) => new( Nombre: "Clasificado Estándar", MedioId: 1, ProductTypeId: productTypeId, RubroId: null, BasePrice: 100.50m, PriceDurationDays: null); // ── Happy path ─────────────────────────────────────────────────────────── [Fact] public async Task Handle_ValidCommand_InsertsAndReturnsDto() { _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(42); var result = await _handler.Handle(ValidCmd()); result.Id.Should().Be(42); result.Nombre.Should().Be("Clasificado Estándar"); result.IsActive.Should().BeTrue(); } [Fact] public async Task Handle_ValidCommand_LogsAuditEvent_ProductoCreated() { _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(7); await _handler.Handle(ValidCmd()); await _audit.Received(1).LogAsync( action: "producto.created", targetType: "Product", targetId: "7", metadata: Arg.Any(), ct: Arg.Any()); } [Fact] public async Task Handle_UsesTimeProvider_NotDateTimeNow() { var expectedDate = _time.GetUtcNow().UtcDateTime; await _handler.Handle(ValidCmd()); await _repo.Received(1).AddAsync( Arg.Is(p => p.FechaCreacion == expectedDate), Arg.Any()); } // ── Medio not found / inactive ──────────────────────────────────────────── [Fact] public async Task Handle_MedioNotFound_ThrowsMedioNotFoundException() { _medioRepo.GetByIdAsync(1, Arg.Any()).Returns((Medio?)null); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync(); } [Fact] public async Task Handle_MedioInactivo_ThrowsMedioInactivoException() { _medioRepo.GetByIdAsync(1, Arg.Any()).Returns(InactiveMedio(1)); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync(); } // ── ProductType not found / inactive ────────────────────────────────────── [Fact] public async Task Handle_ProductTypeNotFound_ThrowsProductTypeNotFoundException() { _ptRepo.GetByIdAsync(2, Arg.Any()).Returns((ProductType?)null); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync(); } [Fact] public async Task Handle_ProductTypeInactivo_ThrowsProductTypeInactivoException() { _ptRepo.GetByIdAsync(5, Arg.Any()).Returns(_inactivePt); _medioRepo.GetByIdAsync(1, Arg.Any()).Returns(ActiveMedio(1)); var act = async () => await _handler.Handle(ValidCmd(productTypeId: 5)); await act.Should().ThrowAsync(); } // ── Flags coherence ─────────────────────────────────────────────────────── [Fact] public async Task Handle_RequiresCategoryTrue_RubroIdNull_ThrowsFlagsException() { _ptRepo.GetByIdAsync(3, Arg.Any()).Returns(_activePtRequiresCategory); var cmd = ValidCmd(productTypeId: 3) with { RubroId = null }; var act = async () => await _handler.Handle(cmd); await act.Should().ThrowAsync(); } [Fact] public async Task Handle_HasDurationTrue_PriceDurationDaysNull_ThrowsFlagsException() { _ptRepo.GetByIdAsync(4, Arg.Any()).Returns(_activePtHasDuration); var cmd = ValidCmd(productTypeId: 4) with { PriceDurationDays = null }; var act = async () => await _handler.Handle(cmd); await act.Should().ThrowAsync(); } // ── Rubro validation ────────────────────────────────────────────────────── [Fact] public async Task Handle_RubroProvided_RubroInactivo_ThrowsRubroInactivoException() { _ptRepo.GetByIdAsync(3, Arg.Any()).Returns(_activePtRequiresCategory); _rubroRepo.GetByIdAsync(10, Arg.Any()).Returns(InactiveRubro(10)); var cmd = ValidCmd(productTypeId: 3) with { RubroId = 10 }; var act = async () => await _handler.Handle(cmd); await act.Should().ThrowAsync(); } // ── Duplicate nombre ────────────────────────────────────────────────────── [Fact] public async Task Handle_NombreDuplicadoEnMedioTipo_ThrowsProductNombreDuplicadoException() { _repo.ExistsByNombreAsync("Clasificado Estándar", 1, 2, null, Arg.Any()).Returns(true); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync(); } // ── Rollback ────────────────────────────────────────────────────────────── [Fact] public async Task Handle_RepoThrows_AuditNotCalled_TransactionRollback() { _repo.AddAsync(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()); } }