UDT-004: Gestión de Roles (tabla maestra + CRUD admin + validator dinámico + UI) #8

Merged
dmolinari merged 4 commits from feature/UDT-004 into main 2026-04-15 16:19:58 +00:00
37 changed files with 1510 additions and 27 deletions
Showing only changes of commit 34b714750a - Show all commits

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,13 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
public interface IRolRepository
{
Task<IReadOnlyList<Rol>> ListAsync(CancellationToken ct = default);
Task<Rol?> GetByCodigoAsync(string codigo, CancellationToken ct = default);
Task<bool> ExistsActiveByCodigoAsync(string codigo, CancellationToken ct = default);
Task<int> AddAsync(Rol rol, CancellationToken ct = default);
Task<bool> UpdateAsync(string codigo, string nombre, string? descripcion, bool activo, CancellationToken ct = default);
Task<bool> HasActiveUsuariosAsync(string codigo, CancellationToken ct = default);
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Roles.Create;
public sealed record CreateRolCommand(
string Codigo,
string Nombre,
string? Descripcion);

View File

@@ -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<CreateRolCommand, RolCreatedDto>
{
private readonly IRolRepository _repository;
public CreateRolCommandHandler(IRolRepository repository)
{
_repository = repository;
}
public async Task<RolCreatedDto> 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);
}
}

View File

@@ -0,0 +1,31 @@
using FluentValidation;
namespace SIGCM2.Application.Roles.Create;
public sealed class CreateRolCommandValidator : AbstractValidator<CreateRolCommand>
{
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);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Roles.Deactivate;
public sealed record DeactivateRolCommand(string Codigo);

View File

@@ -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<DeactivateRolCommand, RolDto>
{
private readonly IRolRepository _repository;
public DeactivateRolCommandHandler(IRolRepository repository)
{
_repository = repository;
}
public async Task<RolDto> 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);
}
}

View File

@@ -0,0 +1,8 @@
namespace SIGCM2.Application.Roles.Dtos;
public sealed record RolCreatedDto(
int Id,
string Codigo,
string Nombre,
string? Descripcion,
bool Activo);

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Roles.Get;
public sealed record GetRolByCodigoQuery(string Codigo);

View File

@@ -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<GetRolByCodigoQuery, RolDto>
{
private readonly IRolRepository _repository;
public GetRolByCodigoQueryHandler(IRolRepository repository)
{
_repository = repository;
}
public async Task<RolDto> 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);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Roles.List;
public sealed record ListRolesQuery();

View File

@@ -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<ListRolesQuery, IReadOnlyList<RolDto>>
{
private readonly IRolRepository _repository;
public ListRolesQueryHandler(IRolRepository repository)
{
_repository = repository;
}
public async Task<IReadOnlyList<RolDto>> 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();
}
}

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Roles.Update;
public sealed record UpdateRolCommand(
string Codigo,
string Nombre,
string? Descripcion,
bool Activo);

View File

@@ -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<UpdateRolCommand, RolDto>
{
private readonly IRolRepository _repository;
public UpdateRolCommandHandler(IRolRepository repository)
{
_repository = repository;
}
public async Task<RolDto> 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);
}
}

View File

@@ -0,0 +1,27 @@
using FluentValidation;
namespace SIGCM2.Application.Roles.Update;
public sealed class UpdateRolCommandValidator : AbstractValidator<UpdateRolCommand>
{
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);
}
}

View File

@@ -5,7 +5,13 @@ namespace SIGCM2.Application.Usuarios.Create;
public sealed class CreateUsuarioCommandValidator : AbstractValidator<CreateUsuarioCommand> public sealed class CreateUsuarioCommandValidator : AbstractValidator<CreateUsuarioCommand>
{ {
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 UsernameMinLength = 3;
private const int UsernameMaxLength = 50; private const int UsernameMaxLength = 50;

View File

@@ -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
);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<IReadOnlyList<Rol>> 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<RolRow>(sql);
return rows.Select(MapRow).ToList();
}
public async Task<Rol?> 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<RolRow>(sql, new { Codigo = codigo });
return row is null ? null : MapRow(row);
}
public async Task<bool> 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<int>(sql, new { Codigo = codigo });
return count > 0;
}
public async Task<int> 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<int>(sql, new
{
rol.Codigo,
rol.Nombre,
rol.Descripcion,
});
}
public async Task<bool> 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<int>(sql, new { Codigo = codigo, Nombre = nombre, Descripcion = descripcion, Activo = activo });
return rows > 0;
}
public async Task<bool> 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<int>(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);
}

View File

@@ -63,7 +63,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
nombre = "Test", nombre = "Test",
apellido = "Usuario", apellido = "Usuario",
email = (string?)null, email = (string?)null,
rol = "vendedor" rol = "cajero"
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -76,7 +76,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
await conn.ExecuteAsync(""" await conn.ExecuteAsync("""
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = @Username) IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = @Username)
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo)
VALUES (@Username, @Hash, 'Vendedor', 'Test', 'vendedor', '[]', 1) VALUES (@Username, @Hash, 'Vendedor', 'Test', 'cajero', '[]', 1)
""", """,
new { Username = username, Hash = passwordHash }); new { Username = username, Hash = passwordHash });
} }
@@ -126,7 +126,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
nombre = "Vendedor", nombre = "Vendedor",
apellido = "Test", apellido = "Test",
email = (string?)null, email = (string?)null,
rol = "vendedor" rol = "cajero"
}, adminToken); }, adminToken);
var createResp = await _client.SendAsync(createRequest); var createResp = await _client.SendAsync(createRequest);
@@ -190,7 +190,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
nombre = "Integration", nombre = "Integration",
apellido = "Test", apellido = "Test",
email = "integration@test.com", email = "integration@test.com",
rol = "vendedor" rol = "cajero"
}, adminToken); }, adminToken);
try try
@@ -211,7 +211,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
Assert.True(id.GetInt32() > 0, "'id' must be positive"); Assert.True(id.GetInt32() > 0, "'id' must be positive");
Assert.Equal(newUsername, username.GetString()); Assert.Equal(newUsername, username.GetString());
Assert.Equal("vendedor", rol.GetString()); Assert.Equal("cajero", rol.GetString());
// Must NOT contain passwordHash // Must NOT contain passwordHash
Assert.False(json.TryGetProperty("passwordHash", out _), "Response must NOT leak 'passwordHash'"); Assert.False(json.TryGetProperty("passwordHash", out _), "Response must NOT leak 'passwordHash'");
@@ -253,7 +253,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
nombre = "First", nombre = "First",
apellido = "User", apellido = "User",
email = (string?)null, email = (string?)null,
rol = "vendedor" rol = "cajero"
}, adminToken); }, adminToken);
var firstResp = await _client.SendAsync(first); var firstResp = await _client.SendAsync(first);
Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode); Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode);
@@ -266,7 +266,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
nombre = "Second", nombre = "Second",
apellido = "User", apellido = "User",
email = (string?)null, email = (string?)null,
rol = "consulta" rol = "reportes"
}, adminToken); }, adminToken);
var secondResp = await _client.SendAsync(second); var secondResp = await _client.SendAsync(second);
@@ -299,7 +299,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
await conn.ExecuteAsync(""" await conn.ExecuteAsync("""
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = @Username) IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = @Username)
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo)
VALUES (@Username, '$2a$12$placeholder_hash_for_race_test', 'Race', 'User', 'vendedor', '[]', 1) VALUES (@Username, '$2a$12$placeholder_hash_for_race_test', 'Race', 'User', 'cajero', '[]', 1)
""", new { Username = username }); """, new { Username = username });
try try
@@ -314,7 +314,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
nombre = "Race", nombre = "Race",
apellido = "User", apellido = "User",
email = (string?)null, email = (string?)null,
rol = "vendedor" rol = "cajero"
}, adminToken); }, adminToken);
var response = await _client.SendAsync(request); var response = await _client.SendAsync(request);
@@ -357,7 +357,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
nombre = "E2E", nombre = "E2E",
apellido = "Test", apellido = "Test",
email = (string?)null, email = (string?)null,
rol = "vendedor" rol = "cajero"
}, adminToken); }, adminToken);
var createResp = await _client.SendAsync(createReq); var createResp = await _client.SendAsync(createReq);
@@ -380,7 +380,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
// Verify usuario in response // Verify usuario in response
Assert.True(loginJson.TryGetProperty("usuario", out var usuario)); Assert.True(loginJson.TryGetProperty("usuario", out var usuario));
Assert.Equal("vendedor", usuario.GetProperty("rol").GetString()); Assert.Equal("cajero", usuario.GetProperty("rol").GetString());
} }
finally finally
{ {

View File

@@ -0,0 +1,82 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Domain;
public class RolTests
{
// Happy path: full constructor sets all properties.
[Fact]
public void Constructor_SetsAllProperties()
{
var created = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc);
var modified = new DateTime(2026, 4, 15, 11, 0, 0, DateTimeKind.Utc);
var rol = new Rol(
id: 1,
codigo: "cajero",
nombre: "Cajero",
descripcion: "Atención de mostrador",
activo: true,
fechaCreacion: created,
fechaModificacion: modified
);
Assert.Equal(1, rol.Id);
Assert.Equal("cajero", rol.Codigo);
Assert.Equal("Cajero", rol.Nombre);
Assert.Equal("Atención de mostrador", rol.Descripcion);
Assert.True(rol.Activo);
Assert.Equal(created, rol.FechaCreacion);
Assert.Equal(modified, rol.FechaModificacion);
}
// Triangulation: descripcion is nullable, fechaModificacion is nullable.
[Fact]
public void Constructor_WithNullOptionals_SetsNulls()
{
var created = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc);
var rol = new Rol(
id: 2,
codigo: "reportes",
nombre: "Reportes",
descripcion: null,
activo: false,
fechaCreacion: created,
fechaModificacion: null
);
Assert.Null(rol.Descripcion);
Assert.Null(rol.FechaModificacion);
Assert.False(rol.Activo);
}
// ForCreation: Id=0 (IDENTITY assigned by DB), Activo=true, FechaCreacion=SYSUTCDATETIME-ish (not set here), FechaModificacion=null.
[Fact]
public void ForCreation_ReturnsNewInstanceWithDefaults()
{
var rol = Rol.ForCreation(
codigo: "picadora",
nombre: "Picadora/Correctora",
descripcion: "Edición de textos"
);
Assert.Equal(0, rol.Id);
Assert.Equal("picadora", rol.Codigo);
Assert.Equal("Picadora/Correctora", rol.Nombre);
Assert.Equal("Edición de textos", rol.Descripcion);
Assert.True(rol.Activo);
Assert.Null(rol.FechaModificacion);
}
// Triangulation: ForCreation accepts null descripcion.
[Fact]
public void ForCreation_WithNullDescripcion_AllowsNull()
{
var rol = Rol.ForCreation(codigo: "admin", nombre: "Administrador", descripcion: null);
Assert.Null(rol.Descripcion);
Assert.Equal("admin", rol.Codigo);
Assert.True(rol.Activo);
}
}

