From 8799a134f8d3fcbdba914b9823f67fdc633a3b9a Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 1 Jul 2026 12:14:22 -0600 Subject: [PATCH] feature/avisos-respaldo-celery-beat --- src/lib/server/api-error.ts | 2 +- src/lib/server/email-service.test.ts | 66 +++++++++ src/lib/server/email-service.ts | 158 ++++++++++---------- src/routes/api/alerts/send/+server.ts | 45 ++++-- src/routes/api/alerts/send/server.test.ts | 173 ++++++++++++++++++++++ src/test/env-private-stub.ts | 8 + vitest.config.ts | 17 ++- 7 files changed, 374 insertions(+), 95 deletions(-) create mode 100644 src/lib/server/email-service.test.ts create mode 100644 src/routes/api/alerts/send/server.test.ts create mode 100644 src/test/env-private-stub.ts diff --git a/src/lib/server/api-error.ts b/src/lib/server/api-error.ts index d5978e5..ff8000c 100644 --- a/src/lib/server/api-error.ts +++ b/src/lib/server/api-error.ts @@ -10,7 +10,7 @@ export function newTraceId(): string { } export function errorJson( - code: 400 | 401 | 403 | 404 | 409 | 422 | 500, + code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 503, message: string, traceId: string ) { diff --git a/src/lib/server/email-service.test.ts b/src/lib/server/email-service.test.ts new file mode 100644 index 0000000..e1cb446 --- /dev/null +++ b/src/lib/server/email-service.test.ts @@ -0,0 +1,66 @@ +/** + * Pruebas de los builders HTML de correo (réplica 1:1 del legacy) y del formateo de fecha. + * Importan las funciones REALES: se valida estructura legacy + escape de campos de texto. + */ +import { describe, it, expect } from 'vitest'; +import { buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } from './email-service'; + +const baseRow = { + visible_name: 'CLIENTE_DB', + clientName: 'ACME S.A.', + last_restore_date: '2026-06-28T17:26:15.290000', + daysWithout: 3 +}; + +describe('buildBackupAlertHtml — 1:1 legacy (overdue)', () => { + it('incluye logo, título, link SCAIIWeb y pie TransmitirAS', () => { + const html = buildBackupAlertHtml([baseRow]); + expect(html).toContain('https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png'); + expect(html).toContain('Notificación de Sincronización'); + expect(html).toContain('no se ha sincronizado correctamente en las últimas 24 horas'); + expect(html).toContain('https://a24.aduanasoft.com/SCAIIWeb'); + expect(html).toContain('© 2024 TransmitirAS'); + expect(html).toContain('ACME S.A.'); // cliente + expect(html).toContain('CLIENTE_DB'); // base de datos + expect(html).toContain('28 de junio de 2026, 17:26'); // fecha formateada + }); + + it('escapa visible_name y clientName (XSS)', () => { + const html = buildBackupAlertHtml([ + { visible_name: '', clientName: 'x', last_restore_date: null, daysWithout: null } + ]); + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + expect(html).not.toContain('x'); + expect(html).toContain('No disponible'); // last_restore null + }); +}); + +describe('buildBackupResolvedHtml — misma plantilla, mensaje positivo', () => { + it('incluye título restablecido, logo, SCAIIWeb y pie', () => { + const html = buildBackupResolvedHtml([baseRow]); + expect(html).toContain('Sincronización Restablecida'); + expect(html).toContain('volvió a sincronizarse correctamente'); + expect(html).toContain('https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png'); + expect(html).toContain('https://a24.aduanasoft.com/SCAIIWeb'); + expect(html).toContain('© 2024 TransmitirAS'); + }); + + it('escapa clientName', () => { + const html = buildBackupResolvedHtml([ + { visible_name: 'DB', clientName: '', last_restore_date: null, daysWithout: null } + ]); + expect(html).not.toContain(''); + expect(html).toContain('<img'); + }); +}); + +describe('formatFechaEs', () => { + it('formatea fecha ISO (con microsegundos) al estilo legacy', () => { + expect(formatFechaEs('2026-06-28T17:26:15.290000')).toBe('28 de junio de 2026, 17:26'); + }); + it('null/invalid -> No disponible', () => { + expect(formatFechaEs(null)).toBe('No disponible'); + expect(formatFechaEs('no-fecha')).toBe('No disponible'); + }); +}); diff --git a/src/lib/server/email-service.ts b/src/lib/server/email-service.ts index 85d8f19..6689821 100644 --- a/src/lib/server/email-service.ts +++ b/src/lib/server/email-service.ts @@ -65,95 +65,91 @@ export async function sendSmtpEmail(opts: SendEmailOptions): Promise { }); } -/** Genera HTML de alerta de respaldo compatible con Gmail/Outlook (tablas, sin flex). */ -export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string { - const rows = alerts - .map((a) => { - const badgeColor = - a.daysWithout === null - ? '#6b7280' - : a.daysWithout >= 7 - ? '#b91c1c' - : a.daysWithout >= 3 - ? '#b45309' - : '#047857'; - const badgeBg = - a.daysWithout === null - ? '#f3f4f6' - : a.daysWithout >= 7 - ? '#fef2f2' - : a.daysWithout >= 3 - ? '#fffbeb' - : '#ecfdf5'; - const diasLabel = - a.daysWithout === null ? 'Sin datos' : `${a.daysWithout} día${a.daysWithout !== 1 ? 's' : ''}`; - const ultimaRest = a.last_restore_date - ? new Date(a.last_restore_date).toLocaleString('es-MX') - : 'Nunca'; - return ` - - ${escHtml(a.visible_name)} - ${escHtml(a.clientName ?? 'N/D')} - ${ultimaRest} - - - ${diasLabel} - - - `; - }) - .join(''); +// ============================================================================ +// Plantillas de correo — réplica 1:1 del legacy index.php (logo, azul, narrativa +// por cliente, link SCAIIWeb, pie © TransmitirAS). +// ============================================================================ +const LOGO_URL = 'https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png'; + +const SCAIIWEB_H4 = + `

` + + `Consulta la última sincronización de datos fácilmente desde ` + + `SCAIIWeb. ` + + `Inicia sesión y encontrarás esta información en la esquina inferior derecha de la pantalla.

`; + +const FOOTER = + `
` + + `© 2024 TransmitirAS. Todos los derechos reservados.
`; + +/** Envuelve el contenido en el mismo cascarón del legacy (Arial, logo centrado, pie). */ +function renderSyncEmail(inner: string): string { return ` - - - -
- - - - - - - - - - - - - -
-

- Alerta de respaldo — Aduanasoft -

-

- ${alerts.length} base${alerts.length !== 1 ? 's' : ''} de datos sin restaurar recientemente -

-
- - - - - - - - - - ${rows} -
Base de datosClienteÚltima restauraciónDías sin sync
-
-

- Este mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft. - Por favor no responda a este correo. -

-
-
+ +
+
+ Logo +
+ ${inner} + ${FOOTER} +
`; } +/** Fecha en español "28 de junio de 2026, 17:26" (o "No disponible"), como el strftime del legacy. */ +export function formatFechaEs(iso: string | null | undefined): string { + if (!iso) return 'No disponible'; + // Normaliza microsegundos (a24c manda isoformat con 6 dígitos) a milisegundos. + const cleaned = String(iso).replace(/(\.\d{3})\d+/, '$1'); + const d = new Date(cleaned); + if (isNaN(d.getTime())) return 'No disponible'; + const meses = [ + 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', + 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre' + ]; + const dd = String(d.getDate()).padStart(2, '0'); + const mm = meses[d.getMonth()]; + const yyyy = d.getFullYear(); + const HH = String(d.getHours()).padStart(2, '0'); + const MM = String(d.getMinutes()).padStart(2, '0'); + return `${dd} de ${mm} de ${yyyy}, ${HH}:${MM}`; +} + +/** Correo de alerta de sincronización (kind=overdue), 1:1 con el legacy index.php. */ +export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string { + const a = alerts[0]; + const nombre = escHtml(a?.clientName ?? a?.visible_name ?? ''); + const bd = escHtml(a?.visible_name ?? ''); + const fecha = escHtml(formatFechaEs(a?.last_restore_date)); + const inner = ` +

Notificación de Sincronización

+

Estimado ${nombre},

+

Detectamos que la base de datos ${bd} no se ha sincronizado correctamente en las últimas 24 horas.

+

La última restauración registrada fue: ${fecha}.

+ ${SCAIIWEB_H4} +

Por favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.

`; + return renderSyncEmail(inner); +} + +/** Correo de "sincronización restablecida" (kind=resolved): misma plantilla legacy, mensaje positivo. */ +export function buildBackupResolvedHtml(alerts: BackupAlertRow[]): string { + const a = alerts[0]; + const nombre = escHtml(a?.clientName ?? a?.visible_name ?? ''); + const bd = escHtml(a?.visible_name ?? ''); + const fecha = escHtml(formatFechaEs(a?.last_restore_date)); + const inner = ` +

Sincronización Restablecida

+

Estimado ${nombre},

+

La base de datos ${bd} volvió a sincronizarse correctamente.

+

La última restauración registrada fue: ${fecha}.

+ ${SCAIIWEB_H4} +

No se requiere ninguna acción de su parte. Gracias por mantener su equipo y la aplicación en funcionamiento.

`; + return renderSyncEmail(inner); +} + export interface BackupAlertRow { visible_name: string; clientName: string | null; diff --git a/src/routes/api/alerts/send/+server.ts b/src/routes/api/alerts/send/+server.ts index 7447048..0ef63ae 100644 --- a/src/routes/api/alerts/send/+server.ts +++ b/src/routes/api/alerts/send/+server.ts @@ -1,9 +1,11 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { pgPool } from '$lib/server/db'; -import { sendSmtpEmail, buildBackupAlertHtml } from '$lib/server/email-service'; +import { sendSmtpEmail, buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } from '$lib/server/email-service'; interface AlertPayload { + /** ID del nodo en a24c.database_nodes; se hace echo en SendResult para reconciliación del cooldown en a24c. */ + database_node_id?: number; /** Clave del nodo (de clientData.NodoSubNodo). Puede estar vacía si el cliente no fue identificado. */ node_subnode_key: string; visible_name: string; @@ -11,11 +13,17 @@ interface AlertPayload { mainEmail: string | null; last_restore_date: string | null; daysWithout: number | null; + /** Tipo de notificación. Ausente => 'overdue' por compatibilidad. */ + kind?: 'overdue' | 'resolved'; } interface SendResult { + /** Echo del id recibido (o null si no vino) para que a24c reconcilie el cooldown. */ + database_node_id: number | null; node_subnode_key: string; visible_name: string; + /** Echo del kind efectivo aplicado. */ + kind: 'overdue' | 'resolved'; recipients: number; status: 'sent' | 'no_recipients' | 'error'; error?: string; @@ -83,40 +91,55 @@ export const POST: RequestHandler = async ({ request }) => { let failed = 0; for (const alert of alerts) { + const kind: 'overdue' | 'resolved' = alert.kind === 'resolved' ? 'resolved' : 'overdue'; + const nodeId = Number.isInteger(alert.database_node_id) ? (alert.database_node_id as number) : null; try { const additional = await getAdditionalEmails(alert.node_subnode_key, alert.visible_name); const toAddrs = dedupeEmails([alert.mainEmail, ...additional]); if (toAddrs.length === 0) { - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: 0, status: 'no_recipients' }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: 0, status: 'no_recipients' }); failed++; continue; } - const htmlBody = buildBackupAlertHtml([{ + const rowData = { visible_name: alert.visible_name, clientName: alert.clientName, last_restore_date: alert.last_restore_date, daysWithout: alert.daysWithout - }]); + }; + const nombre = alert.clientName ?? alert.visible_name; + const fecha = formatFechaEs(alert.last_restore_date); - const diasLabel = alert.daysWithout !== null ? `${alert.daysWithout} días` : 'sin datos'; - const plainBody = `Alerta de respaldo — Aduanasoft\n\nBase de datos: ${alert.visible_name}\nCliente: ${alert.clientName ?? 'N/D'}\nÚltima restauración: ${alert.last_restore_date ? new Date(alert.last_restore_date).toLocaleString('es-MX') : 'Nunca'}\nDías sin sincronizar: ${diasLabel}\n\nEste mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft.`; + let subject: string; + let htmlBody: string; + let plainBody: string; + + if (kind === 'resolved') { + subject = `Sincronización Restablecida - ${nombre}`; + htmlBody = buildBackupResolvedHtml([rowData]); + plainBody = `Sincronización Restablecida\n\nEstimado ${nombre},\n\nLa base de datos ${alert.visible_name} volvió a sincronizarse correctamente.\nLa última restauración registrada fue: ${fecha}.\n\nConsulta la última sincronización desde SCAIIWeb: https://a24.aduanasoft.com/SCAIIWeb\n\nNo se requiere ninguna acción de su parte.\n\n© 2024 TransmitirAS. Todos los derechos reservados.`; + } else { + subject = `Alerta de Sincronización - ${nombre}`; + htmlBody = buildBackupAlertHtml([rowData]); + plainBody = `Notificación de Sincronización\n\nEstimado ${nombre},\n\nDetectamos que la base de datos ${alert.visible_name} no se ha sincronizado correctamente en las últimas 24 horas.\nLa última restauración registrada fue: ${fecha}.\n\nConsulta la última sincronización desde SCAIIWeb: https://a24.aduanasoft.com/SCAIIWeb\n\nPor favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.\n\n© 2024 TransmitirAS. Todos los derechos reservados.`; + } await sendSmtpEmail({ toAddrs, - subject: `Alerta de respaldo: ${alert.visible_name}`, + subject, plainBody, htmlBody, - fromDisplayName: 'Aduanasoft · Panel de Bases', - highImportance: (alert.daysWithout ?? 0) >= 7 + fromDisplayName: 'TransmitirAS Notificaciones', + highImportance: false }); - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: toAddrs.length, status: 'sent' }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: toAddrs.length, status: 'sent' }); sent++; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: 0, status: 'error', error: msg }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: 0, status: 'error', error: msg }); failed++; } } diff --git a/src/routes/api/alerts/send/server.test.ts b/src/routes/api/alerts/send/server.test.ts new file mode 100644 index 0000000..f61582f --- /dev/null +++ b/src/routes/api/alerts/send/server.test.ts @@ -0,0 +1,173 @@ +/** + * Pruebas del endpoint POST /api/alerts/send (sin autenticación — el control es de red). + * + * - Ruteo overdue vs resolved (email-service mockeado). + * - Dedupe principal + adicionales (pgPool mockeado). + * - Echo de database_node_id / kind en details (reconciliación de a24c). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.mock se iza al tope del archivo; las factories no pueden ver variables de +// módulo, así que los mocks se crean con vi.hoisted. +const { queryMock, sendSmtpEmail, buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } = vi.hoisted(() => ({ + queryMock: vi.fn(), + sendSmtpEmail: vi.fn(), + buildBackupAlertHtml: vi.fn(() => ''), + buildBackupResolvedHtml: vi.fn(() => ''), + formatFechaEs: vi.fn(() => '28 de junio de 2026, 10:00') +})); + +vi.mock('$lib/server/db', () => ({ pgPool: { query: queryMock } })); +vi.mock('$lib/server/email-service', () => ({ + sendSmtpEmail, + buildBackupAlertHtml, + buildBackupResolvedHtml, + formatFechaEs +})); + +import { POST } from './+server'; + +function makeRequest(bodyObj: unknown): Request { + return new Request('http://localhost/api/alerts/send', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: typeof bodyObj === 'string' ? bodyObj : JSON.stringify(bodyObj) + }); +} + +async function callPost(req: Request): Promise<{ status: number; body: any }> { + // El handler solo usa `request` del RequestEvent. + const res = await POST({ request: req } as any); + return { status: res.status, body: await res.json() }; +} + +const baseAlert = { + database_node_id: 42, + node_subnode_key: '08037NATM001', + visible_name: 'CLIENTE_DB', + clientName: 'ACME', + mainEmail: 'dest@x.com', + last_restore_date: '2026-06-28T10:00:00Z', + daysWithout: 3 +}; + +beforeEach(() => { + vi.clearAllMocks(); + queryMock.mockResolvedValue({ rows: [] }); // sin correos adicionales por defecto + sendSmtpEmail.mockResolvedValue(undefined); +}); + +describe('POST /api/alerts/send — validación de body', () => { + it('400 si el body no es JSON', async () => { + const { status } = await callPost(makeRequest('{no-json')); + expect(status).toBe(400); + }); + + it('400 si alerts está vacío', async () => { + const { status } = await callPost(makeRequest({ alerts: [] })); + expect(status).toBe(400); + }); +}); + +describe('POST /api/alerts/send — envío y ruteo', () => { + it('overdue: usa plantilla de alerta y responde sent=1', async () => { + const { status, body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, kind: 'overdue' }] }) + ); + expect(status).toBe(200); + expect(buildBackupAlertHtml).toHaveBeenCalledTimes(1); + expect(buildBackupResolvedHtml).not.toHaveBeenCalled(); + expect(sendSmtpEmail).toHaveBeenCalledTimes(1); + expect(sendSmtpEmail.mock.calls[0][0].subject).toBe('Alerta de Sincronización - ACME'); + expect(body.sent).toBe(1); + expect(body.failed).toBe(0); + expect(body.details[0]).toMatchObject({ database_node_id: 42, kind: 'overdue', status: 'sent' }); + }); + + it('kind ausente por defecto es overdue', async () => { + await callPost(makeRequest({ alerts: [baseAlert] })); + expect(buildBackupAlertHtml).toHaveBeenCalledTimes(1); + expect(sendSmtpEmail.mock.calls[0][0].subject).toBe('Alerta de Sincronización - ACME'); + }); + + it('resolved: usa plantilla de normalizado, sin alta importancia', async () => { + const { body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, kind: 'resolved', daysWithout: 0 }] }) + ); + expect(buildBackupResolvedHtml).toHaveBeenCalledTimes(1); + expect(buildBackupAlertHtml).not.toHaveBeenCalled(); + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.subject).toBe('Sincronización Restablecida - ACME'); + expect(arg.highImportance).toBe(false); + expect(body.details[0].kind).toBe('resolved'); + }); + + it('deduplica correo principal + adicionales (case-insensitive)', async () => { + // Primera query (por node_subnode_key) devuelve adicionales, uno duplica el principal. + queryMock.mockResolvedValueOnce({ rows: [{ email: 'DEST@x.com' }, { email: 'extra@x.com' }] }); + await callPost(makeRequest({ alerts: [baseAlert] })); + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.toAddrs).toHaveLength(2); + expect(arg.toAddrs.map((s: string) => s.toLowerCase())).toEqual(['dest@x.com', 'extra@x.com']); + }); + + it('no_recipients: sin correos no envía', async () => { + const { body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, mainEmail: null }] }) + ); + expect(sendSmtpEmail).not.toHaveBeenCalled(); + expect(body.details[0]).toMatchObject({ database_node_id: 42, status: 'no_recipients' }); + expect(body.failed).toBe(1); + }); + + it('error SMTP: status error y el bucle continúa', async () => { + sendSmtpEmail.mockRejectedValueOnce(new Error('smtp caído')); + const alerts = [ + { ...baseAlert, database_node_id: 1, visible_name: 'DB1' }, + { ...baseAlert, database_node_id: 2, visible_name: 'DB2' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + const d1 = body.details.find((d: any) => d.database_node_id === 1); + const d2 = body.details.find((d: any) => d.database_node_id === 2); + expect(d1.status).toBe('error'); + expect(d2.status).toBe('sent'); + expect(body.sent).toBe(1); + expect(body.failed).toBe(1); + }); + + it('multi-nodo: mezcla sent / no_recipients / error emparejada por database_node_id', async () => { + sendSmtpEmail + .mockResolvedValueOnce(undefined) // nodo 1 -> sent + .mockRejectedValueOnce(new Error('x')); // nodo 3 -> error + const alerts = [ + { ...baseAlert, database_node_id: 1, visible_name: 'DB1' }, + { ...baseAlert, database_node_id: 2, visible_name: 'DB2', mainEmail: null }, + { ...baseAlert, database_node_id: 3, visible_name: 'DB3' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + const by = (id: number) => body.details.find((d: any) => d.database_node_id === id); + expect(by(1).status).toBe('sent'); + expect(by(2).status).toBe('no_recipients'); + expect(by(3).status).toBe('error'); + }); + + it('node_subnode_key vacío usa el fallback por database_name (2ª query)', async () => { + // Con key vacío, getAdditionalEmails salta la 1ª query y usa el fallback. + queryMock.mockResolvedValueOnce({ rows: [{ email: 'fallback@x.com' }] }); + await callPost(makeRequest({ alerts: [{ ...baseAlert, node_subnode_key: '' }] })); + expect(queryMock).toHaveBeenCalledTimes(1); + expect(String(queryMock.mock.calls[0][0])).toContain('database_nodes'); // SQL de fallback + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.toAddrs.map((s: string) => s.toLowerCase()).sort()).toEqual(['dest@x.com', 'fallback@x.com']); + }); + + it('database_node_id ausente o no-entero => echo null', async () => { + const alerts = [ + { ...baseAlert, database_node_id: undefined }, + { ...baseAlert, database_node_id: 3.5, visible_name: 'DB2' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + expect(body.details[0].database_node_id).toBeNull(); + expect(body.details[1].database_node_id).toBeNull(); + }); +}); diff --git a/src/test/env-private-stub.ts b/src/test/env-private-stub.ts new file mode 100644 index 0000000..76cb71b --- /dev/null +++ b/src/test/env-private-stub.ts @@ -0,0 +1,8 @@ +/** + * Stub de `$env/dynamic/private` para pruebas Vitest (alias en vitest.config.ts). + * Varios módulos de servidor (p. ej. controldesk-pg.ts, mssql-nodes.ts) importan + * `env` de este módulo virtual de SvelteKit, que no existe fuera del build; el alias + * lo resuelve a este stub para que sus tests puedan cargarse. En build real, + * SvelteKit provee el módulo auténtico y este archivo no se usa. + */ +export const env: Record = {}; diff --git a/vitest.config.ts b/vitest.config.ts index b2f341d..a7c7965 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,21 @@ import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; -// Configuración mínima de Vitest para pruebas unitarias de lógica pura del servidor -// (cifrado, helpers). Las pruebas no dependen de SvelteKit ni del entorno del navegador. +// Configuración de Vitest para pruebas unitarias de lógica del servidor (cifrado, +// helpers, endpoints). Los aliases resuelven los especificadores de SvelteKit +// ($lib, $env/dynamic/private) SOLO en pruebas; el build real usa los módulos +// virtuales auténticos de SvelteKit y no toca esta config. export default defineConfig({ + resolve: { + alias: { + $lib: fileURLToPath(new URL('./src/lib', import.meta.url)), + // Módulos de servidor importan `$env/dynamic/private` (virtual de SvelteKit); + // se resuelve a un stub para poder cargarlos en pruebas unitarias. + '$env/dynamic/private': fileURLToPath( + new URL('./src/test/env-private-stub.ts', import.meta.url) + ) + } + }, test: { environment: 'node', include: ['src/**/*.{test,spec}.{js,ts}']