diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IMedioRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IMedioRepository.cs new file mode 100644 index 0000000..e641117 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IMedioRepository.cs @@ -0,0 +1,13 @@ +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IMedioRepository +{ + Task AddAsync(Medio m, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task ExistsByCodigoAsync(string codigo, CancellationToken ct = default); + Task UpdateAsync(Medio m, CancellationToken ct = default); + Task> GetPagedAsync(MediosQuery q, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/ISeccionRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/ISeccionRepository.cs new file mode 100644 index 0000000..6264d1e --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/ISeccionRepository.cs @@ -0,0 +1,13 @@ +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface ISeccionRepository +{ + Task AddAsync(Seccion s, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task ExistsByCodigoInMedioAsync(int medioId, string codigo, CancellationToken ct = default); + Task UpdateAsync(Seccion s, CancellationToken ct = default); + Task> GetPagedAsync(SeccionesQuery q, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Common/MediosQuery.cs b/src/api/SIGCM2.Application/Common/MediosQuery.cs new file mode 100644 index 0000000..c5f215f --- /dev/null +++ b/src/api/SIGCM2.Application/Common/MediosQuery.cs @@ -0,0 +1,12 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Common; + +/// Query parameters for listing medios with optional filters and paging. +public sealed record MediosQuery( + int Page, + int PageSize, + bool? Activo, + TipoMedio? Tipo, + string? Search +); diff --git a/src/api/SIGCM2.Application/Common/SeccionesQuery.cs b/src/api/SIGCM2.Application/Common/SeccionesQuery.cs new file mode 100644 index 0000000..35ce39b --- /dev/null +++ b/src/api/SIGCM2.Application/Common/SeccionesQuery.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Application.Common; + +/// Query parameters for listing secciones with optional filters and paging. +public sealed record SeccionesQuery( + int Page, + int PageSize, + int? MedioId, + string? Tipo, + bool? Activo, + string? Search +); diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index d71d9c2..fdf48d8 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -5,6 +5,12 @@ using SIGCM2.Application.Auth.Login; using SIGCM2.Application.Auth.Logout; using SIGCM2.Application.Auth.Refresh; using SIGCM2.Application.Common; +using SIGCM2.Application.Medios.Create; +using SIGCM2.Application.Medios.Deactivate; +using SIGCM2.Application.Medios.GetById; +using SIGCM2.Application.Medios.List; +using SIGCM2.Application.Medios.Reactivate; +using SIGCM2.Application.Medios.Update; using SIGCM2.Application.Permisos.Assign; using SIGCM2.Application.Permisos.Dtos; using SIGCM2.Application.Permisos.GetByRol; @@ -15,6 +21,12 @@ using SIGCM2.Application.Roles.Dtos; using SIGCM2.Application.Roles.Get; using SIGCM2.Application.Roles.List; using SIGCM2.Application.Roles.Update; +using SIGCM2.Application.Secciones.Create; +using SIGCM2.Application.Secciones.Deactivate; +using SIGCM2.Application.Secciones.GetById; +using SIGCM2.Application.Secciones.List; +using SIGCM2.Application.Secciones.Reactivate; +using SIGCM2.Application.Secciones.Update; using SIGCM2.Application.Usuarios.ChangeMyPassword; using SIGCM2.Application.Usuarios.Create; using SIGCM2.Application.Usuarios.Deactivate; @@ -62,6 +74,22 @@ public static class DependencyInjection services.AddScoped, GetUsuarioPermisosQueryHandler>(); services.AddScoped, UpdateUsuarioPermisosOverridesCommandHandler>(); + // Medios (ADM-001) + services.AddScoped, CreateMedioCommandHandler>(); + services.AddScoped, UpdateMedioCommandHandler>(); + services.AddScoped, DeactivateMedioCommandHandler>(); + services.AddScoped, ReactivateMedioCommandHandler>(); + services.AddScoped>, ListMediosQueryHandler>(); + services.AddScoped, GetMedioByIdQueryHandler>(); + + // Secciones (ADM-001) + services.AddScoped, CreateSeccionCommandHandler>(); + services.AddScoped, UpdateSeccionCommandHandler>(); + services.AddScoped, DeactivateSeccionCommandHandler>(); + services.AddScoped, ReactivateSeccionCommandHandler>(); + services.AddScoped>, ListSeccionesQueryHandler>(); + services.AddScoped, GetSeccionByIdQueryHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommand.cs new file mode 100644 index 0000000..4ca06ac --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommand.cs @@ -0,0 +1,9 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.Create; + +public sealed record CreateMedioCommand( + string Codigo, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId); diff --git a/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandHandler.cs new file mode 100644 index 0000000..48caf74 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandHandler.cs @@ -0,0 +1,63 @@ +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.Medios.Create; + +public sealed class CreateMedioCommandHandler : ICommandHandler +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public CreateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(CreateMedioCommand command) + { + var codigoNorm = command.Codigo.ToUpperInvariant(); + + var exists = await _repo.ExistsByCodigoAsync(codigoNorm); + if (exists) + throw new MedioCodigoDuplicadoException(codigoNorm); + + var medio = Medio.ForCreation(codigoNorm, command.Nombre, command.Tipo, command.PlataformaEmpresaId); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + var newId = await _repo.AddAsync(medio); + + await _audit.LogAsync( + action: "medio.create", + targetType: "Medio", + targetId: newId.ToString(), + metadata: new + { + after = new + { + medio.Codigo, + medio.Nombre, + medio.Tipo, + medio.PlataformaEmpresaId, + }, + }); + + tx.Complete(); + + return new MedioCreatedDto( + Id: newId, + Codigo: medio.Codigo, + Nombre: medio.Nombre, + Tipo: medio.Tipo, + PlataformaEmpresaId: medio.PlataformaEmpresaId, + Activo: medio.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandValidator.cs b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandValidator.cs new file mode 100644 index 0000000..e8de21b --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.Create; + +public sealed class CreateMedioCommandValidator : AbstractValidator +{ + private const int CodigoMaxLength = 30; + private const int NombreMaxLength = 100; + + public CreateMedioCommandValidator() + { + RuleFor(x => x.Codigo) + .NotEmpty().WithMessage("El código es requerido.") + .MaximumLength(CodigoMaxLength).WithMessage($"El código no puede superar los {CodigoMaxLength} caracteres.") + .Matches(@"^[A-Za-z0-9_]+$").WithMessage("El código solo puede contener letras, dígitos y guiones bajos."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Tipo) + .IsInEnum().WithMessage("El tipo de medio no es válido."); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs b/src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs new file mode 100644 index 0000000..e868a72 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs @@ -0,0 +1,11 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.Create; + +public sealed record MedioCreatedDto( + int Id, + string Codigo, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId, + bool Activo); diff --git a/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommand.cs new file mode 100644 index 0000000..d932353 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.Deactivate; + +public sealed record DeactivateMedioCommand(int Id); diff --git a/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs new file mode 100644 index 0000000..7b87564 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs @@ -0,0 +1,48 @@ +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.Medios.Deactivate; + +public sealed class DeactivateMedioCommandHandler : ICommandHandler +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public DeactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(DeactivateMedioCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new MedioNotFoundException(command.Id); + + // Idempotent: already inactive → return as-is without writing an audit event + if (!target.Activo) + return new MedioStatusDto(target.Id, target.Codigo, target.Activo); + + var updated = target.WithActivo(false); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "medio.deactivate", + targetType: "Medio", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new MedioStatusDto(updated.Id, updated.Codigo, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Deactivate/MedioStatusDto.cs b/src/api/SIGCM2.Application/Medios/Deactivate/MedioStatusDto.cs new file mode 100644 index 0000000..84d3044 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Deactivate/MedioStatusDto.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.Deactivate; + +public sealed record MedioStatusDto(int Id, string Codigo, bool Activo); diff --git a/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQuery.cs b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQuery.cs new file mode 100644 index 0000000..4ca1e0e --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.GetById; + +public sealed record GetMedioByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQueryHandler.cs b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQueryHandler.cs new file mode 100644 index 0000000..486aa50 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQueryHandler.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Medios.GetById; + +public sealed class GetMedioByIdQueryHandler : ICommandHandler +{ + private readonly IMedioRepository _repo; + + public GetMedioByIdQueryHandler(IMedioRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetMedioByIdQuery query) + { + var medio = await _repo.GetByIdAsync(query.Id) + ?? throw new MedioNotFoundException(query.Id); + + return new MedioDetailDto( + Id: medio.Id, + Codigo: medio.Codigo, + Nombre: medio.Nombre, + Tipo: medio.Tipo, + PlataformaEmpresaId: medio.PlataformaEmpresaId, + Activo: medio.Activo, + FechaCreacion: medio.FechaCreacion, + FechaModificacion: medio.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs b/src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs new file mode 100644 index 0000000..ff2b01f --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs @@ -0,0 +1,13 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.GetById; + +public sealed record MedioDetailDto( + int Id, + string Codigo, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs b/src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs new file mode 100644 index 0000000..08ce6a8 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs @@ -0,0 +1,10 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.List; + +public sealed record ListMediosQuery( + int Page = 1, + int PageSize = 20, + bool? Activo = true, + TipoMedio? Tipo = null, + string? Search = null); diff --git a/src/api/SIGCM2.Application/Medios/List/ListMediosQueryHandler.cs b/src/api/SIGCM2.Application/Medios/List/ListMediosQueryHandler.cs new file mode 100644 index 0000000..ac815c5 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/List/ListMediosQueryHandler.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.List; + +public sealed class ListMediosQueryHandler : ICommandHandler> +{ + private readonly IMedioRepository _repo; + + public ListMediosQueryHandler(IMedioRepository repo) + { + _repo = repo; + } + + public async Task> Handle(ListMediosQuery query) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + + var repoQuery = new MediosQuery(page, pageSize, query.Activo, query.Tipo, query.Search); + var paged = await _repo.GetPagedAsync(repoQuery); + + var items = paged.Items + .Select(m => new MedioListItemDto(m.Id, m.Codigo, m.Nombre, m.Tipo, m.PlataformaEmpresaId, m.Activo)) + .ToList(); + + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs b/src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs new file mode 100644 index 0000000..cd26052 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs @@ -0,0 +1,11 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.List; + +public sealed record MedioListItemDto( + int Id, + string Codigo, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId, + bool Activo); diff --git a/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommand.cs new file mode 100644 index 0000000..ef1c9aa --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.Reactivate; + +public sealed record ReactivateMedioCommand(int Id); diff --git a/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommandHandler.cs new file mode 100644 index 0000000..d8447f9 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommandHandler.cs @@ -0,0 +1,49 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Medios.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Medios.Reactivate; + +public sealed class ReactivateMedioCommandHandler : ICommandHandler +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public ReactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(ReactivateMedioCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new MedioNotFoundException(command.Id); + + // Idempotent: already active → return as-is without writing an audit event + if (target.Activo) + return new MedioStatusDto(target.Id, target.Codigo, target.Activo); + + var updated = target.WithActivo(true); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "medio.reactivate", + targetType: "Medio", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new MedioStatusDto(updated.Id, updated.Codigo, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs b/src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs new file mode 100644 index 0000000..c497a07 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs @@ -0,0 +1,11 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.Update; + +public sealed record MedioUpdatedDto( + int Id, + string Codigo, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId, + bool Activo); diff --git a/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommand.cs new file mode 100644 index 0000000..b676188 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommand.cs @@ -0,0 +1,9 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.Update; + +public sealed record UpdateMedioCommand( + int Id, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId); diff --git a/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandHandler.cs new file mode 100644 index 0000000..51e58fd --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandHandler.cs @@ -0,0 +1,55 @@ +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.Medios.Update; + +public sealed class UpdateMedioCommandHandler : ICommandHandler +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public UpdateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(UpdateMedioCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new MedioNotFoundException(command.Id); + + var updated = target.WithUpdatedProfile(command.Nombre, command.Tipo, command.PlataformaEmpresaId); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "medio.update", + targetType: "Medio", + targetId: command.Id.ToString(), + metadata: new + { + before = new { target.Nombre, target.Tipo, target.PlataformaEmpresaId }, + after = new { updated.Nombre, updated.Tipo, updated.PlataformaEmpresaId }, + }); + + tx.Complete(); + + return new MedioUpdatedDto( + Id: updated.Id, + Codigo: updated.Codigo, + Nombre: updated.Nombre, + Tipo: updated.Tipo, + PlataformaEmpresaId: updated.PlataformaEmpresaId, + Activo: updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandValidator.cs b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandValidator.cs new file mode 100644 index 0000000..a503fed --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace SIGCM2.Application.Medios.Update; + +public sealed class UpdateMedioCommandValidator : AbstractValidator +{ + private const int NombreMaxLength = 100; + + public UpdateMedioCommandValidator() + { + RuleFor(x => x.Id) + .GreaterThan(0).WithMessage("El id debe ser mayor a 0."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Tipo) + .IsInEnum().WithMessage("El tipo de medio no es válido."); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommand.cs new file mode 100644 index 0000000..f4ce080 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Secciones.Create; + +public sealed record CreateSeccionCommand( + int MedioId, + string Codigo, + string Nombre, + string Tipo); diff --git a/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandHandler.cs new file mode 100644 index 0000000..e8cb8e7 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandHandler.cs @@ -0,0 +1,68 @@ +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.Secciones.Create; + +public sealed class CreateSeccionCommandHandler : ICommandHandler +{ + private readonly ISeccionRepository _repo; + private readonly IMedioRepository _medioRepo; + private readonly IAuditLogger _audit; + + public CreateSeccionCommandHandler(ISeccionRepository repo, IMedioRepository medioRepo, IAuditLogger audit) + { + _repo = repo; + _medioRepo = medioRepo; + _audit = audit; + } + + public async Task Handle(CreateSeccionCommand command) + { + // Validate medio exists and is active (REQ-SEC-001) + var medio = await _medioRepo.GetByIdAsync(command.MedioId); + if (medio is null || !medio.Activo) + throw new MedioNotFoundException(command.MedioId); + + var exists = await _repo.ExistsByCodigoInMedioAsync(command.MedioId, command.Codigo); + if (exists) + throw new SeccionCodigoDuplicadoEnMedioException(command.MedioId, command.Codigo); + + var seccion = Seccion.ForCreation(command.MedioId, command.Codigo, command.Nombre, command.Tipo); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + var newId = await _repo.AddAsync(seccion); + + await _audit.LogAsync( + action: "seccion.create", + targetType: "Seccion", + targetId: newId.ToString(), + metadata: new + { + after = new + { + seccion.MedioId, + seccion.Codigo, + seccion.Nombre, + seccion.Tipo, + }, + }); + + tx.Complete(); + + return new SeccionCreatedDto( + Id: newId, + MedioId: seccion.MedioId, + Codigo: seccion.Codigo, + Nombre: seccion.Nombre, + Tipo: seccion.Tipo, + Activo: seccion.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandValidator.cs b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandValidator.cs new file mode 100644 index 0000000..db062e7 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Secciones.Create; + +public sealed class CreateSeccionCommandValidator : AbstractValidator +{ + private const int CodigoMaxLength = 30; + private const int NombreMaxLength = 100; + + public CreateSeccionCommandValidator() + { + RuleFor(x => x.MedioId) + .GreaterThan(0).WithMessage("El medioId debe ser mayor a 0."); + + RuleFor(x => x.Codigo) + .NotEmpty().WithMessage("El código es requerido.") + .MaximumLength(CodigoMaxLength).WithMessage($"El código no puede superar los {CodigoMaxLength} caracteres."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Tipo) + .NotEmpty().WithMessage("El tipo es requerido.") + .Must(t => TipoSeccion.AllowedTipos.Contains(t)) + .WithMessage($"El tipo debe ser uno de: {string.Join(", ", TipoSeccion.AllowedTipos)}."); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Create/SeccionCreatedDto.cs b/src/api/SIGCM2.Application/Secciones/Create/SeccionCreatedDto.cs new file mode 100644 index 0000000..ce35275 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/SeccionCreatedDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Secciones.Create; + +public sealed record SeccionCreatedDto( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo); diff --git a/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommand.cs new file mode 100644 index 0000000..8d052c0 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.Deactivate; + +public sealed record DeactivateSeccionCommand(int Id); diff --git a/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommandHandler.cs new file mode 100644 index 0000000..c77c9d9 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommandHandler.cs @@ -0,0 +1,48 @@ +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.Secciones.Deactivate; + +public sealed class DeactivateSeccionCommandHandler : ICommandHandler +{ + private readonly ISeccionRepository _repo; + private readonly IAuditLogger _audit; + + public DeactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(DeactivateSeccionCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new SeccionNotFoundException(command.Id); + + // Idempotent: already inactive → return as-is without writing an audit event + if (!target.Activo) + return new SeccionStatusDto(target.Id, target.Codigo, target.Activo); + + var updated = target.WithActivo(false); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "seccion.deactivate", + targetType: "Seccion", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new SeccionStatusDto(updated.Id, updated.Codigo, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Deactivate/SeccionStatusDto.cs b/src/api/SIGCM2.Application/Secciones/Deactivate/SeccionStatusDto.cs new file mode 100644 index 0000000..471db39 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Deactivate/SeccionStatusDto.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.Deactivate; + +public sealed record SeccionStatusDto(int Id, string Codigo, bool Activo); diff --git a/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQuery.cs b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQuery.cs new file mode 100644 index 0000000..05ceea7 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.GetById; + +public sealed record GetSeccionByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQueryHandler.cs b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQueryHandler.cs new file mode 100644 index 0000000..53017db --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQueryHandler.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Secciones.GetById; + +public sealed class GetSeccionByIdQueryHandler : ICommandHandler +{ + private readonly ISeccionRepository _repo; + + public GetSeccionByIdQueryHandler(ISeccionRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetSeccionByIdQuery query) + { + var seccion = await _repo.GetByIdAsync(query.Id) + ?? throw new SeccionNotFoundException(query.Id); + + return new SeccionDetailDto( + Id: seccion.Id, + MedioId: seccion.MedioId, + Codigo: seccion.Codigo, + Nombre: seccion.Nombre, + Tipo: seccion.Tipo, + Activo: seccion.Activo, + FechaCreacion: seccion.FechaCreacion, + FechaModificacion: seccion.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/GetById/SeccionDetailDto.cs b/src/api/SIGCM2.Application/Secciones/GetById/SeccionDetailDto.cs new file mode 100644 index 0000000..2362ab9 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/GetById/SeccionDetailDto.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Application.Secciones.GetById; + +public sealed record SeccionDetailDto( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQuery.cs b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQuery.cs new file mode 100644 index 0000000..92f7d7e --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQuery.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Secciones.List; + +public sealed record ListSeccionesQuery( + int Page = 1, + int PageSize = 20, + int? MedioId = null, + string? Tipo = null, + bool? Activo = true, + string? Search = null); diff --git a/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQueryHandler.cs b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQueryHandler.cs new file mode 100644 index 0000000..e894157 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQueryHandler.cs @@ -0,0 +1,30 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Secciones.List; + +public sealed class ListSeccionesQueryHandler : ICommandHandler> +{ + private readonly ISeccionRepository _repo; + + public ListSeccionesQueryHandler(ISeccionRepository repo) + { + _repo = repo; + } + + public async Task> Handle(ListSeccionesQuery query) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + + var repoQuery = new SeccionesQuery(page, pageSize, query.MedioId, query.Tipo, query.Activo, query.Search); + var paged = await _repo.GetPagedAsync(repoQuery); + + var items = paged.Items + .Select(s => new SeccionListItemDto(s.Id, s.MedioId, s.Codigo, s.Nombre, s.Tipo, s.Activo)) + .ToList(); + + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/List/SeccionListItemDto.cs b/src/api/SIGCM2.Application/Secciones/List/SeccionListItemDto.cs new file mode 100644 index 0000000..9e8569d --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/List/SeccionListItemDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Secciones.List; + +public sealed record SeccionListItemDto( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo); diff --git a/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommand.cs new file mode 100644 index 0000000..0bca214 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.Reactivate; + +public sealed record ReactivateSeccionCommand(int Id); diff --git a/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommandHandler.cs new file mode 100644 index 0000000..9d1dd5e --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommandHandler.cs @@ -0,0 +1,49 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Secciones.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Secciones.Reactivate; + +public sealed class ReactivateSeccionCommandHandler : ICommandHandler +{ + private readonly ISeccionRepository _repo; + private readonly IAuditLogger _audit; + + public ReactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(ReactivateSeccionCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new SeccionNotFoundException(command.Id); + + // Idempotent: already active → return as-is without writing an audit event + if (target.Activo) + return new SeccionStatusDto(target.Id, target.Codigo, target.Activo); + + var updated = target.WithActivo(true); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "seccion.reactivate", + targetType: "Seccion", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new SeccionStatusDto(updated.Id, updated.Codigo, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Update/SeccionUpdatedDto.cs b/src/api/SIGCM2.Application/Secciones/Update/SeccionUpdatedDto.cs new file mode 100644 index 0000000..4a4b25a --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/SeccionUpdatedDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Secciones.Update; + +public sealed record SeccionUpdatedDto( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo); diff --git a/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommand.cs new file mode 100644 index 0000000..85fbd74 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommand.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Secciones.Update; + +public sealed record UpdateSeccionCommand( + int Id, + string Nombre, + string Tipo); diff --git a/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandHandler.cs new file mode 100644 index 0000000..ca89608 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandHandler.cs @@ -0,0 +1,55 @@ +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.Secciones.Update; + +public sealed class UpdateSeccionCommandHandler : ICommandHandler +{ + private readonly ISeccionRepository _repo; + private readonly IAuditLogger _audit; + + public UpdateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(UpdateSeccionCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new SeccionNotFoundException(command.Id); + + var updated = target.WithUpdatedProfile(command.Nombre, command.Tipo); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "seccion.update", + targetType: "Seccion", + targetId: command.Id.ToString(), + metadata: new + { + before = new { target.Nombre, target.Tipo }, + after = new { updated.Nombre, updated.Tipo }, + }); + + tx.Complete(); + + return new SeccionUpdatedDto( + Id: updated.Id, + MedioId: updated.MedioId, + Codigo: updated.Codigo, + Nombre: updated.Nombre, + Tipo: updated.Tipo, + Activo: updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandValidator.cs b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandValidator.cs new file mode 100644 index 0000000..a101c7b --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Secciones.Update; + +public sealed class UpdateSeccionCommandValidator : AbstractValidator +{ + private const int NombreMaxLength = 100; + + public UpdateSeccionCommandValidator() + { + RuleFor(x => x.Id) + .GreaterThan(0).WithMessage("El id debe ser mayor a 0."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Tipo) + .NotEmpty().WithMessage("El tipo es requerido.") + .Must(t => TipoSeccion.AllowedTipos.Contains(t)) + .WithMessage($"El tipo debe ser uno de: {string.Join(", ", TipoSeccion.AllowedTipos)}."); + } +} diff --git a/src/api/SIGCM2.Domain/Entities/TipoSeccion.cs b/src/api/SIGCM2.Domain/Entities/TipoSeccion.cs new file mode 100644 index 0000000..de77036 --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/TipoSeccion.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Domain.Entities; + +/// +/// Allowed string values for Seccion.Tipo. +/// Enforced at application layer (validator) and database layer (CHECK constraint). +/// +public static class TipoSeccion +{ + public static readonly string[] AllowedTipos = { "clasificados", "notables", "suplementos" }; +} diff --git a/tests/SIGCM2.Application.Tests/Medios/Create/CreateMedioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/Create/CreateMedioCommandHandlerTests.cs new file mode 100644 index 0000000..5df54d0 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/Create/CreateMedioCommandHandlerTests.cs @@ -0,0 +1,113 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Medios.Create; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Medios.Create; + +public class CreateMedioCommandHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly CreateMedioCommandHandler _handler; + + private static CreateMedioCommand ValidCommand() => new( + Codigo: "DIARIO_LA_VOZ", + Nombre: "Diario La Voz", + Tipo: TipoMedio.Diario, + PlataformaEmpresaId: null); + + public CreateMedioCommandHandlerTests() + { + _handler = new CreateMedioCommandHandler(_repo, _audit); + _repo.ExistsByCodigoAsync(Arg.Any(), Arg.Any()).Returns(false); + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(1); + } + + // ── duplicate → throws ─────────────────────────────────────────────────── + + [Fact] + public async Task Handle_DuplicateCodigo_ThrowsMedioCodigoDuplicadoException() + { + _repo.ExistsByCodigoAsync("DIARIO_LA_VOZ", Arg.Any()).Returns(true); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_DuplicateCodigo_DoesNotCallAddAsync() + { + _repo.ExistsByCodigoAsync(Arg.Any(), Arg.Any()).Returns(true); + + try { await _handler.Handle(ValidCommand()); } catch (MedioCodigoDuplicadoException) { } + + await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(42); + + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(42, result.Id); + } + + [Fact] + public async Task Handle_HappyPath_DtoContainsCorrectFields() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(5); + + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal("DIARIO_LA_VOZ", result.Codigo); + Assert.Equal("Diario La Voz", result.Nombre); + Assert.Equal(TipoMedio.Diario, result.Tipo); + Assert.Null(result.PlataformaEmpresaId); + Assert.True(result.Activo); + } + + [Fact] + public async Task Handle_HappyPath_NormalizesCodigoToUppercase() + { + var cmd = new CreateMedioCommand("diario_la_voz", "Diario La Voz", TipoMedio.Diario, null); + + await _handler.Handle(cmd); + + await _repo.Received(1).AddAsync( + Arg.Is(m => m.Codigo == "DIARIO_LA_VOZ"), + Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditLogWithCreateAction() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(7); + + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "medio.create", + targetType: "Medio", + targetId: "7", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_ChecksNormalizedCodigoForDuplicate() + { + var cmd = new CreateMedioCommand("diario_la_voz", "Diario La Voz", TipoMedio.Diario, null); + + await _handler.Handle(cmd); + + // ExistsByCodigoAsync should be called with the uppercased version + await _repo.Received(1).ExistsByCodigoAsync("DIARIO_LA_VOZ", Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Medios/Deactivate/DeactivateMedioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/Deactivate/DeactivateMedioCommandHandlerTests.cs new file mode 100644 index 0000000..ff1c94f --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/Deactivate/DeactivateMedioCommandHandlerTests.cs @@ -0,0 +1,87 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Medios.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Medios.Deactivate; + +public class DeactivateMedioCommandHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly DeactivateMedioCommandHandler _handler; + + private static Medio MakeMedio(int id = 1, bool activo = true) + => new(id, "COD" + id, "Nombre", TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + public DeactivateMedioCommandHandlerTests() + { + _handler = new DeactivateMedioCommandHandler(_repo, _audit); + } + + // ── not found → throws ────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ThrowsMedioNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateMedioCommand(999))); + } + + // ── idempotent ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AlreadyInactive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, false)); + + await _handler.Handle(new DeactivateMedioCommand(1)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyInactive_DoesNotWriteAuditEvent() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, false)); + + await _handler.Handle(new DeactivateMedioCommand(1)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ActiveMedio_CallsUpdateAsyncWithInactiveEntity() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, true)); + + await _handler.Handle(new DeactivateMedioCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(m => !m.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_ActiveMedio_WritesAuditWithDeactivateAction() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, true)); + + await _handler.Handle(new DeactivateMedioCommand(1)); + + await _audit.Received(1).LogAsync( + action: "medio.deactivate", + targetType: "Medio", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Medios/GetById/GetMedioByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/GetById/GetMedioByIdQueryHandlerTests.cs new file mode 100644 index 0000000..637e9ec --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/GetById/GetMedioByIdQueryHandlerTests.cs @@ -0,0 +1,46 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Medios.GetById; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Medios.GetById; + +public class GetMedioByIdQueryHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly GetMedioByIdQueryHandler _handler; + + public GetMedioByIdQueryHandlerTests() + { + _handler = new GetMedioByIdQueryHandler(_repo); + } + + private static Medio MakeMedio(int id) => + new(id, "COD" + id, "Nombre " + id, TipoMedio.Radio, 10, true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_NotFound_ThrowsMedioNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new GetMedioByIdQuery(999))); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithCorrectFields() + { + var medio = MakeMedio(3); + _repo.GetByIdAsync(3, Arg.Any()).Returns(medio); + + var result = await _handler.Handle(new GetMedioByIdQuery(3)); + + Assert.Equal(3, result.Id); + Assert.Equal("COD3", result.Codigo); + Assert.Equal("Nombre 3", result.Nombre); + Assert.Equal(TipoMedio.Radio, result.Tipo); + Assert.Equal(10, result.PlataformaEmpresaId); + Assert.True(result.Activo); + } +} diff --git a/tests/SIGCM2.Application.Tests/Medios/List/ListMediosQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/List/ListMediosQueryHandlerTests.cs new file mode 100644 index 0000000..e60c5e1 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/List/ListMediosQueryHandlerTests.cs @@ -0,0 +1,65 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Medios.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Medios.List; + +public class ListMediosQueryHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly ListMediosQueryHandler _handler; + + public ListMediosQueryHandlerTests() + { + _handler = new ListMediosQueryHandler(_repo); + } + + private static Medio MakeMedio(int id) => + new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_ReturnsPagedDtoItems() + { + var items = new List { MakeMedio(1), MakeMedio(2) }; + var pagedResult = new PagedResult(items, 1, 20, 2); + + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(pagedResult); + + var query = new ListMediosQuery(1, 20, null, null, null); + var result = await _handler.Handle(query); + + Assert.Equal(2, result.Total); + Assert.Equal(2, result.Items.Count); + Assert.Equal("COD1", result.Items[0].Codigo); + Assert.Equal("COD2", result.Items[1].Codigo); + } + + [Fact] + public async Task Handle_ClampsPageSizeToMax100() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 100, 0)); + + await _handler.Handle(new ListMediosQuery(1, 999, null, null, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.PageSize == 100), + Arg.Any()); + } + + [Fact] + public async Task Handle_ClampsPageToMin1() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + await _handler.Handle(new ListMediosQuery(0, 20, null, null, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Medios/Reactivate/ReactivateMedioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/Reactivate/ReactivateMedioCommandHandlerTests.cs new file mode 100644 index 0000000..ac7dd12 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/Reactivate/ReactivateMedioCommandHandlerTests.cs @@ -0,0 +1,88 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Medios.Deactivate; +using SIGCM2.Application.Medios.Reactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Medios.Reactivate; + +public class ReactivateMedioCommandHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly ReactivateMedioCommandHandler _handler; + + private static Medio MakeMedio(int id = 1, bool activo = false) + => new(id, "COD" + id, "Nombre", TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + public ReactivateMedioCommandHandlerTests() + { + _handler = new ReactivateMedioCommandHandler(_repo, _audit); + } + + // ── not found → throws ────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ThrowsMedioNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ReactivateMedioCommand(999))); + } + + // ── idempotent ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AlreadyActive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, true)); + + await _handler.Handle(new ReactivateMedioCommand(1)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyActive_DoesNotWriteAuditEvent() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, true)); + + await _handler.Handle(new ReactivateMedioCommand(1)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_InactiveMedio_CallsUpdateAsyncWithActiveEntity() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, false)); + + await _handler.Handle(new ReactivateMedioCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(m => m.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_InactiveMedio_WritesAuditWithReactivateAction() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, false)); + + await _handler.Handle(new ReactivateMedioCommand(1)); + + await _audit.Received(1).LogAsync( + action: "medio.reactivate", + targetType: "Medio", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Medios/Update/UpdateMedioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/Update/UpdateMedioCommandHandlerTests.cs new file mode 100644 index 0000000..e96aa4c --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/Update/UpdateMedioCommandHandlerTests.cs @@ -0,0 +1,83 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Medios.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Medios.Update; + +public class UpdateMedioCommandHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly UpdateMedioCommandHandler _handler; + + private static Medio MakeMedio(int id = 1, string nombre = "Original", TipoMedio tipo = TipoMedio.Diario, bool activo = true) + => new(id, "COD" + id, nombre, tipo, null, activo, DateTime.UtcNow, null); + + private static UpdateMedioCommand ValidCommand(int id = 1) => new( + Id: id, + Nombre: "Nuevo Nombre", + Tipo: TipoMedio.Radio, + PlataformaEmpresaId: 5); + + public UpdateMedioCommandHandlerTests() + { + _handler = new UpdateMedioCommandHandler(_repo, _audit); + } + + // ── not found → throws ────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ThrowsMedioNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new UpdateMedioCommand(999, "X", TipoMedio.Diario, null))); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_CallsUpdateAsyncOnce() + { + var medio = MakeMedio(1); + _repo.GetByIdAsync(1, Arg.Any()).Returns(medio); + + await _handler.Handle(ValidCommand(1)); + + await _repo.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithUpdatedFields() + { + var medio = MakeMedio(1, "Original", TipoMedio.Diario); + _repo.GetByIdAsync(1, Arg.Any()).Returns(medio); + + var result = await _handler.Handle(ValidCommand(1)); + + Assert.Equal(1, result.Id); + Assert.Equal("Nuevo Nombre", result.Nombre); + Assert.Equal(TipoMedio.Radio, result.Tipo); + Assert.Equal(5, result.PlataformaEmpresaId); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithUpdateAction() + { + var medio = MakeMedio(1); + _repo.GetByIdAsync(1, Arg.Any()).Returns(medio); + + await _handler.Handle(ValidCommand(1)); + + await _audit.Received(1).LogAsync( + action: "medio.update", + targetType: "Medio", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/Create/CreateSeccionCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/Create/CreateSeccionCommandHandlerTests.cs new file mode 100644 index 0000000..b1646e6 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/Create/CreateSeccionCommandHandlerTests.cs @@ -0,0 +1,113 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Secciones.Create; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Secciones.Create; + +public class CreateSeccionCommandHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly IMedioRepository _medioRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly CreateSeccionCommandHandler _handler; + + private static Medio MakeMedio(int id = 1, bool activo = true) => + new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + private static CreateSeccionCommand ValidCommand() => new( + MedioId: 1, + Codigo: "CLASIFICADOS_LUNES", + Nombre: "Clasificados Lunes", + Tipo: "clasificados"); + + public CreateSeccionCommandHandlerTests() + { + _handler = new CreateSeccionCommandHandler(_repo, _medioRepo, _audit); + _medioRepo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1)); + _repo.ExistsByCodigoInMedioAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(10); + } + + // ── medio not found → throws ───────────────────────────────────────────── + + [Fact] + public async Task Handle_MedioNotFound_ThrowsMedioNotFoundException() + { + _medioRepo.GetByIdAsync(1, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + // ── duplicate codigo in medio → throws ────────────────────────────────── + + [Fact] + public async Task Handle_DuplicateCodigoInMedio_ThrowsSeccionCodigoDuplicadoEnMedioException() + { + _repo.ExistsByCodigoInMedioAsync(1, "CLASIFICADOS_LUNES", Arg.Any()).Returns(true); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_DuplicateCodigo_DoesNotCallAddAsync() + { + _repo.ExistsByCodigoInMedioAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + + try { await _handler.Handle(ValidCommand()); } catch (SeccionCodigoDuplicadoEnMedioException) { } + + await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(10); + + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(10, result.Id); + } + + [Fact] + public async Task Handle_HappyPath_DtoContainsCorrectFields() + { + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(1, result.MedioId); + Assert.Equal("CLASIFICADOS_LUNES", result.Codigo); + Assert.Equal("Clasificados Lunes", result.Nombre); + Assert.Equal("clasificados", result.Tipo); + Assert.True(result.Activo); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithCreateAction() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(10); + + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "seccion.create", + targetType: "Seccion", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_InactiveMedio_ThrowsMedioNotFoundException() + { + _medioRepo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, false)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/Deactivate/DeactivateSeccionCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/Deactivate/DeactivateSeccionCommandHandlerTests.cs new file mode 100644 index 0000000..fe72a1f --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/Deactivate/DeactivateSeccionCommandHandlerTests.cs @@ -0,0 +1,81 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Secciones.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Secciones.Deactivate; + +public class DeactivateSeccionCommandHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly DeactivateSeccionCommandHandler _handler; + + private static Seccion MakeSeccion(int id = 1, bool activo = true) + => new(id, 1, "COD" + id, "Nombre", "clasificados", activo, DateTime.UtcNow, null); + + public DeactivateSeccionCommandHandlerTests() + { + _handler = new DeactivateSeccionCommandHandler(_repo, _audit); + } + + [Fact] + public async Task Handle_NotFound_ThrowsSeccionNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Seccion?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateSeccionCommand(999))); + } + + [Fact] + public async Task Handle_AlreadyInactive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, false)); + + await _handler.Handle(new DeactivateSeccionCommand(1)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyInactive_DoesNotWriteAuditEvent() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, false)); + + await _handler.Handle(new DeactivateSeccionCommand(1)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ActiveSeccion_CallsUpdateAsyncWithInactiveEntity() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, true)); + + await _handler.Handle(new DeactivateSeccionCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(s => !s.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_ActiveSeccion_WritesAuditWithDeactivateAction() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, true)); + + await _handler.Handle(new DeactivateSeccionCommand(1)); + + await _audit.Received(1).LogAsync( + action: "seccion.deactivate", + targetType: "Seccion", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/GetById/GetSeccionByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/GetById/GetSeccionByIdQueryHandlerTests.cs new file mode 100644 index 0000000..e5dce2f --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/GetById/GetSeccionByIdQueryHandlerTests.cs @@ -0,0 +1,46 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Secciones.GetById; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Secciones.GetById; + +public class GetSeccionByIdQueryHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly GetSeccionByIdQueryHandler _handler; + + public GetSeccionByIdQueryHandlerTests() + { + _handler = new GetSeccionByIdQueryHandler(_repo); + } + + private static Seccion MakeSeccion(int id) => + new(id, 2, "COD" + id, "Nombre " + id, "suplementos", true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_NotFound_ThrowsSeccionNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Seccion?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new GetSeccionByIdQuery(999))); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithCorrectFields() + { + var seccion = MakeSeccion(5); + _repo.GetByIdAsync(5, Arg.Any()).Returns(seccion); + + var result = await _handler.Handle(new GetSeccionByIdQuery(5)); + + Assert.Equal(5, result.Id); + Assert.Equal(2, result.MedioId); + Assert.Equal("COD5", result.Codigo); + Assert.Equal("Nombre 5", result.Nombre); + Assert.Equal("suplementos", result.Tipo); + Assert.True(result.Activo); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/List/ListSeccionesQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/List/ListSeccionesQueryHandlerTests.cs new file mode 100644 index 0000000..cafc558 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/List/ListSeccionesQueryHandlerTests.cs @@ -0,0 +1,64 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Secciones.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Secciones.List; + +public class ListSeccionesQueryHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly ListSeccionesQueryHandler _handler; + + public ListSeccionesQueryHandlerTests() + { + _handler = new ListSeccionesQueryHandler(_repo); + } + + private static Seccion MakeSeccion(int id) => + new(id, 1, "COD" + id, "Seccion " + id, "clasificados", true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_ReturnsPagedDtoItems() + { + var items = new List { MakeSeccion(1), MakeSeccion(2) }; + var pagedResult = new PagedResult(items, 1, 20, 2); + + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(pagedResult); + + var query = new ListSeccionesQuery(1, 20, null, null, null, null); + var result = await _handler.Handle(query); + + Assert.Equal(2, result.Total); + Assert.Equal(2, result.Items.Count); + Assert.Equal("COD1", result.Items[0].Codigo); + } + + [Fact] + public async Task Handle_ClampsPageSizeToMax100() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 100, 0)); + + await _handler.Handle(new ListSeccionesQuery(1, 500, null, null, null, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.PageSize == 100), + Arg.Any()); + } + + [Fact] + public async Task Handle_ClampsPageToMin1() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + await _handler.Handle(new ListSeccionesQuery(0, 20, null, null, null, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/Reactivate/ReactivateSeccionCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/Reactivate/ReactivateSeccionCommandHandlerTests.cs new file mode 100644 index 0000000..6c95c29 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/Reactivate/ReactivateSeccionCommandHandlerTests.cs @@ -0,0 +1,82 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Secciones.Deactivate; +using SIGCM2.Application.Secciones.Reactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Secciones.Reactivate; + +public class ReactivateSeccionCommandHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly ReactivateSeccionCommandHandler _handler; + + private static Seccion MakeSeccion(int id = 1, bool activo = false) + => new(id, 1, "COD" + id, "Nombre", "clasificados", activo, DateTime.UtcNow, null); + + public ReactivateSeccionCommandHandlerTests() + { + _handler = new ReactivateSeccionCommandHandler(_repo, _audit); + } + + [Fact] + public async Task Handle_NotFound_ThrowsSeccionNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Seccion?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ReactivateSeccionCommand(999))); + } + + [Fact] + public async Task Handle_AlreadyActive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, true)); + + await _handler.Handle(new ReactivateSeccionCommand(1)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyActive_DoesNotWriteAuditEvent() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, true)); + + await _handler.Handle(new ReactivateSeccionCommand(1)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_InactiveSeccion_CallsUpdateAsyncWithActiveEntity() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, false)); + + await _handler.Handle(new ReactivateSeccionCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(s => s.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_InactiveSeccion_WritesAuditWithReactivateAction() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, false)); + + await _handler.Handle(new ReactivateSeccionCommand(1)); + + await _audit.Received(1).LogAsync( + action: "seccion.reactivate", + targetType: "Seccion", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/Update/UpdateSeccionCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/Update/UpdateSeccionCommandHandlerTests.cs new file mode 100644 index 0000000..3cb09ed --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/Update/UpdateSeccionCommandHandlerTests.cs @@ -0,0 +1,74 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Secciones.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Secciones.Update; + +public class UpdateSeccionCommandHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly UpdateSeccionCommandHandler _handler; + + private static Seccion MakeSeccion(int id = 1, string nombre = "Original", string tipo = "clasificados") + => new(id, 1, "COD" + id, nombre, tipo, true, DateTime.UtcNow, null); + + private static UpdateSeccionCommand ValidCommand(int id = 1) => new( + Id: id, + Nombre: "Nuevo Nombre", + Tipo: "notables"); + + public UpdateSeccionCommandHandlerTests() + { + _handler = new UpdateSeccionCommandHandler(_repo, _audit); + } + + [Fact] + public async Task Handle_NotFound_ThrowsSeccionNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Seccion?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new UpdateSeccionCommand(999, "X", "clasificados"))); + } + + [Fact] + public async Task Handle_HappyPath_CallsUpdateAsyncOnce() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1)); + + await _handler.Handle(ValidCommand(1)); + + await _repo.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithUpdatedFields() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, "Original", "clasificados")); + + var result = await _handler.Handle(ValidCommand(1)); + + Assert.Equal(1, result.Id); + Assert.Equal("Nuevo Nombre", result.Nombre); + Assert.Equal("notables", result.Tipo); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithUpdateAction() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1)); + + await _handler.Handle(ValidCommand(1)); + + await _audit.Received(1).LogAsync( + action: "seccion.update", + targetType: "Seccion", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +}