feature/asignacion-masiva-restauradores #13
@@ -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
|
||||
) {
|
||||
|
||||
66
src/lib/server/email-service.test.ts
Normal file
66
src/lib/server/email-service.test.ts
Normal 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('<script>');
|
||||
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('© 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('<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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
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 `
|
||||
<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('');
|
||||
// ============================================================================
|
||||
// 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 =
|
||||
`<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>© 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>
|
||||
<html lang="es">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#f8fafc;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<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);">
|
||||
<!-- Encabezado -->
|
||||
<tr>
|
||||
<td style="background:#b91c1c;padding:20px 24px;">
|
||||
<p style="margin:0;font-family:sans-serif;font-size:18px;font-weight:700;color:#ffffff;">
|
||||
Alerta de respaldo — 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 style="margin:0;padding:0;background:#ffffff;">
|
||||
<div style="font-family:Arial,sans-serif;line-height:1.5;color:#333;">
|
||||
<div style="text-align:center;margin-bottom:20px;">
|
||||
<img src="${LOGO_URL}" alt="Logo" style="max-width:150px;">
|
||||
</div>
|
||||
${inner}
|
||||
${FOOTER}
|
||||
</div>
|
||||
</body>
|
||||
</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 {
|
||||
visible_name: string;
|
||||
clientName: string | null;
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
173
src/routes/api/alerts/send/server.test.ts
Normal file
173
src/routes/api/alerts/send/server.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
8
src/test/env-private-stub.ts
Normal file
8
src/test/env-private-stub.ts
Normal 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> = {};
|
||||
@@ -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}']
|
||||
|
||||
Reference in New Issue
Block a user