feat(medios,secciones): application layer + handlers TDD — ADM-001 B3+B4
- IMedioRepository, ISeccionRepository interfaces - MediosQuery, SeccionesQuery common records - TipoSeccion static AllowedTipos helper - Medios: 6 use cases (Create/Update/Deactivate/Reactivate/List/GetById) with validators, handlers and DTOs - Secciones: 6 use cases mirroring Medios; Create validates MedioId active via IMedioRepository - 52 unit tests (xUnit + NSubstitute) all green; audit LogAsync asserted per mutating handler - DI registrations for all 12 handlers and validators auto-scanned via AddValidatorsFromAssemblyContaining
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.Secciones.Create;
|
||||
|
||||
public sealed record CreateSeccionCommand(
|
||||
int MedioId,
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string Tipo);
|
||||
@@ -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<CreateSeccionCommand, SeccionCreatedDto>
|
||||
{
|
||||
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<SeccionCreatedDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Secciones.Create;
|
||||
|
||||
public sealed class CreateSeccionCommandValidator : AbstractValidator<CreateSeccionCommand>
|
||||
{
|
||||
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)}.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Secciones.Deactivate;
|
||||
|
||||
public sealed record DeactivateSeccionCommand(int Id);
|
||||
@@ -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<DeactivateSeccionCommand, SeccionStatusDto>
|
||||
{
|
||||
private readonly ISeccionRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public DeactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<SeccionStatusDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Secciones.Deactivate;
|
||||
|
||||
public sealed record SeccionStatusDto(int Id, string Codigo, bool Activo);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Secciones.GetById;
|
||||
|
||||
public sealed record GetSeccionByIdQuery(int Id);
|
||||
@@ -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<GetSeccionByIdQuery, SeccionDetailDto>
|
||||
{
|
||||
private readonly ISeccionRepository _repo;
|
||||
|
||||
public GetSeccionByIdQueryHandler(ISeccionRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<SeccionDetailDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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<ListSeccionesQuery, PagedResult<SeccionListItemDto>>
|
||||
{
|
||||
private readonly ISeccionRepository _repo;
|
||||
|
||||
public ListSeccionesQueryHandler(ISeccionRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<SeccionListItemDto>> 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<SeccionListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Secciones.Reactivate;
|
||||
|
||||
public sealed record ReactivateSeccionCommand(int Id);
|
||||
@@ -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<ReactivateSeccionCommand, SeccionStatusDto>
|
||||
{
|
||||
private readonly ISeccionRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public ReactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<SeccionStatusDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Secciones.Update;
|
||||
|
||||
public sealed record UpdateSeccionCommand(
|
||||
int Id,
|
||||
string Nombre,
|
||||
string Tipo);
|
||||
@@ -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<UpdateSeccionCommand, SeccionUpdatedDto>
|
||||
{
|
||||
private readonly ISeccionRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public UpdateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<SeccionUpdatedDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Secciones.Update;
|
||||
|
||||
public sealed class UpdateSeccionCommandValidator : AbstractValidator<UpdateSeccionCommand>
|
||||
{
|
||||
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)}.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user