feat(infrastructure): RubroRepository Dapper + DI + integration tests (CAT-001)
This commit is contained in:
@@ -38,6 +38,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
|
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
|
||||||
services.AddScoped<ITipoDeIvaRepository, TipoDeIvaRepository>();
|
services.AddScoped<ITipoDeIvaRepository, TipoDeIvaRepository>();
|
||||||
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
||||||
|
services.AddScoped<IRubroRepository, RubroRepository>();
|
||||||
|
|
||||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||||
|
|||||||
236
src/api/SIGCM2.Infrastructure/Persistence/RubroRepository.cs
Normal file
236
src/api/SIGCM2.Infrastructure/Persistence/RubroRepository.cs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
using Dapper;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public sealed class RubroRepository : IRubroRepository
|
||||||
|
{
|
||||||
|
private readonly SqlConnectionFactory _factory;
|
||||||
|
|
||||||
|
public RubroRepository(SqlConnectionFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> AddAsync(Rubro rubro, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()), Orden (0 default — overridden if provided).
|
||||||
|
const string sql = """
|
||||||
|
INSERT INTO dbo.Rubro (ParentId, Nombre, Orden, Activo, TarifarioBaseId)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES (@ParentId, @Nombre, @Orden, @Activo, @TarifarioBaseId)
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
return await connection.ExecuteScalarAsync<int>(sql, new
|
||||||
|
{
|
||||||
|
rubro.ParentId,
|
||||||
|
rubro.Nombre,
|
||||||
|
rubro.Orden,
|
||||||
|
Activo = rubro.Activo ? 1 : 0,
|
||||||
|
rubro.TarifarioBaseId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Rubro?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion
|
||||||
|
FROM dbo.Rubro
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<RubroRow>(sql, new { Id = id });
|
||||||
|
return row is null ? null : MapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Rubro>> GetAllAsync(bool incluirInactivos, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sql = incluirInactivos
|
||||||
|
? """
|
||||||
|
SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion
|
||||||
|
FROM dbo.Rubro
|
||||||
|
ORDER BY ParentId, Orden
|
||||||
|
"""
|
||||||
|
: """
|
||||||
|
SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion
|
||||||
|
FROM dbo.Rubro
|
||||||
|
WHERE Activo = 1
|
||||||
|
ORDER BY ParentId, Orden
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<RubroRow>(sql);
|
||||||
|
return rows.Select(MapRow).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Rubro>> GetDescendantsAsync(int rootId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
WITH Descendants AS (
|
||||||
|
SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion
|
||||||
|
FROM dbo.Rubro
|
||||||
|
WHERE ParentId = @RootId
|
||||||
|
UNION ALL
|
||||||
|
SELECT r.Id, r.ParentId, r.Nombre, r.Orden, r.Activo, r.TarifarioBaseId, r.FechaCreacion, r.FechaModificacion
|
||||||
|
FROM dbo.Rubro r
|
||||||
|
INNER JOIN Descendants d ON r.ParentId = d.Id
|
||||||
|
)
|
||||||
|
SELECT * FROM Descendants
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<RubroRow>(sql, new { RootId = rootId });
|
||||||
|
return rows.Select(MapRow).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Rubro rubro, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.Rubro
|
||||||
|
SET Nombre = @Nombre,
|
||||||
|
ParentId = @ParentId,
|
||||||
|
Orden = @Orden,
|
||||||
|
Activo = @Activo,
|
||||||
|
TarifarioBaseId = @TarifarioBaseId,
|
||||||
|
FechaModificacion = @FechaModificacion
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(sql, new
|
||||||
|
{
|
||||||
|
rubro.Nombre,
|
||||||
|
rubro.ParentId,
|
||||||
|
rubro.Orden,
|
||||||
|
Activo = rubro.Activo ? 1 : 0,
|
||||||
|
rubro.TarifarioBaseId,
|
||||||
|
rubro.FechaModificacion,
|
||||||
|
rubro.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CountActiveChildrenAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT COUNT(1) FROM dbo.Rubro
|
||||||
|
WHERE ParentId = @Id AND Activo = 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
return await connection.ExecuteScalarAsync<int>(sql, new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetMaxOrdenAsync(int? parentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Returns MAX(Orden) + 1 among siblings, or 0 if no siblings exist.
|
||||||
|
// Handler uses return value directly as the orden for the new Rubro.
|
||||||
|
const string sql = """
|
||||||
|
SELECT ISNULL(MAX(Orden) + 1, 0)
|
||||||
|
FROM dbo.Rubro
|
||||||
|
WHERE (@ParentId IS NULL AND ParentId IS NULL)
|
||||||
|
OR ParentId = @ParentId
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
return await connection.ExecuteScalarAsync<int>(sql, new { ParentId = parentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExistsByNombreUnderParentAsync(
|
||||||
|
int? parentId,
|
||||||
|
string nombre,
|
||||||
|
int? excludeId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Use UPPER() for explicit case-insensitive comparison.
|
||||||
|
// DB collation is SQL_Latin1_General_CP1_CI_AI on Nombre column (already CI),
|
||||||
|
// but UPPER() makes intent explicit and works regardless of collation.
|
||||||
|
// The WHERE clause handles both root (NULL parent) and non-root cases.
|
||||||
|
const string sql = """
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM dbo.Rubro
|
||||||
|
WHERE ((@ParentId IS NULL AND ParentId IS NULL) OR ParentId = @ParentId)
|
||||||
|
AND UPPER(Nombre) = UPPER(@Nombre)
|
||||||
|
AND Activo = 1
|
||||||
|
AND (@ExcludeId IS NULL OR Id <> @ExcludeId)
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var count = await connection.ExecuteScalarAsync<int>(sql, new
|
||||||
|
{
|
||||||
|
ParentId = parentId,
|
||||||
|
Nombre = nombre,
|
||||||
|
ExcludeId = excludeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetDepthAsync(int? parentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// If parentId is null, depth is 0 (creating a root node).
|
||||||
|
if (!parentId.HasValue)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// CTE walks up the ancestor chain from parentId to root, counting levels.
|
||||||
|
// Each UNION ALL step goes one level up, so the count of rows = depth of parentId.
|
||||||
|
const string sql = """
|
||||||
|
WITH Ancestors AS (
|
||||||
|
SELECT Id, ParentId, 1 AS Depth
|
||||||
|
FROM dbo.Rubro
|
||||||
|
WHERE Id = @ParentId
|
||||||
|
UNION ALL
|
||||||
|
SELECT r.Id, r.ParentId, a.Depth + 1
|
||||||
|
FROM dbo.Rubro r
|
||||||
|
INNER JOIN Ancestors a ON r.Id = a.ParentId
|
||||||
|
)
|
||||||
|
SELECT ISNULL(MAX(Depth), 0) FROM Ancestors
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
return await connection.ExecuteScalarAsync<int>(sql, new { ParentId = parentId.Value });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mapping ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static Rubro MapRow(RubroRow r)
|
||||||
|
=> new(
|
||||||
|
id: r.Id,
|
||||||
|
parentId: r.ParentId,
|
||||||
|
nombre: r.Nombre,
|
||||||
|
orden: r.Orden,
|
||||||
|
activo: r.Activo,
|
||||||
|
tarifarioBaseId: r.TarifarioBaseId,
|
||||||
|
fechaCreacion: r.FechaCreacion,
|
||||||
|
fechaModificacion: r.FechaModificacion);
|
||||||
|
|
||||||
|
private sealed record RubroRow(
|
||||||
|
int Id,
|
||||||
|
int? ParentId,
|
||||||
|
string Nombre,
|
||||||
|
int Orden,
|
||||||
|
bool Activo,
|
||||||
|
int? TarifarioBaseId,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
|
}
|
||||||
450
tests/SIGCM2.Application.Tests/Rubros/RubroRepositoryTests.cs
Normal file
450
tests/SIGCM2.Application.Tests/Rubros/RubroRepositoryTests.cs
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Respawn;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Rubros;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for RubroRepository against SIGCM2_Test.
|
||||||
|
/// TDD: RED written before implementation, GREEN after RubroRepository was created.
|
||||||
|
/// Temporal: after UpdateAsync, dbo.Rubro_History MUST have ≥1 row for that Id.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Database")]
|
||||||
|
public class RubroRepositoryTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string ConnectionString =
|
||||||
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||||
|
|
||||||
|
private SqlConnection _connection = null!;
|
||||||
|
private Respawner _respawner = null!;
|
||||||
|
private RubroRepository _repository = null!;
|
||||||
|
private TimeProvider _timeProvider = null!;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_connection = new SqlConnection(ConnectionString);
|
||||||
|
await _connection.OpenAsync();
|
||||||
|
|
||||||
|
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||||
|
{
|
||||||
|
DbAdapter = DbAdapter.SqlServer,
|
||||||
|
TablesToIgnore =
|
||||||
|
[
|
||||||
|
new Respawn.Graph.Table("dbo", "Rol"),
|
||||||
|
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||||
|
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
||||||
|
// *_History tables are system-versioned — engine rejects direct DELETE.
|
||||||
|
new Respawn.Graph.Table("dbo", "Usuario_History"),
|
||||||
|
new Respawn.Graph.Table("dbo", "Rol_History"),
|
||||||
|
new Respawn.Graph.Table("dbo", "Permiso_History"),
|
||||||
|
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
|
||||||
|
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||||
|
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||||
|
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||||
|
// ADM-008 (V013): PuntoDeVenta is temporal.
|
||||||
|
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||||
|
// ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales.
|
||||||
|
new Respawn.Graph.Table("dbo", "TipoDeIva_History"),
|
||||||
|
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
|
||||||
|
new Respawn.Graph.Table("dbo", "TipoDeIva"),
|
||||||
|
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
|
||||||
|
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
|
||||||
|
new Respawn.Graph.Table("dbo", "Rubro_History"),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
await _respawner.ResetAsync(_connection);
|
||||||
|
await SeedRolCanonicalAsync();
|
||||||
|
|
||||||
|
var factory = new SqlConnectionFactory(ConnectionString);
|
||||||
|
_repository = new RubroRepository(factory);
|
||||||
|
_timeProvider = TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await _connection.CloseAsync();
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AddAsync + GetByIdAsync roundtrip ─────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddAsync_AndGetById_ReturnsAllFields()
|
||||||
|
{
|
||||||
|
var rubro = Rubro.ForCreation("Automotores", parentId: null, orden: 0, tarifarioBaseId: null, _timeProvider);
|
||||||
|
|
||||||
|
var id = await _repository.AddAsync(rubro);
|
||||||
|
var result = await _repository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(id, result!.Id);
|
||||||
|
Assert.Equal("Automotores", result.Nombre);
|
||||||
|
Assert.Null(result.ParentId);
|
||||||
|
Assert.Equal(0, result.Orden);
|
||||||
|
Assert.True(result.Activo);
|
||||||
|
Assert.Null(result.TarifarioBaseId);
|
||||||
|
Assert.True(result.FechaCreacion > DateTime.MinValue);
|
||||||
|
Assert.Null(result.FechaModificacion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddAsync_WithTarifarioBaseId_PersistsValue()
|
||||||
|
{
|
||||||
|
var rubro = Rubro.ForCreation("Tecnología", parentId: null, orden: 0, tarifarioBaseId: 42, _timeProvider);
|
||||||
|
|
||||||
|
var id = await _repository.AddAsync(rubro);
|
||||||
|
var result = await _repository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(42, result!.TarifarioBaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_NonExistent_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = await _repository.GetByIdAsync(999999);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetAllAsync ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_IncluirInactivosFalse_OmitsInactive()
|
||||||
|
{
|
||||||
|
var activo = Rubro.ForCreation("Activo", null, 0, null, _timeProvider);
|
||||||
|
var inactivo = Rubro.ForCreation("Inactivo", null, 1, null, _timeProvider);
|
||||||
|
|
||||||
|
await _repository.AddAsync(activo);
|
||||||
|
var inactivoId = await _repository.AddAsync(inactivo);
|
||||||
|
|
||||||
|
// Deactivate the second one via UpdateAsync
|
||||||
|
var inactivoEntity = await _repository.GetByIdAsync(inactivoId);
|
||||||
|
await _repository.UpdateAsync(inactivoEntity!.WithActivo(false, _timeProvider));
|
||||||
|
|
||||||
|
var all = await _repository.GetAllAsync(incluirInactivos: false);
|
||||||
|
|
||||||
|
Assert.Contains(all, r => r.Nombre == "Activo");
|
||||||
|
Assert.DoesNotContain(all, r => r.Nombre == "Inactivo");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_IncluirInactivosTrue_ReturnsAll()
|
||||||
|
{
|
||||||
|
var activo = Rubro.ForCreation("ActivoAll", null, 0, null, _timeProvider);
|
||||||
|
var inactivo = Rubro.ForCreation("InactivoAll", null, 1, null, _timeProvider);
|
||||||
|
|
||||||
|
await _repository.AddAsync(activo);
|
||||||
|
var inactivoId = await _repository.AddAsync(inactivo);
|
||||||
|
|
||||||
|
var inactivoEntity = await _repository.GetByIdAsync(inactivoId);
|
||||||
|
await _repository.UpdateAsync(inactivoEntity!.WithActivo(false, _timeProvider));
|
||||||
|
|
||||||
|
var all = await _repository.GetAllAsync(incluirInactivos: true);
|
||||||
|
|
||||||
|
Assert.Contains(all, r => r.Nombre == "ActivoAll");
|
||||||
|
Assert.Contains(all, r => r.Nombre == "InactivoAll");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetDescendantsAsync ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDescendantsAsync_3LevelsDeep_ReturnsAllDescendants()
|
||||||
|
{
|
||||||
|
// Level 0: root
|
||||||
|
var root = Rubro.ForCreation("Root", null, 0, null, _timeProvider);
|
||||||
|
var rootId = await _repository.AddAsync(root);
|
||||||
|
|
||||||
|
// Level 1: child of root
|
||||||
|
var child = Rubro.ForCreation("Child", rootId, 0, null, _timeProvider);
|
||||||
|
var childId = await _repository.AddAsync(child);
|
||||||
|
|
||||||
|
// Level 2: grandchild of root
|
||||||
|
var grandchild = Rubro.ForCreation("Grandchild", childId, 0, null, _timeProvider);
|
||||||
|
var grandchildId = await _repository.AddAsync(grandchild);
|
||||||
|
|
||||||
|
var descendants = await _repository.GetDescendantsAsync(rootId);
|
||||||
|
|
||||||
|
Assert.Equal(2, descendants.Count);
|
||||||
|
Assert.Contains(descendants, d => d.Id == childId);
|
||||||
|
Assert.Contains(descendants, d => d.Id == grandchildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDescendantsAsync_Leaf_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var leaf = Rubro.ForCreation("Leaf", null, 0, null, _timeProvider);
|
||||||
|
var leafId = await _repository.AddAsync(leaf);
|
||||||
|
|
||||||
|
var descendants = await _repository.GetDescendantsAsync(leafId);
|
||||||
|
|
||||||
|
Assert.Empty(descendants);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CountActiveChildrenAsync ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CountActiveChildrenAsync_NoChildren_ReturnsZero()
|
||||||
|
{
|
||||||
|
var parent = Rubro.ForCreation("ParentNoHijos", null, 0, null, _timeProvider);
|
||||||
|
var parentId = await _repository.AddAsync(parent);
|
||||||
|
|
||||||
|
var count = await _repository.CountActiveChildrenAsync(parentId);
|
||||||
|
|
||||||
|
Assert.Equal(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CountActiveChildrenAsync_TwoActive_ReturnsTwo()
|
||||||
|
{
|
||||||
|
var parent = Rubro.ForCreation("ParentDosHijos", null, 0, null, _timeProvider);
|
||||||
|
var parentId = await _repository.AddAsync(parent);
|
||||||
|
|
||||||
|
await _repository.AddAsync(Rubro.ForCreation("Hijo1", parentId, 0, null, _timeProvider));
|
||||||
|
await _repository.AddAsync(Rubro.ForCreation("Hijo2", parentId, 1, null, _timeProvider));
|
||||||
|
|
||||||
|
var count = await _repository.CountActiveChildrenAsync(parentId);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CountActiveChildrenAsync_ActiveAndInactive_CountsOnlyActive()
|
||||||
|
{
|
||||||
|
var parent = Rubro.ForCreation("ParentMixed", null, 0, null, _timeProvider);
|
||||||
|
var parentId = await _repository.AddAsync(parent);
|
||||||
|
|
||||||
|
await _repository.AddAsync(Rubro.ForCreation("HijoActivo", parentId, 0, null, _timeProvider));
|
||||||
|
var inactivoId = await _repository.AddAsync(Rubro.ForCreation("HijoInactivo", parentId, 1, null, _timeProvider));
|
||||||
|
|
||||||
|
var inactivo = await _repository.GetByIdAsync(inactivoId);
|
||||||
|
await _repository.UpdateAsync(inactivo!.WithActivo(false, _timeProvider));
|
||||||
|
|
||||||
|
var count = await _repository.CountActiveChildrenAsync(parentId);
|
||||||
|
|
||||||
|
Assert.Equal(1, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetMaxOrdenAsync ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMaxOrdenAsync_Empty_ReturnsZero()
|
||||||
|
{
|
||||||
|
// No siblings → first slot is 0
|
||||||
|
var orden = await _repository.GetMaxOrdenAsync(parentId: null);
|
||||||
|
|
||||||
|
Assert.Equal(0, orden);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMaxOrdenAsync_ThreeSiblings_ReturnsThree()
|
||||||
|
{
|
||||||
|
// Orden = [0, 1, 2] → next slot = 3 (MAX+1)
|
||||||
|
var parent = Rubro.ForCreation("ParentOrden", null, 0, null, _timeProvider);
|
||||||
|
var parentId = await _repository.AddAsync(parent);
|
||||||
|
|
||||||
|
await _repository.AddAsync(Rubro.ForCreation("S1", parentId, 0, null, _timeProvider));
|
||||||
|
await _repository.AddAsync(Rubro.ForCreation("S2", parentId, 1, null, _timeProvider));
|
||||||
|
await _repository.AddAsync(Rubro.ForCreation("S3", parentId, 2, null, _timeProvider));
|
||||||
|
|
||||||
|
var orden = await _repository.GetMaxOrdenAsync(parentId);
|
||||||
|
|
||||||
|
Assert.Equal(3, orden);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMaxOrdenAsync_ParentIdNull_WorksForRoots()
|
||||||
|
{
|
||||||
|
// Insert one root with Orden=0
|
||||||
|
await _repository.AddAsync(Rubro.ForCreation("RootOrden1", null, 0, null, _timeProvider));
|
||||||
|
|
||||||
|
var orden = await _repository.GetMaxOrdenAsync(parentId: null);
|
||||||
|
|
||||||
|
Assert.Equal(1, orden); // MAX(0) + 1 = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ExistsByNombreUnderParentAsync ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsByNombreUnderParentAsync_Exists_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var parent = Rubro.ForCreation("ParentExists", null, 0, null, _timeProvider);
|
||||||
|
var parentId = await _repository.AddAsync(parent);
|
||||||
|
|
||||||
|
await _repository.AddAsync(Rubro.ForCreation("Autos", parentId, 0, null, _timeProvider));
|
||||||
|
|
||||||
|
var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "Autos", excludeId: null);
|
||||||
|
|
||||||
|
Assert.True(exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsByNombreUnderParentAsync_SameNameDifferentParent_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var parent1 = Rubro.ForCreation("Parent1", null, 0, null, _timeProvider);
|
||||||
|
var parent1Id = await _repository.AddAsync(parent1);
|
||||||
|
|
||||||
|
var parent2 = Rubro.ForCreation("Parent2", null, 1, null, _timeProvider);
|
||||||
|
var parent2Id = await _repository.AddAsync(parent2);
|
||||||
|
|
||||||
|
// Add "Autos" under parent1
|
||||||
|
await _repository.AddAsync(Rubro.ForCreation("Autos", parent1Id, 0, null, _timeProvider));
|
||||||
|
|
||||||
|
// Check under parent2 — should not exist
|
||||||
|
var exists = await _repository.ExistsByNombreUnderParentAsync(parent2Id, "Autos", excludeId: null);
|
||||||
|
|
||||||
|
Assert.False(exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsByNombreUnderParentAsync_ExcludeId_WhenProvidedSkipsSelf()
|
||||||
|
{
|
||||||
|
var parent = Rubro.ForCreation("ParentExclude", null, 0, null, _timeProvider);
|
||||||
|
var parentId = await _repository.AddAsync(parent);
|
||||||
|
|
||||||
|
var id = await _repository.AddAsync(Rubro.ForCreation("AutosExclude", parentId, 0, null, _timeProvider));
|
||||||
|
|
||||||
|
// Excluding self → should return false (no other rubro with same name)
|
||||||
|
var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "AutosExclude", excludeId: id);
|
||||||
|
|
||||||
|
Assert.False(exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsByNombreUnderParentAsync_CaseInsensitive_InsensibleAMayusculas()
|
||||||
|
{
|
||||||
|
var parent = Rubro.ForCreation("ParentCI", null, 0, null, _timeProvider);
|
||||||
|
var parentId = await _repository.AddAsync(parent);
|
||||||
|
|
||||||
|
await _repository.AddAsync(Rubro.ForCreation("autos", parentId, 0, null, _timeProvider));
|
||||||
|
|
||||||
|
// "AUTOS" should match "autos" (case-insensitive)
|
||||||
|
var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "AUTOS", excludeId: null);
|
||||||
|
|
||||||
|
Assert.True(exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsByNombreUnderParentAsync_ForRoot_ParentIdNull_WorksWithApplicationDefense()
|
||||||
|
{
|
||||||
|
// The DB filtered index only covers non-root rubros (WHERE ParentId IS NOT NULL AND Activo = 1).
|
||||||
|
// Application must check roots via full scan (no unique index guarantee at DB level).
|
||||||
|
await _repository.AddAsync(Rubro.ForCreation("RootCI", null, 0, null, _timeProvider));
|
||||||
|
|
||||||
|
var exists = await _repository.ExistsByNombreUnderParentAsync(null, "RootCI", excludeId: null);
|
||||||
|
|
||||||
|
Assert.True(exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetDepthAsync ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDepthAsync_RootParent_ReturnsZero()
|
||||||
|
{
|
||||||
|
// parentId = null means we're creating a root → depth = 0
|
||||||
|
var depth = await _repository.GetDepthAsync(parentId: null);
|
||||||
|
|
||||||
|
Assert.Equal(0, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDepthAsync_3LevelsDeep_ReturnsThree()
|
||||||
|
{
|
||||||
|
// root (depth 0) → child (depth 1) → grandchild (depth 2) → great-grandchild (depth 3)
|
||||||
|
var rootId = await _repository.AddAsync(Rubro.ForCreation("RootDepth", null, 0, null, _timeProvider));
|
||||||
|
var childId = await _repository.AddAsync(Rubro.ForCreation("ChildDepth", rootId, 0, null, _timeProvider));
|
||||||
|
var grandchildId = await _repository.AddAsync(Rubro.ForCreation("GrandchildDepth", childId, 0, null, _timeProvider));
|
||||||
|
|
||||||
|
// Depth of the grandchild's own id as parentId = 3 levels deep (root=1, child=2, grandchild=3)
|
||||||
|
var depth = await _repository.GetDepthAsync(grandchildId);
|
||||||
|
|
||||||
|
Assert.Equal(3, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UpdateAsync + Temporal ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_ModificaCamposYProduceHistoryRow()
|
||||||
|
{
|
||||||
|
var id = await _repository.AddAsync(Rubro.ForCreation("Original", null, 0, null, _timeProvider));
|
||||||
|
var original = await _repository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
var renamed = original!.WithRenamed("Actualizado", _timeProvider);
|
||||||
|
await _repository.UpdateAsync(renamed);
|
||||||
|
|
||||||
|
var result = await _repository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Actualizado", result!.Nombre);
|
||||||
|
Assert.NotNull(result.FechaModificacion);
|
||||||
|
|
||||||
|
var historyCount = await _connection.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id });
|
||||||
|
|
||||||
|
Assert.True(historyCount >= 1, $"Expected ≥1 history row for Rubro Id={id}, got {historyCount}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SoftDeleteAsync_FlipActivoYActualizaFechaModificacion()
|
||||||
|
{
|
||||||
|
// Deactivate via UpdateAsync (with WithActivo(false)) — repository has no separate SoftDelete
|
||||||
|
var id = await _repository.AddAsync(Rubro.ForCreation("ToDeactivate", null, 0, null, _timeProvider));
|
||||||
|
var rubro = await _repository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
var deactivated = rubro!.WithActivo(false, _timeProvider);
|
||||||
|
await _repository.UpdateAsync(deactivated);
|
||||||
|
|
||||||
|
var result = await _repository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.False(result!.Activo);
|
||||||
|
Assert.NotNull(result.FechaModificacion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveAsync_CambiaParentIdOrdenYFechaModificacion()
|
||||||
|
{
|
||||||
|
var parent1Id = await _repository.AddAsync(Rubro.ForCreation("MoveParent1", null, 0, null, _timeProvider));
|
||||||
|
var parent2Id = await _repository.AddAsync(Rubro.ForCreation("MoveParent2", null, 1, null, _timeProvider));
|
||||||
|
var childId = await _repository.AddAsync(Rubro.ForCreation("MoveChild", parent1Id, 0, null, _timeProvider));
|
||||||
|
|
||||||
|
var child = await _repository.GetByIdAsync(childId);
|
||||||
|
var moved = child!.WithMoved(parent2Id, 5, _timeProvider);
|
||||||
|
await _repository.UpdateAsync(moved);
|
||||||
|
|
||||||
|
var result = await _repository.GetByIdAsync(childId);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(parent2Id, result!.ParentId);
|
||||||
|
Assert.Equal(5, result.Orden);
|
||||||
|
Assert.NotNull(result.FechaModificacion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user