diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IPuntoDeVentaRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IPuntoDeVentaRepository.cs new file mode 100644 index 0000000..5d5cabd --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IPuntoDeVentaRepository.cs @@ -0,0 +1,16 @@ +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IPuntoDeVentaRepository +{ + Task AddAsync(PuntoDeVenta pdv, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task ExistsByNumeroAFIPInMedioAsync(int medioId, short numeroAFIP, int? excludeId = null, CancellationToken ct = default); + Task UpdateAsync(PuntoDeVenta pdv, CancellationToken ct = default); + Task> GetPagedAsync(PuntosDeVentaQuery q, CancellationToken ct = default); + Task ReservarNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default); + Task GetUltimoNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs b/src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs new file mode 100644 index 0000000..668242f --- /dev/null +++ b/src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Common; + +/// Query parameters for listing puntos de venta with optional filters and paging. +public sealed record PuntosDeVentaQuery( + int Page, + int PageSize, + int? MedioId, + bool? Activo +); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommand.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommand.cs new file mode 100644 index 0000000..8cb9d87 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.PuntosDeVenta.Create; + +public sealed record CreatePuntoDeVentaCommand( + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandler.cs new file mode 100644 index 0000000..10cfc19 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandler.cs @@ -0,0 +1,75 @@ +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.PuntosDeVenta.Create; + +public sealed class CreatePuntoDeVentaCommandHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + private readonly IMedioRepository _medioRepo; + private readonly IAuditLogger _audit; + + public CreatePuntoDeVentaCommandHandler( + IPuntoDeVentaRepository repo, + IMedioRepository medioRepo, + IAuditLogger audit) + { + _repo = repo; + _medioRepo = medioRepo; + _audit = audit; + } + + public async Task Handle(CreatePuntoDeVentaCommand command) + { + // Validate medio exists and is active (REQ-PDV-001, -002) + var medio = await _medioRepo.GetByIdAsync(command.MedioId) + ?? throw new MedioNotFoundException(command.MedioId); + + if (!medio.Activo) + throw new MedioInactivoException(medio.Id); + + // Check uniqueness NumeroAFIP within Medio (REQ-PDV-003) + var exists = await _repo.ExistsByNumeroAFIPInMedioAsync(command.MedioId, command.NumeroAFIP, excludeId: null); + if (exists) + throw new NumeroAFIPDuplicadoException(command.MedioId, command.NumeroAFIP); + + var pdv = PuntoDeVenta.ForCreation(command.MedioId, command.NumeroAFIP, command.Nombre, command.Descripcion); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + var newId = await _repo.AddAsync(pdv); + + // fail-closed: if LogAsync throws, tx rolls back (REQ-PDV-010) + await _audit.LogAsync( + action: "punto_de_venta.create", + targetType: "PuntoDeVenta", + targetId: newId.ToString(), + metadata: new + { + after = new + { + pdv.MedioId, + pdv.NumeroAFIP, + pdv.Nombre, + pdv.Descripcion, + }, + }); + + tx.Complete(); + + return new PuntoDeVentaCreatedDto( + Id: newId, + MedioId: pdv.MedioId, + NumeroAFIP: pdv.NumeroAFIP, + Nombre: pdv.Nombre, + Descripcion: pdv.Descripcion, + Activo: pdv.Activo); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandValidator.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandValidator.cs new file mode 100644 index 0000000..d6db8cc --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Create/CreatePuntoDeVentaCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; + +namespace SIGCM2.Application.PuntosDeVenta.Create; + +public sealed class CreatePuntoDeVentaCommandValidator : AbstractValidator +{ + private const int NombreMaxLength = 100; + private const int DescripcionMaxLength = 255; + private const short NumeroAFIPMin = 1; + private const short NumeroAFIPMax = 9999; + + public CreatePuntoDeVentaCommandValidator() + { + RuleFor(x => x.MedioId) + .GreaterThan(0).WithMessage("El medioId debe ser mayor a 0."); + + RuleFor(x => x.NumeroAFIP) + .InclusiveBetween(NumeroAFIPMin, NumeroAFIPMax) + .WithMessage($"El número AFIP debe estar entre {NumeroAFIPMin} y {NumeroAFIPMax}."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Descripcion) + .MaximumLength(DescripcionMaxLength).WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.") + .When(x => x.Descripcion is not null); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Create/PuntoDeVentaCreatedDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Create/PuntoDeVentaCreatedDto.cs new file mode 100644 index 0000000..787ef1c --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Create/PuntoDeVentaCreatedDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.PuntosDeVenta.Create; + +public sealed record PuntoDeVentaCreatedDto( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion, + bool Activo); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommand.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommand.cs new file mode 100644 index 0000000..6218c4e --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.PuntosDeVenta.Deactivate; + +public sealed record DeactivatePuntoDeVentaCommand(int Id); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandler.cs new file mode 100644 index 0000000..87fc908 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandler.cs @@ -0,0 +1,53 @@ +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.PuntosDeVenta.Deactivate; + +public sealed class DeactivatePuntoDeVentaCommandHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + private readonly IMedioRepository _medioRepo; + private readonly IAuditLogger _audit; + + public DeactivatePuntoDeVentaCommandHandler( + IPuntoDeVentaRepository repo, + IMedioRepository medioRepo, + IAuditLogger audit) + { + _repo = repo; + _medioRepo = medioRepo; + _audit = audit; + } + + public async Task Handle(DeactivatePuntoDeVentaCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new PuntoDeVentaNotFoundException(command.Id); + + // Idempotent: already inactive → return as-is without writing an audit event + if (!target.Activo) + return new PuntoDeVentaStatusDto(target.Id, target.NumeroAFIP, 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: "punto_de_venta.deactivate", + targetType: "PuntoDeVenta", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new PuntoDeVentaStatusDto(updated.Id, updated.NumeroAFIP, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/PuntoDeVentaStatusDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/PuntoDeVentaStatusDto.cs new file mode 100644 index 0000000..2209877 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Deactivate/PuntoDeVentaStatusDto.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.PuntosDeVenta.Deactivate; + +public sealed record PuntoDeVentaStatusDto(int Id, short NumeroAFIP, bool Activo); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQuery.cs b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQuery.cs new file mode 100644 index 0000000..dbf8085 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.PuntosDeVenta.GetById; + +public sealed record GetPuntoDeVentaByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandler.cs new file mode 100644 index 0000000..ca0e24b --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandler.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.PuntosDeVenta.GetById; + +public sealed class GetPuntoDeVentaByIdQueryHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + + public GetPuntoDeVentaByIdQueryHandler(IPuntoDeVentaRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetPuntoDeVentaByIdQuery query) + { + var pdv = await _repo.GetByIdAsync(query.Id) + ?? throw new PuntoDeVentaNotFoundException(query.Id); + + return new PuntoDeVentaDetailDto( + Id: pdv.Id, + MedioId: pdv.MedioId, + NumeroAFIP: pdv.NumeroAFIP, + Nombre: pdv.Nombre, + Descripcion: pdv.Descripcion, + Activo: pdv.Activo, + FechaCreacion: pdv.FechaCreacion, + FechaModificacion: pdv.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/GetById/PuntoDeVentaDetailDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/PuntoDeVentaDetailDto.cs new file mode 100644 index 0000000..b1136ca --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/GetById/PuntoDeVentaDetailDto.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Application.PuntosDeVenta.GetById; + +public sealed record PuntoDeVentaDetailDto( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQuery.cs b/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQuery.cs new file mode 100644 index 0000000..c93239f --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQuery.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.PuntosDeVenta.List; + +public sealed record ListPuntosDeVentaQuery( + int Page, + int PageSize, + int? MedioId, + bool? Activo); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQueryHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQueryHandler.cs new file mode 100644 index 0000000..a4653cb --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/List/ListPuntosDeVentaQueryHandler.cs @@ -0,0 +1,30 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.PuntosDeVenta.List; + +public sealed class ListPuntosDeVentaQueryHandler : ICommandHandler> +{ + private readonly IPuntoDeVentaRepository _repo; + + public ListPuntosDeVentaQueryHandler(IPuntoDeVentaRepository repo) + { + _repo = repo; + } + + public async Task> Handle(ListPuntosDeVentaQuery query) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + + var repoQuery = new PuntosDeVentaQuery(page, pageSize, query.MedioId, query.Activo); + var paged = await _repo.GetPagedAsync(repoQuery); + + var items = paged.Items + .Select(p => new PuntoDeVentaListItemDto(p.Id, p.MedioId, p.NumeroAFIP, p.Nombre, p.Activo)) + .ToList(); + + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/List/PuntoDeVentaListItemDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/List/PuntoDeVentaListItemDto.cs new file mode 100644 index 0000000..c10cd67 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/List/PuntoDeVentaListItemDto.cs @@ -0,0 +1,8 @@ +namespace SIGCM2.Application.PuntosDeVenta.List; + +public sealed record PuntoDeVentaListItemDto( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + bool Activo); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQuery.cs b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQuery.cs new file mode 100644 index 0000000..7fb0ada --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQuery.cs @@ -0,0 +1,5 @@ +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero; + +public sealed record GetProximoNumeroQuery(int PuntoDeVentaId, TipoComprobante TipoComprobante); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandler.cs new file mode 100644 index 0000000..e0c930c --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandler.cs @@ -0,0 +1,27 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero; + +/// +/// Consulta el próximo número disponible sin reservarlo (read-only, REQ-SEC-CMB-005). +/// Retorna UltimoNumero+1; si no existe fila devuelve 1. +/// +public sealed class GetProximoNumeroQueryHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + + public GetProximoNumeroQueryHandler(IPuntoDeVentaRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetProximoNumeroQuery query) + { + var ultimoNumero = await _repo.GetUltimoNumeroAsync(query.PuntoDeVentaId, query.TipoComprobante); + + var proximo = ultimoNumero.HasValue ? ultimoNumero.Value + 1 : 1; + + return new ProximoNumeroDto(query.TipoComprobante, proximo); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/ProximoNumeroDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/ProximoNumeroDto.cs new file mode 100644 index 0000000..3b2fb8f --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/ProximoNumero/ProximoNumeroDto.cs @@ -0,0 +1,5 @@ +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero; + +public sealed record ProximoNumeroDto(TipoComprobante TipoComprobante, int ProximoNumero); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommand.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommand.cs new file mode 100644 index 0000000..e60a889 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.PuntosDeVenta.Reactivate; + +public sealed record ReactivatePuntoDeVentaCommand(int Id); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandler.cs new file mode 100644 index 0000000..6ea1e69 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandler.cs @@ -0,0 +1,60 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.PuntosDeVenta.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.PuntosDeVenta.Reactivate; + +public sealed class ReactivatePuntoDeVentaCommandHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + private readonly IMedioRepository _medioRepo; + private readonly IAuditLogger _audit; + + public ReactivatePuntoDeVentaCommandHandler( + IPuntoDeVentaRepository repo, + IMedioRepository medioRepo, + IAuditLogger audit) + { + _repo = repo; + _medioRepo = medioRepo; + _audit = audit; + } + + public async Task Handle(ReactivatePuntoDeVentaCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new PuntoDeVentaNotFoundException(command.Id); + + var medio = await _medioRepo.GetByIdAsync(target.MedioId) + ?? throw new MedioNotFoundException(target.MedioId); + + if (!medio.Activo) + throw new MedioInactivoException(medio.Id); + + // Idempotent: already active → return as-is without writing an audit event + if (target.Activo) + return new PuntoDeVentaStatusDto(target.Id, target.NumeroAFIP, 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: "punto_de_venta.reactivate", + targetType: "PuntoDeVenta", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new PuntoDeVentaStatusDto(updated.Id, updated.NumeroAFIP, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservaNumeroDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservaNumeroDto.cs new file mode 100644 index 0000000..1544c7e --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservaNumeroDto.cs @@ -0,0 +1,5 @@ +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.PuntosDeVenta.Reservar; + +public sealed record ReservaNumeroDto(TipoComprobante TipoComprobante, int NumeroReservado); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommand.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommand.cs new file mode 100644 index 0000000..44d4918 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommand.cs @@ -0,0 +1,5 @@ +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.PuntosDeVenta.Reservar; + +public sealed record ReservarNumeroCommand(int PuntoDeVentaId, TipoComprobante TipoComprobante); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs new file mode 100644 index 0000000..ce19ee1 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Reservar/ReservarNumeroCommandHandler.cs @@ -0,0 +1,53 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.PuntosDeVenta.Reservar; + +/// +/// Reserva el próximo número correlativo para (PdvId × TipoComprobante) ejecutando +/// usp_ReservarNumeroComprobante vía el repositorio. +/// +/// NOTAS DE DISEÑO (AD4, AD9): +/// - NO se envuelve en TransactionScope: el SP ya es atómico bajo SERIALIZABLE. +/// Un TransactionScope ambiente aquí escalaría a DTC → innecesario. +/// - NO usa Polly: no está en el proyecto. Retry deadlock con bucle simple. +/// - Infrastructure traduce SqlException 1205 → DeadlockTransientException. +/// - Backoff en ms: [50, 150, 450] — 3 intentos máximo. +/// - La auditoría de reservas corre solo vía Temporal Tables (AD8). +/// +public sealed class ReservarNumeroCommandHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + private readonly int[] _deadlockBackoffMs; + + private static readonly int[] DefaultBackoffMs = [50, 150, 450]; + + public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo) + : this(repo, DefaultBackoffMs) { } + + /// Constructor with custom backoff for testing (e.g., [0,0,0] for fast tests). + public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo, int[] deadlockBackoffMs) + { + _repo = repo; + _deadlockBackoffMs = deadlockBackoffMs; + } + + public async Task Handle(ReservarNumeroCommand command) + { + for (var i = 0; ; i++) + { + try + { + var numero = await _repo.ReservarNumeroAsync(command.PuntoDeVentaId, command.TipoComprobante); + return new ReservaNumeroDto(command.TipoComprobante, numero); + } + catch (DeadlockTransientException) when (i < _deadlockBackoffMs.Length) + { + // Deadlock — retry with backoff + await Task.Delay(_deadlockBackoffMs[i]); + } + // All other exceptions bubble up immediately + } + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Update/PuntoDeVentaUpdatedDto.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Update/PuntoDeVentaUpdatedDto.cs new file mode 100644 index 0000000..ee5065d --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Update/PuntoDeVentaUpdatedDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.PuntosDeVenta.Update; + +public sealed record PuntoDeVentaUpdatedDto( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion, + bool Activo); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommand.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommand.cs new file mode 100644 index 0000000..69bff98 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.PuntosDeVenta.Update; + +public sealed record UpdatePuntoDeVentaCommand( + int Id, + string Nombre, + short NumeroAFIP, + string? Descripcion); diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandler.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandler.cs new file mode 100644 index 0000000..8daf0c0 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandler.cs @@ -0,0 +1,71 @@ +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.PuntosDeVenta.Update; + +public sealed class UpdatePuntoDeVentaCommandHandler : ICommandHandler +{ + private readonly IPuntoDeVentaRepository _repo; + private readonly IMedioRepository _medioRepo; + private readonly IAuditLogger _audit; + + public UpdatePuntoDeVentaCommandHandler( + IPuntoDeVentaRepository repo, + IMedioRepository medioRepo, + IAuditLogger audit) + { + _repo = repo; + _medioRepo = medioRepo; + _audit = audit; + } + + public async Task Handle(UpdatePuntoDeVentaCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new PuntoDeVentaNotFoundException(command.Id); + + var medio = await _medioRepo.GetByIdAsync(target.MedioId) + ?? throw new MedioNotFoundException(target.MedioId); + + if (!medio.Activo) + throw new MedioInactivoException(medio.Id); + + // Re-validate uniqueness excluding current entity (REQ-PDV-004) + var exists = await _repo.ExistsByNumeroAFIPInMedioAsync(target.MedioId, command.NumeroAFIP, excludeId: command.Id); + if (exists) + throw new NumeroAFIPDuplicadoException(target.MedioId, command.NumeroAFIP); + + var updated = target.WithUpdatedProfile(command.Nombre, command.NumeroAFIP, command.Descripcion); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "punto_de_venta.update", + targetType: "PuntoDeVenta", + targetId: command.Id.ToString(), + metadata: new + { + before = new { target.Nombre, target.NumeroAFIP, target.Descripcion }, + after = new { updated.Nombre, updated.NumeroAFIP, updated.Descripcion }, + }); + + tx.Complete(); + + return new PuntoDeVentaUpdatedDto( + Id: updated.Id, + MedioId: updated.MedioId, + NumeroAFIP: updated.NumeroAFIP, + Nombre: updated.Nombre, + Descripcion: updated.Descripcion, + Activo: updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandValidator.cs b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandValidator.cs new file mode 100644 index 0000000..eb313a2 --- /dev/null +++ b/src/api/SIGCM2.Application/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; + +namespace SIGCM2.Application.PuntosDeVenta.Update; + +public sealed class UpdatePuntoDeVentaCommandValidator : AbstractValidator +{ + private const int NombreMaxLength = 100; + private const int DescripcionMaxLength = 255; + private const short NumeroAFIPMin = 1; + private const short NumeroAFIPMax = 9999; + + public UpdatePuntoDeVentaCommandValidator() + { + RuleFor(x => x.Id) + .GreaterThan(0).WithMessage("El id debe ser mayor a 0."); + + RuleFor(x => x.NumeroAFIP) + .InclusiveBetween(NumeroAFIPMin, NumeroAFIPMax) + .WithMessage($"El número AFIP debe estar entre {NumeroAFIPMin} y {NumeroAFIPMax}."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Descripcion) + .MaximumLength(DescripcionMaxLength).WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.") + .When(x => x.Descripcion is not null); + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/DeadlockTransientException.cs b/src/api/SIGCM2.Domain/Exceptions/DeadlockTransientException.cs new file mode 100644 index 0000000..fef02a6 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/DeadlockTransientException.cs @@ -0,0 +1,14 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown by Infrastructure when a database deadlock (SQL 1205) is detected. +/// Allows Application handlers to retry without referencing SqlClient. +/// +public sealed class DeadlockTransientException : DomainException +{ + public DeadlockTransientException() + : base("Se detectó un deadlock en la base de datos. Reintentando operación.") { } + + public DeadlockTransientException(Exception innerException) + : base("Se detectó un deadlock en la base de datos. Reintentando operación.", innerException) { } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandlerTests.cs new file mode 100644 index 0000000..da2744a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Create/CreatePuntoDeVentaCommandHandlerTests.cs @@ -0,0 +1,122 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.PuntosDeVenta.Create; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.Create; + +public class CreatePuntoDeVentaCommandHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly IMedioRepository _medioRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly CreatePuntoDeVentaCommandHandler _handler; + + private static Medio MakeMedio(int id = 5, bool activo = true) + => new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + private static CreatePuntoDeVentaCommand ValidCommand() => new( + MedioId: 5, + NumeroAFIP: 1, + Nombre: "PdV Central", + Descripcion: null); + + public CreatePuntoDeVentaCommandHandlerTests() + { + _handler = new CreatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit); + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5)); + _repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any(), 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(5, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + // ── medio inactivo → throws ────────────────────────────────────────────── + + [Fact] + public async Task Handle_MedioInactivo_ThrowsMedioInactivoException() + { + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5, false)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + // ── NumeroAFIP duplicado → throws ──────────────────────────────────────── + + [Fact] + public async Task Handle_NumeroAFIPDuplicado_ThrowsNumeroAFIPDuplicadoException() + { + _repo.ExistsByNumeroAFIPInMedioAsync(5, 1, null, Arg.Any()).Returns(true); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_NumeroAFIPDuplicado_DoesNotCallAddAsync() + { + _repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + + try { await _handler.Handle(ValidCommand()); } catch (NumeroAFIPDuplicadoException) { } + + await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository() + { + 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(5, result.MedioId); + Assert.Equal(1, result.NumeroAFIP); + Assert.Equal("PdV Central", result.Nombre); + Assert.True(result.Activo); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithCreateAction() + { + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "punto_de_venta.create", + targetType: "PuntoDeVenta", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + // ── audit fail-closed ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AuditLoggerThrows_ExceptionBubblesUpAndAddNotCommitted() + { + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("audit fail"))); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandlerTests.cs new file mode 100644 index 0000000..5ac8a93 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandlerTests.cs @@ -0,0 +1,86 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.PuntosDeVenta.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.Deactivate; + +public class DeactivatePuntoDeVentaCommandHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly IMedioRepository _medioRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly DeactivatePuntoDeVentaCommandHandler _handler; + + private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = true) + => new(id, medioId, 1, "PdV Test", null, activo, DateTime.UtcNow, null); + + private static Medio MakeMedio(int id = 5, bool activo = true) + => new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + public DeactivatePuntoDeVentaCommandHandlerTests() + { + _handler = new DeactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit); + _medioRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(MakeMedio(5, true)); + } + + [Fact] + public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((PuntoDeVenta?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivatePuntoDeVentaCommand(999))); + } + + [Fact] + public async Task Handle_AlreadyInactive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + + await _handler.Handle(new DeactivatePuntoDeVentaCommand(10)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyInactive_DoesNotWriteAuditEvent() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + + await _handler.Handle(new DeactivatePuntoDeVentaCommand(10)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ActivePdv_CallsUpdateAsyncWithInactiveEntity() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: true)); + + await _handler.Handle(new DeactivatePuntoDeVentaCommand(10)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(p => !p.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_ActivePdv_WritesAuditWithDeactivateAction() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: true)); + + await _handler.Handle(new DeactivatePuntoDeVentaCommand(10)); + + await _audit.Received(1).LogAsync( + action: "punto_de_venta.deactivate", + targetType: "PuntoDeVenta", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandlerTests.cs new file mode 100644 index 0000000..2602f11 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/GetById/GetPuntoDeVentaByIdQueryHandlerTests.cs @@ -0,0 +1,46 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.PuntosDeVenta.GetById; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.GetById; + +public class GetPuntoDeVentaByIdQueryHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly GetPuntoDeVentaByIdQueryHandler _handler; + + public GetPuntoDeVentaByIdQueryHandlerTests() + { + _handler = new GetPuntoDeVentaByIdQueryHandler(_repo); + } + + private static PuntoDeVenta MakePdv(int id = 5) => + new(id, 2, 3, "PdV " + id, "Desc", true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((PuntoDeVenta?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new GetPuntoDeVentaByIdQuery(999))); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithCorrectFields() + { + var pdv = MakePdv(5); + _repo.GetByIdAsync(5, Arg.Any()).Returns(pdv); + + var result = await _handler.Handle(new GetPuntoDeVentaByIdQuery(5)); + + Assert.Equal(5, result.Id); + Assert.Equal(2, result.MedioId); + Assert.Equal(3, result.NumeroAFIP); + Assert.Equal("PdV 5", result.Nombre); + Assert.Equal("Desc", result.Descripcion); + Assert.True(result.Activo); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/List/ListPuntosDeVentaQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/List/ListPuntosDeVentaQueryHandlerTests.cs new file mode 100644 index 0000000..faa8e0c --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/List/ListPuntosDeVentaQueryHandlerTests.cs @@ -0,0 +1,76 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.PuntosDeVenta.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.List; + +public class ListPuntosDeVentaQueryHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly ListPuntosDeVentaQueryHandler _handler; + + public ListPuntosDeVentaQueryHandlerTests() + { + _handler = new ListPuntosDeVentaQueryHandler(_repo); + } + + private static PuntoDeVenta MakePdv(int id) => + new(id, 5, (short)id, "PdV " + id, null, true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_ReturnsPagedDtoItems() + { + var items = new List { MakePdv(1), MakePdv(2) }; + var pagedResult = new PagedResult(items, 1, 20, 2); + + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(pagedResult); + + var result = await _handler.Handle(new ListPuntosDeVentaQuery(1, 20, null, null)); + + Assert.Equal(2, result.Total); + Assert.Equal(2, result.Items.Count); + Assert.Equal(1, result.Items[0].NumeroAFIP); + } + + [Fact] + public async Task Handle_ClampsPageSizeToMax100() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 100, 0)); + + await _handler.Handle(new ListPuntosDeVentaQuery(1, 500, 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 ListPuntosDeVentaQuery(0, 20, null, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } + + [Fact] + public async Task Handle_FiltersByMedioId() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + await _handler.Handle(new ListPuntosDeVentaQuery(1, 20, MedioId: 5, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.MedioId == 5), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandlerTests.cs new file mode 100644 index 0000000..4adc03b --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/ProximoNumero/GetProximoNumeroQueryHandlerTests.cs @@ -0,0 +1,52 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.PuntosDeVenta.ProximoNumero; +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.ProximoNumero; + +public class GetProximoNumeroQueryHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly GetProximoNumeroQueryHandler _handler; + + public GetProximoNumeroQueryHandlerTests() + { + _handler = new GetProximoNumeroQueryHandler(_repo); + } + + [Fact] + public async Task Handle_ExistingSequence_ReturnsUltimoNumeroMasUno() + { + _repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns(7); + + var result = await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaA)); + + Assert.Equal(TipoComprobante.FacturaA, result.TipoComprobante); + Assert.Equal(8, result.ProximoNumero); + } + + [Fact] + public async Task Handle_NoExistingSequence_ReturnsOne() + { + _repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaB, Arg.Any()) + .Returns((int?)null); + + var result = await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaB)); + + Assert.Equal(1, result.ProximoNumero); + } + + [Fact] + public async Task Handle_DoesNotCallReservarNumero() + { + _repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns(5); + + await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaA)); + + await _repo.DidNotReceive().ReservarNumeroAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandlerTests.cs new file mode 100644 index 0000000..b810d82 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandlerTests.cs @@ -0,0 +1,98 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.PuntosDeVenta.Deactivate; +using SIGCM2.Application.PuntosDeVenta.Reactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.Reactivate; + +public class ReactivatePuntoDeVentaCommandHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly IMedioRepository _medioRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly ReactivatePuntoDeVentaCommandHandler _handler; + + private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = false) + => new(id, medioId, 1, "PdV Test", null, activo, DateTime.UtcNow, null); + + private static Medio MakeMedio(int id = 5, bool activo = true) + => new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + public ReactivatePuntoDeVentaCommandHandlerTests() + { + _handler = new ReactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit); + _medioRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(MakeMedio(5, true)); + } + + [Fact] + public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((PuntoDeVenta?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ReactivatePuntoDeVentaCommand(999))); + } + + [Fact] + public async Task Handle_MedioInactivo_ThrowsMedioInactivoException() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5, false)); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ReactivatePuntoDeVentaCommand(10))); + } + + [Fact] + public async Task Handle_AlreadyActive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: true)); + + await _handler.Handle(new ReactivatePuntoDeVentaCommand(10)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_InactivePdv_CallsUpdateAsyncWithActiveEntity() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + + await _handler.Handle(new ReactivatePuntoDeVentaCommand(10)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(p => p.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_InactivePdv_WritesAuditWithReactivateAction() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + + await _handler.Handle(new ReactivatePuntoDeVentaCommand(10)); + + await _audit.Received(1).LogAsync( + action: "punto_de_venta.reactivate", + targetType: "PuntoDeVenta", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_MedioInactivo_NoAuditLogged() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10, activo: false)); + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5, false)); + + try { await _handler.Handle(new ReactivatePuntoDeVentaCommand(10)); } catch (MedioInactivoException) { } + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reservar/ReservarNumeroCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reservar/ReservarNumeroCommandHandlerTests.cs new file mode 100644 index 0000000..a3cf99f --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reservar/ReservarNumeroCommandHandlerTests.cs @@ -0,0 +1,126 @@ +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.PuntosDeVenta.Reservar; +using SIGCM2.Domain.Enums; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.Reservar; + +public class ReservarNumeroCommandHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly ReservarNumeroCommandHandler _handler; + + private static readonly ReservarNumeroCommand ValidCommand = + new(PuntoDeVentaId: 10, TipoComprobante: TipoComprobante.FacturaA); + + public ReservarNumeroCommandHandlerTests() + { + // Use delay = 0 for fast tests + _handler = new ReservarNumeroCommandHandler(_repo, deadlockBackoffMs: [0, 0, 0]); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsNumeroReservado() + { + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns(7); + + var result = await _handler.Handle(ValidCommand); + + Assert.Equal(TipoComprobante.FacturaA, result.TipoComprobante); + Assert.Equal(7, result.NumeroReservado); + } + + // ── retry deadlock ──────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_DeadlockTwiceThenSucceeds_ReturnsResult() + { + var deadlock = new DeadlockTransientException(); + + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns( + _ => Task.FromException(deadlock), + _ => Task.FromException(deadlock), + _ => Task.FromResult(3)); + + var result = await _handler.Handle(ValidCommand); + + Assert.Equal(3, result.NumeroReservado); + } + + [Fact] + public async Task Handle_DeadlockThreeTimes_BubblesUpDeadlockException() + { + var deadlock = new DeadlockTransientException(); + + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns( + _ => Task.FromException(deadlock), + _ => Task.FromException(deadlock), + _ => Task.FromException(deadlock)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand)); + } + + [Fact] + public async Task Handle_DeadlockExhaustsBackoff_TriedFourTimesTotal() + { + // backoff = [0,0,0] → 3 retries → 4 total attempts (1 initial + 3 retries) + var deadlock = new DeadlockTransientException(); + + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Returns(_ => Task.FromException(deadlock)); + + try { await _handler.Handle(ValidCommand); } catch (DeadlockTransientException) { } + + await _repo.Received(4).ReservarNumeroAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + // ── domain exceptions bubble up without retry ───────────────────────────── + + [Fact] + public async Task Handle_PuntoDeVentaInactivo_BubblesUpImmediately() + { + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Throws(new PuntoDeVentaInactivoException(10)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand)); + + await _repo.Received(1).ReservarNumeroAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_MedioInactivo_BubblesUpImmediately() + { + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Throws(new MedioInactivoException(5)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand)); + + await _repo.Received(1).ReservarNumeroAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_PdvNotFound_BubblesUpImmediately() + { + _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) + .Throws(new PuntoDeVentaNotFoundException(10)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand)); + + await _repo.Received(1).ReservarNumeroAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandlerTests.cs new file mode 100644 index 0000000..1724e35 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandlerTests.cs @@ -0,0 +1,103 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.PuntosDeVenta.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.PuntosDeVenta.Update; + +public class UpdatePuntoDeVentaCommandHandlerTests +{ + private readonly IPuntoDeVentaRepository _repo = Substitute.For(); + private readonly IMedioRepository _medioRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly UpdatePuntoDeVentaCommandHandler _handler; + + private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = true) + => new(id, medioId, 1, "Original", null, activo, DateTime.UtcNow, null); + + private static Medio MakeMedio(int id = 5, bool activo = true) + => new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + private static UpdatePuntoDeVentaCommand ValidCommand(int id = 10) => + new(Id: id, Nombre: "Nuevo Nombre", NumeroAFIP: 3, Descripcion: null); + + public UpdatePuntoDeVentaCommandHandlerTests() + { + _handler = new UpdatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit); + _repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10)); + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5)); + _repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); + } + + [Fact] + public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((PuntoDeVenta?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new UpdatePuntoDeVentaCommand(999, "X", 1, null))); + } + + [Fact] + public async Task Handle_MedioInactivo_ThrowsMedioInactivoException() + { + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5, false)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_NumeroAFIPDuplicado_ThrowsNumeroAFIPDuplicadoException() + { + _repo.ExistsByNumeroAFIPInMedioAsync(5, 3, 10, Arg.Any()).Returns(true); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_HappyPath_CallsUpdateAsyncOnce() + { + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithUpdatedFields() + { + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(10, result.Id); + Assert.Equal("Nuevo Nombre", result.Nombre); + Assert.Equal(3, result.NumeroAFIP); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithUpdateAction() + { + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "punto_de_venta.update", + targetType: "PuntoDeVenta", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_MedioInactivo_NoAuditLogged() + { + _medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5, false)); + + try { await _handler.Handle(ValidCommand()); } catch (MedioInactivoException) { } + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } +}