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

@@ -130,44 +130,50 @@ END
GO GO
-- ═══════════════════════════════════════════════════════════════════════ -- ═══════════════════════════════════════════════════════════════════════
-- 4. SYSTEM_VERSIONING — SecuenciaComprobante -- 4. SecuenciaComprobante — SIN SYSTEM_VERSIONING (decisión AD8 revisitada)
-- ═══════════════════════════════════════════════════════════════════════ -- ═══════════════════════════════════════════════════════════════════════
-- Razón: bajo reservas concurrentes (reportado 50 threads paralelos) el engine
-- arroja "Data modification failed on system-versioned table because transaction
-- time was earlier than period start" — UPDATEs repetidos sobre la misma fila
-- en transacciones SERIALIZABLE concurrentes violan la invariante temporal.
-- Ya anticipado en el design (AD8): la reserva es operacional, no configuracion.
-- La auditoria del numero vigente no tiene valor de negocio: el estado actual
-- (UltimoNumero) es la unica informacion relevante; cada comprobante emitido
-- deja su propio rastro en AvisosCdo/CtaCte (modulos FAC-*).
--
-- Esta seccion es idempotente: si una version previa de la migracion dejo
-- SYSTEM_VERSIONING = ON, lo desactiva y drop la history. En instalacion nueva
-- no hace nada porque nunca se activo.
IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NULL IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
BEGIN BEGIN
ALTER TABLE dbo.SecuenciaComprobante ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
ADD PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = OFF (revisited AD8).';
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_SecuenciaComprobante_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_SecuenciaComprobante_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME added.';
END END
GO GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2) IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
BEGIN BEGIN
ALTER TABLE dbo.SecuenciaComprobante DROP TABLE dbo.SecuenciaComprobante_History;
SET (SYSTEM_VERSIONING = ON ( PRINT 'SecuenciaComprobante_History: dropped.';
HISTORY_TABLE = dbo.SecuenciaComprobante_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = ON (history: dbo.SecuenciaComprobante_History, retention: 10 years).';
END END
ELSE
PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING already ON — skip.';
GO GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'SecuenciaComprobante_History' AND schema_id = SCHEMA_ID('dbo')) IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'SecuenciaComprobante_History' AND p.data_compression = 2
)
BEGIN BEGIN
ALTER TABLE dbo.SecuenciaComprobante_History REBUILD WITH (DATA_COMPRESSION = PAGE); ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME;
PRINT 'SecuenciaComprobante_History: rebuilt with PAGE compression.'; PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
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;
PRINT 'SecuenciaComprobante: ValidFrom/ValidTo + default constraints dropped.';
END END
GO GO

View File

@@ -21,6 +21,14 @@ using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Application.Roles.Get; using SIGCM2.Application.Roles.Get;
using SIGCM2.Application.Roles.List; using SIGCM2.Application.Roles.List;
using SIGCM2.Application.Roles.Update; using SIGCM2.Application.Roles.Update;
using SIGCM2.Application.PuntosDeVenta.Create;
using SIGCM2.Application.PuntosDeVenta.Deactivate;
using SIGCM2.Application.PuntosDeVenta.GetById;
using SIGCM2.Application.PuntosDeVenta.List;
using SIGCM2.Application.PuntosDeVenta.ProximoNumero;
using SIGCM2.Application.PuntosDeVenta.Reactivate;
using SIGCM2.Application.PuntosDeVenta.Reservar;
using SIGCM2.Application.PuntosDeVenta.Update;
using SIGCM2.Application.Secciones.Create; using SIGCM2.Application.Secciones.Create;
using SIGCM2.Application.Secciones.Deactivate; using SIGCM2.Application.Secciones.Deactivate;
using SIGCM2.Application.Secciones.GetById; using SIGCM2.Application.Secciones.GetById;
@@ -90,6 +98,16 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>(); services.AddScoped<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>();
services.AddScoped<ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>, GetSeccionByIdQueryHandler>(); services.AddScoped<ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>, GetSeccionByIdQueryHandler>();
// Puntos de Venta (ADM-008)
services.AddScoped<ICommandHandler<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>, CreatePuntoDeVentaCommandHandler>();
services.AddScoped<ICommandHandler<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>, UpdatePuntoDeVentaCommandHandler>();
services.AddScoped<ICommandHandler<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, DeactivatePuntoDeVentaCommandHandler>();
services.AddScoped<ICommandHandler<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, ReactivatePuntoDeVentaCommandHandler>();
services.AddScoped<ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>, ListPuntosDeVentaQueryHandler>();
services.AddScoped<ICommandHandler<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>, GetPuntoDeVentaByIdQueryHandler>();
services.AddScoped<ICommandHandler<ReservarNumeroCommand, ReservaNumeroDto>, ReservarNumeroCommandHandler>();
services.AddScoped<ICommandHandler<GetProximoNumeroQuery, ProximoNumeroDto>, GetProximoNumeroQueryHandler>();
// FluentValidation validators (scans entire Application assembly) // FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>(); services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