View File

@@ -29,10 +29,13 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions _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 _respawner.ResetAsync(_connection); await _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
await SeedTestUserAsync(); await SeedTestUserAsync();
_testUserId = await _connection.QuerySingleAsync<int>( _testUserId = await _connection.QuerySingleAsync<int>(
@@ -49,6 +52,29 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
await _connection.DisposeAsync(); 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() private async Task SeedTestUserAsync()
{ {
await _connection.ExecuteAsync(""" await _connection.ExecuteAsync("""

View File

@@ -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<string?>(
"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<int>(
"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"));
}
}

View File

@@ -21,11 +21,14 @@ public class UsuarioRepositoryTests : IAsyncLifetime
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions _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 _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
await SeedAdminAsync(); await SeedAdminAsync();
var factory = new SqlConnectionFactory(ConnectionString); 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) // Triangulation: case-sensitive username lookup (SQL Server UNIQUE constraint is case-insensitive by default)
[Fact] [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( await _connection.ExecuteAsync(
"INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) " + "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 admin = await _repository.GetByUsernameAsync("admin");
var vendedor = await _repository.GetByUsernameAsync("vendedor1"); var cajero = await _repository.GetByUsernameAsync("cajero1");
Assert.NotNull(admin); Assert.NotNull(admin);
Assert.NotNull(vendedor); Assert.NotNull(cajero);
Assert.NotEqual(admin.Id, vendedor.Id); Assert.NotEqual(admin.Id, cajero.Id);
Assert.Equal("admin", admin.Rol); 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() private async Task SeedAdminAsync()

View File

@@ -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<IRolRepository>();
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<RolAlreadyExistsException>(
() => _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<string>())
.Returns(new Rol(1, "cajero_senior", "X", null, true, now, null));
try { await _handler.Handle(ValidCommand()); } catch (RolAlreadyExistsException) { }
await _repository.DidNotReceive().AddAsync(Arg.Any<Rol>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Happy_AddsAndReturnsDtoWithId()
{
_repository.GetByCodigoAsync(Arg.Any<string>()).Returns((Rol?)null);
_repository.AddAsync(Arg.Any<Rol>(), Arg.Any<CancellationToken>()).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<string>()).Returns((Rol?)null);
_repository.AddAsync(Arg.Any<Rol>(), Arg.Any<CancellationToken>()).Returns(5);
await _handler.Handle(ValidCommand());
await _repository.Received(1).AddAsync(
Arg.Is<Rol>(r => r.Codigo == "cajero_senior" && r.Nombre == "Cajero Senior" && r.Activo),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Happy_WithNullDescripcion_PassesNullToRepository()
{
_repository.GetByCodigoAsync(Arg.Any<string>()).Returns((Rol?)null);
_repository.AddAsync(Arg.Any<Rol>(), Arg.Any<CancellationToken>()).Returns(1);
await _handler.Handle(new CreateRolCommand("nuevo_rol", "Nuevo", null));
await _repository.Received(1).AddAsync(
Arg.Is<Rol>(r => r.Descripcion == null),
Arg.Any<CancellationToken>());
}
}

View File

@@ -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);
}
}

View File

@@ -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<IRolRepository>();
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<RolNotFoundException>(
() => _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<RolInUseException>(
() => _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<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
}
[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<CancellationToken>())
.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<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(true);
await _handler.Handle(new DeactivateRolCommand("reportes"));
await _repository.Received(1).UpdateAsync("reportes", "Nombre", "Desc", false, Arg.Any<CancellationToken>());
}
}

View File

@@ -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<IRolRepository>();
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<RolNotFoundException>(
() => _handler.Handle(new GetRolByCodigoQuery("missing")));
Assert.Equal("missing", ex.Codigo);
}
}

