Compare commits

..

8 Commits

Author SHA1 Message Date
9886524645 Merge pull request 'fix: issue #29 — integration tests flakiness (DB split + SqlTestFixture consolidado)' (#34) from fix/issue-29-flakiness into main 2026-04-19 10:41:27 +00:00
8daadc8a77 fix(tests): timestamp determinístico en QueryAsync_Limit_EmitsCursor
DATETIME2(3) + cursor roundtrip via O format perdía sub-ms de
DateTime.UtcNow causando ~37% flake rate. Timestamp fijo con sub-ms=0
elimina la ambigüedad.

Fixes residual flake del issue #29.
2026-04-19 07:40:32 -03:00
a0dcc7258b docs(database): actualiza README con V013-V015 y sección Test DBs
Agrega filas V013, V014, V015 a la tabla de migraciones. Actualiza
convención de "3 bases" (SIGCM2, SIGCM2_Test_App, SIGCM2_Test_Api).
Añade sección "Bases de datos de integration tests" con tabla de
propósito y referencia al script de creación.
2026-04-18 21:44:45 -03:00
e5b6c06f64 refactor(tests): Api.Tests apunta a SIGCM2_Test_Api via TestConnectionStrings
Todos los archivos de Api.Tests reemplazan la connection string hardcodeada
por TestConnectionStrings.ApiTestDb. Cada proyecto de tests ahora tiene su
propia base de datos aislada, eliminando la contención entre Application.Tests
y Api.Tests que causaba flakiness.
2026-04-18 21:44:40 -03:00
e0b9cba948 refactor(tests): Application.Tests elimina Respawner inline; usa SqlTestFixture compartido
6 clases que instanciaban Respawner directamente migran a recibir SqlTestFixture
vía ICollectionFixture. 8 clases restantes solo actualizan ConnectionString a
TestConnectionStrings.AppTestDb. Cada clase ahora es responsable únicamente de
sus seeds específicos; la limpieza de la base queda centralizada en el fixture.
2026-04-18 21:44:36 -03:00
03a695feb9 refactor(tests): DatabaseCollection centraliza ICollectionFixture<SqlTestFixture>
Registra la colección "Database" con SqlTestFixture como fixture compartido
para Application.Tests (elimina el ctor-con-string inline en cada test class).
Agrega Using global a ambos proyectos para evitar usings por archivo.
2026-04-18 21:44:24 -03:00
e987228f14 refactor(tests): SqlTestFixture usa TestConnectionStrings; ctor interno para Api.Tests
Agrega ctor parameterless que apunta a SIGCM2_Test_App (requerido por
xUnit ICollectionFixture<T>). El ctor con string se marca internal y
expone via InternalsVisibleTo a SIGCM2.Api.Tests. TestWebAppFactory
apunta a SIGCM2_Test_Api. Se agrega propiedad Connection pública para
que los tests que necesitan queries ad-hoc la usen.
2026-04-18 21:44:19 -03:00
d4a2b3bc3e feat(tests): añade TestConnectionStrings y script de creación de DBs de test
Introduce SIGCM2_Test_App y SIGCM2_Test_Api como bases aisladas para
Application.Tests y Api.Tests respectivamente. TestConnectionStrings.cs
centraliza las connection strings; create-test-api-db.sql documenta
el setup idempotente de ambas bases con COLLATE Modern_Spanish_CI_AS.
2026-04-18 21:44:12 -03:00
41 changed files with 228 additions and 514 deletions

View File

@@ -29,6 +29,9 @@ database/
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** |
| V011 | `V011__create_medio_seccion.sql` | ADM-001 | Medio + Seccion (temporal, retention 10y) + permiso `administracion:secciones:gestionar` |
| V012 | `V012__seed_medios.sql` | ADM-001 | Seed idempotente de Medios ELDIA y ELPLATA |
| V013 | `V013__create_punto_de_venta.sql` | ADM-008 | PuntoDeVenta (temporal) + permiso `administracion:puntosdeventa:gestionar` |
| V014 | `V014__create_tipo_de_iva_ingresos_brutos.sql` | ADM-009 | TipoDeIva + IngresosBrutos (temporales con vigencias) + permiso fiscal |
| V015 | `V015__create_audit_views.sql` | UDT-011 | Vistas `v_AuditEvent_Local` + `v_SecurityEvent_Local` con timezone local |
## Convenciones
@@ -36,23 +39,24 @@ database/
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
- **Se aplican a AMBAS bases**: `SIGCM2` (dev) y `SIGCM2_Test` (integration tests). El orden debe ser idéntico.
- **Se aplican a TRES bases**: `SIGCM2` (dev), `SIGCM2_Test_App` (Application.Tests) y `SIGCM2_Test_Api` (Api.Tests). El orden debe ser idéntico en las tres.
## Cómo aplicar migraciones
### En dev (manual)
```bash
# Con sqlcmd:
# Con sqlcmd (aplicar a las tres bases en orden):
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
sqlcmd -S TECNICA3 -d SIGCM2_Test -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
sqlcmd -S TECNICA3 -d SIGCM2_Test_App -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
sqlcmd -S TECNICA3 -d SIGCM2_Test_Api -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
```
O desde SSMS: abrir el archivo, conectar a cada base, F5.
### En integration tests
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). **NO** hace falta correr el script manualmente.
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test_App` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). `TestWebAppFactory` hace lo mismo contra `SIGCM2_Test_Api`. **NO** hace falta correr los scripts manualmente si el fixture ya lo cubre.
### En producción (roadmap futuro)
@@ -90,6 +94,22 @@ O desde SSMS: abrir el archivo, conectar a cada base, F5.
- `Seccion.Tipo` acepta `'clasificados' | 'notables' | 'suplementos'` (CHECK constraint). Config avanzada (par/impar, suplementos, cupos) se introduce en ADM-003.
- Rollback: `V012_ROLLBACK.sql` (falla si hay Secciones vivas) → `V011_ROLLBACK.sql`. Rollback en prod NO soportado si ADM-008/009 o PRC-* ya aplicados.
## Bases de datos de integration tests
| Base | Propósito | Usada por |
|---|---|---|
| `SIGCM2_Test_App` | Tests de repositorios y Application layer | `SIGCM2.Application.Tests` vía `SqlTestFixture` (parameterless ctor) |
| `SIGCM2_Test_Api` | Tests de endpoints HTTP / WebApplicationFactory | `SIGCM2.Api.Tests` vía `TestWebAppFactory` |
**Script de creación inicial** (idempotente): `database/init/create-test-api-db.sql`
Ambas bases deben tener **todas las migraciones V001V015** aplicadas en orden. Al crear una base nueva o al agregar un desarrollador:
1. Crear las bases con `create-test-api-db.sql`
2. Aplicar V001V015 en orden (ver tabla de arriba) contra cada base de test
3. Las `EnsureV0XX` del fixture validan presencia; no aplican migraciones pesadas
Fuente única de connection strings: `tests/SIGCM2.TestSupport/TestConnectionStrings.cs`
## Recursos
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`