@@ -13,7 +13,7 @@ namespace SIGCM2.Application.PuntosDeVenta.Reservar;
/// Un TransactionScope ambiente aquí escalaría a DTC → innecesario. /// Un TransactionScope ambiente aquí escalaría a DTC → innecesario.
/// - NO usa Polly: no está en el proyecto. Retry deadlock con bucle simple. /// - NO usa Polly: no está en el proyecto. Retry deadlock con bucle simple.
/// - Infrastructure traduce SqlException 1205 → DeadlockTransientException. /// - Infrastructure traduce SqlException 1205 → DeadlockTransientException.
/// - Backoff en ms: [50, 150, 450] — 3 intentos máximo. /// - Backoff en ms: [25, 75, 200, 500, 1200] — 5 retries máximo (6 intentos totales).
/// - La auditoría de reservas corre solo vía Temporal Tables (AD8). /// - La auditoría de reservas corre solo vía Temporal Tables (AD8).
/// </summary> /// </summary>
public sealed class ReservarNumeroCommandHandler : ICommandHandler<ReservarNumeroCommand, ReservaNumeroDto> public sealed class ReservarNumeroCommandHandler : ICommandHandler<ReservarNumeroCommand, ReservaNumeroDto>
@@ -21,7 +21,7 @@ public sealed class ReservarNumeroCommandHandler : ICommandHandler<ReservarNumer
private readonly IPuntoDeVentaRepository _repo; private readonly IPuntoDeVentaRepository _repo;
private readonly int[] _deadlockBackoffMs; private readonly int[] _deadlockBackoffMs;
private static readonly int[] DefaultBackoffMs = [50, 150, 450]; private static readonly int[] DefaultBackoffMs = [25, 75, 200, 500, 1200];
public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo) public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo)
: this(repo, DefaultBackoffMs) { } : this(repo, DefaultBackoffMs) { }

View File

@@ -41,7 +41,7 @@ public sealed class PuntoDeVentaRepository : IPuntoDeVentaRepository
pdv.Descripcion, pdv.Descripcion,
}); });
} }
catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_MedioId_NumeroAFIP")) catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_Medio_AFIP"))
{ {
throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP); throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP);
} }
@@ -102,7 +102,7 @@ public sealed class PuntoDeVentaRepository : IPuntoDeVentaRepository
pdv.Id, pdv.Id,
}); });
} }
catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_MedioId_NumeroAFIP")) catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_Medio_AFIP"))
{ {
throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP); throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP);
} }

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> /// <summary>T5.3 — 409 medio_inactivo al actualizar PdV con Medio inactivo.</summary>
[Fact] [Fact]
public async Task UpdatePdv_WhenMedioInactive_Returns409MedioInactivo() 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(nombre.GetString()), "'usuario.nombre' must not be empty");
Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty"); Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty");
Assert.Equal(JsonValueKind.Array, permisos.ValueKind); Assert.Equal(JsonValueKind.Array, permisos.ValueKind);
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
Assert.Equal(22, permisos.GetArrayLength()); // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
Assert.Equal(23, permisos.GetArrayLength());
} }
// Scenario: invalid credentials return 401 with opaque error // Scenario: invalid credentials return 401 with opaque error

View File

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

View File

@@ -44,6 +44,8 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted. // 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", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_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(); var list = await _repository.ListAsync();
// V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos // V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos
// + V011 (ADM-001) adds 'administracion:secciones:gestionar' = 22 total // + V011 (ADM-001) adds 'administracion:secciones:gestionar'
Assert.Equal(22, list.Count); // + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' = 23 total
Assert.Equal(23, list.Count);
} }
[Fact] [Fact]

View File

@@ -177,10 +177,11 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
public async Task GetByRolCodigoAsync_Admin_Returns22Permisos() public async Task GetByRolCodigoAsync_Admin_Returns22Permisos()
{ {
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006) // 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"); var permisos = await _repository.GetByRolCodigoAsync("admin");
Assert.Equal(22, permisos.Count); Assert.Equal(23, permisos.Count);
} }
[Fact] [Fact]

View File

@@ -36,6 +36,8 @@ public class UsuarioRepositoryTests : IAsyncLifetime
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted. // 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", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_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. // 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", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_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. // 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", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_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. // 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", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_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. // 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", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_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'. // V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'.
await EnsureV011SchemaAsync(); await EnsureV011SchemaAsync();
// V013 (ADM-008): ensure dbo.PuntoDeVenta, dbo.SecuenciaComprobante + temporal + SP usp_ReservarNumeroComprobante.
await EnsureV013SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{ {
DbAdapter = DbAdapter.SqlServer, DbAdapter = DbAdapter.SqlServer,
@@ -56,6 +59,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
new Respawn.Graph.Table("dbo", "RolPermiso_History"), new Respawn.Graph.Table("dbo", "RolPermiso_History"),
new Respawn.Graph.Table("dbo", "Medio_History"), new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_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: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'), ('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 -- 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) ) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN WHEN NOT MATCHED BY TARGET THEN
@@ -207,6 +213,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
('admin', 'administracion:permisos:ver'), ('admin', 'administracion:permisos:ver'),
-- V011 (ADM-001) -- V011 (ADM-001)
('admin', 'administracion:secciones:gestionar'), ('admin', 'administracion:secciones:gestionar'),
-- V013 (ADM-008)
('admin', 'administracion:puntos_de_venta:gestionar'),
('cajero', 'ventas:contado:crear'), ('cajero', 'ventas:contado:crear'),
('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:modificar'),
('cajero', 'ventas:contado:cobrar'), ('cajero', 'ventas:contado:cobrar'),
@@ -373,6 +381,201 @@ public sealed class SqlTestFixture : IAsyncLifetime
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). // 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> /// <summary>
/// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset. /// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset.
/// </summary> /// </summary>