Compare commits

..

26 Commits

Author SHA1 Message Date
389dda6e5e fix(tests): consolidar V016 en SqlTestFixture post issue #29
Rebase de CAT-001 sobre main (post #29) requiere:
- EnsureV016SchemaAsync en SqlTestFixture
- Rubro_History en TablesToIgnore central (el commit original b1be4a5 se skipeo por ser obsoleto post consolidacion)
- catalogo:rubros:gestionar en seed canonical de Permiso + RolPermiso admin
- RubroRepositoryTests refactorizado al patron [Collection] + SqlTestFixture
- RubrosControllerTests apunta a TestConnectionStrings.ApiTestDb
- Counts de permisos admin actualizados 24 -> 25 en 5 tests

Verify: App 819/819 + Api 251/251 + vitest 349/349 verde post-rebase.
2026-04-19 07:49:18 -03:00
bd2febf411 fix(frontend): MoveRubroDialog type cast para zodResolver output (CAT-001) 2026-04-19 07:42:56 -03:00
46ef3878de feat(frontend): MoveRubroDialog + wire en RubrosPage + aria-describedby (CAT-001)
Implementa MoveRubroDialog con flattenExcludingSubtree para prevenir ciclos en UI,
lo conecta en RubrosPage y agrega DialogDescription en RubroFormDialog.
2026-04-19 07:42:55 -03:00
022a36a90c test(application): GetRubroByIdQueryHandlerTests dedicado (CAT-001) 2026-04-19 07:42:55 -03:00
f07802f769 fix(frontend): corregir tipos zodResolver en RubroFormDialog (CAT-001)
- Reemplaza z.union([z.coerce.number(), z.literal('')]) por z.string().transform+pipe para evitar inferencia unknown en zodResolver
- Simplifica RubroFormValues a {nombre: string, tarifarioBaseId?: number | null}
- Actualiza RubrosPage: tarifarioId ya llega como number|null del schema transform
2026-04-19 07:42:55 -03:00
b22e9fe59a feat(frontend): rubros feature + CategoryTree + CRUD dialogs (CAT-001)
Co-Authored-By: none
2026-04-19 07:42:54 -03:00
5e2323e0bc feat(api): RubrosController + integration tests e2e + audit verification (CAT-001) 2026-04-19 07:42:54 -03:00
f8e9d18379 feat(infrastructure): RubroRepository Dapper + DI + integration tests (CAT-001) 2026-04-19 07:42:53 -03:00
d9fc9a2867 feat(application): Rubros commands/queries + RubroTreeBuilder + audit (CAT-001) 2026-04-19 07:42:26 -03:00
dcb2e5ada6 feat(domain): Rubro entity + domain exceptions (CAT-001) 2026-04-19 07:42:26 -03:00
9f78425a93 fix(bd): V016 COLLATE order — SQL Server requiere COLLATE antes de NOT NULL (CAT-001) 2026-04-19 07:42:25 -03:00
0d50d4f3cc feat(bd): V016 create Rubro table con SYSTEM_VERSIONING (CAT-001)
- dbo.Rubro: adjacency list, self-FK, soft-delete, temporal retention 10y
- Filtered unique index UQ_Rubro_ParentId_Nombre_Activo + covering IX_Rubro_ParentId_Activo
- Permission catalogo:rubros:gestionar seeded + assigned to admin role
- V016_ROLLBACK.sql: full reversal script
- RubrosOptions class (MaxDepth=10) + appsettings.json Rubros section
- services.Configure<RubrosOptions> registered in Infrastructure DI
- database/README.md updated with V013-V016 entries
2026-04-19 07:42:25 -03:00
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
bcbba2c012 Merge pull request 'chore(frontend): limpiar lint errors pre-existentes' (#33) from chore/frontend-lint-preexisting into main 2026-04-19 10:41:16 +00:00
3cb89f80a3 Merge pull request 'chore(tests): dotnet format sobre archivos pre-existentes' (#32) from chore/dotnet-format-testfixtures into main 2026-04-19 10:41:14 +00:00
18ce4f6841 Merge pull request 'chore(frontend): DialogDescription en dialogs para a11y' (#31) from chore/dialog-aria-describedby into main 2026-04-19 10:41:09 +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
50a3c87b14 chore(frontend): limpiar lint errors pre-existentes
11 errores en archivos pre-existentes (0 en rubros/). Categorización:
2 bugs reales removidos, 1 FP con disable comentado, 8 FPs suprimidos con eslint-disable-next-line.

Files:
- src/web/src/components/ui/badge.tsx — react-refresh/only-export-components (FP: shadcn/ui co-ubica badgeVariants con el componente por diseño)
- src/web/src/components/ui/button.tsx — react-refresh/only-export-components (FP: ídem, buttonVariants)
- src/web/src/components/ui/form.tsx — react-refresh/only-export-components (FP: shadcn/ui co-ubica useFormField hook)
- src/web/src/pages/admin/audit/AuditFilters.tsx — react-refresh/only-export-components x2 (FP: EMPTY_FILTERS y toApiFilter co-ubicados con el componente que los consume)
- src/web/src/features/permisos/components/RolPermisosEditor.tsx — react-hooks/set-state-in-effect (FP: patrón válido de derived state desde prop externa asignados)
- src/web/src/features/users/components/PermisosEditor.tsx — react-hooks/set-state-in-effect (FP: ídem, permisoData → mapa local de overrides)
- src/web/src/pages/admin/audit/AuditPage.tsx — react-hooks/set-state-in-effect (FP: acumulación de páginas paginadas desde query externa)
- src/web/src/features/users/pages/CreateUserPage.tsx — @typescript-eslint/no-unused-vars (FP: _created existe por contrato de callback, no se necesita el valor)
- src/web/src/lib/dateFormat.ts — @typescript-eslint/no-unused-vars (FP: _opts reservado para extensibilidad futura; formato hardcodeado por compatibilidad Intl)
- src/web/src/tests/api/axiosClient.test.ts — @typescript-eslint/no-unused-vars (bug real: requestCount incrementado en mock handler pero nunca asercionado; variable eliminada)
2026-04-18 21:00:00 -03:00
9957724c40 chore(tests): dotnet format sobre archivos pre-existentes (surfaced durante CAT-001)
Fix mecánico de whitespace detectado por dotnet format --verify-no-changes durante la verify phase de CAT-001 (PR #30). Sin cambios funcionales.
2026-04-18 20:56:23 -03:00
1cb69cbaf3 chore(frontend): DialogDescription en dialogs para a11y (silencia Radix warning) 2026-04-18 20:55:36 -03:00
68 changed files with 441 additions and 769 deletions

View File

@@ -40,23 +40,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)
@@ -94,6 +95,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

@@ -33,4 +33,5 @@ function Badge({ className, variant, ...props }: BadgeProps) {
) )
} }
// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui generated: badgeVariants is intentionally co-located with the component
export { Badge, badgeVariants } export { Badge, badgeVariants }

View File

@@ -53,4 +53,5 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
) )
Button.displayName = "Button" Button.displayName = "Button"
// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui generated: buttonVariants is intentionally co-located with the component
export { Button, buttonVariants } export { Button, buttonVariants }