View File

@@ -0,0 +1,30 @@
-- create-test-api-db.sql
-- Creates test databases for integration tests (idempotent).
-- Run once per environment on TECNICA3 before executing integration tests.
--
-- SIGCM2_Test_App -> used by SIGCM2.Application.Tests
-- SIGCM2_Test_Api -> used by SIGCM2.Api.Tests
-- SIGCM2_Test -> legacy (kept for old branches e.g. pre-merge CAT-001)
--
-- After creating the DBs, apply V010 to both new DBs:
-- See database/README.md > "Test DBs" section for the PowerShell runbook.
IF DB_ID(N'SIGCM2_Test_App') IS NULL
BEGIN
CREATE DATABASE [SIGCM2_Test_App]
COLLATE Modern_Spanish_CI_AS;
PRINT 'Database SIGCM2_Test_App created.';
END
ELSE
PRINT 'Database SIGCM2_Test_App already exists -- skip.';
GO
IF DB_ID(N'SIGCM2_Test_Api') IS NULL
BEGIN
CREATE DATABASE [SIGCM2_Test_Api]
COLLATE Modern_Spanish_CI_AS;
PRINT 'Database SIGCM2_Test_Api created.';
END
ELSE
PRINT 'Database SIGCM2_Test_Api already exists -- skip.';
GO

View File

@@ -17,8 +17,7 @@ namespace SIGCM2.Api.Tests.Admin;
[Collection("ApiIntegration")]
public sealed class FiscalControllerTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private const string IvaEndpoint = "/api/v1/admin/fiscal/iva";
private const string IibbEndpoint = "/api/v1/admin/fiscal/iibb";

View File

@@ -15,8 +15,7 @@ namespace SIGCM2.Api.Tests.Admin;
[Collection("ApiIntegration")]
public sealed class MediosControllerTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private const string Endpoint = "/api/v1/admin/medios";
private const string AdminUsername = "admin";

View File

@@ -16,8 +16,7 @@ namespace SIGCM2.Api.Tests.Admin;
[Collection("ApiIntegration")]
public sealed class PuntosDeVentaControllerTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private const string Endpoint = "/api/v1/admin/puntos-de-venta";
private const string MediosEndpoint = "/api/v1/admin/medios";

View File

