diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 49bd760..e67fec9 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -70,6 +70,11 @@ using SIGCM2.Application.Rubros.Dtos; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Avisos; using SIGCM2.Application.Products; +using SIGCM2.Application.Products.Create; +using SIGCM2.Application.Products.Update; +using SIGCM2.Application.Products.Deactivate; +using SIGCM2.Application.Products.GetById; +using SIGCM2.Application.Products.List; using SIGCM2.Application.ProductTypes.Create; using SIGCM2.Application.ProductTypes.Update; using SIGCM2.Application.ProductTypes.Deactivate; @@ -171,6 +176,13 @@ public static class DependencyInjection services.AddScoped>, GetRubroTreeQueryHandler>(); services.AddScoped, GetRubroByIdQueryHandler>(); + // Products (PRD-002) + services.AddScoped, CreateProductCommandHandler>(); + services.AddScoped, UpdateProductCommandHandler>(); + services.AddScoped, DeactivateProductCommandHandler>(); + services.AddScoped, GetProductByIdQueryHandler>(); + services.AddScoped>, ListProductsQueryHandler>(); + // ProductTypes (PRD-001) // PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product. services.AddScoped(); diff --git a/src/api/SIGCM2.Application/Products/Create/CreateProductCommandHandler.cs b/src/api/SIGCM2.Application/Products/Create/CreateProductCommandHandler.cs new file mode 100644 index 0000000..cb47215 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Create/CreateProductCommandHandler.cs @@ -0,0 +1,112 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.Create; + +public sealed class CreateProductCommandHandler + : ICommandHandler +{ + private readonly IProductRepository _repo; + private readonly IProductTypeRepository _ptRepo; + private readonly IMedioRepository _medioRepo; + private readonly IRubroRepository _rubroRepo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public CreateProductCommandHandler( + IProductRepository repo, + IProductTypeRepository ptRepo, + IMedioRepository medioRepo, + IRubroRepository rubroRepo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _ptRepo = ptRepo; + _medioRepo = medioRepo; + _rubroRepo = rubroRepo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(CreateProductCommand command) + { + // 1. Validate Medio exists and is active + var medio = await _medioRepo.GetByIdAsync(command.MedioId) + ?? throw new MedioNotFoundException(command.MedioId); + if (!medio.Activo) + throw new MedioInactivoException(command.MedioId); + + // 2. Validate ProductType exists and is active + var productType = await _ptRepo.GetByIdAsync(command.ProductTypeId) + ?? throw new ProductTypeNotFoundException(command.ProductTypeId); + if (!productType.IsActive) + throw new ProductTypeInactivoException(command.ProductTypeId); + + // 3. Flags coherence: RequiresCategory → RubroId required + if (productType.RequiresCategory && !command.RubroId.HasValue) + throw new ProductTipoFlagsIncoherentesException( + $"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId"); + + // 4. Flags coherence: HasDuration → PriceDurationDays required + if (productType.HasDuration && !command.PriceDurationDays.HasValue) + throw new ProductTipoFlagsIncoherentesException( + $"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays"); + + // 5. Validate Rubro if provided: must be active + if (command.RubroId.HasValue) + { + var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value); + if (rubro == null || !rubro.Activo) + throw new RubroInactivoException(command.RubroId.Value); + } + + // 6. Duplicate nombre check (filtered on IsActive=1 — allows reuse after soft-delete) + var exists = await _repo.ExistsByNombreAsync(command.Nombre, command.MedioId, command.ProductTypeId, excludeId: null); + if (exists) + throw new ProductNombreDuplicadoEnMedioTipoException(command.MedioId, command.ProductTypeId, command.Nombre); + + // 7. Build entity + var entity = Product.ForCreation( + command.Nombre, command.MedioId, command.ProductTypeId, + command.RubroId, command.BasePrice, command.PriceDurationDays, + _timeProvider); + + // 8. Persist + audit (fail-closed) + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + var newId = await _repo.AddAsync(entity); + + await _audit.LogAsync( + action: "producto.created", + targetType: "Product", + targetId: newId.ToString(), + metadata: new + { + after = new + { + entity.Nombre, + entity.MedioId, + entity.ProductTypeId, + entity.RubroId, + entity.BasePrice, + entity.PriceDurationDays, + } + }); + + tx.Complete(); + + return new ProductCreatedDto( + newId, entity.Nombre, + entity.MedioId, entity.ProductTypeId, entity.RubroId, + entity.BasePrice, entity.PriceDurationDays, + entity.IsActive, entity.FechaCreacion); + } +} diff --git a/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommandHandler.cs b/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommandHandler.cs new file mode 100644 index 0000000..87d2672 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommandHandler.cs @@ -0,0 +1,62 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.Deactivate; + +public sealed class DeactivateProductCommandHandler + : ICommandHandler +{ + private readonly IProductRepository _repo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public DeactivateProductCommandHandler( + IProductRepository repo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(DeactivateProductCommand command) + { + // 1. Load entity + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new ProductNotFoundException(command.Id); + + // 2. Idempotent: already inactive → return without side effects + if (!target.IsActive) + return new ProductStatusDto(command.Id, false, target.FechaModificacion); + + // 3. Deactivate (immutable) + var deactivated = target.WithDeactivated(_timeProvider); + + // 4. Persist + audit (fail-closed) + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(deactivated); + + await _audit.LogAsync( + action: "producto.deactivated", + targetType: "Product", + targetId: command.Id.ToString(), + metadata: new + { + productId = command.Id, + nombre = target.Nombre, + }); + + tx.Complete(); + + return new ProductStatusDto(deactivated.Id, deactivated.IsActive, deactivated.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQueryHandler.cs b/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQueryHandler.cs new file mode 100644 index 0000000..101c7c0 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQueryHandler.cs @@ -0,0 +1,29 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.GetById; + +public sealed class GetProductByIdQueryHandler + : ICommandHandler +{ + private readonly IProductRepository _repo; + + public GetProductByIdQueryHandler(IProductRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetProductByIdQuery query) + { + var product = await _repo.GetByIdAsync(query.Id) + ?? throw new ProductNotFoundException(query.Id); + + return new ProductDetailDto( + product.Id, product.Nombre, + product.MedioId, product.ProductTypeId, product.RubroId, + product.BasePrice, product.PriceDurationDays, + product.IsActive, + product.FechaCreacion, product.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Products/List/ListProductsQueryHandler.cs b/src/api/SIGCM2.Application/Products/List/ListProductsQueryHandler.cs new file mode 100644 index 0000000..91aeb2a --- /dev/null +++ b/src/api/SIGCM2.Application/Products/List/ListProductsQueryHandler.cs @@ -0,0 +1,35 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Products.List; + +public sealed class ListProductsQueryHandler + : ICommandHandler> +{ + private readonly IProductRepository _repo; + + public ListProductsQueryHandler(IProductRepository repo) + { + _repo = repo; + } + + public async Task> Handle(ListProductsQuery query) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + + var repoQuery = new ProductsQuery( + page, pageSize, query.Activo, query.Search, + query.MedioId, query.ProductTypeId, query.RubroId); + var paged = await _repo.GetPagedAsync(repoQuery); + + var items = paged.Items.Select(p => new ProductListItemDto( + p.Id, p.Nombre, + p.MedioId, p.ProductTypeId, p.RubroId, + p.BasePrice, p.PriceDurationDays, + p.IsActive)).ToList(); + + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandHandler.cs b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandHandler.cs new file mode 100644 index 0000000..5742c28 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandHandler.cs @@ -0,0 +1,97 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.Update; + +public sealed class UpdateProductCommandHandler + : ICommandHandler +{ + private readonly IProductRepository _repo; + private readonly IProductTypeRepository _ptRepo; + private readonly IRubroRepository _rubroRepo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public UpdateProductCommandHandler( + IProductRepository repo, + IProductTypeRepository ptRepo, + IRubroRepository rubroRepo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _ptRepo = ptRepo; + _rubroRepo = rubroRepo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(UpdateProductCommand command) + { + // 1. Load entity + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new ProductNotFoundException(command.Id); + + // 2. Load ProductType (MedioId + ProductTypeId are immutable post-creation) + var productType = await _ptRepo.GetByIdAsync(target.ProductTypeId) + ?? throw new ProductTypeNotFoundException(target.ProductTypeId); + + // 3. Flags coherence + if (productType.RequiresCategory && !command.RubroId.HasValue) + throw new ProductTipoFlagsIncoherentesException( + $"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId"); + + if (productType.HasDuration && !command.PriceDurationDays.HasValue) + throw new ProductTipoFlagsIncoherentesException( + $"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays"); + + // 4. Validate Rubro if provided: must be active + if (command.RubroId.HasValue) + { + var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value); + if (rubro == null || !rubro.Activo) + throw new RubroInactivoException(command.RubroId.Value); + } + + // 5. Duplicate nombre check (skip if name unchanged — optimization) + if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase)) + { + var exists = await _repo.ExistsByNombreAsync(command.Nombre, target.MedioId, target.ProductTypeId, excludeId: command.Id); + if (exists) + throw new ProductNombreDuplicadoEnMedioTipoException(target.MedioId, target.ProductTypeId, command.Nombre); + } + + // 6. Apply mutation (immutable) + var updated = target.WithUpdated(command.Nombre, command.RubroId, command.BasePrice, command.PriceDurationDays, _timeProvider); + + // 7. Persist + audit + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "producto.updated", + targetType: "Product", + targetId: command.Id.ToString(), + metadata: new + { + before = new { target.Nombre, target.RubroId, target.BasePrice, target.PriceDurationDays }, + after = new { updated.Nombre, updated.RubroId, updated.BasePrice, updated.PriceDurationDays } + }); + + tx.Complete(); + + return new ProductUpdatedDto( + updated.Id, updated.Nombre, + updated.MedioId, updated.ProductTypeId, updated.RubroId, + updated.BasePrice, updated.PriceDurationDays, + updated.IsActive, updated.FechaCreacion, updated.FechaModificacion); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs index cf91367..1f1f9e4 100644 --- a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -82,8 +82,9 @@ public class PermisoRepositoryTests : IAsyncLifetime // + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' // + V014 (ADM-009) adds 'administracion:fiscal:gestionar' // + V016 (CAT-001) adds 'catalogo:rubros:gestionar' - // + V017 (PRD-001) adds 'catalogo:tipos:gestionar' = 26 total - Assert.Equal(26, list.Count); + // + V017 (PRD-001) adds 'catalogo:tipos:gestionar' + // + V018 (PRD-002) adds 'catalogo:productos:gestionar' = 27 total + Assert.Equal(27, list.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs index 71fdfd7..dc21349 100644 --- a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -173,17 +173,18 @@ public class RolPermisoRepositoryTests : IAsyncLifetime // ── GetByRolCodigoAsync ────────────────────────────────────────────────── [Fact] - public async Task GetByRolCodigoAsync_Admin_Returns26Permisos() + public async Task GetByRolCodigoAsync_Admin_Returns27Permisos() { // admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006) // + 1 from V011 (ADM-001): 'administracion:secciones:gestionar' // + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar' // + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar' // + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar' - // + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' = 26 total + // + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' + // + 1 from V018 (PRD-002): 'catalogo:productos:gestionar' = 27 total var permisos = await _repository.GetByRolCodigoAsync("admin"); - Assert.Equal(26, permisos.Count); + Assert.Equal(27, permisos.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Products/Create/CreateProductCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Create/CreateProductCommandHandlerTests.cs new file mode 100644 index 0000000..83f8f8e --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Create/CreateProductCommandHandlerTests.cs @@ -0,0 +1,246 @@ +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()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Deactivate/DeactivateProductCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Deactivate/DeactivateProductCommandHandlerTests.cs new file mode 100644 index 0000000..1b08bef --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Deactivate/DeactivateProductCommandHandlerTests.cs @@ -0,0 +1,133 @@ +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()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/GetById/GetProductByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/GetById/GetProductByIdQueryHandlerTests.cs new file mode 100644 index 0000000..8cdec55 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/GetById/GetProductByIdQueryHandlerTests.cs @@ -0,0 +1,60 @@ +using FluentAssertions; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Products.GetById; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Products.GetById; + +/// +/// PRD-002 — GetProductByIdQueryHandler tests. +/// +public class GetProductByIdQueryHandlerTests +{ + private readonly IProductRepository _repo = Substitute.For(); + private readonly GetProductByIdQueryHandler _handler; + + public GetProductByIdQueryHandlerTests() + { + _handler = new GetProductByIdQueryHandler(_repo); + } + + private static Product AProduct(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); + + [Fact] + public async Task Handle_ExistingId_ReturnsMappedDto() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(AProduct(1)); + + var result = await _handler.Handle(new GetProductByIdQuery(1)); + + result.Id.Should().Be(1); + result.Nombre.Should().Be("Clasificado Estándar"); + result.MedioId.Should().Be(1); + result.ProductTypeId.Should().Be(2); + result.BasePrice.Should().Be(100.50m); + result.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Handle_NotFound_ThrowsProductNotFoundException() + { + _repo.GetByIdAsync(99, Arg.Any()).Returns((Product?)null); + + var act = async () => await _handler.Handle(new GetProductByIdQuery(99)); + + await act.Should().ThrowAsync() + .Where(e => e.ProductId == 99); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/List/ListProductsQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/List/ListProductsQueryHandlerTests.cs new file mode 100644 index 0000000..b4744dd --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/List/ListProductsQueryHandlerTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Products.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Products.List; + +/// +/// PRD-002 — ListProductsQueryHandler tests. +/// +public class ListProductsQueryHandlerTests +{ + private readonly IProductRepository _repo = Substitute.For(); + private readonly ListProductsQueryHandler _handler; + + public ListProductsQueryHandlerTests() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + _handler = new ListProductsQueryHandler(_repo); + } + + private static Product AProduct(int id = 1) => new( + id: id, + nombre: "Clasificado", + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 50m, + priceDurationDays: null, + isActive: true, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + fechaModificacion: null); + + [Fact] + public async Task Handle_EmptyPage_ReturnsPaged_WithZeroTotal() + { + var result = await _handler.Handle(new ListProductsQuery()); + + result.Total.Should().Be(0); + result.Items.Should().BeEmpty(); + } + + [Fact] + public async Task Handle_WithItems_ReturnsMappedListItemDtos() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([AProduct(1), AProduct(2)], 1, 20, 2)); + + var result = await _handler.Handle(new ListProductsQuery()); + + result.Items.Should().HaveCount(2); + result.Items[0].Id.Should().Be(1); + result.Items[1].Id.Should().Be(2); + } + + [Fact] + public async Task Handle_PageNormalization_ClampsPageSizeTo100() + { + await _handler.Handle(new ListProductsQuery(Page: 1, PageSize: 200)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.PageSize == 100), + Arg.Any()); + } + + [Fact] + public async Task Handle_PageBelowOne_NormalizesToOne() + { + await _handler.Handle(new ListProductsQuery(Page: -1)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } + + [Fact] + public async Task Handle_PassesFiltersToRepo() + { + await _handler.Handle(new ListProductsQuery(MedioId: 5, ProductTypeId: 3, RubroId: 7)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.MedioId == 5 && q.ProductTypeId == 3 && q.RubroId == 7), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Update/UpdateProductCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Update/UpdateProductCommandHandlerTests.cs new file mode 100644 index 0000000..8104953 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Update/UpdateProductCommandHandlerTests.cs @@ -0,0 +1,149 @@ +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()); + } +}