View File

@@ -165,6 +165,7 @@ const FormMessage = React.forwardRef<
FormMessage.displayName = 'FormMessage' FormMessage.displayName = 'FormMessage'
export { export {
// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui generated: useFormField hook is intentionally co-located with form components
useFormField, useFormField,
Form, Form,
FormItem, FormItem,

View File

@@ -14,6 +14,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
@@ -173,6 +174,11 @@ export function IngresosBrutosFormModal({
<DialogTitle> <DialogTitle>
{isEdit ? 'Editar Ingresos Brutos' : 'Crear Ingresos Brutos'} {isEdit ? 'Editar Ingresos Brutos' : 'Crear Ingresos Brutos'}
</DialogTitle> </DialogTitle>
<DialogDescription>
{isEdit
? 'Modificá los datos de Ingresos Brutos. La alícuota no puede cambiarse aquí; usá "Nueva vigencia".'
: 'Completá los datos para crear un nuevo registro de Ingresos Brutos con su alícuota inicial.'}
</DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>

View File

@@ -12,6 +12,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
@@ -139,6 +140,9 @@ export function NuevaVigenciaIibbModal({
<TriangleAlert className="h-5 w-5 text-warning" /> <TriangleAlert className="h-5 w-5 text-warning" />
Nueva vigencia {item?.provinciaDisplay} Nueva vigencia {item?.provinciaDisplay}
</DialogTitle> </DialogTitle>
<DialogDescription>
Crea una nueva versión de Ingresos Brutos para esta provincia con una alícuota y fecha de vigencia nuevas. La versión actual quedará cerrada.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div <div

View File

@@ -13,6 +13,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
@@ -140,6 +141,9 @@ export function NuevaVigenciaModal({
<TriangleAlert className="h-5 w-5 text-warning" /> <TriangleAlert className="h-5 w-5 text-warning" />
Nueva vigencia {item?.codigo} Nueva vigencia {item?.codigo}
</DialogTitle> </DialogTitle>
<DialogDescription>
Crea una nueva versión del tipo de IVA con un porcentaje y fecha de vigencia nuevos. La versión actual quedará cerrada.
</DialogDescription>
</DialogHeader> </DialogHeader>
{/* Banner de advertencia — usa token --warning-bg */} {/* Banner de advertencia — usa token --warning-bg */}

View File

@@ -14,6 +14,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
@@ -180,6 +181,11 @@ export function TipoDeIvaFormModal({
<DialogTitle> <DialogTitle>
{isEdit ? 'Editar tipo de IVA' : 'Crear tipo de IVA'} {isEdit ? 'Editar tipo de IVA' : 'Crear tipo de IVA'}
</DialogTitle> </DialogTitle>
<DialogDescription>
{isEdit
? 'Modificá los datos del tipo de IVA. El porcentaje no puede cambiarse aquí; usá "Nueva vigencia".'
: 'Completá los datos para crear un nuevo tipo de IVA con su alícuota inicial.'}
</DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>

View File

@@ -33,6 +33,7 @@ export function RolPermisosEditor({ rolCodigo }: RolPermisosEditorProps) {
// Prefill checkboxes cuando lleguen los permisos asignados al rol // Prefill checkboxes cuando lleguen los permisos asignados al rol
useEffect(() => { useEffect(() => {
if (asignados) { if (asignados) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sincroniza prop externa (asignados) con estado local; patrón válido de derived state
setSelected(new Set(asignados.map((p) => p.codigo))) setSelected(new Set(asignados.map((p) => p.codigo)))
setSaved(false) setSaved(false)
} }

View File

@@ -59,6 +59,7 @@ export function PermisosEditor({ userId }: PermisosEditorProps) {
for (const c of permisoData.overrides.grant) map.set(c, 'concedido') for (const c of permisoData.overrides.grant) map.set(c, 'concedido')
// Apply deny overrides // Apply deny overrides
for (const c of permisoData.overrides.deny) map.set(c, 'denegado') for (const c of permisoData.overrides.deny) map.set(c, 'denegado')
// eslint-disable-next-line react-hooks/set-state-in-effect -- sincroniza prop externa (permisoData) con mapa local de overrides; patrón válido de derived state
setStates(map) setStates(map)
setSaveError(null) setSaveError(null)
}, [permisoData]) }, [permisoData])

View File

@@ -12,6 +12,7 @@ import type { CreatedUserDto } from '../api/createUser'
export function CreateUserPage() { export function CreateUserPage() {
const navigate = useNavigate() const navigate = useNavigate()
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- el callback recibe CreatedUserDto por contrato de UserForm pero solo necesitamos navegar
function handleSuccess(_created: CreatedUserDto) { function handleSuccess(_created: CreatedUserDto) {
void navigate('/') void navigate('/')
} }

View File

@@ -25,6 +25,7 @@ interface FormatInstantOptions {
*/ */
export function formatInstant( export function formatInstant(
iso: string, iso: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- parámetro reservado para futura extensibilidad; el formato está hardcodeado por compatibilidad con entornos donde Intl.DateTimeFormat ignora dateStyle/timeStyle
_opts: FormatInstantOptions = { dateStyle: 'short', timeStyle: 'medium' } _opts: FormatInstantOptions = { dateStyle: 'short', timeStyle: 'medium' }
): string { ): string {
const parts = new Intl.DateTimeFormat('es-AR', { const parts = new Intl.DateTimeFormat('es-AR', {

View File

@@ -13,6 +13,7 @@ export interface AuditFiltersValue {
to: string to: string
} }
// eslint-disable-next-line react-refresh/only-export-components -- constante de reset co-ubicada con el componente que la consume como valor inicial
export const EMPTY_FILTERS: AuditFiltersValue = { export const EMPTY_FILTERS: AuditFiltersValue = {
actor: '', actor: '',
targetType: '', targetType: '',
@@ -137,6 +138,7 @@ export function AuditFilters({
* Los convertimos a ISO UTC vía `parseArgentinaDateTimeToUtc()` (fix BUG-FE-05). * Los convertimos a ISO UTC vía `parseArgentinaDateTimeToUtc()` (fix BUG-FE-05).
* - Strings vacíos → omitidos. * - Strings vacíos → omitidos.
*/ */
// eslint-disable-next-line react-refresh/only-export-components -- función utilitaria de mapeo co-ubicada con el componente que la necesita; extraerla a otro archivo aumentaría la fragmentación innecesariamente
export function toApiFilter( export function toApiFilter(
value: AuditFiltersValue, value: AuditFiltersValue,
): import('@/api/audit').AuditEventsFilter { ): import('@/api/audit').AuditEventsFilter {

View File

@@ -67,6 +67,7 @@ export function AuditPage() {
useEffect(() => { useEffect(() => {
if (!data) return if (!data) return
if (cursor === undefined) { if (cursor === undefined) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- acumula datos paginados de una query externa; reset en primera página es intencional
setAccumulated(data.items) setAccumulated(data.items)
} else { } else {
setAccumulated((prev) => { setAccumulated((prev) => {

View File

@@ -131,11 +131,8 @@ describe('axiosClient', () => {
setAuth('expired-access', 'valid-refresh') setAuth('expired-access', 'valid-refresh')
let refreshCallCount = 0 let refreshCallCount = 0
let requestCount = 0
server.use( server.use(
http.get(`${API_URL}/api/v1/protected`, ({ request }) => { http.get(`${API_URL}/api/v1/protected`, ({ request }) => {
requestCount++
const auth = request.headers.get('Authorization') const auth = request.headers.get('Authorization')
if (auth === 'Bearer new-access-from-refresh') { if (auth === 'Bearer new-access-from-refresh') {
return HttpResponse.json({ data: 'ok' }) return HttpResponse.json({ data: 'ok' })

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

@@ -17,8 +17,7 @@ namespace SIGCM2.Api.Tests.Rubros;
[Collection("ApiIntegration")] [Collection("ApiIntegration")]
public sealed class RubrosControllerTests : IAsyncLifetime public sealed class RubrosControllerTests : 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 ReadEndpoint = "/api/v1/rubros"; private const string ReadEndpoint = "/api/v1/rubros";
private const string AdminEndpoint = "/api/v1/admin/rubros"; private const string AdminEndpoint = "/api/v1/admin/rubros";

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

@@ -44,7 +44,7 @@ public sealed class PermisoResolverTests
Assert.DoesNotContain("A", result); Assert.DoesNotContain("A", result);
Assert.Contains("B", result); Assert.Contains("B", result);
Assert.Equal(1, result.Count); Assert.Single(result);
} }
// R-04: Grant duplicado (ya en rol) → idempotente, no duplicados // R-04: Grant duplicado (ya en rol) → idempotente, no duplicados

View File

@@ -66,10 +66,10 @@ public class TempPasswordGeneratorTests
{ {
var pwd = TempPasswordGenerator.Generate(12); var pwd = TempPasswordGenerator.Generate(12);
Assert.True(pwd.Length >= 12); Assert.True(pwd.Length >= 12);
Assert.True(pwd.Any(char.IsUpper)); Assert.Contains(pwd, char.IsUpper);
Assert.True(pwd.Any(char.IsLower)); Assert.Contains(pwd, char.IsLower);
Assert.True(pwd.Any(char.IsDigit)); Assert.Contains(pwd, char.IsDigit);
Assert.True(pwd.Any(c => symbols.Contains(c))); Assert.Contains(pwd, c => symbols.Contains(c));
} }
} }

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!;
@@ -130,7 +129,10 @@ public sealed class AuditEventRepositoryTests : IAsyncLifetime
[Fact] [Fact]
public async Task QueryAsync_Limit_EmitsCursor_WhenMoreRowsAvailable() 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); await Seed(5, t0);
var page1 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, null, Limit: 2)); 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")] [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

@@ -154,9 +154,17 @@ public sealed class JsonSanitizerTests
var input = new var input = new
{ {
password = "1", passwordHash = "2", token = "3", refreshToken = "4", password = "1",
accessToken = "5", cvv = "6", card = "7", cardNumber = "8", passwordHash = "2",
secret = "9", apiKey = "10", privateKey = "11", token = "3",
refreshToken = "4",
accessToken = "5",
cvv = "6",
card = "7",
cardNumber = "8",
secret = "9",
apiKey = "10",
privateKey = "11",
keep = "yes", keep = "yes",
}; };

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,105 +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"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});
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,68 +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"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});
// 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]
@@ -90,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', '[]')");
@@ -103,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,86 +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"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});
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;
@@ -89,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 });
@@ -114,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"]}""";
@@ -127,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,68 +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"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});
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]
@@ -112,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);
@@ -125,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)
"""); """);
@@ -133,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);
@@ -145,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);
@@ -168,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'
@@ -177,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)
"""); """);
@@ -185,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);
@@ -198,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 (
@@ -208,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);
@@ -216,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>
@@ -266,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,71 +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"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});
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 ─────────────────────────────────────
@@ -172,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}");
@@ -243,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

@@ -1,73 +1,37 @@
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.Rubros; namespace SIGCM2.Application.Tests.Rubros;
/// <summary> /// <summary>
/// Integration tests for RubroRepository against SIGCM2_Test. /// Integration tests for RubroRepository against SIGCM2_Test_App.
/// TDD: RED written before implementation, GREEN after RubroRepository was created. /// Uses shared SqlTestFixture via [Collection("Database")] — fixture maneja Respawn + seeds.
/// Temporal: after UpdateAsync, dbo.Rubro_History MUST have ≥1 row for that Id. /// Temporal: after UpdateAsync, dbo.Rubro_History MUST have ≥1 row for that Id.
/// </summary> /// </summary>
[Collection("Database")] [Collection("Database")]
public class RubroRepositoryTests : IAsyncLifetime public class RubroRepositoryTests : 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 RubroRepository _repository = null!; private RubroRepository _repository = null!;
private TimeProvider _timeProvider = null!; private TimeProvider _timeProvider = null!;
public RubroRepositoryTests(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.
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"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});
await _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
var factory = new SqlConnectionFactory(ConnectionString);
_repository = new RubroRepository(factory); _repository = new RubroRepository(factory);
_timeProvider = TimeProvider.System; _timeProvider = TimeProvider.System;
} }
public async Task DisposeAsync() public Task DisposeAsync() => Task.CompletedTask;
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
// ── AddAsync + GetByIdAsync roundtrip ───────────────────────────────────── // ── AddAsync + GetByIdAsync roundtrip ─────────────────────────────────────
@@ -381,7 +345,7 @@ public class RubroRepositoryTests : IAsyncLifetime
Assert.Equal("Actualizado", result!.Nombre); Assert.Equal("Actualizado", result!.Nombre);
Assert.NotNull(result.FechaModificacion); Assert.NotNull(result.FechaModificacion);
var historyCount = await _connection.ExecuteScalarAsync<int>( var historyCount = await _db.Connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id }); "SELECT COUNT(*) FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id });
Assert.True(historyCount >= 1, $"Expected ≥1 history row for Rubro Id={id}, got {historyCount}"); Assert.True(historyCount >= 1, $"Expected ≥1 history row for Rubro Id={id}, got {historyCount}");
@@ -423,28 +387,4 @@ public class RubroRepositoryTests : IAsyncLifetime
Assert.NotNull(result.FechaModificacion); Assert.NotNull(result.FechaModificacion);
} }
// ── 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,64 +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"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});
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);
@@ -66,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 ─────────────────────────────────────
@@ -173,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}");
@@ -236,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

@@ -42,7 +42,7 @@ public class ListTiposDeIvaQueryHandlerTests
var result = await _handler.Handle(new ListTiposDeIvaQuery(1, 10, null, null)); var result = await _handler.Handle(new ListTiposDeIvaQuery(1, 10, null, null));
Assert.Equal(0, result.Items.Count); Assert.Empty(result.Items);
Assert.Equal(0, result.Total); Assert.Equal(0, result.Total);
} }

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;
} }
@@ -77,7 +84,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
// Seed de TipoDeIva e IngresosBrutos son datos de referencia — no limpiar con Respawn. // Seed de TipoDeIva e IngresosBrutos son datos de referencia — no limpiar con Respawn.
new Respawn.Graph.Table("dbo", "TipoDeIva"), new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"), new Respawn.Graph.Table("dbo", "IngresosBrutos"),
// CAT-001 (V016): Rubro es system-versioned — Respawn no puede DELETE su history. // CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"), new Respawn.Graph.Table("dbo", "Rubro_History"),
] ]
}); });
@@ -85,6 +92,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);
@@ -787,11 +801,70 @@ public sealed class SqlTestFixture : IAsyncLifetime
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). // desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
} }
/// <summary>
/// UDT-011 (V015): applies dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local views
/// idempotently to the test database. Mirrors V015__create_local_timezone_views.sql.
/// Views expose OccurredAtLocal (DateTimeOffset, offset -03:00 Argentina Standard Time).
/// Note: CREATE VIEW cannot be inside IF...BEGIN...END directly — uses EXEC('CREATE VIEW ...').
/// </summary>
private async Task EnsureV015SchemaAsync()
{
const string createAuditEventLocal = """
IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NULL
BEGIN
EXEC('
CREATE VIEW dbo.v_AuditEvent_Local AS
SELECT
Id,
OccurredAt,
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
ActorUserId,
ActorRoleId,
Action,
TargetType,
TargetId,
CorrelationId,
IpAddress,
UserAgent,
Metadata
FROM dbo.AuditEvent;
');
END
""";
const string createSecurityEventLocal = """
IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NULL
BEGIN
EXEC('
CREATE VIEW dbo.v_SecurityEvent_Local AS
SELECT
Id,
OccurredAt,
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
ActorUserId,
AttemptedUsername,
SessionId,
Action,
Result,
FailureReason,
IpAddress,
UserAgent,
Metadata
FROM dbo.SecurityEvent;
');
END
""";
await _connection.ExecuteAsync(createAuditEventLocal);
await _connection.ExecuteAsync(createSecurityEventLocal);
}
/// <summary> /// <summary>
/// CAT-001 (V016): applies dbo.Rubro schema + temporal + filtered unique index + covering index /// CAT-001 (V016): applies dbo.Rubro schema + temporal + filtered unique index + covering index
/// + permiso 'catalogo:rubros:gestionar' idempotentemente. Mirrors V016__create_rubro.sql. /// idempotentemente. Mirrors V016__create_rubro.sql.
/// Nota: COLLATE debe ir ANTES de NOT NULL — parser de SQL Server 2019 es estricto con ese orden. /// Nota: COLLATE debe ir ANTES de NOT NULL — parser de SQL Server 2019 es estricto con ese orden.
/// Permiso y asignación a admin se siembran desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync. /// Permiso 'catalogo:rubros:gestionar' y asignación a admin se siembran
/// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
/// </summary> /// </summary>
private async Task EnsureV016SchemaAsync() private async Task EnsureV016SchemaAsync()
{ {
@@ -859,65 +932,5 @@ public sealed class SqlTestFixture : IAsyncLifetime
await _connection.ExecuteAsync(setRubroVersioning); await _connection.ExecuteAsync(setRubroVersioning);
await _connection.ExecuteAsync(createUqIndex); await _connection.ExecuteAsync(createUqIndex);
await _connection.ExecuteAsync(createCoveringIndex); await _connection.ExecuteAsync(createCoveringIndex);
// Permiso 'catalogo:rubros:gestionar' y asignación a admin se siembran
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
}
/// <summary>
/// UDT-011 (V015): applies dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local views
/// idempotently to the test database. Mirrors V015__create_local_timezone_views.sql.
/// Views expose OccurredAtLocal (DateTimeOffset, offset -03:00 Argentina Standard Time).
/// Note: CREATE VIEW cannot be inside IF...BEGIN...END directly — uses EXEC('CREATE VIEW ...').
/// </summary>
private async Task EnsureV015SchemaAsync()
{
const string createAuditEventLocal = """
IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NULL
BEGIN
EXEC('
CREATE VIEW dbo.v_AuditEvent_Local AS
SELECT
Id,
OccurredAt,
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
ActorUserId,
ActorRoleId,
Action,
TargetType,
TargetId,
CorrelationId,
IpAddress,
UserAgent,
Metadata
FROM dbo.AuditEvent;
');
END
""";
const string createSecurityEventLocal = """
IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NULL
BEGIN
EXEC('
CREATE VIEW dbo.v_SecurityEvent_Local AS
SELECT
Id,
OccurredAt,
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
ActorUserId,
AttemptedUsername,
SessionId,
Action,
Result,
FailureReason,
IpAddress,
UserAgent,
Metadata
FROM dbo.SecurityEvent;
');
END
""";
await _connection.ExecuteAsync(createAuditEventLocal);
await _connection.ExecuteAsync(createSecurityEventLocal);
} }
} }

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();