diff --git a/database/migrations/V003__create_rol.sql b/database/migrations/V003__create_rol.sql new file mode 100644 index 0000000..c2c4982 --- /dev/null +++ b/database/migrations/V003__create_rol.sql @@ -0,0 +1,58 @@ +-- V003__create_rol.sql +-- Creates dbo.Rol master table (referenced by Usuario.Rol via FK in V004) and seeds +-- the 8 canonical business roles (RBAC doc §2.4.2). +-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests) + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +IF OBJECT_ID(N'dbo.Rol', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Rol + ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rol PRIMARY KEY, + Codigo VARCHAR(30) NOT NULL, + Nombre NVARCHAR(60) NOT NULL, + Descripcion NVARCHAR(250) NULL, + Activo BIT NOT NULL CONSTRAINT DF_Rol_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rol_FC DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT UQ_Rol_Codigo UNIQUE (Codigo), + -- Codigo format: lowercase letter followed by lowercase letters, digits or underscore. + -- Using binary collation to enforce case-sensitivity (default DB collation is case-insensitive). + CONSTRAINT CK_Rol_Codigo_Format CHECK ( + PATINDEX('[a-z]%', Codigo COLLATE Latin1_General_BIN2) = 1 + AND PATINDEX('%[^a-z0-9_]%', Codigo COLLATE Latin1_General_BIN2) = 0 + ) + ); + + PRINT 'Table dbo.Rol created successfully.'; +END +ELSE +BEGIN + PRINT 'Table dbo.Rol already exists — skipping create.'; +END +GO + +-- Seed 8 canonical roles (idempotent). +MERGE dbo.Rol AS target +USING (VALUES + ('admin', N'Administrador', N'Supervisor total del sistema'), + ('cajero', N'Cajero', N'Atención de mostrador, contado'), + ('operador_ctacte', N'Operador Cta Cte', N'Gestión de cuenta corriente'), + ('picadora', N'Picadora/Correctora', N'Edición de textos y corrección'), + ('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta y recursos'), + ('productor', N'Productor', N'Consulta y carga restringida'), + ('diagramacion', N'Diagramación/Taller', N'Solo lectura de pauta'), + ('reportes', N'Reportes', N'Solo lectura de reportes y estadísticas') +) AS source (Codigo, Nombre, Descripcion) +ON target.Codigo = source.Codigo +WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Activo) + VALUES (source.Codigo, source.Nombre, source.Descripcion, 1); +GO + +PRINT 'Rol seeds applied (8 canonical roles).'; +GO diff --git a/database/migrations/V004__alter_usuario_rol_fk.sql b/database/migrations/V004__alter_usuario_rol_fk.sql new file mode 100644 index 0000000..de38ec8 --- /dev/null +++ b/database/migrations/V004__alter_usuario_rol_fk.sql @@ -0,0 +1,44 @@ +-- V004__alter_usuario_rol_fk.sql +-- Replaces the hardcoded CHECK constraint on Usuario.Rol with a FOREIGN KEY +-- against dbo.Rol(Codigo). Must run AFTER V003 (which creates dbo.Rol and seeds the +-- codes already in use, including 'admin'). +-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests) + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- 1) Drop the old hardcoded whitelist CHECK constraint (if still present). +IF EXISTS ( + SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_Usuario_Rol' + AND parent_object_id = OBJECT_ID(N'dbo.Usuario') +) +BEGIN + ALTER TABLE dbo.Usuario DROP CONSTRAINT CK_Usuario_Rol; + PRINT 'Dropped CK_Usuario_Rol (hardcoded whitelist).'; +END +ELSE +BEGIN + PRINT 'CK_Usuario_Rol not present — skipping drop.'; +END +GO + +-- 2) Add the FK Usuario.Rol -> Rol.Codigo (only if not already present). +IF NOT EXISTS ( + SELECT 1 FROM sys.foreign_keys + WHERE name = 'FK_Usuario_Rol' + AND parent_object_id = OBJECT_ID(N'dbo.Usuario') +) +BEGIN + ALTER TABLE dbo.Usuario + ADD CONSTRAINT FK_Usuario_Rol + FOREIGN KEY (Rol) REFERENCES dbo.Rol(Codigo); + PRINT 'Added FK_Usuario_Rol -> dbo.Rol(Codigo).'; +END +ELSE +BEGIN + PRINT 'FK_Usuario_Rol already present — skipping.'; +END +GO diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IRolRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IRolRepository.cs new file mode 100644 index 0000000..20f456d --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IRolRepository.cs @@ -0,0 +1,13 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IRolRepository +{ + Task> ListAsync(CancellationToken ct = default); + Task GetByCodigoAsync(string codigo, CancellationToken ct = default); + Task ExistsActiveByCodigoAsync(string codigo, CancellationToken ct = default); + Task AddAsync(Rol rol, CancellationToken ct = default); + Task UpdateAsync(string codigo, string nombre, string? descripcion, bool activo, CancellationToken ct = default); + Task HasActiveUsuariosAsync(string codigo, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Roles/Create/CreateRolCommand.cs b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommand.cs new file mode 100644 index 0000000..998ca64 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommand.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Roles.Create; + +public sealed record CreateRolCommand( + string Codigo, + string Nombre, + string? Descripcion); diff --git a/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandHandler.cs b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandHandler.cs new file mode 100644 index 0000000..072dab6 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandHandler.cs @@ -0,0 +1,36 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Roles.Create; + +public sealed class CreateRolCommandHandler : ICommandHandler +{ + private readonly IRolRepository _repository; + + public CreateRolCommandHandler(IRolRepository repository) + { + _repository = repository; + } + + public async Task Handle(CreateRolCommand command) + { + // Check-then-insert: explicit check produces a clear 409 message. + // SqlException 2627 (UQ violation) acts as race-condition fallback — caught in ExceptionFilter. + var existing = await _repository.GetByCodigoAsync(command.Codigo); + if (existing is not null) + throw new RolAlreadyExistsException(command.Codigo); + + var rol = Rol.ForCreation(command.Codigo, command.Nombre, command.Descripcion); + var newId = await _repository.AddAsync(rol); + + return new RolCreatedDto( + Id: newId, + Codigo: rol.Codigo, + Nombre: rol.Nombre, + Descripcion: rol.Descripcion, + Activo: rol.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandValidator.cs b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandValidator.cs new file mode 100644 index 0000000..2e7dfa8 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; + +namespace SIGCM2.Application.Roles.Create; + +public sealed class CreateRolCommandValidator : AbstractValidator +{ + private const int CodigoMinLength = 3; + private const int CodigoMaxLength = 30; + private const int NombreMaxLength = 60; + private const int DescripcionMaxLength = 250; + + public CreateRolCommandValidator() + { + RuleFor(x => x.Codigo) + .NotEmpty().WithMessage("El código es requerido.") + .Length(CodigoMinLength, CodigoMaxLength) + .WithMessage($"El código debe tener entre {CodigoMinLength} y {CodigoMaxLength} caracteres.") + .Matches(@"^[a-z][a-z0-9_]*$") + .WithMessage("El código debe empezar con una letra minúscula y contener solo minúsculas, dígitos o guion bajo."); + + 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/Roles/Deactivate/DeactivateRolCommand.cs b/src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommand.cs new file mode 100644 index 0000000..daf4f0d --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Roles.Deactivate; + +public sealed record DeactivateRolCommand(string Codigo); diff --git a/src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommandHandler.cs b/src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommandHandler.cs new file mode 100644 index 0000000..3aa630b --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommandHandler.cs @@ -0,0 +1,36 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Roles.Deactivate; + +public sealed class DeactivateRolCommandHandler : ICommandHandler +{ + private readonly IRolRepository _repository; + + public DeactivateRolCommandHandler(IRolRepository repository) + { + _repository = repository; + } + + public async Task Handle(DeactivateRolCommand command) + { + var existing = await _repository.GetByCodigoAsync(command.Codigo) + ?? throw new RolNotFoundException(command.Codigo); + + // Guard: block soft-delete when active usuarios reference this rol. + if (await _repository.HasActiveUsuariosAsync(command.Codigo)) + throw new RolInUseException(command.Codigo); + + var updated = await _repository.UpdateAsync( + existing.Codigo, existing.Nombre, existing.Descripcion, activo: false); + if (!updated) + throw new RolNotFoundException(command.Codigo); + + var rol = await _repository.GetByCodigoAsync(command.Codigo) + ?? throw new RolNotFoundException(command.Codigo); + + return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs b/src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs new file mode 100644 index 0000000..2be6060 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs @@ -0,0 +1,8 @@ +namespace SIGCM2.Application.Roles.Dtos; + +public sealed record RolCreatedDto( + int Id, + string Codigo, + string Nombre, + string? Descripcion, + bool Activo); diff --git a/src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs b/src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs new file mode 100644 index 0000000..0ca89a1 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Roles.Dtos; + +public sealed record RolDto( + int Id, + string Codigo, + string Nombre, + string? Descripcion, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQuery.cs b/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQuery.cs new file mode 100644 index 0000000..9ca0ec2 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Roles.Get; + +public sealed record GetRolByCodigoQuery(string Codigo); diff --git a/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQueryHandler.cs b/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQueryHandler.cs new file mode 100644 index 0000000..5cc65a1 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQueryHandler.cs @@ -0,0 +1,25 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Roles.Get; + +public sealed class GetRolByCodigoQueryHandler : ICommandHandler +{ + private readonly IRolRepository _repository; + + public GetRolByCodigoQueryHandler(IRolRepository repository) + { + _repository = repository; + } + + public async Task Handle(GetRolByCodigoQuery query) + { + var rol = await _repository.GetByCodigoAsync(query.Codigo); + if (rol is null) + throw new RolNotFoundException(query.Codigo); + + return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs b/src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs new file mode 100644 index 0000000..44b41a2 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Roles.List; + +public sealed record ListRolesQuery(); diff --git a/src/api/SIGCM2.Application/Roles/List/ListRolesQueryHandler.cs b/src/api/SIGCM2.Application/Roles/List/ListRolesQueryHandler.cs new file mode 100644 index 0000000..004a92f --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/List/ListRolesQueryHandler.cs @@ -0,0 +1,23 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Dtos; + +namespace SIGCM2.Application.Roles.List; + +public sealed class ListRolesQueryHandler : ICommandHandler> +{ + private readonly IRolRepository _repository; + + public ListRolesQueryHandler(IRolRepository repository) + { + _repository = repository; + } + + public async Task> Handle(ListRolesQuery query) + { + var roles = await _repository.ListAsync(); + return roles + .Select(r => new RolDto(r.Id, r.Codigo, r.Nombre, r.Descripcion, r.Activo, r.FechaCreacion, r.FechaModificacion)) + .ToList(); + } +} diff --git a/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommand.cs b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommand.cs new file mode 100644 index 0000000..3b6565c --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Roles.Update; + +public sealed record UpdateRolCommand( + string Codigo, + string Nombre, + string? Descripcion, + bool Activo); diff --git a/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandHandler.cs b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandHandler.cs new file mode 100644 index 0000000..eb14aba --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandHandler.cs @@ -0,0 +1,30 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Roles.Update; + +public sealed class UpdateRolCommandHandler : ICommandHandler +{ + private readonly IRolRepository _repository; + + public UpdateRolCommandHandler(IRolRepository repository) + { + _repository = repository; + } + + public async Task Handle(UpdateRolCommand command) + { + var updated = await _repository.UpdateAsync( + command.Codigo, command.Nombre, command.Descripcion, command.Activo); + + if (!updated) + throw new RolNotFoundException(command.Codigo); + + var rol = await _repository.GetByCodigoAsync(command.Codigo) + ?? throw new RolNotFoundException(command.Codigo); + + return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandValidator.cs b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandValidator.cs new file mode 100644 index 0000000..f3f711d --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; + +namespace SIGCM2.Application.Roles.Update; + +public sealed class UpdateRolCommandValidator : AbstractValidator +{ + private const int NombreMaxLength = 60; + private const int DescripcionMaxLength = 250; + + public UpdateRolCommandValidator() + { + // Codigo is taken from the URL route — we don't re-validate format here, + // but we require it to be non-empty so handler always has a target to match. + RuleFor(x => x.Codigo) + .NotEmpty().WithMessage("El código es requerido."); + + 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/Usuarios/Create/CreateUsuarioCommandValidator.cs b/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs index c200def..2ede281 100644 --- a/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs +++ b/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs @@ -5,7 +5,13 @@ namespace SIGCM2.Application.Usuarios.Create; public sealed class CreateUsuarioCommandValidator : AbstractValidator { - private static readonly string[] ValidRoles = ["admin", "vendedor", "tasador", "consulta"]; + // Whitelist aligned with canonical seeds in dbo.Rol (migration V003). + // Phase 5 of UDT-004 will replace this array with an async lookup against IRolRepository. + private static readonly string[] ValidRoles = + [ + "admin", "cajero", "operador_ctacte", "picadora", + "jefe_publicidad", "productor", "diagramacion", "reportes" + ]; private const int UsernameMinLength = 3; private const int UsernameMaxLength = 50; diff --git a/src/api/SIGCM2.Domain/Entities/Rol.cs b/src/api/SIGCM2.Domain/Entities/Rol.cs new file mode 100644 index 0000000..cadc65c --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/Rol.cs @@ -0,0 +1,44 @@ +namespace SIGCM2.Domain.Entities; + +public sealed class Rol +{ + public int Id { get; } + public string Codigo { get; } + public string Nombre { get; } + public string? Descripcion { get; } + public bool Activo { get; } + public DateTime FechaCreacion { get; } + public DateTime? FechaModificacion { get; } + + public Rol( + int id, + string codigo, + string nombre, + string? descripcion, + bool activo, + DateTime fechaCreacion, + DateTime? fechaModificacion) + { + Id = id; + Codigo = codigo; + Nombre = nombre; + Descripcion = descripcion; + Activo = activo; + FechaCreacion = fechaCreacion; + FechaModificacion = fechaModificacion; + } + + // Factory for creating a new Rol (Id=0 — DB assigns via IDENTITY; Activo=true; FechaCreacion set by DB default). + public static Rol ForCreation(string codigo, string nombre, string? descripcion) + { + return new Rol( + id: 0, + codigo: codigo, + nombre: nombre, + descripcion: descripcion, + activo: true, + fechaCreacion: default, + fechaModificacion: null + ); + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/RolAlreadyExistsException.cs b/src/api/SIGCM2.Domain/Exceptions/RolAlreadyExistsException.cs new file mode 100644 index 0000000..a45324e --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RolAlreadyExistsException.cs @@ -0,0 +1,12 @@ +namespace SIGCM2.Domain.Exceptions; + +public sealed class RolAlreadyExistsException : Exception +{ + public string Codigo { get; } + + public RolAlreadyExistsException(string codigo) + : base($"El rol '{codigo}' ya existe.") + { + Codigo = codigo; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/RolInUseException.cs b/src/api/SIGCM2.Domain/Exceptions/RolInUseException.cs new file mode 100644 index 0000000..477368b --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RolInUseException.cs @@ -0,0 +1,12 @@ +namespace SIGCM2.Domain.Exceptions; + +public sealed class RolInUseException : Exception +{ + public string Codigo { get; } + + public RolInUseException(string codigo) + : base($"El rol '{codigo}' no puede desactivarse porque existen usuarios activos que lo referencian.") + { + Codigo = codigo; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/RolNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/RolNotFoundException.cs new file mode 100644 index 0000000..2546444 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RolNotFoundException.cs @@ -0,0 +1,12 @@ +namespace SIGCM2.Domain.Exceptions; + +public sealed class RolNotFoundException : Exception +{ + public string Codigo { get; } + + public RolNotFoundException(string codigo) + : base($"El rol '{codigo}' no existe.") + { + Codigo = codigo; + } +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/RolRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/RolRepository.cs new file mode 100644 index 0000000..12f0ce4 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/RolRepository.cs @@ -0,0 +1,129 @@ +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class RolRepository : IRolRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public RolRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task> ListAsync(CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Codigo, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion + FROM dbo.Rol + ORDER BY Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql); + return rows.Select(MapRow).ToList(); + } + + public async Task GetByCodigoAsync(string codigo, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Codigo, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion + FROM dbo.Rol + WHERE Codigo = @Codigo + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Codigo = codigo }); + return row is null ? null : MapRow(row); + } + + public async Task ExistsActiveByCodigoAsync(string codigo, CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) FROM dbo.Rol WHERE Codigo = @Codigo AND Activo = 1 + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { Codigo = codigo }); + return count > 0; + } + + public async Task AddAsync(Rol rol, CancellationToken ct = default) + { + // DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()). + const string sql = """ + INSERT INTO dbo.Rol (Codigo, Nombre, Descripcion) + OUTPUT INSERTED.Id + VALUES (@Codigo, @Nombre, @Descripcion) + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new + { + rol.Codigo, + rol.Nombre, + rol.Descripcion, + }); + } + + public async Task UpdateAsync(string codigo, string nombre, string? descripcion, bool activo, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Rol + SET Nombre = @Nombre, + Descripcion = @Descripcion, + Activo = @Activo, + FechaModificacion = SYSUTCDATETIME() + WHERE Codigo = @Codigo; + SELECT @@ROWCOUNT; + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.ExecuteScalarAsync(sql, new { Codigo = codigo, Nombre = nombre, Descripcion = descripcion, Activo = activo }); + return rows > 0; + } + + public async Task HasActiveUsuariosAsync(string codigo, CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) FROM dbo.Usuario WHERE Rol = @Codigo AND Activo = 1 + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { Codigo = codigo }); + return count > 0; + } + + private static Rol MapRow(RolRow row) + => new( + id: row.Id, + codigo: row.Codigo, + nombre: row.Nombre, + descripcion: row.Descripcion, + activo: row.Activo, + fechaCreacion: row.FechaCreacion, + fechaModificacion: row.FechaModificacion); + + private sealed record RolRow( + int Id, + string Codigo, + string Nombre, + string? Descripcion, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); +} diff --git a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs index 3deba6f..a84669e 100644 --- a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs @@ -63,7 +63,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture 0, "'id' must be positive"); Assert.Equal(newUsername, username.GetString()); - Assert.Equal("vendedor", rol.GetString()); + Assert.Equal("cajero", rol.GetString()); // Must NOT contain passwordHash Assert.False(json.TryGetProperty("passwordHash", out _), "Response must NOT leak 'passwordHash'"); @@ -253,7 +253,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture( @@ -49,6 +52,29 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime await _connection.DisposeAsync(); } + private async Task SeedRolCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Rol AS t + USING (VALUES + ('admin', N'Administrador', N'Supervisor total'), + ('cajero', N'Cajero', N'Mostrador contado'), + ('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'), + ('picadora', N'Picadora/Correctora', N'Edición de textos'), + ('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'), + ('productor', N'Productor', N'Carga restringida'), + ('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'), + ('reportes', N'Reportes', N'Solo lectura reportes') + ) AS s (Codigo, Nombre, Descripcion) + ON t.Codigo = s.Codigo + WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Activo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, 1); + """; + await _connection.ExecuteAsync(sql); + } + private async Task SeedTestUserAsync() { await _connection.ExecuteAsync(""" diff --git a/tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs new file mode 100644 index 0000000..ee1cf60 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs @@ -0,0 +1,240 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.Domain.Entities; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Integration; + +[Collection("Database")] +public class RolRepositoryTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private RolRepository _repository = null!; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + + // Clean Usuario first (FK), then custom Rol codes created by tests. + await _connection.ExecuteAsync("DELETE FROM dbo.Usuario;"); + await _connection.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo NOT IN ('admin','cajero','operador_ctacte','picadora','jefe_publicidad','productor','diagramacion','reportes');"); + // Ensure canonical Rol seeds exist (idempotent — previous test classes may have wiped them via Respawn). + await SeedRolCanonicalAsync(); + // Reset any mutations applied to canonical seeds during prior tests. + await _connection.ExecuteAsync("UPDATE dbo.Rol SET Activo = 1, FechaModificacion = NULL WHERE Codigo IN ('admin','cajero','operador_ctacte','picadora','jefe_publicidad','productor','diagramacion','reportes');"); + // Seed admin usuario (needed by HasActiveUsuariosAsync test expecting admin active). + await _connection.ExecuteAsync( + "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " + + "VALUES ('admin', '$2a$12$hash', 'Administrador', 'Sistema', 'admin', '[\"*\"]', 1);"); + + var factory = new SqlConnectionFactory(ConnectionString); + _repository = new RolRepository(factory); + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + private async Task SeedRolCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Rol AS t + USING (VALUES + ('admin', N'Administrador', N'Supervisor total'), + ('cajero', N'Cajero', N'Mostrador contado'), + ('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'), + ('picadora', N'Picadora/Correctora', N'Edición de textos'), + ('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'), + ('productor', N'Productor', N'Carga restringida'), + ('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'), + ('reportes', N'Reportes', N'Solo lectura reportes') + ) AS s (Codigo, Nombre, Descripcion) + ON t.Codigo = s.Codigo + WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Activo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, 1); + """; + await _connection.ExecuteAsync(sql); + } + + // ── ListAsync ──────────────────────────────────────────────────────────── + + [Fact] + public async Task ListAsync_ReturnsAllCanonicalSeeds() + { + var list = await _repository.ListAsync(); + + var codes = list.Select(r => r.Codigo).ToHashSet(); + Assert.Contains("admin", codes); + Assert.Contains("cajero", codes); + Assert.Contains("operador_ctacte", codes); + Assert.Contains("picadora", codes); + Assert.Contains("jefe_publicidad", codes); + Assert.Contains("productor", codes); + Assert.Contains("diagramacion", codes); + Assert.Contains("reportes", codes); + Assert.True(list.Count >= 8); + } + + [Fact] + public async Task ListAsync_IncludesInactiveRoles() + { + // Triangulation: list must include deactivated rows too. + await _connection.ExecuteAsync("INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES ('listtest_inactive', N'Inactivo de test', 0);"); + + var list = await _repository.ListAsync(); + + var inactive = list.Single(r => r.Codigo == "listtest_inactive"); + Assert.False(inactive.Activo); + } + + // ── GetByCodigoAsync ──────────────────────────────────────────────────── + + [Fact] + public async Task GetByCodigoAsync_ExistingCodigo_ReturnsRol() + { + var rol = await _repository.GetByCodigoAsync("cajero"); + + Assert.NotNull(rol); + Assert.Equal("cajero", rol!.Codigo); + Assert.Equal("Cajero", rol.Nombre); + Assert.True(rol.Activo); + } + + [Fact] + public async Task GetByCodigoAsync_NonExistentCodigo_ReturnsNull() + { + var rol = await _repository.GetByCodigoAsync("no_existe"); + Assert.Null(rol); + } + + // ── ExistsActiveByCodigoAsync ─────────────────────────────────────────── + + [Fact] + public async Task ExistsActiveByCodigoAsync_ActiveCodigo_ReturnsTrue() + { + Assert.True(await _repository.ExistsActiveByCodigoAsync("admin")); + } + + [Fact] + public async Task ExistsActiveByCodigoAsync_InactiveCodigo_ReturnsFalse() + { + await _connection.ExecuteAsync("INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES ('exists_inactive', N'Test inactivo', 0);"); + + Assert.False(await _repository.ExistsActiveByCodigoAsync("exists_inactive")); + } + + [Fact] + public async Task ExistsActiveByCodigoAsync_MissingCodigo_ReturnsFalse() + { + Assert.False(await _repository.ExistsActiveByCodigoAsync("missing_codigo_xyz")); + } + + // ── AddAsync ──────────────────────────────────────────────────────────── + + [Fact] + public async Task AddAsync_NewRol_PersistsAndReturnsId() + { + var rol = Rol.ForCreation("addtest_new", "Add Test", "Rol de prueba add"); + + var newId = await _repository.AddAsync(rol); + + Assert.True(newId > 0); + + var persisted = await _connection.QuerySingleAsync<(string Codigo, string Nombre, string? Descripcion, bool Activo)>( + "SELECT Codigo, Nombre, Descripcion, Activo FROM dbo.Rol WHERE Id = @Id", + new { Id = newId }); + + Assert.Equal("addtest_new", persisted.Codigo); + Assert.Equal("Add Test", persisted.Nombre); + Assert.Equal("Rol de prueba add", persisted.Descripcion); + Assert.True(persisted.Activo); + } + + [Fact] + public async Task AddAsync_WithNullDescripcion_PersistsNull() + { + var rol = Rol.ForCreation("addtest_nulldesc", "Null Desc", null); + + var newId = await _repository.AddAsync(rol); + + var desc = await _connection.ExecuteScalarAsync( + "SELECT Descripcion FROM dbo.Rol WHERE Id = @Id", new { Id = newId }); + Assert.Null(desc); + } + + // ── UpdateAsync ───────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_ExistingCodigo_UpdatesMutableFieldsAndSetsFechaModificacion() + { + await _connection.ExecuteAsync( + "INSERT INTO dbo.Rol (Codigo, Nombre, Descripcion, Activo) VALUES ('updtest_one', N'Nombre Viejo', N'Desc vieja', 1);"); + + var updated = await _repository.UpdateAsync("updtest_one", "Nombre Nuevo", "Desc nueva", activo: true); + + Assert.True(updated); + + var row = await _connection.QuerySingleAsync<(string Nombre, string? Descripcion, bool Activo, DateTime? FechaModificacion)>( + "SELECT Nombre, Descripcion, Activo, FechaModificacion FROM dbo.Rol WHERE Codigo = 'updtest_one'"); + + Assert.Equal("Nombre Nuevo", row.Nombre); + Assert.Equal("Desc nueva", row.Descripcion); + Assert.True(row.Activo); + Assert.NotNull(row.FechaModificacion); + } + + [Fact] + public async Task UpdateAsync_NonExistentCodigo_ReturnsFalse() + { + var updated = await _repository.UpdateAsync("updtest_missing", "X", null, true); + Assert.False(updated); + } + + [Fact] + public async Task UpdateAsync_DoesNotChangeCodigo() + { + await _connection.ExecuteAsync( + "INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES ('updtest_codigo', N'Test Codigo', 1);"); + + await _repository.UpdateAsync("updtest_codigo", "Nombre Cambiado", null, true); + + var stillExists = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM dbo.Rol WHERE Codigo = 'updtest_codigo';"); + Assert.Equal(1, stillExists); + } + + // ── HasActiveUsuariosAsync ────────────────────────────────────────────── + + [Fact] + public async Task HasActiveUsuariosAsync_WithActiveUsuario_ReturnsTrue() + { + // 'admin' Usuario is seeded active and references Rol.admin. + Assert.True(await _repository.HasActiveUsuariosAsync("admin")); + } + + [Fact] + public async Task HasActiveUsuariosAsync_NoUsuariosReferencing_ReturnsFalse() + { + // 'reportes' seed has no Usuario referencing it in a clean test DB. + Assert.False(await _repository.HasActiveUsuariosAsync("reportes")); + } + + [Fact] + public async Task HasActiveUsuariosAsync_OnlyInactiveUsuarioReferencing_ReturnsFalse() + { + // Insert an INACTIVE usuario referencing 'cajero'. + await _connection.ExecuteAsync( + "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " + + "VALUES ('inactivo1', '$2a$12$hash', 'Test', 'Inactivo', 'cajero', '[]', 0);"); + + Assert.False(await _repository.HasActiveUsuariosAsync("cajero")); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs index 6e1e66d..33e55f7 100644 --- a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs @@ -21,11 +21,14 @@ public class UsuarioRepositoryTests : IAsyncLifetime _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { - DbAdapter = DbAdapter.SqlServer + DbAdapter = DbAdapter.SqlServer, + // Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks. + TablesToIgnore = [new Respawn.Graph.Table("dbo", "Rol")] }); - // Reset DB and seed admin user for each test class run + // Reset DB, re-seed Rol canonical table (lookup) and admin user for each test class run. await _respawner.ResetAsync(_connection); + await SeedRolCanonicalAsync(); await SeedAdminAsync(); var factory = new SqlConnectionFactory(ConnectionString); @@ -62,21 +65,44 @@ public class UsuarioRepositoryTests : IAsyncLifetime // Triangulation: case-sensitive username lookup (SQL Server UNIQUE constraint is case-insensitive by default) [Fact] - public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser() + public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser_Cajero() { - // Insert a second user + // Insert a second user with canonical rol 'cajero' (post-UDT-004 FK requires Rol.Codigo to exist). await _connection.ExecuteAsync( "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) " + - "VALUES ('vendedor1', '$2a$12$hash2', 'Juan', 'Pérez', 'vendedor', '[]')"); + "VALUES ('cajero1', '$2a$12$hash2', 'Juan', 'Pérez', 'cajero', '[]')"); var admin = await _repository.GetByUsernameAsync("admin"); - var vendedor = await _repository.GetByUsernameAsync("vendedor1"); + var cajero = await _repository.GetByUsernameAsync("cajero1"); Assert.NotNull(admin); - Assert.NotNull(vendedor); - Assert.NotEqual(admin.Id, vendedor.Id); + Assert.NotNull(cajero); + Assert.NotEqual(admin.Id, cajero.Id); Assert.Equal("admin", admin.Rol); - Assert.Equal("vendedor", vendedor.Rol); + Assert.Equal("cajero", cajero.Rol); + } + + private async Task SeedRolCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Rol AS t + USING (VALUES + ('admin', N'Administrador', N'Supervisor total'), + ('cajero', N'Cajero', N'Mostrador contado'), + ('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'), + ('picadora', N'Picadora/Correctora', N'Edición de textos'), + ('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'), + ('productor', N'Productor', N'Carga restringida'), + ('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'), + ('reportes', N'Reportes', N'Solo lectura reportes') + ) AS s (Codigo, Nombre, Descripcion) + ON t.Codigo = s.Codigo + WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Activo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, 1); + """; + await _connection.ExecuteAsync(sql); } private async Task SeedAdminAsync() diff --git a/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandHandlerTests.cs new file mode 100644 index 0000000..04b47f2 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandHandlerTests.cs @@ -0,0 +1,86 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Create; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Roles.Create; + +public class CreateRolCommandHandlerTests +{ + private readonly IRolRepository _repository = Substitute.For(); + private readonly CreateRolCommandHandler _handler; + + private static CreateRolCommand ValidCommand() => new("cajero_senior", "Cajero Senior", "Con más permisos"); + + public CreateRolCommandHandlerTests() + { + _handler = new CreateRolCommandHandler(_repository); + } + + [Fact] + public async Task Handle_CodigoDuplicado_ThrowsRolAlreadyExistsException() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _repository.GetByCodigoAsync("cajero_senior") + .Returns(new Rol(99, "cajero_senior", "Cajero Senior", null, true, now, null)); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + + Assert.Equal("cajero_senior", ex.Codigo); + } + + [Fact] + public async Task Handle_CodigoDuplicado_DoesNotCallAddAsync() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _repository.GetByCodigoAsync(Arg.Any()) + .Returns(new Rol(1, "cajero_senior", "X", null, true, now, null)); + + try { await _handler.Handle(ValidCommand()); } catch (RolAlreadyExistsException) { } + + await _repository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Happy_AddsAndReturnsDtoWithId() + { + _repository.GetByCodigoAsync(Arg.Any()).Returns((Rol?)null); + _repository.AddAsync(Arg.Any(), Arg.Any()).Returns(42); + + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(42, result.Id); + Assert.Equal("cajero_senior", result.Codigo); + Assert.Equal("Cajero Senior", result.Nombre); + Assert.Equal("Con más permisos", result.Descripcion); + Assert.True(result.Activo); + } + + [Fact] + public async Task Handle_Happy_CallsAddAsyncOnce() + { + _repository.GetByCodigoAsync(Arg.Any()).Returns((Rol?)null); + _repository.AddAsync(Arg.Any(), Arg.Any()).Returns(5); + + await _handler.Handle(ValidCommand()); + + await _repository.Received(1).AddAsync( + Arg.Is(r => r.Codigo == "cajero_senior" && r.Nombre == "Cajero Senior" && r.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_Happy_WithNullDescripcion_PassesNullToRepository() + { + _repository.GetByCodigoAsync(Arg.Any()).Returns((Rol?)null); + _repository.AddAsync(Arg.Any(), Arg.Any()).Returns(1); + + await _handler.Handle(new CreateRolCommand("nuevo_rol", "Nuevo", null)); + + await _repository.Received(1).AddAsync( + Arg.Is(r => r.Descripcion == null), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandValidatorTests.cs new file mode 100644 index 0000000..51476a8 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandValidatorTests.cs @@ -0,0 +1,97 @@ +using FluentValidation.TestHelper; +using SIGCM2.Application.Roles.Create; + +namespace SIGCM2.Application.Tests.Roles.Create; + +public class CreateRolCommandValidatorTests +{ + private static CreateRolCommandValidator BuildValidator() => new(); + private static CreateRolCommand Valid() => new("cajero_senior", "Cajero Senior", "Cajero con permisos extendidos"); + + // ── Happy path ───────────────────────────────────────────────────────── + + [Fact] + public void Validate_Valid_NoErrors() + { + BuildValidator().TestValidate(Valid()).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_NullDescripcion_IsValid() + { + BuildValidator().TestValidate(Valid() with { Descripcion = null }).ShouldNotHaveAnyValidationErrors(); + } + + // ── Codigo ───────────────────────────────────────────────────────────── + + [Fact] + public void Validate_EmptyCodigo_HasError() + { + BuildValidator().TestValidate(Valid() with { Codigo = "" }) + .ShouldHaveValidationErrorFor(c => c.Codigo); + } + + [Fact] + public void Validate_CodigoTooShort_HasError() + { + BuildValidator().TestValidate(Valid() with { Codigo = "ab" }) + .ShouldHaveValidationErrorFor(c => c.Codigo); + } + + [Fact] + public void Validate_CodigoTooLong_HasError() + { + BuildValidator().TestValidate(Valid() with { Codigo = new string('a', 31) }) + .ShouldHaveValidationErrorFor(c => c.Codigo); + } + + [Theory] + [InlineData("abc")] // boundary short + [InlineData("cajero")] + [InlineData("operador_ctacte")] + [InlineData("jefe_publicidad")] + [InlineData("a1b2")] + public void Validate_CodigoValidFormats_NoError(string codigo) + { + BuildValidator().TestValidate(Valid() with { Codigo = codigo }) + .ShouldNotHaveValidationErrorFor(c => c.Codigo); + } + + [Theory] + [InlineData("Cajero")] // uppercase + [InlineData("1cajero")] // starts with digit + [InlineData("_cajero")] // starts with underscore + [InlineData("cajero senior")] // space + [InlineData("cajero-senior")] // dash + [InlineData("cajero.senior")] // dot + public void Validate_CodigoInvalidFormats_HasError(string codigo) + { + BuildValidator().TestValidate(Valid() with { Codigo = codigo }) + .ShouldHaveValidationErrorFor(c => c.Codigo); + } + + // ── Nombre ───────────────────────────────────────────────────────────── + + [Fact] + public void Validate_EmptyNombre_HasError() + { + BuildValidator().TestValidate(Valid() with { Nombre = "" }) + .ShouldHaveValidationErrorFor(c => c.Nombre); + } + + [Fact] + public void Validate_NombreTooLong_HasError() + { + BuildValidator().TestValidate(Valid() with { Nombre = new string('a', 61) }) + .ShouldHaveValidationErrorFor(c => c.Nombre); + } + + // ── Descripcion ──────────────────────────────────────────────────────── + + [Fact] + public void Validate_DescripcionTooLong_HasError() + { + BuildValidator().TestValidate(Valid() with { Descripcion = new string('a', 251) }) + .ShouldHaveValidationErrorFor(c => c.Descripcion); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/Deactivate/DeactivateRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/Deactivate/DeactivateRolCommandHandlerTests.cs new file mode 100644 index 0000000..71af502 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Deactivate/DeactivateRolCommandHandlerTests.cs @@ -0,0 +1,98 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Roles.Deactivate; + +public class DeactivateRolCommandHandlerTests +{ + private readonly IRolRepository _repository = Substitute.For(); + private readonly DeactivateRolCommandHandler _handler; + + private static Rol RolActive(string codigo, int id = 10) + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + return new Rol(id, codigo, "Nombre", "Desc", true, now, null); + } + + public DeactivateRolCommandHandlerTests() + { + _handler = new DeactivateRolCommandHandler(_repository); + } + + [Fact] + public async Task Handle_NonExistentCodigo_ThrowsRolNotFoundException() + { + _repository.GetByCodigoAsync("missing").Returns((Rol?)null); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateRolCommand("missing"))); + + Assert.Equal("missing", ex.Codigo); + } + + [Fact] + public async Task Handle_CodigoConUsuariosActivos_ThrowsRolInUseException() + { + _repository.GetByCodigoAsync("cajero").Returns(RolActive("cajero")); + _repository.HasActiveUsuariosAsync("cajero").Returns(true); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateRolCommand("cajero"))); + + Assert.Equal("cajero", ex.Codigo); + } + + [Fact] + public async Task Handle_RolInUse_DoesNotCallUpdateAsync() + { + _repository.GetByCodigoAsync("cajero").Returns(RolActive("cajero")); + _repository.HasActiveUsuariosAsync("cajero").Returns(true); + + try { await _handler.Handle(new DeactivateRolCommand("cajero")); } catch (RolInUseException) { } + + await _repository.DidNotReceive().UpdateAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Happy_SetsActivoFalseAndReturnsDto() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + var afterDeactivation = new DateTime(2026, 4, 15, 13, 0, 0, DateTimeKind.Utc); + + _repository.GetByCodigoAsync("reportes") + .Returns( + RolActive("reportes", 20), + new Rol(20, "reportes", "Nombre", "Desc", false, now, afterDeactivation)); + _repository.HasActiveUsuariosAsync("reportes").Returns(false); + _repository.UpdateAsync("reportes", "Nombre", "Desc", false, Arg.Any()) + .Returns(true); + + var dto = await _handler.Handle(new DeactivateRolCommand("reportes")); + + Assert.Equal(20, dto.Id); + Assert.False(dto.Activo); + Assert.Equal(afterDeactivation, dto.FechaModificacion); + } + + [Fact] + public async Task Handle_Happy_CallsUpdateAsyncWithActivoFalse() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + + _repository.GetByCodigoAsync("reportes") + .Returns( + RolActive("reportes"), + new Rol(10, "reportes", "Nombre", "Desc", false, now, now)); + _repository.HasActiveUsuariosAsync("reportes").Returns(false); + _repository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + await _handler.Handle(new DeactivateRolCommand("reportes")); + + await _repository.Received(1).UpdateAsync("reportes", "Nombre", "Desc", false, Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/Get/GetRolByCodigoQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/Get/GetRolByCodigoQueryHandlerTests.cs new file mode 100644 index 0000000..289e9e3 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Get/GetRolByCodigoQueryHandlerTests.cs @@ -0,0 +1,43 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Get; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Roles.Get; + +public class GetRolByCodigoQueryHandlerTests +{ + private readonly IRolRepository _repository = Substitute.For(); + private readonly GetRolByCodigoQueryHandler _handler; + + public GetRolByCodigoQueryHandlerTests() + { + _handler = new GetRolByCodigoQueryHandler(_repository); + } + + [Fact] + public async Task Handle_ExistingCodigo_ReturnsDto() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _repository.GetByCodigoAsync("cajero").Returns(new Rol(5, "cajero", "Cajero", "Desc", true, now, null)); + + var dto = await _handler.Handle(new GetRolByCodigoQuery("cajero")); + + Assert.Equal(5, dto.Id); + Assert.Equal("cajero", dto.Codigo); + Assert.Equal("Cajero", dto.Nombre); + Assert.True(dto.Activo); + } + + [Fact] + public async Task Handle_NonExistentCodigo_ThrowsRolNotFoundException() + { + _repository.GetByCodigoAsync("missing").Returns((Rol?)null); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new GetRolByCodigoQuery("missing"))); + + Assert.Equal("missing", ex.Codigo); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/List/ListRolesQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/List/ListRolesQueryHandlerTests.cs new file mode 100644 index 0000000..8c4510a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/List/ListRolesQueryHandlerTests.cs @@ -0,0 +1,62 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Roles.List; + +public class ListRolesQueryHandlerTests +{ + private readonly IRolRepository _repository = Substitute.For(); + private readonly ListRolesQueryHandler _handler; + + public ListRolesQueryHandlerTests() + { + _handler = new ListRolesQueryHandler(_repository); + } + + [Fact] + public async Task Handle_ReturnsAllRolesFromRepositoryAsDtos() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _repository.ListAsync().Returns(new List + { + new(1, "admin", "Administrador", "Desc admin", true, now, null), + new(2, "cajero", "Cajero", "Desc cajero", true, now, null), + }); + + var result = await _handler.Handle(new ListRolesQuery()); + + Assert.Equal(2, result.Count); + Assert.Equal("admin", result[0].Codigo); + Assert.Equal("Administrador", result[0].Nombre); + Assert.Equal("cajero", result[1].Codigo); + } + + [Fact] + public async Task Handle_IncludesInactiveRoles() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _repository.ListAsync().Returns(new List + { + new(1, "active_code", "Activo", null, true, now, null), + new(2, "inactive_code", "Inactivo", null, false, now, null), + }); + + var result = await _handler.Handle(new ListRolesQuery()); + + Assert.Equal(2, result.Count); + Assert.True(result[0].Activo); + Assert.False(result[1].Activo); + } + + [Fact] + public async Task Handle_EmptyRepository_ReturnsEmptyList() + { + _repository.ListAsync().Returns(new List()); + + var result = await _handler.Handle(new ListRolesQuery()); + + Assert.Empty(result); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandHandlerTests.cs new file mode 100644 index 0000000..9edb511 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandHandlerTests.cs @@ -0,0 +1,64 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Roles.Update; + +public class UpdateRolCommandHandlerTests +{ + private readonly IRolRepository _repository = Substitute.For(); + private readonly UpdateRolCommandHandler _handler; + + public UpdateRolCommandHandlerTests() + { + _handler = new UpdateRolCommandHandler(_repository); + } + + [Fact] + public async Task Handle_NonExistentCodigo_ThrowsRolNotFoundException() + { + _repository.UpdateAsync("missing", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new UpdateRolCommand("missing", "X", null, true))); + + Assert.Equal("missing", ex.Codigo); + } + + [Fact] + public async Task Handle_Happy_ReturnsDtoWithUpdatedFields() + { + var fechaCreacion = new DateTime(2026, 4, 10, 9, 0, 0, DateTimeKind.Utc); + var fechaModificacion = new DateTime(2026, 4, 15, 12, 0, 0, DateTimeKind.Utc); + + _repository.UpdateAsync("cajero", "Cajero V2", "Desc V2", true, Arg.Any()) + .Returns(true); + _repository.GetByCodigoAsync("cajero") + .Returns(new Rol(10, "cajero", "Cajero V2", "Desc V2", true, fechaCreacion, fechaModificacion)); + + var dto = await _handler.Handle(new UpdateRolCommand("cajero", "Cajero V2", "Desc V2", true)); + + Assert.Equal(10, dto.Id); + Assert.Equal("Cajero V2", dto.Nombre); + Assert.Equal("Desc V2", dto.Descripcion); + Assert.True(dto.Activo); + Assert.Equal(fechaModificacion, dto.FechaModificacion); + } + + [Fact] + public async Task Handle_Happy_CallsUpdateAsyncWithExactFields() + { + var now = new DateTime(2026, 4, 15, 12, 0, 0, DateTimeKind.Utc); + _repository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + _repository.GetByCodigoAsync("cajero") + .Returns(new Rol(1, "cajero", "X", null, false, now, now)); + + await _handler.Handle(new UpdateRolCommand("cajero", "X", null, false)); + + await _repository.Received(1).UpdateAsync("cajero", "X", null, false, Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandValidatorTests.cs new file mode 100644 index 0000000..562c51a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandValidatorTests.cs @@ -0,0 +1,51 @@ +using FluentValidation.TestHelper; +using SIGCM2.Application.Roles.Update; + +namespace SIGCM2.Application.Tests.Roles.Update; + +public class UpdateRolCommandValidatorTests +{ + private static UpdateRolCommandValidator BuildValidator() => new(); + private static UpdateRolCommand Valid() => new("cajero", "Cajero Updated", "Desc updated", true); + + [Fact] + public void Validate_Valid_NoErrors() + { + BuildValidator().TestValidate(Valid()).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyCodigo_HasError() + { + BuildValidator().TestValidate(Valid() with { Codigo = "" }) + .ShouldHaveValidationErrorFor(c => c.Codigo); + } + + [Fact] + public void Validate_EmptyNombre_HasError() + { + BuildValidator().TestValidate(Valid() with { Nombre = "" }) + .ShouldHaveValidationErrorFor(c => c.Nombre); + } + + [Fact] + public void Validate_NombreTooLong_HasError() + { + BuildValidator().TestValidate(Valid() with { Nombre = new string('a', 61) }) + .ShouldHaveValidationErrorFor(c => c.Nombre); + } + + [Fact] + public void Validate_NullDescripcion_Allowed() + { + BuildValidator().TestValidate(Valid() with { Descripcion = null }) + .ShouldNotHaveValidationErrorFor(c => c.Descripcion); + } + + [Fact] + public void Validate_DescripcionTooLong_HasError() + { + BuildValidator().TestValidate(Valid() with { Descripcion = new string('a', 251) }) + .ShouldHaveValidationErrorFor(c => c.Descripcion); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs index 53dacf8..f1c5ced 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs @@ -15,7 +15,7 @@ public class CreateUsuarioCommandValidatorTests Nombre: "Juan", Apellido: "Pérez", Email: null, - Rol: "vendedor"); + Rol: "cajero"); // ── Happy paths ────────────────────────────────────────────────────────── @@ -156,9 +156,13 @@ public class CreateUsuarioCommandValidatorTests [Theory] [InlineData("admin")] - [InlineData("vendedor")] - [InlineData("tasador")] - [InlineData("consulta")] + [InlineData("cajero")] + [InlineData("operador_ctacte")] + [InlineData("picadora")] + [InlineData("jefe_publicidad")] + [InlineData("productor")] + [InlineData("diagramacion")] + [InlineData("reportes")] public void Validate_ValidRoles_NoError(string rol) { var cmd = ValidCommand() with { Rol = rol }; diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index d4b1c63..ac9419d 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -28,7 +28,9 @@ public sealed class SqlTestFixture : IAsyncLifetime _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { - DbAdapter = DbAdapter.SqlServer + DbAdapter = DbAdapter.SqlServer, + // Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks. + TablesToIgnore = [new Respawn.Graph.Table("dbo", "Rol")] }); await ResetAndSeedAsync(); @@ -37,9 +39,33 @@ public sealed class SqlTestFixture : IAsyncLifetime public async Task ResetAndSeedAsync() { await _respawner.ResetAsync(_connection); + await SeedRolCanonicalAsync(); await SeedAdminAsync(); } + private async Task SeedRolCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Rol AS t + USING (VALUES + ('admin', N'Administrador', N'Supervisor total'), + ('cajero', N'Cajero', N'Mostrador contado'), + ('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'), + ('picadora', N'Picadora/Correctora', N'Edición de textos'), + ('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'), + ('productor', N'Productor', N'Carga restringida'), + ('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'), + ('reportes', N'Reportes', N'Solo lectura reportes') + ) AS s (Codigo, Nombre, Descripcion) + ON t.Codigo = s.Codigo + WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Activo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, 1); + """; + await _connection.ExecuteAsync(sql); + } + public async Task DisposeAsync() { if (_connection is not null)