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.Deactivate; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; namespace SIGCM2.Application.Tests.Products.Deactivate; /// /// PRD-002 — DeactivateProductCommandHandler tests. /// public class DeactivateProductCommandHandlerTests { private readonly IProductRepository _repo = Substitute.For(); private readonly IAuditLogger _audit = Substitute.For(); private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 14, 0, 0, TimeSpan.Zero)); private readonly DeactivateProductCommandHandler _handler; public DeactivateProductCommandHandlerTests() { _handler = new DeactivateProductCommandHandler(_repo, _audit, _time); } private static Product ActiveProduct(int id = 1) => new( id: id, nombre: "Clasificado Estándar", medioId: 1, productTypeId: 2, rubroId: null, basePrice: 100.50m, priceDurationDays: null, isActive: true, fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); private static Product InactiveProduct(int id = 1) => new( id: id, nombre: "Clasificado Estándar", medioId: 1, productTypeId: 2, rubroId: null, basePrice: 100.50m, priceDurationDays: null, isActive: false, fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); // ── Not found ──────────────────────────────────────────────────────────── [Fact] public async Task Handle_NotFound_ThrowsProductNotFoundException() { _repo.GetByIdAsync(99, Arg.Any()).Returns((Product?)null); var act = async () => await _handler.Handle(new DeactivateProductCommand(99)); await act.Should().ThrowAsync() .Where(e => e.ProductId == 99); } // ── Already inactive (idempotent) ───────────────────────────────────────── [Fact] public async Task Handle_AlreadyInactive_ReturnsDto_NoAudit_NoRepoUpdate() { _repo.GetByIdAsync(1, Arg.Any()).Returns(InactiveProduct()); var result = await _handler.Handle(new DeactivateProductCommand(1)); result.Id.Should().Be(1); result.IsActive.Should().BeFalse(); await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); await _audit.DidNotReceive().LogAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } // ── Happy path ─────────────────────────────────────────────────────────── [Fact] public async Task Handle_ActiveProduct_DeactivatesAndAudits() { _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveProduct()); await _handler.Handle(new DeactivateProductCommand(1)); await _repo.Received(1).UpdateAsync( Arg.Is(p => !p.IsActive), Arg.Any()); await _audit.Received(1).LogAsync( action: "producto.deactivated", targetType: "Product", targetId: "1", metadata: Arg.Any(), ct: Arg.Any()); } [Fact] public async Task Handle_UsesTimeProviderInDeactivate() { _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveProduct()); var expectedDate = _time.GetUtcNow().UtcDateTime; await _handler.Handle(new DeactivateProductCommand(1)); await _repo.Received(1).UpdateAsync( Arg.Is(p => p.FechaModificacion == expectedDate), Arg.Any()); } [Fact] public async Task Handle_ReturnsDtoWithIsActiveFalse() { _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveProduct()); var result = await _handler.Handle(new DeactivateProductCommand(1)); result.IsActive.Should().BeFalse(); } // ── Rollback ────────────────────────────────────────────────────────────── [Fact] public async Task Handle_RepoThrows_AuditNotCalled() { _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveProduct()); _repo.UpdateAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("DB error")); var act = async () => await _handler.Handle(new DeactivateProductCommand(1)); await act.Should().ThrowAsync(); await _audit.DidNotReceive().LogAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } }