Compare commits

...

6 Commits

Author SHA1 Message Date
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 224 additions and 513 deletions

View File

@@ -29,6 +29,9 @@ database/
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** | | **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` | | 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 | | 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 ## 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. - **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`. - **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`. - **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 ## Cómo aplicar migraciones
### En dev (manual) ### En dev (manual)
```bash ```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 -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. O desde SSMS: abrir el archivo, conectar a cada base, F5.
### En integration tests ### 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) ### 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. - `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. - 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 ## Recursos
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md` - 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")] [Collection("ApiIntegration")]
public sealed class FiscalControllerTests : IAsyncLifetime public sealed class FiscalControllerTests : IAsyncLifetime
{ {
private const string TestConnectionString = private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string IvaEndpoint = "/api/v1/admin/fiscal/iva"; private const string IvaEndpoint = "/api/v1/admin/fiscal/iva";
private const string IibbEndpoint = "/api/v1/admin/fiscal/iibb"; private const string IibbEndpoint = "/api/v1/admin/fiscal/iibb";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,8 +15,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
[Collection("ApiIntegration")] [Collection("ApiIntegration")]
public sealed class UsuarioPermisosEndpointTests : IAsyncLifetime public sealed class UsuarioPermisosEndpointTests : IAsyncLifetime
{ {
private const string TestConnectionString = private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string AdminUsername = "admin"; private const string AdminUsername = "admin";
private const string AdminPassword = "@Diego550@"; 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")] [Collection("Database")]
public sealed class AuditEventRepositoryTests : IAsyncLifetime public sealed class AuditEventRepositoryTests : IAsyncLifetime
{ {
private const string ConnectionString = private const string ConnectionString = TestConnectionStrings.AppTestDb;
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!; private SqlConnection _connection = null!;
private AuditEventRepository _repo = null!; private AuditEventRepository _repo = null!;

View File

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

View File

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

View File

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

View File

@@ -1,103 +1,44 @@
using Dapper; using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.Domain.Entities; using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Persistence; using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Infrastructure; namespace SIGCM2.Application.Tests.Infrastructure;
/// <summary> /// <summary>
/// Integration tests for RefreshTokenRepository against SIGCM2_Test. /// Integration tests for RefreshTokenRepository against SIGCM2_Test_App.
/// Uses Respawn to reset the DB between test classes; the repository opens its own /// Uses shared SqlTestFixture via xUnit collection fixture; the repository opens its own
/// connections so transaction-scoped isolation would block on FK locks. /// connections so transaction-scoped isolation would block on FK locks.
/// </summary> /// </summary>
[Collection("Database")] [Collection("Database")]
public class RefreshTokenRepositoryTests : IAsyncLifetime public class RefreshTokenRepositoryTests : IAsyncLifetime
{ {
private const string ConnectionString = private readonly SqlTestFixture _db;
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private RefreshTokenRepository _repository = null!; private RefreshTokenRepository _repository = null!;
private int _testUserId; private int _testUserId;
public RefreshTokenRepositoryTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
_connection = new SqlConnection(ConnectionString); await _db.ResetAndSeedAsync();
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 SeedTestUserAsync(); await SeedTestUserAsync();
_testUserId = await _connection.QuerySingleAsync<int>( _testUserId = await _db.Connection.QuerySingleAsync<int>(
"SELECT Id FROM dbo.Usuario WHERE Username = 'test_rt_user'"); "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); _repository = new RefreshTokenRepository(factory);
} }
public async Task DisposeAsync() public Task DisposeAsync() => Task.CompletedTask;
{
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);
}
private async Task SeedTestUserAsync() private async Task SeedTestUserAsync()
{ {
await _connection.ExecuteAsync(""" await _db.Connection.ExecuteAsync("""
SET QUOTED_IDENTIFIER ON; SET QUOTED_IDENTIFIER ON;
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'test_rt_user') IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'test_rt_user')
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,66 +1,29 @@
using Microsoft.Data.SqlClient; using Dapper;
using Respawn;
using SIGCM2.Infrastructure.Persistence; using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Integration; namespace SIGCM2.Application.Tests.Integration;
[Collection("Database")] [Collection("Database")]
public class UsuarioRepositoryTests : IAsyncLifetime public class UsuarioRepositoryTests : IAsyncLifetime
{ {
private const string ConnectionString = private readonly SqlTestFixture _db;
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private UsuarioRepository _repository = null!; private UsuarioRepository _repository = null!;
public UsuarioRepositoryTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
_connection = new SqlConnection(ConnectionString); await _db.ResetAndSeedAsync();
await _connection.OpenAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
{
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);
_repository = new UsuarioRepository(factory); _repository = new UsuarioRepository(factory);
} }
public async Task DisposeAsync() public Task DisposeAsync() => Task.CompletedTask;
{
await _respawner.ResetAsync(_connection);
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
// Scenario: GetByUsername returns correct entity when user exists // Scenario: GetByUsername returns correct entity when user exists
[Fact] [Fact]
@@ -88,7 +51,7 @@ public class UsuarioRepositoryTests : IAsyncLifetime
public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser_Cajero() public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser_Cajero()
{ {
// Insert a second user with canonical rol 'cajero' (post-UDT-004 FK requires Rol.Codigo to exist). // 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) " + "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) " +
"VALUES ('cajero1', '$2a$12$hash2', 'Juan', 'Pérez', 'cajero', '[]')"); "VALUES ('cajero1', '$2a$12$hash2', 'Juan', 'Pérez', 'cajero', '[]')");
@@ -101,48 +64,4 @@ public class UsuarioRepositoryTests : IAsyncLifetime
Assert.Equal("admin", admin.Rol); Assert.Equal("admin", admin.Rol);
Assert.Equal("cajero", cajero.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 Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.Infrastructure.Persistence; using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Integration; namespace SIGCM2.Application.Tests.Integration;
/// <summary> /// <summary>
/// Integration tests for IUsuarioRepository.UpdatePermisosJsonAsync (UDT-009). /// Integration tests for IUsuarioRepository.UpdatePermisosJsonAsync (UDT-009).
/// Uses SIGCM2_Test database directly. /// Uses SIGCM2_Test_App database via shared SqlTestFixture.
/// </summary> /// </summary>
[Collection("Database")] [Collection("Database")]
public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
{ {
private const string ConnectionString = private readonly SqlTestFixture _db;
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private UsuarioRepository _repository = null!; private UsuarioRepository _repository = null!;
public UsuarioRepository_PermisosTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
_connection = new SqlConnection(ConnectionString); await _db.ResetAndSeedAsync();
await _connection.OpenAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
{
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);
_repository = new UsuarioRepository(factory); _repository = new UsuarioRepository(factory);
// Seed a test user // Seed a test user
await _connection.ExecuteAsync(""" await _db.Connection.ExecuteAsync("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('testuser', '$2a$12$hash', 'Test', 'User', 'cajero', '{"grant":[],"deny":[]}', 1, 0) VALUES ('testuser', '$2a$12$hash', 'Test', 'User', 'cajero', '{"grant":[],"deny":[]}', 1, 0)
"""); """);
} }
public async Task DisposeAsync() public Task DisposeAsync() => Task.CompletedTask;
{
if (_connection is not null)
{
await _respawner.ResetAsync(_connection);
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
}
// UPJ-01: UpdatePermisosJsonAsync persists PermisosJson and FechaModificacion // UPJ-01: UpdatePermisosJsonAsync persists PermisosJson and FechaModificacion
[Fact] [Fact]
public async Task UpdatePermisosJsonAsync_PersistsJsonAndFechaModificacion() public async Task UpdatePermisosJsonAsync_PersistsJsonAndFechaModificacion()
{ {
// Arrange // Arrange
var userId = await _connection.QuerySingleAsync<int>( var userId = await _db.Connection.QuerySingleAsync<int>(
"SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'"); "SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'");
var newJson = """{"grant":["textos:editar"],"deny":[]}"""; var newJson = """{"grant":["textos:editar"],"deny":[]}""";
var fechaMod = DateTime.UtcNow; var fechaMod = DateTime.UtcNow;
@@ -87,7 +49,7 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
await _repository.UpdatePermisosJsonAsync(userId, newJson, fechaMod); await _repository.UpdatePermisosJsonAsync(userId, newJson, fechaMod);
// Assert // 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", "SELECT PermisosJson, FechaModificacion FROM dbo.Usuario WHERE Id = @Id",
new { Id = userId }); new { Id = userId });
@@ -112,7 +74,7 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
public async Task UpdatePermisosJsonAsync_GetByIdReflectsChange() public async Task UpdatePermisosJsonAsync_GetByIdReflectsChange()
{ {
// Arrange // Arrange
var userId = await _connection.QuerySingleAsync<int>( var userId = await _db.Connection.QuerySingleAsync<int>(
"SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'"); "SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'");
var newJson = """{"grant":["pauta:azanu:ver"],"deny":["ventas:contado:cobrar"]}"""; var newJson = """{"grant":["pauta:azanu:ver"],"deny":["ventas:contado:cobrar"]}""";
@@ -125,22 +87,4 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
Assert.NotNull(usuario); Assert.NotNull(usuario);
Assert.Equal(newJson, usuario!.PermisosJson); 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 Dapper;
using Microsoft.Data.SqlClient; using SIGCM2.TestSupport;
using Respawn;
namespace SIGCM2.Application.Tests.Integration; namespace SIGCM2.Application.Tests.Integration;
/// <summary> /// <summary>
/// SUITE-B-MIGRATION-V009 — M-01 a M-07 (UDT-009) /// SUITE-B-MIGRATION-V009 — M-01 a M-07 (UDT-009)
/// Validates the V009 migration SQL and SqlTestFixture.EnsureV009SchemaAsync. /// Validates the V009 migration SQL and SqlTestFixture.EnsureV009SchemaAsync.
/// Uses SIGCM2_Test database directly. /// Uses SIGCM2_Test_App database via shared SqlTestFixture.
/// </summary> /// </summary>
[Collection("Database")] [Collection("Database")]
public sealed class V009MigrationTests : IAsyncLifetime public sealed class V009MigrationTests : IAsyncLifetime
{ {
private const string ConnectionString = private readonly SqlTestFixture _db;
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!; public V009MigrationTests(SqlTestFixture db)
private Respawner _respawner = null!; {
_db = db;
}
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
_connection = new SqlConnection(ConnectionString); await _db.ResetAndSeedAsync();
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();
} }
public async Task DisposeAsync() public Task DisposeAsync() => Task.CompletedTask;
{
if (_connection is not null)
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
}
// M-01: migration file exists on filesystem // M-01: migration file exists on filesystem
[Fact] [Fact]
@@ -110,7 +73,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
AND name = 'PermisosJson' AND name = 'PermisosJson'
"""; """;
var definition = await _connection.QuerySingleOrDefaultAsync<string>(sql); var definition = await _db.Connection.QuerySingleOrDefaultAsync<string>(sql);
Assert.NotNull(definition); Assert.NotNull(definition);
Assert.Contains(@"{""grant"":[]", definition); Assert.Contains(@"{""grant"":[]", definition);
@@ -123,7 +86,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
{ {
await EnsureV009SchemaAsync(); await EnsureV009SchemaAsync();
await _connection.ExecuteAsync(""" await _db.Connection.ExecuteAsync("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('legacyempty', '$2a$12$hash', 'L', 'E', 'admin', '[]', 1, 0) 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 // Run migration again to migrate the newly inserted row
await EnsureV009SchemaAsync(); await EnsureV009SchemaAsync();
var permisosJson = await _connection.QuerySingleAsync<string>( var permisosJson = await _db.Connection.QuerySingleAsync<string>(
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacyempty'"); "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacyempty'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson); Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
@@ -143,14 +106,14 @@ public sealed class V009MigrationTests : IAsyncLifetime
{ {
await EnsureV009SchemaAsync(); await EnsureV009SchemaAsync();
await _connection.ExecuteAsync(""" await _db.Connection.ExecuteAsync("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('legacywild', '$2a$12$hash', 'L', 'W', 'admin', '["*"]', 1, 0) VALUES ('legacywild', '$2a$12$hash', 'L', 'W', 'admin', '["*"]', 1, 0)
"""); """);
await EnsureV009SchemaAsync(); await EnsureV009SchemaAsync();
var permisosJson = await _connection.QuerySingleAsync<string>( var permisosJson = await _db.Connection.QuerySingleAsync<string>(
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacywild'"); "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacywild'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson); Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
@@ -166,7 +129,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
await EnsureV009SchemaAsync(); await EnsureV009SchemaAsync();
// Temporarily drop and re-add without the DEFAULT so we can insert '' // Temporarily drop and re-add without the DEFAULT so we can insert ''
await _connection.ExecuteAsync(""" await _db.Connection.ExecuteAsync("""
IF EXISTS ( IF EXISTS (
SELECT 1 FROM sys.default_constraints SELECT 1 FROM sys.default_constraints
WHERE name = 'DF_Usuario_Permisos' WHERE name = 'DF_Usuario_Permisos'
@@ -175,7 +138,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos; 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) INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('emptystruser', '$2a$12$hash', 'E', 'S', 'admin', '', 1, 0) 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) // Re-apply V009 (which restores constraint and migrates '' rows)
await EnsureV009SchemaAsync(); await EnsureV009SchemaAsync();
var permisosJson = await _connection.QuerySingleAsync<string>( var permisosJson = await _db.Connection.QuerySingleAsync<string>(
"SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'emptystruser'"); "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'emptystruser'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson); Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
@@ -196,7 +159,7 @@ public sealed class V009MigrationTests : IAsyncLifetime
await EnsureV009SchemaAsync(); await EnsureV009SchemaAsync();
// Seed admin as TestFixture does post-V009 // 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') IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ( 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'"); "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'admin'");
Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson); Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
@@ -214,19 +177,6 @@ public sealed class V009MigrationTests : IAsyncLifetime
// ── helpers ─────────────────────────────────────────────────────────────── // ── 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> /// <summary>
/// Replicates V009 migration idempotently — mirrors SqlTestFixture.EnsureV009SchemaAsync. /// Replicates V009 migration idempotently — mirrors SqlTestFixture.EnsureV009SchemaAsync.
/// </summary> /// </summary>
@@ -264,8 +214,8 @@ public sealed class V009MigrationTests : IAsyncLifetime
OR LTRIM(RTRIM(PermisosJson)) = '' OR LTRIM(RTRIM(PermisosJson)) = ''
"""; """;
await _connection.ExecuteAsync(dropConstraint); await _db.Connection.ExecuteAsync(dropConstraint);
await _connection.ExecuteAsync(addConstraint); await _db.Connection.ExecuteAsync(addConstraint);
await _connection.ExecuteAsync(migrateRows); await _db.Connection.ExecuteAsync(migrateRows);
} }
} }

View File

@@ -1,69 +1,35 @@
using Dapper; using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.Domain.Entities; using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Persistence; using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Medios; namespace SIGCM2.Application.Tests.Medios;
/// <summary> /// <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. /// TDD: RED written before implementation, GREEN after MedioRepository was created.
/// Temporal: after UpdateAsync, dbo.Medio_History MUST have ≥1 row for that Id. /// Temporal: after UpdateAsync, dbo.Medio_History MUST have ≥1 row for that Id.
/// </summary> /// </summary>
[Collection("Database")] [Collection("Database")]
public class MedioRepositoryTests : IAsyncLifetime public class MedioRepositoryTests : IAsyncLifetime
{ {
private const string ConnectionString = private readonly SqlTestFixture _db;
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private MedioRepository _repository = null!; private MedioRepository _repository = null!;
public MedioRepositoryTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
_connection = new SqlConnection(ConnectionString); await _db.ResetAndSeedAsync();
await _connection.OpenAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
{
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);
_repository = new MedioRepository(factory); _repository = new MedioRepository(factory);
} }
public async Task DisposeAsync() public Task DisposeAsync() => Task.CompletedTask;
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
// ── AddAsync + GetByIdAsync roundtrip ───────────────────────────────────── // ── AddAsync + GetByIdAsync roundtrip ─────────────────────────────────────
@@ -170,7 +136,7 @@ public class MedioRepositoryTests : IAsyncLifetime
var updated = original!.WithUpdatedProfile("Historial v2", TipoMedio.Web, null, DateTime.UtcNow); var updated = original!.WithUpdatedProfile("Historial v2", TipoMedio.Web, null, DateTime.UtcNow);
await _repository.UpdateAsync(updated); 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 }); "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}"); 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(3, result.Total);
Assert.Equal(2, result.Items.Count); 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.Application\SIGCM2.Application.csproj" />
<ProjectReference Include="..\..\src\api\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" /> <ProjectReference Include="..\..\src\api\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\api\SIGCM2.Domain\SIGCM2.Domain.csproj" /> <ProjectReference Include="..\..\src\api\SIGCM2.Domain\SIGCM2.Domain.csproj" />
<ProjectReference Include="..\SIGCM2.TestSupport\SIGCM2.TestSupport.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
<Using Include="SIGCM2.TestSupport" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,62 +1,33 @@
using Dapper; using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.Domain.Entities; using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Persistence; using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Secciones; namespace SIGCM2.Application.Tests.Secciones;
/// <summary> /// <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. /// TDD: RED written before implementation, GREEN after SeccionRepository was created.
/// Temporal: after UpdateAsync, dbo.Seccion_History MUST have ≥1 row for that Id. /// Temporal: after UpdateAsync, dbo.Seccion_History MUST have ≥1 row for that Id.
/// </summary> /// </summary>
[Collection("Database")] [Collection("Database")]
public class SeccionRepositoryTests : IAsyncLifetime public class SeccionRepositoryTests : IAsyncLifetime
{ {
private const string ConnectionString = private readonly SqlTestFixture _db;
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private SeccionRepository _repository = null!; private SeccionRepository _repository = null!;
private MedioRepository _medioRepository = null!; private MedioRepository _medioRepository = null!;
private int _medioId; private int _medioId;
public SeccionRepositoryTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
_connection = new SqlConnection(ConnectionString); await _db.ResetAndSeedAsync();
await _connection.OpenAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
{
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);
_repository = new SeccionRepository(factory); _repository = new SeccionRepository(factory);
_medioRepository = new MedioRepository(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)); _medioId = await _medioRepository.AddAsync(Medio.ForCreation("TESTMEDIO", "Medio de Prueba", TipoMedio.Diario, null));
} }
public async Task DisposeAsync() public Task DisposeAsync() => Task.CompletedTask;
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
// ── AddAsync + GetByIdAsync roundtrip ───────────────────────────────────── // ── AddAsync + GetByIdAsync roundtrip ─────────────────────────────────────
@@ -171,7 +138,7 @@ public class SeccionRepositoryTests : IAsyncLifetime
var updated = original!.WithUpdatedProfile("Historial v2", "suplementos", DateTime.UtcNow); var updated = original!.WithUpdatedProfile("Historial v2", "suplementos", DateTime.UtcNow);
await _repository.UpdateAsync(updated); 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 }); "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}"); 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(3, result.Total);
Assert.Equal(2, result.Items.Count); 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 SqlConnection _connection = null!;
private Respawner _respawner = 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; _connectionString = connectionString;
} }
@@ -80,6 +87,13 @@ public sealed class SqlTestFixture : IAsyncLifetime
await ResetAndSeedAsync(); 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() public async Task ResetAndSeedAsync()
{ {
await _respawner.ResetAsync(_connection); 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> /// <summary>
/// WebApplicationFactory for integration tests against SIGCM2.Api. /// 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> /// </summary>
public sealed class TestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime public sealed class TestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{ {
private const string TestConnectionString = private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
// Resolved once — absolute paths independent of working directory // Resolved once — absolute paths independent of working directory
private static readonly string RepoRoot = ResolveRepoRoot(); private static readonly string RepoRoot = ResolveRepoRoot();