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:
2026-04-16 18:53:57 -03:00
parent bb98dbf217
commit f672de78ce
56 changed files with 1844 additions and 0 deletions

View File

@@ -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<IMedioRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
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<string>(), Arg.Any<CancellationToken>()).Returns(false);
_repo.AddAsync(Arg.Any<Medio>(), Arg.Any<CancellationToken>()).Returns(1);
}
// ── duplicate → throws ───────────────────────────────────────────────────
[Fact]
public async Task Handle_DuplicateCodigo_ThrowsMedioCodigoDuplicadoException()
{
_repo.ExistsByCodigoAsync("DIARIO_LA_VOZ", Arg.Any<CancellationToken>()).Returns(true);
await Assert.ThrowsAsync<MedioCodigoDuplicadoException>(
() => _handler.Handle(ValidCommand()));
}
[Fact]
public async Task Handle_DuplicateCodigo_DoesNotCallAddAsync()
{
_repo.ExistsByCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(true);
try { await _handler.Handle(ValidCommand()); } catch (MedioCodigoDuplicadoException) { }
await _repo.DidNotReceive().AddAsync(Arg.Any<Medio>(), Arg.Any<CancellationToken>());
}
// ── happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository()
{
_repo.AddAsync(Arg.Any<Medio>(), Arg.Any<CancellationToken>()).Returns(42);
var result = await _handler.Handle(ValidCommand());
Assert.Equal(42, result.Id);
}
[Fact]
public async Task Handle_HappyPath_DtoContainsCorrectFields()
{
_repo.AddAsync(Arg.Any<Medio>(), Arg.Any<CancellationToken>()).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<Medio>(m => m.Codigo == "DIARIO_LA_VOZ"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_HappyPath_CallsAuditLogWithCreateAction()
{
_repo.AddAsync(Arg.Any<Medio>(), Arg.Any<CancellationToken>()).Returns(7);
await _handler.Handle(ValidCommand());
await _audit.Received(1).LogAsync(
action: "medio.create",
targetType: "Medio",
targetId: "7",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
[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<CancellationToken>());
}
}

View File

@@ -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<IMedioRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
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<CancellationToken>()).Returns((Medio?)null);
await Assert.ThrowsAsync<MedioNotFoundException>(
() => _handler.Handle(new DeactivateMedioCommand(999)));
}
// ── idempotent ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_AlreadyInactive_IsIdempotentAndDoesNotCallUpdateAsync()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(MakeMedio(1, false));
await _handler.Handle(new DeactivateMedioCommand(1));
await _repo.DidNotReceive().UpdateAsync(Arg.Any<Medio>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_AlreadyInactive_DoesNotWriteAuditEvent()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(MakeMedio(1, false));
await _handler.Handle(new DeactivateMedioCommand(1));
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
// ── happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_ActiveMedio_CallsUpdateAsyncWithInactiveEntity()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(MakeMedio(1, true));
await _handler.Handle(new DeactivateMedioCommand(1));
await _repo.Received(1).UpdateAsync(
Arg.Is<Medio>(m => !m.Activo),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ActiveMedio_WritesAuditWithDeactivateAction()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).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<object?>(),
ct: Arg.Any<CancellationToken>());
}
}

View File

@@ -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<IMedioRepository>();
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<CancellationToken>()).Returns((Medio?)null);
await Assert.ThrowsAsync<MedioNotFoundException>(
() => _handler.Handle(new GetMedioByIdQuery(999)));
}
[Fact]
public async Task Handle_HappyPath_ReturnsDtoWithCorrectFields()
{
var medio = MakeMedio(3);
_repo.GetByIdAsync(3, Arg.Any<CancellationToken>()).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);
}
}

View File

@@ -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<IMedioRepository>();
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<Medio> { MakeMedio(1), MakeMedio(2) };
var pagedResult = new PagedResult<Medio>(items, 1, 20, 2);
_repo.GetPagedAsync(Arg.Any<MediosQuery>(), Arg.Any<CancellationToken>())
.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<MediosQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<Medio>([], 1, 100, 0));
await _handler.Handle(new ListMediosQuery(1, 999, null, null, null));
await _repo.Received(1).GetPagedAsync(
Arg.Is<MediosQuery>(q => q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ClampsPageToMin1()
{
_repo.GetPagedAsync(Arg.Any<MediosQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<Medio>([], 1, 20, 0));
await _handler.Handle(new ListMediosQuery(0, 20, null, null, null));
await _repo.Received(1).GetPagedAsync(
Arg.Is<MediosQuery>(q => q.Page == 1),
Arg.Any<CancellationToken>());
}
}

View File

@@ -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<IMedioRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
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<CancellationToken>()).Returns((Medio?)null);
await Assert.ThrowsAsync<MedioNotFoundException>(
() => _handler.Handle(new ReactivateMedioCommand(999)));
}
// ── idempotent ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_AlreadyActive_IsIdempotentAndDoesNotCallUpdateAsync()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(MakeMedio(1, true));
await _handler.Handle(new ReactivateMedioCommand(1));
await _repo.DidNotReceive().UpdateAsync(Arg.Any<Medio>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_AlreadyActive_DoesNotWriteAuditEvent()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(MakeMedio(1, true));
await _handler.Handle(new ReactivateMedioCommand(1));
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
// ── happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_InactiveMedio_CallsUpdateAsyncWithActiveEntity()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(MakeMedio(1, false));
await _handler.Handle(new ReactivateMedioCommand(1));
await _repo.Received(1).UpdateAsync(
Arg.Is<Medio>(m => m.Activo),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_InactiveMedio_WritesAuditWithReactivateAction()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).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<object?>(),
ct: Arg.Any<CancellationToken>());
}
}

View File

@@ -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<IMedioRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
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<CancellationToken>()).Returns((Medio?)null);
await Assert.ThrowsAsync<MedioNotFoundException>(
() => _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<CancellationToken>()).Returns(medio);
await _handler.Handle(ValidCommand(1));
await _repo.Received(1).UpdateAsync(Arg.Any<Medio>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_HappyPath_ReturnsDtoWithUpdatedFields()
{
var medio = MakeMedio(1, "Original", TipoMedio.Diario);
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).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<CancellationToken>()).Returns(medio);
await _handler.Handle(ValidCommand(1));
await _audit.Received(1).LogAsync(
action: "medio.update",
targetType: "Medio",
targetId: "1",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
}