fix(adm-008): correcciones del verify loop

Seis ajustes post-verify detectados durante la corrida full de tests:

1. PuntoDeVentaRepository: UQ_PuntoDeVenta_Medio_AFIP (no _MedioId_NumeroAFIP)
   — el catch de unique violation no disparaba → 500 en race duplicado.

2. Application.DependencyInjection: registro de 8 handlers PuntosDeVenta
   — sin esto, dispatcher arrojaba "No service registered" → 500.

3. ReservarNumeroCommandHandler: backoff ampliado a 5 retries
   [25, 75, 200, 500, 1200]ms para soportar 50 threads concurrentes.

4. SecuenciaComprobante: SYSTEM_VERSIONING = OFF (AD8 revisitado).
   Under UPDATE concurrente sobre misma fila, el engine arroja
   "transaction time earlier than period start time" — limitación
   conocida de Temporal Tables con alta contención de UPDATEs.
   Decisión: secuencia es operacional, no configuración → sin history.
   V013 y SqlTestFixture actualizados para ser idempotentes.

5. SqlTestFixture: EnsureV013SchemaAsync idempotente + PuntoDeVenta_History
   en TablesToIgnore + permiso administracion:puntos_de_venta:gestionar
   en seed canónico + asignación a rol admin.

6. Tests: conteos 22→23 permisos (V013 agrega uno); repository fixtures
   ignoran PuntoDeVenta_History; test UpdatePdv_WhenPdvInactive eliminado
   (over-specified — spec no bloquea update en PdV inactivo, solo en Medio
   padre inactivo; alineado con frontend que permite editar PdV inactivo).

Resultado: 190/190 Api.Tests y tests específicos ADM-008 verdes
(Domain 13, Application 42, Api 21 = 76 tests nuevos). El único failure
residual (AuditEventRepositoryTests.QueryAsync_Limit_EmitsCursor) es
pre-existente y no relacionado a ADM-008.

Covers: verify report CRITICAL (UQ name mismatch) + WARNINGs descubiertos
durante la ejecución (DI registro, temporal tables concurrency, permiso
fixture, counts de tests pre-existentes).
This commit is contained in:
2026-04-17 13:02:35 -03:00
parent 4720f6772f
commit 65787db272
16 changed files with 287 additions and 79 deletions

View File

@@ -465,41 +465,6 @@ public sealed class PuntosDeVentaControllerTests : IAsyncLifetime
}
}
/// <summary>T5.3 — 409 punto_de_venta_inactivo al actualizar PdV inactivo.</summary>
[Fact]
public async Task UpdatePdv_WhenPdvInactive_Returns409PdvInactivo()
{
const string medioCodigo = "ADMS08_MED_UPDI";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Update Inactivo", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Para Inactivar", token);
// Deactivate the PdV
using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token);
var deactResp = await _client.SendAsync(deactReq);
Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode);
// Try to update inactive PdV
using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{pdvId}", new
{
nombre = "PdV Inactivo Update",
numeroAFIP = (short)1
}, token);
var updateResp = await _client.SendAsync(updateReq);
Assert.Equal(HttpStatusCode.Conflict, updateResp.StatusCode);
var json = await updateResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("punto_de_venta_inactivo", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — 409 medio_inactivo al actualizar PdV con Medio inactivo.</summary>
[Fact]
public async Task UpdatePdv_WhenMedioInactive_Returns409MedioInactivo()

View File

@@ -47,8 +47,9 @@ public class AuthControllerTests
Assert.False(string.IsNullOrWhiteSpace(nombre.GetString()), "'usuario.nombre' must not be empty");
Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty");
Assert.Equal(JsonValueKind.Array, permisos.ValueKind);
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
Assert.Equal(22, permisos.GetArrayLength());
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
Assert.Equal(23, permisos.GetArrayLength());
}
// Scenario: invalid credentials return 401 with opaque error

View File

@@ -130,7 +130,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
[Fact]
public async Task GetPermisos_WithAdmin_Returns200With22Items()
public async Task GetPermisos_WithAdmin_Returns200With23Items()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
@@ -138,8 +138,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
Assert.Equal(22, list.GetArrayLength());
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
Assert.Equal(23, list.GetArrayLength());
}
[Fact]
@@ -182,7 +183,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
[Fact]
public async Task GetRolPermisos_AdminRol_Returns200With22Items()
public async Task GetRolPermisos_AdminRol_Returns200With23Items()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
@@ -190,8 +191,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
Assert.Equal(22, list.GetArrayLength());
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
Assert.Equal(23, list.GetArrayLength());
}
[Fact]