@@ -15,8 +15,7 @@ namespace SIGCM2.Api.Tests.Admin;
[Collection("ApiIntegration")]
public sealed class SeccionesControllerTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private const string Endpoint = "/api/v1/admin/secciones";
private const string MediosEndpoint = "/api/v1/admin/medios";

View File

@@ -21,8 +21,7 @@ namespace SIGCM2.Api.Tests.Admin;
[Collection("ApiIntegration")]
public sealed class V014MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
public V014MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
{

View File

@@ -21,8 +21,7 @@ namespace SIGCM2.Api.Tests.Admin;
[Collection("ApiIntegration")]
public sealed class V015MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
public V015MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
{

View File

@@ -18,8 +18,7 @@ namespace SIGCM2.Api.Tests.Audit;
[Collection("ApiIntegration")]
public sealed class AuditControllerTests : IClassFixture<TestWebAppFactory>
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
private readonly TestWebAppFactory _factory;

View File

@@ -12,8 +12,7 @@ namespace SIGCM2.Api.Tests.Audit;
[Collection("ApiIntegration")]
public sealed class TransactionScopeSpikeTests
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
[Fact]
public async Task TransactionScope_DoesNotEscalateToMSDTC_WithSingleConnectionString()

View File

@@ -13,8 +13,7 @@ namespace SIGCM2.Api.Tests.Audit;
[Collection("ApiIntegration")]
public sealed class V010MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
public V010MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
{

View File

@@ -15,8 +15,7 @@ namespace SIGCM2.Api.Tests.Permisos;
[Collection("ApiIntegration")]
public sealed class PermisosEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private const string AdminUsername = "admin";
private const string AdminPassword = "@Diego550@";

View File

@@ -14,8 +14,7 @@ namespace SIGCM2.Api.Tests.Roles;
[Collection("ApiIntegration")]
public sealed class RolesEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private const string Endpoint = "/api/v1/roles";
private const string AdminUsername = "admin";

View File

@@ -27,6 +27,7 @@
<ItemGroup>
<Using Include="Xunit" />
<Using Include="SIGCM2.TestSupport" />
</ItemGroup>
</Project>

View File

@@ -14,8 +14,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
[Collection("ApiIntegration")]
public sealed class ChangeMyPasswordEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
// This hash corresponds to "@Diego550@"
private const string DefaultHash = "$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW";

View File

@@ -17,8 +17,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
[Collection("ApiIntegration")]
public sealed class CreateUsuarioEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private const string Endpoint = "/api/v1/users";
private const string AdminUsername = "admin";

View File

@@ -14,8 +14,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
[Collection("ApiIntegration")]
public sealed class DeactivateReactivateEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private readonly HttpClient _client;
private readonly SqlTestFixture _db;

View File

@@ -14,8 +14,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
[Collection("ApiIntegration")]
public sealed class GetUsuarioByIdEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private readonly HttpClient _client;
private readonly SqlTestFixture _db;

View File

@@ -14,8 +14,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
[Collection("ApiIntegration")]
public sealed class ListUsuariosEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private readonly HttpClient _client;
private readonly SqlTestFixture _db;

View File

@@ -14,8 +14,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
[Collection("ApiIntegration")]
public sealed class ResetPasswordEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private readonly HttpClient _client;
private readonly SqlTestFixture _db;

View File

@@ -14,8 +14,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
[Collection("ApiIntegration")]
public sealed class UpdateUsuarioEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private readonly HttpClient _client;
private readonly SqlTestFixture _db;

View File

@@ -15,8 +15,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
[Collection("ApiIntegration")]
public sealed class UsuarioPermisosEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private const string AdminUsername = "admin";
private const string AdminPassword = "@Diego550@";

View File

@@ -0,0 +1,15 @@
using SIGCM2.TestSupport;
using Xunit;
namespace SIGCM2.Application.Tests;
/// <summary>
/// Declares the "Database" xUnit collection backed by a single shared SqlTestFixture.
/// All test classes decorated with [Collection("Database")] share one fixture instance
/// per test run — eliminating concurrent Respawner collisions.
/// </summary>
[CollectionDefinition("Database")]
public sealed class DatabaseCollection : ICollectionFixture<SqlTestFixture>
{
// Intentionally empty: this class only exists to declare the collection/fixture binding.
}

View File

@@ -13,8 +13,7 @@ namespace SIGCM2.Application.Tests.Infrastructure.Audit;
[Collection("Database")]
public sealed class AuditEventRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.AppTestDb;
private SqlConnection _connection = null!;
private AuditEventRepository _repo = null!;
@@ -130,7 +129,10 @@ public sealed class AuditEventRepositoryTests : IAsyncLifetime
[Fact]
public async Task QueryAsync_Limit_EmitsCursor_WhenMoreRowsAvailable()
{
var t0 = DateTime.UtcNow.AddMinutes(-10);
// Determinístico: DATETIME2(3) + cursor roundtrip via "O" format puede perder ticks
// sub-ms de `DateTime.UtcNow` (observado ~37% flake rate, cursor vuelve como parentesis
// de la página anterior). Timestamp fijo con sub-ms = 0 elimina la ambigüedad.
var t0 = new DateTime(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
await Seed(5, t0);
var page1 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, null, Limit: 2));

View File

@@ -16,8 +16,7 @@ namespace SIGCM2.Application.Tests.Infrastructure.Audit;
[Collection("Database")]
public sealed class AuditJobsTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.AppTestDb;
private SqlConnection _connection = null!;
private SqlConnectionFactory _factory = null!;

View File

@@ -11,8 +11,7 @@ namespace SIGCM2.Application.Tests.Infrastructure.Audit;
[Collection("Database")]
public sealed class SecurityEventRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.AppTestDb;
private SqlConnection _connection = null!;
private SecurityEventRepository _repo = null!;

View File

@@ -18,8 +18,7 @@ namespace SIGCM2.Application.Tests.Infrastructure;
[Collection("Database")]
public class IngresosBrutosRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.AppTestDb;
private SqlConnection _connection = null!;
private IIngresosBrutosRepository _repo = null!;

View File

@@ -1,103 +1,44 @@
using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Infrastructure;
/// <summary>
/// Integration tests for RefreshTokenRepository against SIGCM2_Test.
/// Uses Respawn to reset the DB between test classes; the repository opens its own
/// Integration tests for RefreshTokenRepository against SIGCM2_Test_App.
/// Uses shared SqlTestFixture via xUnit collection fixture; the repository opens its own
/// connections so transaction-scoped isolation would block on FK locks.
/// </summary>
[Collection("Database")]
public class RefreshTokenRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private readonly SqlTestFixture _db;
private RefreshTokenRepository _repository = null!;
private int _testUserId;
public RefreshTokenRepositoryTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
// ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales.
new Respawn.Graph.Table("dbo", "TipoDeIva_History"),
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
]
});
await _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
await _db.ResetAndSeedAsync();
await SeedTestUserAsync();
_testUserId = await _connection.QuerySingleAsync<int>(
_testUserId = await _db.Connection.QuerySingleAsync<int>(
"SELECT Id FROM dbo.Usuario WHERE Username = 'test_rt_user'");
var factory = new SqlConnectionFactory(ConnectionString);
var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
_repository = new RefreshTokenRepository(factory);
}
public async Task DisposeAsync()
{
await _respawner.ResetAsync(_connection);
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
private async Task SeedRolCanonicalAsync()
{
const string sql = """
SET QUOTED_IDENTIFIER ON;
MERGE dbo.Rol AS t
USING (VALUES
('admin', N'Administrador', N'Supervisor total'),
('cajero', N'Cajero', N'Mostrador contado'),
('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'),
('picadora', N'Picadora/Correctora', N'Edición de textos'),
('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'),
('productor', N'Productor', N'Carga restringida'),
('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'),
('reportes', N'Reportes', N'Solo lectura reportes')
) AS s (Codigo, Nombre, Descripcion)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Activo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
""";
await _connection.ExecuteAsync(sql);
}
public Task DisposeAsync() => Task.CompletedTask;
private async Task SeedTestUserAsync()
{
await _connection.ExecuteAsync("""
await _db.Connection.ExecuteAsync("""
SET QUOTED_IDENTIFIER ON;
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'test_rt_user')
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo)

View File

@@ -19,8 +19,7 @@ namespace SIGCM2.Application.Tests.Infrastructure;
[Collection("Database")]
public class TipoDeIvaRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.AppTestDb;
private SqlConnection _connection = null!;
private ITipoDeIvaRepository _repo = null!;

View File

@@ -11,8 +11,7 @@ namespace SIGCM2.Application.Tests.Integration;
[Collection("Database")]
public class PermisoRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.AppTestDb;
private SqlConnection _connection = null!;
private PermisoRepository _repository = null!;

View File

@@ -11,8 +11,7 @@ namespace SIGCM2.Application.Tests.Integration;
[Collection("Database")]
public class RolPermisoRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.AppTestDb;
private SqlConnection _connection = null!;
private RolPermisoRepository _repository = null!;

View File

@@ -8,8 +8,7 @@ namespace SIGCM2.Application.Tests.Integration;
[Collection("Database")]
public class RolRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string ConnectionString = TestConnectionStrings.AppTestDb;
private SqlConnection _connection = null!;
private RolRepository _repository = null!;

View File

@@ -1,66 +1,29 @@
using Microsoft.Data.SqlClient;
using Respawn;
using Dapper;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Integration;
[Collection("Database")]
public class UsuarioRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private readonly SqlTestFixture _db;
private UsuarioRepository _repository = null!;
public UsuarioRepositoryTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
await _db.ResetAndSeedAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
// ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales.
new Respawn.Graph.Table("dbo", "TipoDeIva_History"),
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
]
});
// Reset DB, re-seed Rol canonical table (lookup) and admin user for each test class run.
await _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
await SeedAdminAsync();
var factory = new SqlConnectionFactory(ConnectionString);
var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
_repository = new UsuarioRepository(factory);
}
public async Task DisposeAsync()
{
await _respawner.ResetAsync(_connection);
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
// Scenario: GetByUsername returns correct entity when user exists
[Fact]
@@ -88,7 +51,7 @@ public class UsuarioRepositoryTests : IAsyncLifetime
public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser_Cajero()
{
// Insert a second user with canonical rol 'cajero' (post-UDT-004 FK requires Rol.Codigo to exist).
await _connection.ExecuteAsync(
await _db.Connection.ExecuteAsync(
"INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) " +
"VALUES ('cajero1', '$2a$12$hash2', 'Juan', 'Pérez', 'cajero', '[]')");
@@ -101,48 +64,4 @@ public class UsuarioRepositoryTests : IAsyncLifetime
Assert.Equal("admin", admin.Rol);
Assert.Equal("cajero", cajero.Rol);
}
private async Task SeedRolCanonicalAsync()
{
const string sql = """
SET QUOTED_IDENTIFIER ON;
MERGE dbo.Rol AS t
USING (VALUES
('admin', N'Administrador', N'Supervisor total'),
('cajero', N'Cajero', N'Mostrador contado'),
('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'),
('picadora', N'Picadora/Correctora', N'Edición de textos'),
('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'),
('productor', N'Productor', N'Carga restringida'),
('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'),
('reportes', N'Reportes', N'Solo lectura reportes')
) AS s (Codigo, Nombre, Descripcion)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Activo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
""";
await _connection.ExecuteAsync(sql);
}
private async Task SeedAdminAsync()
{
await _connection.ExecuteAsync(
"SET QUOTED_IDENTIFIER ON; " +
"IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin') " +
"INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " +
"VALUES ('admin', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', " +
"'Administrador', 'Sistema', 'admin', '[\"*\"]', 1)");
}
}
// Dapper extension helper for IDbConnection
file static class DapperHelper
{
public static async Task ExecuteAsync(this SqlConnection conn, string sql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
await cmd.ExecuteNonQueryAsync();
}
}

View File

@@ -1,84 +1,46 @@
using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Integration;
/// <summary>
/// Integration tests for IUsuarioRepository.UpdatePermisosJsonAsync (UDT-009).
/// Uses SIGCM2_Test database directly.
/// Uses SIGCM2_Test_App database via shared SqlTestFixture.
/// </summary>
[Collection("Database")]
public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private readonly SqlTestFixture _db;
private UsuarioRepository _repository = null!;
public UsuarioRepository_PermisosTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
await _db.ResetAndSeedAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
// ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales.
new Respawn.Graph.Table("dbo", "TipoDeIva_History"),
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
]
});
await _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
var factory = new SqlConnectionFactory(ConnectionString);
var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
_repository = new UsuarioRepository(factory);
// Seed a test user
await _connection.ExecuteAsync("""
await _db.Connection.ExecuteAsync("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('testuser', '$2a$12$hash', 'Test', 'User', 'cajero', '{"grant":[],"deny":[]}', 1, 0)
""");
}
public async Task DisposeAsync()
{
if (_connection is not null)
{
await _respawner.ResetAsync(_connection);
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
}
public Task DisposeAsync() => Task.CompletedTask;
// UPJ-01: UpdatePermisosJsonAsync persists PermisosJson and FechaModificacion
[Fact]
public async Task UpdatePermisosJsonAsync_PersistsJsonAndFechaModificacion()
{
// Arrange
var userId = await _connection.QuerySingleAsync<int>(
var userId = await _db.Connection.QuerySingleAsync<int>(
"SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'");
var newJson = """{"grant":["textos:editar"],"deny":[]}""";
var fechaMod = DateTime.UtcNow;
@@ -87,7 +49,7 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
await _repository.UpdatePermisosJsonAsync(userId, newJson, fechaMod);
// Assert
var row = await _connection.QuerySingleAsync<(string PermisosJson, DateTime? FechaModificacion)>(
var row = await _db.Connection.QuerySingleAsync<(string PermisosJson, DateTime? FechaModificacion)>(
"SELECT PermisosJson, FechaModificacion FROM dbo.Usuario WHERE Id = @Id",
new { Id = userId });
@@ -112,7 +74,7 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
public async Task UpdatePermisosJsonAsync_GetByIdReflectsChange()
{
// Arrange
var userId = await _connection.QuerySingleAsync<int>(
var userId = await _db.Connection.QuerySingleAsync<int>(
"SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'");
var newJson = """{"grant":["pauta:azanu:ver"],"deny":["ventas:contado:cobrar"]}""";
@@ -125,22 +87,4 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
Assert.NotNull(usuario);
Assert.Equal(newJson, usuario!.PermisosJson);
}
// ── helpers ───────────────────────────────────────────────────────────────
private async Task SeedRolCanonicalAsync()
{
await _connection.ExecuteAsync("""
SET QUOTED_IDENTIFIER ON;
MERGE dbo.Rol AS t
USING (VALUES
('admin', N'Administrador', N'Supervisor total'),
('cajero', N'Cajero', N'Mostrador contado')
) 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);
""");
}
}

View File

@@ -1,66 +1,29 @@
using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Integration;
/// <summary>
/// SUITE-B-MIGRATION-V009 — M-01 a M-07 (UDT-009)
/// Validates the V009 migration SQL and SqlTestFixture.EnsureV009SchemaAsync.
/// Uses SIGCM2_Test database directly.
/// Uses SIGCM2_Test_App database via shared SqlTestFixture.
/// </summary>
[Collection("Database")]
public sealed class V009MigrationTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private readonly SqlTestFixture _db;
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
public V009MigrationTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
// ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales.
new Respawn.Graph.Table("dbo", "TipoDeIva_History"),
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
]
});
await _respawner.ResetAsync(_connection);
await SeedRolAsync();
await _db.ResetAndSeedAsync();
}
public async Task DisposeAsync()
{
if (_connection is not null)
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
}
public Task DisposeAsync() => Task.CompletedTask;
// M-01: migration file exists on filesystem
[Fact]
@@ -110,7 +73,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
AND name = 'PermisosJson'
""";
var definition = await _connection.QuerySingleOrDefaultAsync<string>(sql);
var definition = await _db.Connection.QuerySingleOrDefaultAsync<string>(sql);
Assert.NotNull(definition);
Assert.Contains(@"{""grant"":[]", definition);
@@ -123,7 +86,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
{
await EnsureV009SchemaAsync();
await _connection.ExecuteAsync("""
await _db.Connection.ExecuteAsync("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('legacyempty', '$2a$12$hash', 'L', 'E', 'admin', '[]', 1, 0)
""");
@@ -131,7 +94,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
// Run migration again to migrate the newly inserted row
await EnsureV009SchemaAsync();
var permisosJson = await _connection.QuerySingleAsync<string>(
var permisosJson = await _db.Connection.QuerySingleAsync<string>(
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacyempty'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
@@ -143,14 +106,14 @@ public sealed class V009MigrationTests : IAsyncLifetime
{
await EnsureV009SchemaAsync();
await _connection.ExecuteAsync("""
await _db.Connection.ExecuteAsync("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('legacywild', '$2a$12$hash', 'L', 'W', 'admin', '["*"]', 1, 0)
""");
await EnsureV009SchemaAsync();
var permisosJson = await _connection.QuerySingleAsync<string>(
var permisosJson = await _db.Connection.QuerySingleAsync<string>(
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacywild'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
@@ -166,7 +129,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
await EnsureV009SchemaAsync();
// Temporarily drop and re-add without the DEFAULT so we can insert ''
await _connection.ExecuteAsync("""
await _db.Connection.ExecuteAsync("""
IF EXISTS (
SELECT 1 FROM sys.default_constraints
WHERE name = 'DF_Usuario_Permisos'
@@ -175,7 +138,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
""");
await _connection.ExecuteAsync("""
await _db.Connection.ExecuteAsync("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('emptystruser', '$2a$12$hash', 'E', 'S', 'admin', '', 1, 0)
""");
@@ -183,7 +146,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
// Re-apply V009 (which restores constraint and migrates '' rows)
await EnsureV009SchemaAsync();
var permisosJson = await _connection.QuerySingleAsync<string>(
var permisosJson = await _db.Connection.QuerySingleAsync<string>(
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'emptystruser'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
@@ -196,7 +159,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
await EnsureV009SchemaAsync();
// Seed admin as TestFixture does post-V009
await _connection.ExecuteAsync("""
await _db.Connection.ExecuteAsync("""
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES (
@@ -206,7 +169,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
)
""");
var permisosJson = await _connection.QuerySingleAsync<string>(
var permisosJson = await _db.Connection.QuerySingleAsync<string>(
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'admin'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
@@ -214,19 +177,6 @@ public sealed class V009MigrationTests : IAsyncLifetime
// ── helpers ───────────────────────────────────────────────────────────────
private async Task SeedRolAsync()
{
await _connection.ExecuteAsync("""
SET QUOTED_IDENTIFIER ON;
MERGE dbo.Rol AS t
USING (VALUES ('admin', N'Administrador', N'Supervisor total'))
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);
""");
}
/// <summary>
/// Replicates V009 migration idempotently — mirrors SqlTestFixture.EnsureV009SchemaAsync.
/// </summary>
@@ -264,8 +214,8 @@ public sealed class V009MigrationTests : IAsyncLifetime
OR LTRIM(RTRIM(PermisosJson)) = ''
""";
await _connection.ExecuteAsync(dropConstraint);
await _connection.ExecuteAsync(addConstraint);
await _connection.ExecuteAsync(migrateRows);
await _db.Connection.ExecuteAsync(dropConstraint);
await _db.Connection.ExecuteAsync(addConstraint);
await _db.Connection.ExecuteAsync(migrateRows);
}
}

View File

@@ -1,69 +1,35 @@
using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Medios;
/// <summary>
/// Integration tests for MedioRepository against SIGCM2_Test.
/// Integration tests for MedioRepository against SIGCM2_Test_App.
/// TDD: RED written before implementation, GREEN after MedioRepository was created.
/// Temporal: after UpdateAsync, dbo.Medio_History MUST have ≥1 row for that Id.
/// </summary>
[Collection("Database")]
public class MedioRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private readonly SqlTestFixture _db;
private MedioRepository _repository = null!;
public MedioRepositoryTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
await _db.ResetAndSeedAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
// *_History tables are system-versioned — engine rejects direct DELETE.
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
// ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales.
new Respawn.Graph.Table("dbo", "TipoDeIva_History"),
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
]
});
await _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
var factory = new SqlConnectionFactory(ConnectionString);
var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
_repository = new MedioRepository(factory);
}
public async Task DisposeAsync()
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
// ── AddAsync + GetByIdAsync roundtrip ─────────────────────────────────────
@@ -170,7 +136,7 @@ public class MedioRepositoryTests : IAsyncLifetime
var updated = original!.WithUpdatedProfile("Historial v2", TipoMedio.Web, null, DateTime.UtcNow);
await _repository.UpdateAsync(updated);
var historyCount = await _connection.ExecuteScalarAsync<int>(
var historyCount = await _db.Connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM dbo.Medio_History WHERE Id = @Id", new { Id = id });
Assert.True(historyCount >= 1, $"Expected ≥1 history row for Medio Id={id}, got {historyCount}");
@@ -241,29 +207,4 @@ public class MedioRepositoryTests : IAsyncLifetime
Assert.Equal(3, result.Total);
Assert.Equal(2, result.Items.Count);
}
// ── helpers ───────────────────────────────────────────────────────────────
private async Task SeedRolCanonicalAsync()
{
const string sql = """
SET QUOTED_IDENTIFIER ON;
MERGE dbo.Rol AS t
USING (VALUES
('admin', N'Administrador', N'Supervisor total'),
('cajero', N'Cajero', N'Mostrador contado'),
('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'),
('picadora', N'Picadora/Correctora', N'Edición de textos'),
('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'),
('productor', N'Productor', N'Carga restringida'),
('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'),
('reportes', N'Reportes', N'Solo lectura reportes')
) AS s (Codigo, Nombre, Descripcion)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Activo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
""";
await _connection.ExecuteAsync(sql);
}
}

