feature/avisos-respaldo-celery-beat
This commit is contained in:
@@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
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 {
|
// 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>© 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 — 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;
|
||||||
|
|||||||
@@ -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++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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}']
|
||||||
|
|||||||
Reference in New Issue
Block a user