2026-04-13 21:36:09 -03:00
|
|
|
using Dapper;
|
|
|
|
|
using Microsoft.Data.SqlClient;
|
|
|
|
|
using Respawn;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace SIGCM2.TestSupport;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Manages a real SQL Server test database.
|
|
|
|
|
/// Resets state between test runs using Respawn.
|
|
|
|
|
/// Seeds the admin user after each reset.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class SqlTestFixture : IAsyncLifetime
|
|
|
|
|
{
|
|
|
|
|
private readonly string _connectionString;
|
|
|
|
|
private SqlConnection _connection = null!;
|
|
|
|
|
private Respawner _respawner = null!;
|
|
|
|
|
|
|
|
|
|
public SqlTestFixture(string connectionString)
|
|
|
|
|
{
|
|
|
|
|
_connectionString = connectionString;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task InitializeAsync()
|
|
|
|
|
{
|
|
|
|
|
_connection = new SqlConnection(_connectionString);
|
|
|
|
|
await _connection.OpenAsync();
|
|
|
|
|
|
2026-04-15 17:36:46 -03:00
|
|
|
// V008: ensure MustChangePassword column and IX_Usuario_Activo_Rol exist in test DB
|
|
|
|
|
await EnsureV008SchemaAsync();
|
|
|
|
|
|
2026-04-15 21:27:29 -03:00
|
|
|
// V009: update PermisosJson DEFAULT constraint and migrate legacy rows
|
|
|
|
|
await EnsureV009SchemaAsync();
|
|
|
|
|
|
feat(db): V010 audit infrastructure + temporal tables
Applied to SIGCM2 (dev) and SIGCM2_Test.
V010__audit_infrastructure.sql (idempotent, ~280 LoC):
- Filegroups AUDIT_HOT + AUDIT_COLD with physical files (per-DB logical names
via DB_NAME() prefix to avoid collision in dev/test).
- pf/ps_AuditEvent_Monthly + pf/ps_SecurityEvent_Monthly (RANGE RIGHT,
DATETIME2(3), 14 boundaries 2026-01..2027-02 → 15 partitions). Job extends
forward monthly in B11.
- dbo.AuditEvent (partitioned, clustered PK on OccurredAt+Id) + 4 indexes
(Actor/Target/Action/Correlation) with PAGE compression.
- dbo.SecurityEvent (partitioned) + 3 indexes (Actor/Action_Result/Ip_Failure).
- CHECK constraints: Action LIKE '%.%', ISJSON(Metadata), Result IN (success|failure).
- SYSTEM_VERSIONING ON in Usuario/Rol/Permiso/RolPermiso with 10 YEARS retention +
PAGE compression in history tables.
- No hard FK on ActorUserId → Usuario.Id (soft FK — audit must survive user deletion).
V010_ROLLBACK.sql: emergency reversal (WARNING: destroys all audit history).
database/README.md: migration order + V010 prod-apply notes.
tests/SIGCM2.TestSupport/SqlTestFixture.cs:
- EnsureV010SchemaAsync() validates audit infra is applied (fails fast with
clear message if not — migration itself requires ALTER DATABASE privileges
and is applied manually via sqlcmd).
- Respawn TablesToIgnore extended with *_History (engine rejects direct DELETE
on system-versioned history tables).
tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs — 5 smoke tests:
- AuditEvent insert+roundtrip with CorrelationId.
- CK_AuditEvent_Action rejects Action without '.'.
- CK_AuditEvent_Metadata rejects non-JSON.
- CK_SecurityEvent_Result rejects invalid Result.
- Usuario SYSTEM_VERSIONING: temporal query FOR SYSTEM_TIME AS OF returns
pre-update state + Usuario_History populated.
Suite: 130/130 passing (previous 124 + spike B0 + 5 new B1). No regressions.
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-1,2, #REQ-SEC-1,
design#D-4, tasks}
2026-04-16 13:10:04 -03:00
|
|
|
// V010 (UDT-010): verify audit infrastructure + temporal tables are active.
|
|
|
|
|
// Applied manually via: sqlcmd ... -i database/migrations/V010__audit_infrastructure.sql
|
|
|
|
|
await EnsureV010SchemaAsync();
|
|
|
|
|
|
2026-04-16 18:13:54 -03:00
|
|
|
// V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'.
|
|
|
|
|
await EnsureV011SchemaAsync();
|
|
|
|
|
|
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).
2026-04-17 13:02:35 -03:00
|
|
|
// V013 (ADM-008): ensure dbo.PuntoDeVenta, dbo.SecuenciaComprobante + temporal + SP usp_ReservarNumeroComprobante.
|
|
|
|
|
await EnsureV013SchemaAsync();
|
|
|
|
|
|
2026-04-13 21:36:09 -03:00
|
|
|
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
|
|
|
|
{
|
2026-04-15 12:31:29 -03:00
|
|
|
DbAdapter = DbAdapter.SqlServer,
|
|
|
|
|
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
|
2026-04-15 15:26:19 -03:00
|
|
|
// Permiso and RolPermiso are seeded by V005/V006 — never wipe or integration tests lose the permission catalog.
|
2026-04-16 18:13:54 -03:00
|
|
|
// *_History tables: UDT-010/ADM-001 system-versioned — Respawn cannot DELETE them directly (engine rejects).
|
2026-04-15 15:26:19 -03:00
|
|
|
TablesToIgnore =
|
|
|
|
|
[
|
|
|
|
|
new Respawn.Graph.Table("dbo", "Rol"),
|
|
|
|
|
new Respawn.Graph.Table("dbo", "Permiso"),
|
|
|
|
|
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
feat(db): V010 audit infrastructure + temporal tables
Applied to SIGCM2 (dev) and SIGCM2_Test.
V010__audit_infrastructure.sql (idempotent, ~280 LoC):
- Filegroups AUDIT_HOT + AUDIT_COLD with physical files (per-DB logical names
via DB_NAME() prefix to avoid collision in dev/test).
- pf/ps_AuditEvent_Monthly + pf/ps_SecurityEvent_Monthly (RANGE RIGHT,
DATETIME2(3), 14 boundaries 2026-01..2027-02 → 15 partitions). Job extends
forward monthly in B11.
- dbo.AuditEvent (partitioned, clustered PK on OccurredAt+Id) + 4 indexes
(Actor/Target/Action/Correlation) with PAGE compression.
- dbo.SecurityEvent (partitioned) + 3 indexes (Actor/Action_Result/Ip_Failure).
- CHECK constraints: Action LIKE '%.%', ISJSON(Metadata), Result IN (success|failure).
- SYSTEM_VERSIONING ON in Usuario/Rol/Permiso/RolPermiso with 10 YEARS retention +
PAGE compression in history tables.
- No hard FK on ActorUserId → Usuario.Id (soft FK — audit must survive user deletion).
V010_ROLLBACK.sql: emergency reversal (WARNING: destroys all audit history).
database/README.md: migration order + V010 prod-apply notes.
tests/SIGCM2.TestSupport/SqlTestFixture.cs:
- EnsureV010SchemaAsync() validates audit infra is applied (fails fast with
clear message if not — migration itself requires ALTER DATABASE privileges
and is applied manually via sqlcmd).
- Respawn TablesToIgnore extended with *_History (engine rejects direct DELETE
on system-versioned history tables).
tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs — 5 smoke tests:
- AuditEvent insert+roundtrip with CorrelationId.
- CK_AuditEvent_Action rejects Action without '.'.
- CK_AuditEvent_Metadata rejects non-JSON.
- CK_SecurityEvent_Result rejects invalid Result.
- Usuario SYSTEM_VERSIONING: temporal query FOR SYSTEM_TIME AS OF returns
pre-update state + Usuario_History populated.
Suite: 130/130 passing (previous 124 + spike B0 + 5 new B1). No regressions.
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-1,2, #REQ-SEC-1,
design#D-4, tasks}
2026-04-16 13:10:04 -03:00
|
|
|
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"),
|
2026-04-16 18:13:54 -03:00
|
|
|
new Respawn.Graph.Table("dbo", "Medio_History"),
|
|
|
|
|
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
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).
2026-04-17 13:02:35 -03:00
|
|
|
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
2026-04-15 15:26:19 -03:00
|
|
|
]
|
2026-04-13 21:36:09 -03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await ResetAndSeedAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task ResetAndSeedAsync()
|
|
|
|
|
{
|
|
|
|
|
await _respawner.ResetAsync(_connection);
|
2026-04-15 12:31:29 -03:00
|
|
|
await SeedRolCanonicalAsync();
|
2026-04-15 15:39:25 -03:00
|
|
|
await SeedPermisosCanonicalAsync();
|
|
|
|
|
await SeedRolPermisosCanonicalAsync();
|
2026-04-13 21:36:09 -03:00
|
|
|
await SeedAdminAsync();
|
2026-04-16 18:13:54 -03:00
|
|
|
await SeedMediosCanonicalAsync();
|
2026-04-13 21:36:09 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 12:31:29 -03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 21:36:09 -03:00
|
|
|
public async Task DisposeAsync()
|
|
|
|
|
{
|
|
|
|
|
if (_connection is not null)
|
|
|
|
|
{
|
|
|
|
|
await _connection.CloseAsync();
|
|
|
|
|
await _connection.DisposeAsync();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 17:36:46 -03:00
|
|
|
/// <summary>
|
|
|
|
|
/// Applies V008 schema changes idempotently to the test database.
|
|
|
|
|
/// Mirrors V008__add_mustchangepassword_and_indexes.sql.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task EnsureV008SchemaAsync()
|
|
|
|
|
{
|
|
|
|
|
const string addColumn = """
|
|
|
|
|
IF COL_LENGTH('dbo.Usuario', 'MustChangePassword') IS NULL
|
|
|
|
|
BEGIN
|
|
|
|
|
ALTER TABLE dbo.Usuario
|
|
|
|
|
ADD MustChangePassword BIT NOT NULL
|
|
|
|
|
CONSTRAINT DF_Usuario_MustChangePassword DEFAULT(0);
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
const string addIndex = """
|
|
|
|
|
IF NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM sys.indexes
|
|
|
|
|
WHERE name = 'IX_Usuario_Activo_Rol'
|
|
|
|
|
AND object_id = OBJECT_ID('dbo.Usuario')
|
|
|
|
|
)
|
|
|
|
|
BEGIN
|
|
|
|
|
CREATE INDEX IX_Usuario_Activo_Rol
|
|
|
|
|
ON dbo.Usuario(Activo, Rol)
|
|
|
|
|
INCLUDE (Id, Username, Email, UltimoLogin, FechaModificacion);
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
await _connection.ExecuteAsync(addColumn);
|
|
|
|
|
await _connection.ExecuteAsync(addIndex);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 15:39:25 -03:00
|
|
|
private async Task SeedPermisosCanonicalAsync()
|
|
|
|
|
{
|
|
|
|
|
const string sql = """
|
|
|
|
|
SET QUOTED_IDENTIFIER ON;
|
|
|
|
|
MERGE dbo.Permiso AS t
|
|
|
|
|
USING (VALUES
|
|
|
|
|
('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'),
|
|
|
|
|
('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'),
|
|
|
|
|
('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'),
|
|
|
|
|
('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'),
|
|
|
|
|
('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'),
|
|
|
|
|
('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'),
|
|
|
|
|
('textos:editar', N'Editar textos', NULL, 'textos'),
|
|
|
|
|
('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'),
|
|
|
|
|
('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'),
|
|
|
|
|
('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'),
|
|
|
|
|
('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'),
|
|
|
|
|
('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'),
|
|
|
|
|
('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'),
|
|
|
|
|
('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'),
|
2026-04-15 16:34:32 -03:00
|
|
|
('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'),
|
|
|
|
|
('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'),
|
|
|
|
|
('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'),
|
|
|
|
|
('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion'),
|
|
|
|
|
-- V007 (UDT-006): permisos administrativos RBAC
|
|
|
|
|
('administracion:roles:gestionar', N'Gestionar roles del sistema', N'Crear, editar y desactivar roles RBAC', 'administracion'),
|
|
|
|
|
('administracion:roles_permisos:gestionar', N'Gestionar asignacion de permisos', N'Asignar y revocar permisos por rol', 'administracion'),
|
2026-04-16 18:13:54 -03:00
|
|
|
('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
|
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).
2026-04-17 13:02:35 -03:00
|
|
|
('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')
|
2026-04-15 15:39:25 -03:00
|
|
|
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
|
|
|
|
ON t.Codigo = s.Codigo
|
|
|
|
|
WHEN NOT MATCHED BY TARGET THEN
|
|
|
|
|
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
|
|
|
|
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
|
|
|
|
""";
|
|
|
|
|
await _connection.ExecuteAsync(sql);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task SeedRolPermisosCanonicalAsync()
|
|
|
|
|
{
|
|
|
|
|
const string sql = """
|
|
|
|
|
SET QUOTED_IDENTIFIER ON;
|
|
|
|
|
MERGE dbo.RolPermiso AS t
|
|
|
|
|
USING (
|
|
|
|
|
SELECT r.Id AS RolId, p.Id AS PermisoId
|
|
|
|
|
FROM (VALUES
|
|
|
|
|
('admin', 'ventas:contado:crear'),
|
|
|
|
|
('admin', 'ventas:contado:modificar'),
|
|
|
|
|
('admin', 'ventas:contado:cobrar'),
|
|
|
|
|
('admin', 'ventas:contado:facturar'),
|
|
|
|
|
('admin', 'ventas:ctacte:crear'),
|
|
|
|
|
('admin', 'ventas:ctacte:facturar'),
|
|
|
|
|
('admin', 'textos:editar'),
|
|
|
|
|
('admin', 'textos:reclamos:ver'),
|
|
|
|
|
('admin', 'pauta:azanu:ver'),
|
|
|
|
|
('admin', 'pauta:limpiar'),
|
|
|
|
|
('admin', 'pauta:recursos:fueradehora'),
|
|
|
|
|
('admin', 'productores:deuda:ver'),
|
|
|
|
|
('admin', 'productores:pendientes:crear'),
|
|
|
|
|
('admin', 'productores:deuda:bypass'),
|
|
|
|
|
('admin', 'administracion:usuarios:gestionar'),
|
|
|
|
|
('admin', 'administracion:tarifarios:gestionar'),
|
|
|
|
|
('admin', 'administracion:medios:gestionar'),
|
|
|
|
|
('admin', 'administracion:auditoria:ver'),
|
2026-04-15 16:34:32 -03:00
|
|
|
-- V007 (UDT-006): permisos administrativos RBAC para admin
|
|
|
|
|
('admin', 'administracion:roles:gestionar'),
|
|
|
|
|
('admin', 'administracion:roles_permisos:gestionar'),
|
|
|
|
|
('admin', 'administracion:permisos:ver'),
|
2026-04-16 18:13:54 -03:00
|
|
|
-- V011 (ADM-001)
|
|
|
|
|
('admin', 'administracion:secciones:gestionar'),
|
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).
2026-04-17 13:02:35 -03:00
|
|
|
-- V013 (ADM-008)
|
|
|
|
|
('admin', 'administracion:puntos_de_venta:gestionar'),
|
2026-04-15 15:39:25 -03:00
|
|
|
('cajero', 'ventas:contado:crear'),
|
|
|
|
|
('cajero', 'ventas:contado:modificar'),
|
|
|
|
|
('cajero', 'ventas:contado:cobrar'),
|
|
|
|
|
('cajero', 'ventas:contado:facturar'),
|
|
|
|
|
('operador_ctacte', 'ventas:ctacte:crear'),
|
|
|
|
|
('operador_ctacte', 'ventas:ctacte:facturar'),
|
|
|
|
|
('picadora', 'textos:editar'),
|
|
|
|
|
('picadora', 'textos:reclamos:ver'),
|
|
|
|
|
('jefe_publicidad', 'textos:editar'),
|
|
|
|
|
('jefe_publicidad', 'textos:reclamos:ver'),
|
|
|
|
|
('jefe_publicidad', 'pauta:azanu:ver'),
|
|
|
|
|
('jefe_publicidad', 'pauta:limpiar'),
|
|
|
|
|
('jefe_publicidad', 'pauta:recursos:fueradehora'),
|
|
|
|
|
('jefe_publicidad', 'productores:deuda:ver'),
|
|
|
|
|
('jefe_publicidad', 'productores:deuda:bypass'),
|
|
|
|
|
('productor', 'productores:deuda:ver'),
|
|
|
|
|
('productor', 'productores:pendientes:crear'),
|
|
|
|
|
('diagramacion', 'pauta:azanu:ver')
|
|
|
|
|
) AS x (RolCodigo, PermisoCodigo)
|
|
|
|
|
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
|
|
|
|
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
|
|
|
|
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
|
|
|
|
WHEN NOT MATCHED BY TARGET THEN
|
|
|
|
|
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
|
|
|
|
""";
|
|
|
|
|
await _connection.ExecuteAsync(sql);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 21:36:09 -03:00
|
|
|
private async Task SeedAdminAsync()
|
|
|
|
|
{
|
2026-04-15 21:27:29 -03:00
|
|
|
// V009: PermisosJson uses new canonical shape {"grant":[],"deny":[]} — NOT legacy '["*"]'
|
2026-04-13 21:36:09 -03:00
|
|
|
const string sql = """
|
|
|
|
|
SET QUOTED_IDENTIFIER ON;
|
|
|
|
|
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
|
2026-04-15 17:36:46 -03:00
|
|
|
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
2026-04-13 21:36:09 -03:00
|
|
|
VALUES (
|
|
|
|
|
'admin',
|
|
|
|
|
'$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
|
2026-04-15 21:27:29 -03:00
|
|
|
'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0
|
2026-04-13 21:36:09 -03:00
|
|
|
);
|
|
|
|
|
""";
|
2026-04-16 18:13:54 -03:00
|
|
|
await _connection.ExecuteAsync(sql);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// ADM-001 (V011): applies Medio/Seccion schema + temporal + permiso 'administracion:secciones:gestionar'
|
|
|
|
|
/// idempotently to the test database. Mirrors V011__create_medio_seccion.sql.
|
|
|
|
|
/// Canonical seed (ELDIA, ELPLATA) vive en SeedMediosCanonicalAsync — se reaplica tras cada Respawn.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task EnsureV011SchemaAsync()
|
|
|
|
|
{
|
|
|
|
|
const string createMedio = """
|
|
|
|
|
IF OBJECT_ID(N'dbo.Medio', N'U') IS NULL
|
|
|
|
|
BEGIN
|
|
|
|
|
CREATE TABLE dbo.Medio (
|
|
|
|
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Medio PRIMARY KEY,
|
|
|
|
|
Codigo VARCHAR(30) NOT NULL,
|
|
|
|
|
Nombre NVARCHAR(100) NOT NULL,
|
|
|
|
|
Tipo TINYINT NOT NULL,
|
|
|
|
|
PlataformaEmpresaId INT NULL,
|
|
|
|
|
Activo BIT NOT NULL CONSTRAINT DF_Medio_Activo DEFAULT(1),
|
|
|
|
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Medio_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
|
|
|
|
FechaModificacion DATETIME2(3) NULL,
|
|
|
|
|
CONSTRAINT UQ_Medio_Codigo UNIQUE (Codigo),
|
|
|
|
|
CONSTRAINT CK_Medio_Tipo CHECK (Tipo BETWEEN 1 AND 4)
|
|
|
|
|
);
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
const string createMedioIndex = """
|
|
|
|
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Medio_Activo_Tipo' AND object_id = OBJECT_ID('dbo.Medio'))
|
|
|
|
|
BEGIN
|
|
|
|
|
CREATE INDEX IX_Medio_Activo_Tipo
|
|
|
|
|
ON dbo.Medio(Activo, Tipo)
|
|
|
|
|
INCLUDE (Codigo, Nombre, PlataformaEmpresaId);
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
const string createSeccion = """
|
|
|
|
|
IF OBJECT_ID(N'dbo.Seccion', N'U') IS NULL
|
|
|
|
|
BEGIN
|
|
|
|
|
CREATE TABLE dbo.Seccion (
|
|
|
|
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Seccion PRIMARY KEY,
|
|
|
|
|
MedioId INT NOT NULL,
|
|
|
|
|
Codigo VARCHAR(30) NOT NULL,
|
|
|
|
|
Nombre NVARCHAR(100) NOT NULL,
|
|
|
|
|
Tipo VARCHAR(20) NOT NULL,
|
|
|
|
|
Activo BIT NOT NULL CONSTRAINT DF_Seccion_Activo DEFAULT(1),
|
|
|
|
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Seccion_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
|
|
|
|
FechaModificacion DATETIME2(3) NULL,
|
|
|
|
|
CONSTRAINT FK_Seccion_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
|
|
|
|
CONSTRAINT UQ_Seccion_MedioId_Codigo UNIQUE (MedioId, Codigo),
|
|
|
|
|
CONSTRAINT CK_Seccion_Tipo CHECK (Tipo IN ('clasificados','notables','suplementos'))
|
|
|
|
|
);
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
const string createSeccionIndex = """
|
|
|
|
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Seccion_MedioId_Activo' AND object_id = OBJECT_ID('dbo.Seccion'))
|
|
|
|
|
BEGIN
|
|
|
|
|
CREATE INDEX IX_Seccion_MedioId_Activo
|
|
|
|
|
ON dbo.Seccion(MedioId, Activo)
|
|
|
|
|
INCLUDE (Codigo, Nombre, Tipo);
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
const string addMedioPeriod = """
|
|
|
|
|
IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NULL
|
|
|
|
|
BEGIN
|
|
|
|
|
ALTER TABLE dbo.Medio
|
|
|
|
|
ADD
|
|
|
|
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
|
|
|
|
CONSTRAINT DF_Medio_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
|
|
|
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
|
|
|
|
CONSTRAINT DF_Medio_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
|
|
|
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
const string setMedioVersioning = """
|
|
|
|
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2)
|
|
|
|
|
BEGIN
|
|
|
|
|
ALTER TABLE dbo.Medio
|
|
|
|
|
SET (SYSTEM_VERSIONING = ON (
|
|
|
|
|
HISTORY_TABLE = dbo.Medio_History,
|
|
|
|
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
|
|
|
|
));
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
const string addSeccionPeriod = """
|
|
|
|
|
IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NULL
|
|
|
|
|
BEGIN
|
|
|
|
|
ALTER TABLE dbo.Seccion
|
|
|
|
|
ADD
|
|
|
|
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
|
|
|
|
CONSTRAINT DF_Seccion_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
|
|
|
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
|
|
|
|
CONSTRAINT DF_Seccion_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
|
|
|
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
const string setSeccionVersioning = """
|
|
|
|
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2)
|
|
|
|
|
BEGIN
|
|
|
|
|
ALTER TABLE dbo.Seccion
|
|
|
|
|
SET (SYSTEM_VERSIONING = ON (
|
|
|
|
|
HISTORY_TABLE = dbo.Seccion_History,
|
|
|
|
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
|
|
|
|
));
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
await _connection.ExecuteAsync(createMedio);
|
|
|
|
|
await _connection.ExecuteAsync(createMedioIndex);
|
|
|
|
|
await _connection.ExecuteAsync(createSeccion);
|
|
|
|
|
await _connection.ExecuteAsync(createSeccionIndex);
|
|
|
|
|
await _connection.ExecuteAsync(addMedioPeriod);
|
|
|
|
|
await _connection.ExecuteAsync(setMedioVersioning);
|
|
|
|
|
await _connection.ExecuteAsync(addSeccionPeriod);
|
|
|
|
|
await _connection.ExecuteAsync(setSeccionVersioning);
|
|
|
|
|
// Permiso 'administracion:secciones:gestionar' + asignación a admin se siembran
|
|
|
|
|
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
|
|
|
|
|
}
|
|
|
|
|
|
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).
2026-04-17 13:02:35 -03:00
|
|
|
/// <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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 18:13:54 -03:00
|
|
|
/// <summary>
|
|
|
|
|
/// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task SeedMediosCanonicalAsync()
|
|
|
|
|
{
|
|
|
|
|
const string sql = """
|
|
|
|
|
SET QUOTED_IDENTIFIER ON;
|
|
|
|
|
MERGE dbo.Medio AS t
|
|
|
|
|
USING (VALUES
|
|
|
|
|
('ELDIA', N'El Día', 1),
|
|
|
|
|
('ELPLATA', N'El Plata', 1)
|
|
|
|
|
) AS s (Codigo, Nombre, Tipo)
|
|
|
|
|
ON t.Codigo = s.Codigo
|
|
|
|
|
WHEN NOT MATCHED BY TARGET THEN
|
|
|
|
|
INSERT (Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo)
|
|
|
|
|
VALUES (s.Codigo, s.Nombre, s.Tipo, NULL, 1);
|
|
|
|
|
""";
|
2026-04-13 21:36:09 -03:00
|
|
|
await _connection.ExecuteAsync(sql);
|
|
|
|
|
}
|
2026-04-15 21:27:29 -03:00
|
|
|
|
feat(db): V010 audit infrastructure + temporal tables
Applied to SIGCM2 (dev) and SIGCM2_Test.
V010__audit_infrastructure.sql (idempotent, ~280 LoC):
- Filegroups AUDIT_HOT + AUDIT_COLD with physical files (per-DB logical names
via DB_NAME() prefix to avoid collision in dev/test).
- pf/ps_AuditEvent_Monthly + pf/ps_SecurityEvent_Monthly (RANGE RIGHT,
DATETIME2(3), 14 boundaries 2026-01..2027-02 → 15 partitions). Job extends
forward monthly in B11.
- dbo.AuditEvent (partitioned, clustered PK on OccurredAt+Id) + 4 indexes
(Actor/Target/Action/Correlation) with PAGE compression.
- dbo.SecurityEvent (partitioned) + 3 indexes (Actor/Action_Result/Ip_Failure).
- CHECK constraints: Action LIKE '%.%', ISJSON(Metadata), Result IN (success|failure).
- SYSTEM_VERSIONING ON in Usuario/Rol/Permiso/RolPermiso with 10 YEARS retention +
PAGE compression in history tables.
- No hard FK on ActorUserId → Usuario.Id (soft FK — audit must survive user deletion).
V010_ROLLBACK.sql: emergency reversal (WARNING: destroys all audit history).
database/README.md: migration order + V010 prod-apply notes.
tests/SIGCM2.TestSupport/SqlTestFixture.cs:
- EnsureV010SchemaAsync() validates audit infra is applied (fails fast with
clear message if not — migration itself requires ALTER DATABASE privileges
and is applied manually via sqlcmd).
- Respawn TablesToIgnore extended with *_History (engine rejects direct DELETE
on system-versioned history tables).
tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs — 5 smoke tests:
- AuditEvent insert+roundtrip with CorrelationId.
- CK_AuditEvent_Action rejects Action without '.'.
- CK_AuditEvent_Metadata rejects non-JSON.
- CK_SecurityEvent_Result rejects invalid Result.
- Usuario SYSTEM_VERSIONING: temporal query FOR SYSTEM_TIME AS OF returns
pre-update state + Usuario_History populated.
Suite: 130/130 passing (previous 124 + spike B0 + 5 new B1). No regressions.
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-1,2, #REQ-SEC-1,
design#D-4, tasks}
2026-04-16 13:10:04 -03:00
|
|
|
/// <summary>
|
|
|
|
|
/// UDT-010 (V010): verifies that the audit infrastructure is present.
|
|
|
|
|
/// Does NOT re-apply the migration (the ALTER DATABASE ADD FILEGROUP/FILE + partition
|
|
|
|
|
/// function/scheme creation requires the full script). If missing, fails with a clear
|
|
|
|
|
/// message pointing to the migration script.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task EnsureV010SchemaAsync()
|
|
|
|
|
{
|
|
|
|
|
const string check = """
|
|
|
|
|
SELECT
|
|
|
|
|
CAST(CASE WHEN OBJECT_ID('dbo.AuditEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasAuditEvent,
|
|
|
|
|
CAST(CASE WHEN OBJECT_ID('dbo.SecurityEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasSecurityEvent,
|
|
|
|
|
CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2) THEN 1 ELSE 0 END AS BIT) AS UsuarioVersioned
|
|
|
|
|
""";
|
|
|
|
|
var result = await _connection.QuerySingleAsync<(bool HasAuditEvent, bool HasSecurityEvent, bool UsuarioVersioned)>(check);
|
|
|
|
|
if (!result.HasAuditEvent || !result.HasSecurityEvent || !result.UsuarioVersioned)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
"V010 audit infrastructure is not applied in the test database. " +
|
|
|
|
|
"Run: sqlcmd -S <server> -d SIGCM2_Test -U <user> -P <pass> -i database/migrations/V010__audit_infrastructure.sql");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 21:27:29 -03:00
|
|
|
/// <summary>
|
|
|
|
|
/// Applies V009 schema changes idempotently to the test database.
|
|
|
|
|
/// Mirrors V009__activate_permisos_overrides.sql.
|
|
|
|
|
/// Drops and re-adds DF_Usuario_Permisos with the new shape, then migrates legacy rows.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task EnsureV009SchemaAsync()
|
|
|
|
|
{
|
|
|
|
|
const string dropConstraint = """
|
|
|
|
|
IF EXISTS (
|
|
|
|
|
SELECT 1 FROM sys.default_constraints
|
|
|
|
|
WHERE name = 'DF_Usuario_Permisos'
|
|
|
|
|
AND parent_object_id = OBJECT_ID('dbo.Usuario')
|
|
|
|
|
)
|
|
|
|
|
BEGIN
|
|
|
|
|
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
const string addConstraint = """
|
|
|
|
|
IF NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM sys.default_constraints
|
|
|
|
|
WHERE name = 'DF_Usuario_Permisos'
|
|
|
|
|
AND parent_object_id = OBJECT_ID('dbo.Usuario')
|
|
|
|
|
)
|
|
|
|
|
BEGIN
|
|
|
|
|
ALTER TABLE dbo.Usuario
|
|
|
|
|
ADD CONSTRAINT DF_Usuario_Permisos
|
|
|
|
|
DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
|
|
|
|
|
END
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
const string migrateRows = """
|
|
|
|
|
UPDATE dbo.Usuario
|
|
|
|
|
SET PermisosJson = '{"grant":[],"deny":[]}'
|
|
|
|
|
WHERE PermisosJson IN ('[]', '["*"]', '')
|
|
|
|
|
OR PermisosJson IS NULL
|
|
|
|
|
OR LTRIM(RTRIM(PermisosJson)) = ''
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
await _connection.ExecuteAsync(dropConstraint);
|
|
|
|
|
await _connection.ExecuteAsync(addConstraint);
|
|
|
|
|
await _connection.ExecuteAsync(migrateRows);
|
|
|
|
|
}
|
2026-04-13 21:36:09 -03:00
|
|
|
}
|