View File

@@ -24,10 +24,12 @@
<ProjectReference Include="..\..\src\api\SIGCM2.Application\SIGCM2.Application.csproj" />
<ProjectReference Include="..\..\src\api\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\api\SIGCM2.Domain\SIGCM2.Domain.csproj" />
<ProjectReference Include="..\SIGCM2.TestSupport\SIGCM2.TestSupport.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="SIGCM2.TestSupport" />
</ItemGroup>
</Project>

View File

@@ -1,62 +1,33 @@
using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Secciones;
/// <summary>
/// Integration tests for SeccionRepository against SIGCM2_Test.
/// Integration tests for SeccionRepository against SIGCM2_Test_App.
/// TDD: RED written before implementation, GREEN after SeccionRepository was created.
/// Temporal: after UpdateAsync, dbo.Seccion_History MUST have ≥1 row for that Id.
/// </summary>
[Collection("Database")]
public class SeccionRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private readonly SqlTestFixture _db;
private SeccionRepository _repository = null!;
private MedioRepository _medioRepository = null!;
private int _medioId;
public SeccionRepositoryTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
await _db.ResetAndSeedAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
// *_History tables are system-versioned — engine rejects direct DELETE.
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
// ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales.
new Respawn.Graph.Table("dbo", "TipoDeIva_History"),
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
]
});
await _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
var factory = new SqlConnectionFactory(ConnectionString);
var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
_repository = new SeccionRepository(factory);
_medioRepository = new MedioRepository(factory);
@@ -64,11 +35,7 @@ public class SeccionRepositoryTests : IAsyncLifetime
_medioId = await _medioRepository.AddAsync(Medio.ForCreation("TESTMEDIO", "Medio de Prueba", TipoMedio.Diario, null));
}
public async Task DisposeAsync()
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
// ── AddAsync + GetByIdAsync roundtrip ─────────────────────────────────────
@@ -171,7 +138,7 @@ public class SeccionRepositoryTests : IAsyncLifetime
var updated = original!.WithUpdatedProfile("Historial v2", "suplementos", DateTime.UtcNow);
await _repository.UpdateAsync(updated);
var historyCount = await _connection.ExecuteScalarAsync<int>(
var historyCount = await _db.Connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM dbo.Seccion_History WHERE Id = @Id", new { Id = id });
Assert.True(historyCount >= 1, $"Expected ≥1 history row for Seccion Id={id}, got {historyCount}");
@@ -234,29 +201,4 @@ public class SeccionRepositoryTests : IAsyncLifetime
Assert.Equal(3, result.Total);
Assert.Equal(2, result.Items.Count);
}
// ── helpers ───────────────────────────────────────────────────────────────
private async Task SeedRolCanonicalAsync()
{
const string sql = """
SET QUOTED_IDENTIFIER ON;
MERGE dbo.Rol AS t
USING (VALUES
('admin', N'Administrador', N'Supervisor total'),
('cajero', N'Cajero', N'Mostrador contado'),
('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'),
('picadora', N'Picadora/Correctora', N'Edición de textos'),
('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'),
('productor', N'Productor', N'Carga restringida'),
('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'),
('reportes', N'Reportes', N'Solo lectura reportes')
) AS s (Codigo, Nombre, Descripcion)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Activo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
""";
await _connection.ExecuteAsync(sql);
}
}