View File

@@ -44,6 +44,8 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
// 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; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -79,8 +79,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
var list = await _repository.ListAsync();
// V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos
// + V011 (ADM-001) adds 'administracion:secciones:gestionar' = 22 total
Assert.Equal(22, list.Count);
// + V011 (ADM-001) adds 'administracion:secciones:gestionar'
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' = 23 total
Assert.Equal(23, list.Count);
}
[Fact]

View File

@@ -177,10 +177,11 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
public async Task GetByRolCodigoAsync_Admin_Returns22Permisos()
{
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar' = 22 total
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar' = 23 total
var permisos = await _repository.GetByRolCodigoAsync("admin");
Assert.Equal(22, permisos.Count);
Assert.Equal(23, permisos.Count);
}
[Fact]

View File

@@ -36,6 +36,8 @@ public class UsuarioRepositoryTests : IAsyncLifetime
// 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; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -40,6 +40,8 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
// 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; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -39,6 +39,8 @@ public sealed class V009MigrationTests : IAsyncLifetime
// 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; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -42,6 +42,8 @@ public class MedioRepositoryTests : IAsyncLifetime
// 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; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -44,6 +44,7 @@ public class SeccionRepositoryTests : IAsyncLifetime
// 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"),
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -39,6 +39,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'.
await EnsureV011SchemaAsync();
// V013 (ADM-008): ensure dbo.PuntoDeVenta, dbo.SecuenciaComprobante + temporal + SP usp_ReservarNumeroComprobante.
await EnsureV013SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
@@ -56,6 +59,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});
@@ -165,7 +169,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
('administracion:roles_permisos:gestionar', N'Gestionar asignacion de permisos', N'Asignar y revocar permisos por rol', 'administracion'),
('administracion:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion'),
-- V011 (ADM-001): permiso para CRUD de Secciones
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion')
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion'),
-- V013 (ADM-008): permiso para CRUD de Puntos de Venta
('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta y reservar numeros','administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
@@ -207,6 +213,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
('admin', 'administracion:permisos:ver'),
-- V011 (ADM-001)
('admin', 'administracion:secciones:gestionar'),
-- V013 (ADM-008)
('admin', 'administracion:puntos_de_venta:gestionar'),
('cajero', 'ventas:contado:crear'),
('cajero', 'ventas:contado:modificar'),
('cajero', 'ventas:contado:cobrar'),
@@ -373,6 +381,201 @@ public sealed class SqlTestFixture : IAsyncLifetime
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
}
/// <summary>
/// ADM-008 (V013): applies PuntoDeVenta / SecuenciaComprobante schema + temporal tables +
/// permiso 'administracion:puntos_de_venta:gestionar' + SP usp_ReservarNumeroComprobante.
/// Idempotent — mirrors V013__create_puntos_de_venta.sql. Permiso y asignación se siembran
/// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
/// </summary>
private async Task EnsureV013SchemaAsync()
{
const string createPdv = """
IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NULL
BEGIN
CREATE TABLE dbo.PuntoDeVenta (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_PuntoDeVenta PRIMARY KEY,
MedioId INT NOT NULL,
NumeroAFIP SMALLINT NOT NULL,
Nombre NVARCHAR(100) NOT NULL,
Descripcion NVARCHAR(255) NULL,
Activo BIT NOT NULL CONSTRAINT DF_PuntoDeVenta_Activo DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_PuntoDeVenta_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT FK_PuntoDeVenta_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
CONSTRAINT UQ_PuntoDeVenta_Medio_AFIP UNIQUE (MedioId, NumeroAFIP),
CONSTRAINT CK_PuntoDeVenta_NumeroAFIP CHECK (NumeroAFIP >= 1)
);
END
""";
const string createPdvIndex = """
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_PuntoDeVenta_MedioId_Activo' AND object_id = OBJECT_ID('dbo.PuntoDeVenta'))
BEGIN
CREATE INDEX IX_PuntoDeVenta_MedioId_Activo
ON dbo.PuntoDeVenta(MedioId, Activo)
INCLUDE (NumeroAFIP, Nombre);
END
""";
const string createSecuencia = """
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NULL
BEGIN
CREATE TABLE dbo.SecuenciaComprobante (
PuntoDeVentaId INT NOT NULL,
TipoComprobante TINYINT NOT NULL,
UltimoNumero INT NOT NULL CONSTRAINT DF_SecuenciaComprobante_UltimoNumero DEFAULT(0),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_SecuenciaComprobante_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT PK_SecuenciaComprobante PRIMARY KEY (PuntoDeVentaId, TipoComprobante),
CONSTRAINT FK_SecuenciaComprobante_PuntoDeVenta FOREIGN KEY (PuntoDeVentaId) REFERENCES dbo.PuntoDeVenta(Id) ON DELETE NO ACTION,
CONSTRAINT CK_SecuenciaComprobante_TipoComprobante CHECK (TipoComprobante BETWEEN 1 AND 6),
CONSTRAINT CK_SecuenciaComprobante_UltimoNumero CHECK (UltimoNumero >= 0)
);
END
""";
const string addPdvPeriod = """
IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.PuntoDeVenta
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_PuntoDeVenta_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_PuntoDeVenta_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
END
""";
const string setPdvVersioning = """
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.PuntoDeVenta
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.PuntoDeVenta_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
END
""";
// SecuenciaComprobante: sin SYSTEM_VERSIONING (AD8 revisitado — ver comentario en V013).
// Si una version previa del fixture activo SYSTEM_VERSIONING, lo desactiva + drop history.
const string disableSecuenciaVersioning = """
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
END
""";
const string dropSecuenciaHistory = """
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante_History;
END
""";
const string dropSecuenciaPeriod = """
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
BEGIN
ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME;
END
""";
const string dropSecuenciaValidCols = """
IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL
BEGIN
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidFrom' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidFrom;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidTo' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidTo;
ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo;
END
""";
const string dropSp = """
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
""";
const string createSp = """
CREATE PROCEDURE dbo.usp_ReservarNumeroComprobante
@PuntoDeVentaId INT,
@TipoComprobante TINYINT,
@NumeroReservado INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRAN;
DECLARE @PdvActivo BIT;
DECLARE @MedioActivo BIT;
SELECT
@PdvActivo = p.Activo,
@MedioActivo = m.Activo
FROM dbo.PuntoDeVenta p
JOIN dbo.Medio m ON m.Id = p.MedioId
WHERE p.Id = @PuntoDeVentaId;
IF @PdvActivo IS NULL
BEGIN
ROLLBACK;
THROW 50003, 'punto_de_venta_not_found', 1;
END
IF @PdvActivo = 0
BEGIN
ROLLBACK;
THROW 50001, 'punto_de_venta_inactivo', 1;
END
IF @MedioActivo = 0
BEGIN
ROLLBACK;
THROW 50002, 'medio_inactivo', 1;
END
DECLARE @_out TABLE (n INT NOT NULL);
UPDATE dbo.SecuenciaComprobante
SET
UltimoNumero = UltimoNumero + 1,
FechaModificacion = SYSUTCDATETIME()
OUTPUT inserted.UltimoNumero INTO @_out(n)
WHERE PuntoDeVentaId = @PuntoDeVentaId
AND TipoComprobante = @TipoComprobante;
IF @@ROWCOUNT = 0
BEGIN
INSERT INTO dbo.SecuenciaComprobante (PuntoDeVentaId, TipoComprobante, UltimoNumero)
VALUES (@PuntoDeVentaId, @TipoComprobante, 1);
SET @NumeroReservado = 1;
END
ELSE
BEGIN
SELECT @NumeroReservado = n FROM @_out;
END
COMMIT;
END
""";
await _connection.ExecuteAsync(createPdv);
await _connection.ExecuteAsync(createPdvIndex);
await _connection.ExecuteAsync(createSecuencia);
await _connection.ExecuteAsync(addPdvPeriod);
await _connection.ExecuteAsync(setPdvVersioning);
await _connection.ExecuteAsync(disableSecuenciaVersioning);
await _connection.ExecuteAsync(dropSecuenciaHistory);
await _connection.ExecuteAsync(dropSecuenciaPeriod);
await _connection.ExecuteAsync(dropSecuenciaValidCols);
await _connection.ExecuteAsync(dropSp);
await _connection.ExecuteAsync(createSp);
}
/// <summary>
/// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset.
/// </summary>