View File

@@ -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<IRolRepository>();
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<Rol>
{
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<Rol>
{
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<Rol>());
var result = await _handler.Handle(new ListRolesQuery());
Assert.Empty(result);
}
}

View File

@@ -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<IRolRepository>();
private readonly UpdateRolCommandHandler _handler;
public UpdateRolCommandHandlerTests()
{
_handler = new UpdateRolCommandHandler(_repository);
}
[Fact]
public async Task Handle_NonExistentCodigo_ThrowsRolNotFoundException()
{
_repository.UpdateAsync("missing", Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(false);
var ex = await Assert.ThrowsAsync<RolNotFoundException>(
() => _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<CancellationToken>())
.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<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.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<CancellationToken>());
}
}

View File

@@ -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);
}
}

View File

@@ -15,7 +15,7 @@ public class CreateUsuarioCommandValidatorTests
Nombre: "Juan", Nombre: "Juan",
Apellido: "Pérez", Apellido: "Pérez",
Email: null, Email: null,
Rol: "vendedor"); Rol: "cajero");
// ── Happy paths ────────────────────────────────────────────────────────── // ── Happy paths ──────────────────────────────────────────────────────────
@@ -156,9 +156,13 @@ public class CreateUsuarioCommandValidatorTests
[Theory] [Theory]
[InlineData("admin")] [InlineData("admin")]
[InlineData("vendedor")] [InlineData("cajero")]
[InlineData("tasador")] [InlineData("operador_ctacte")]
[InlineData("consulta")] [InlineData("picadora")]
[InlineData("jefe_publicidad")]
[InlineData("productor")]
[InlineData("diagramacion")]
[InlineData("reportes")]
public void Validate_ValidRoles_NoError(string rol) public void Validate_ValidRoles_NoError(string rol)
{ {
var cmd = ValidCommand() with { Rol = rol }; var cmd = ValidCommand() with { Rol = rol };

View File

@@ -28,7 +28,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions _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(); await ResetAndSeedAsync();
@@ -37,9 +39,33 @@ public sealed class SqlTestFixture : IAsyncLifetime
public async Task ResetAndSeedAsync() public async Task ResetAndSeedAsync()
{ {
await _respawner.ResetAsync(_connection); await _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
await SeedAdminAsync(); 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() public async Task DisposeAsync()
{ {
if (_connection is not null) if (_connection is not null)