View File

@@ -16,7 +16,14 @@ public sealed class SqlTestFixture : IAsyncLifetime
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
public SqlTestFixture(string connectionString)
/// <summary>Parameterless ctor for xUnit ICollectionFixture — uses SIGCM2_Test_App.</summary>
public SqlTestFixture() : this(TestConnectionStrings.AppTestDb) { }
/// <summary>
/// Explicit connection string ctor — used by TestWebAppFactory (same assembly).
/// Internal to satisfy xUnit's "single public constructor" rule for ICollectionFixture.
/// </summary>
internal SqlTestFixture(string connectionString)
{
_connectionString = connectionString;
}
@@ -80,6 +87,13 @@ public sealed class SqlTestFixture : IAsyncLifetime
await ResetAndSeedAsync();
}
/// <summary>
/// Exposes the open SqlConnection for tests that need to run ad-hoc queries
/// (e.g. seed extra rows, assert history tables). Connection is opened during
/// InitializeAsync and closed in DisposeAsync.
/// </summary>
public SqlConnection Connection => _connection;
public async Task ResetAndSeedAsync()
{
await _respawner.ResetAsync(_connection);

View File

@@ -0,0 +1,20 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SIGCM2.Api.Tests")]
namespace SIGCM2.TestSupport;
/// <summary>
/// Centralized connection string constants for integration test databases.
/// Single source of truth — change server/credentials here only.
/// </summary>
public static class TestConnectionStrings
{
/// <summary>Used by SIGCM2.Application.Tests via SqlTestFixture (parameterless ctor).</summary>
public const string AppTestDb =
"Server=TECNICA3;Database=SIGCM2_Test_App;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
/// <summary>Used by SIGCM2.Api.Tests via TestWebAppFactory.</summary>
public const string ApiTestDb =
"Server=TECNICA3;Database=SIGCM2_Test_Api;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
}

View File

@@ -13,12 +13,11 @@ namespace SIGCM2.TestSupport;
/// <summary>
/// WebApplicationFactory for integration tests against SIGCM2.Api.
/// Uses SIGCM2_Test database (separate from production SIGCM2).
/// Uses SIGCM2_Test_Api database (isolated from Application.Tests which uses SIGCM2_Test_App).
/// </summary>
public sealed class TestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
// Resolved once — absolute paths independent of working directory
private static readonly string RepoRoot = ResolveRepoRoot();