feature/avisos-respaldo-celery-beat

This commit is contained in:
2026-07-01 12:14:22 -06:00
parent cb4324979b
commit 8799a134f8
7 changed files with 374 additions and 95 deletions

View File

@@ -10,7 +10,7 @@ export function newTraceId(): string {
} }
export function errorJson( export function errorJson(
code: 400 | 401 | 403 | 404 | 409 | 422 | 500, code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 503,
message: string, message: string,
traceId: string traceId: string
) { ) {

View File

@@ -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: '<script>alert(1)</script>', clientName: '<b>x</b>', last_restore_date: null, daysWithout: null }
]);
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<b>x</b>');
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('&copy; 2024 TransmitirAS');
});
it('escapa clientName', () => {
const html = buildBackupResolvedHtml([
{ visible_name: 'DB', clientName: '<img src=x onerror=1>', last_restore_date: null, daysWithout: null }
]);
expect(html).not.toContain('<img src=x onerror=1>');
expect(html).toContain('&lt;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');
});
});

View File

@@ -65,95 +65,91 @@ export async function sendSmtpEmail(opts: SendEmailOptions): Promise<void> {
}); });
} }
/** Genera HTML de alerta de respaldo compatible con Gmail/Outlook (tablas, sin flex). */ // ============================================================================
export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string { // Plantillas de correo — réplica 1:1 del legacy index.php (logo, azul, narrativa
const rows = alerts // por cliente, link SCAIIWeb, pie © TransmitirAS).
.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 `
<tr style="border-bottom:1px solid #e2e8f0;">
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#1e293b;">${escHtml(a.visible_name)}</td>
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#475569;">${escHtml(a.clientName ?? 'N/D')}</td>
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#475569;">${ultimaRest}</td>
<td style="padding:8px 12px;text-align:center;">
<span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-family:sans-serif;font-size:11px;font-weight:600;color:${badgeColor};background:${badgeBg};border:1px solid ${badgeColor}30;">
${diasLabel}
</span>
</td>
</tr>`;
})
.join('');
const LOGO_URL = 'https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png';
const SCAIIWEB_H4 =
`<h4 style="color:#007bff;text-align:center;">` +
`Consulta la última sincronización de datos fácilmente desde ` +
`<a href="https://a24.aduanasoft.com/SCAIIWeb" style="color:#007bff;text-decoration:none;">SCAIIWeb</a>. ` +
`Inicia sesión y encontrarás esta información en la esquina inferior derecha de la pantalla.</h4>`;
const FOOTER =
`<div style="background-color:#007bff;color:#fff;text-align:center;padding:10px;">` +
`<small>&copy; 2024 TransmitirAS. Todos los derechos reservados.</small></div>`;
/** Envuelve el contenido en el mismo cascarón del legacy (Arial, logo centrado, pie). */
function renderSyncEmail(inner: string): string {
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="es"> <html lang="es">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f8fafc;"> <body style="margin:0;padding:0;background:#ffffff;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;padding:24px 0;"> <div style="font-family:Arial,sans-serif;line-height:1.5;color:#333;">
<tr><td align="center"> <div style="text-align:center;margin-bottom:20px;">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1);"> <img src="${LOGO_URL}" alt="Logo" style="max-width:150px;">
<!-- Encabezado --> </div>
<tr> ${inner}
<td style="background:#b91c1c;padding:20px 24px;"> ${FOOTER}
<p style="margin:0;font-family:sans-serif;font-size:18px;font-weight:700;color:#ffffff;"> </div>
Alerta de respaldo &mdash; Aduanasoft
</p>
<p style="margin:4px 0 0;font-family:sans-serif;font-size:12px;color:#fecaca;">
${alerts.length} base${alerts.length !== 1 ? 's' : ''} de datos sin restaurar recientemente
</p>
</td>
</tr>
<!-- Tabla de alertas -->
<tr>
<td style="padding:20px 24px;">
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;">
<thead>
<tr style="background:#f1f5f9;">
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Base de datos</th>
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Cliente</th>
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Última restauración</th>
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:center;text-transform:uppercase;">Días sin sync</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</td>
</tr>
<!-- Pie -->
<tr>
<td style="padding:12px 24px 20px;border-top:1px solid #f1f5f9;">
<p style="margin:0;font-family:sans-serif;font-size:11px;color:#94a3b8;">
Este mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft.
Por favor no responda a este correo.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body> </body>
</html>`; </html>`;
} }
/** 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 = `
<h2 style="color:#007bff;text-align:center;">Notificación de Sincronización</h2>
<p>Estimado <strong>${nombre}</strong>,</p>
<p>Detectamos que la base de datos <strong>${bd}</strong> no se ha sincronizado correctamente en las últimas 24 horas.</p>
<p>La última restauración registrada fue: <strong>${fecha}</strong>.</p>
${SCAIIWEB_H4}
<p>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.</p>`;
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 = `
<h2 style="color:#28a745;text-align:center;">Sincronización Restablecida</h2>
<p>Estimado <strong>${nombre}</strong>,</p>
<p>La base de datos <strong>${bd}</strong> volvió a sincronizarse correctamente.</p>
<p>La última restauración registrada fue: <strong>${fecha}</strong>.</p>
${SCAIIWEB_H4}
<p>No se requiere ninguna acción de su parte. Gracias por mantener su equipo y la aplicación en funcionamiento.</p>`;
return renderSyncEmail(inner);
}
export interface BackupAlertRow { export interface BackupAlertRow {
visible_name: string; visible_name: string;
clientName: string | null; clientName: string | null;

View File

@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { pgPool } from '$lib/server/db'; 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 { 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. */ /** Clave del nodo (de clientData.NodoSubNodo). Puede estar vacía si el cliente no fue identificado. */
node_subnode_key: string; node_subnode_key: string;
visible_name: string; visible_name: string;
@@ -11,11 +13,17 @@ interface AlertPayload {
mainEmail: string | null; mainEmail: string | null;
last_restore_date: string | null; last_restore_date: string | null;
daysWithout: number | null; daysWithout: number | null;
/** Tipo de notificación. Ausente => 'overdue' por compatibilidad. */
kind?: 'overdue' | 'resolved';
} }
interface SendResult { 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; node_subnode_key: string;
visible_name: string; visible_name: string;
/** Echo del kind efectivo aplicado. */
kind: 'overdue' | 'resolved';
recipients: number; recipients: number;
status: 'sent' | 'no_recipients' | 'error'; status: 'sent' | 'no_recipients' | 'error';
error?: string; error?: string;
@@ -83,40 +91,55 @@ export const POST: RequestHandler = async ({ request }) => {
let failed = 0; let failed = 0;
for (const alert of alerts) { 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 { try {
const additional = await getAdditionalEmails(alert.node_subnode_key, alert.visible_name); const additional = await getAdditionalEmails(alert.node_subnode_key, alert.visible_name);
const toAddrs = dedupeEmails([alert.mainEmail, ...additional]); const toAddrs = dedupeEmails([alert.mainEmail, ...additional]);
if (toAddrs.length === 0) { 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++; failed++;
continue; continue;
} }
const htmlBody = buildBackupAlertHtml([{ const rowData = {
visible_name: alert.visible_name, visible_name: alert.visible_name,
clientName: alert.clientName, clientName: alert.clientName,
last_restore_date: alert.last_restore_date, last_restore_date: alert.last_restore_date,
daysWithout: alert.daysWithout 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'; let subject: string;
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 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({ await sendSmtpEmail({
toAddrs, toAddrs,
subject: `Alerta de respaldo: ${alert.visible_name}`, subject,
plainBody, plainBody,
htmlBody, htmlBody,
fromDisplayName: 'Aduanasoft · Panel de Bases', fromDisplayName: 'TransmitirAS Notificaciones',
highImportance: (alert.daysWithout ?? 0) >= 7 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++; sent++;
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(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++; failed++;
} }
} }

View File

@@ -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(() => '<alert/>'),
buildBackupResolvedHtml: vi.fn(() => '<resolved/>'),
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();
});
});

View File

@@ -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<string, string | undefined> = {};

View File

@@ -1,8 +1,21 @@
import { defineConfig } from 'vitest/config'; 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 // Configuración de Vitest para pruebas unitarias de lógica del servidor (cifrado,
// (cifrado, helpers). Las pruebas no dependen de SvelteKit ni del entorno del navegador. // 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({ 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: { test: {
environment: 'node', environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'] include: ['src/**/*.{test,spec}.{js,ts}']