xpeditis2.0/apps/backend/src/infrastructure/email/templates/email-templates.spec.ts
2026-09-14 20:02:12 +02:00

239 lines
7.5 KiB
TypeScript

import { EmailTemplates } from './email-templates';
import { setEmailValidationLevel } from './email-layout';
/**
* Controle mecanique des 14 emails, d'apres la checklist d'accessibilite du
* skill « email-best-practices » (Resend) et les regles MJML : compilation
* sans erreur, langue declaree deux fois, titre, texte alternatif du logo,
* aucune donnee brute injectee, et un poids sous le seuil de coupure de Gmail.
*/
const APP_URL = 'https://app.preprod.xpeditis.com';
const settings: Record<string, string> = { APP_URL };
const config = { get: jest.fn((key: string, fallback?: unknown) => settings[key] ?? fallback) };
const templates = new EmailTemplates(config as never);
/** Injecte dans chaque champ libre : ne doit jamais ressortir tel quel. */
const XSS = '<script>alert("x")</script>';
const bookingRequest = {
bookingId: '0f6c8a52-3d0e-4b7e-9a51-2b7c1d9e4f10',
bookingNumber: 'XPD-2026-R9KE8U',
documentPassword: 'K7PQ2MXA',
origin: 'FRLEH',
destination: 'EGEDK',
volumeCBM: 2.4,
weightKG: 850,
palletCount: 2,
priceUSD: 196,
priceEUR: 180,
primaryCurrency: 'EUR',
transitDays: 11,
containerType: 'LCL',
documents: [
{ type: 'COMMERCIAL_INVOICE', fileName: 'facture-2026-031.pdf' },
{ type: 'PACKING_LIST', fileName: `colisage ${XSS}.pdf` },
],
notes: `Livraison le matin.\n${XSS}`,
acceptUrl: `${APP_URL}/carrier/accept/token-123`,
rejectUrl: `${APP_URL}/carrier/reject/token-123`,
};
const cases: Array<[string, () => Promise<string>, string[]]> = [
[
'verification',
() => templates.renderVerificationEmail({ verifyUrl: `${APP_URL}/verify-email?token=v1` }),
[`${APP_URL}/verify-email?token=v1`],
],
[
'reinitialisation du mot de passe',
() => templates.renderPasswordResetEmail({ resetUrl: `${APP_URL}/reset-password?token=r1` }),
[`${APP_URL}/reset-password?token=r1`, '1 heure'],
],
[
'bienvenue',
() => templates.renderWelcomeEmail({ firstName: XSS, dashboardUrl: `${APP_URL}/dashboard` }),
[`${APP_URL}/dashboard`],
],
[
'compte cree par un administrateur',
() =>
templates.renderUserInvitation({
organizationName: 'Acme Logistique',
inviterName: XSS,
tempPassword: 'Temp-42-Xyz',
loginUrl: `${APP_URL}/login`,
email: 'marie@acme.test',
}),
['Temp-42-Xyz', 'marie@acme.test', `${APP_URL}/login`],
],
[
'invitation par lien',
() =>
templates.renderInvitationWithToken({
firstName: 'Marie',
lastName: 'Dupont',
organizationName: 'Acme Logistique',
inviterName: 'Paul Martin',
invitationLink: `${APP_URL}/register?token=i1`,
expiresAt: '21 septembre 2026 à 10:00',
}),
[`${APP_URL}/register?token=i1`, '21 septembre 2026'],
],
[
'confirmation de reservation',
() =>
templates.renderBookingConfirmation({
bookingNumber: 'WCM-2026-000042',
bookingDetails: {
origin: 'Le Havre',
destination: 'Alexandrie',
carrier: 'SSC Consolidation',
etd: new Date('2026-10-02T00:00:00Z'),
eta: new Date('2026-10-13T00:00:00Z'),
},
dashboardUrl: `${APP_URL}/dashboard/bookings`,
}),
['WCM-2026-000042', `${APP_URL}/dashboard/bookings`],
],
[
'demande de reservation au transporteur',
() => templates.renderCsvBookingRequest(bookingRequest),
[bookingRequest.acceptUrl, bookingRequest.rejectUrl, 'K7PQ2MXA', 'Facture commerciale'],
],
[
'documents disponibles',
() =>
templates.renderDocumentAccess({
carrierName: XSS,
bookingId: bookingRequest.bookingId,
bookingNumber: bookingRequest.bookingNumber,
documentPassword: 'K7PQ2MXA',
origin: 'FRLEH',
destination: 'EGEDK',
volumeCBM: 2.4,
weightKG: 850,
documentCount: 3,
documentsUrl: `${APP_URL}/carrier/documents/token-123`,
}),
[`${APP_URL}/carrier/documents/token-123`, '3 documents'],
],
[
'nouveaux documents',
() =>
templates.renderNewDocuments({
carrierName: 'SSC Consolidation',
bookingId: bookingRequest.bookingId,
origin: 'FRLEH',
destination: 'EGEDK',
newDocumentsCount: 2,
totalDocumentsCount: 5,
documentsUrl: `${APP_URL}/carrier/documents/token-123`,
}),
['2 nouveaux documents', '5 documents'],
],
[
'compte transporteur cree',
() =>
templates.renderCarrierAccountCreated({
carrierName: XSS,
email: 'booking@ssc.test',
temporaryPassword: 'Carrier-Temp-1',
loginUrl: `${APP_URL}/login`,
}),
['Carrier-Temp-1', 'booking@ssc.test'],
],
[
'mot de passe transporteur reinitialise',
() =>
templates.renderCarrierPasswordReset({
carrierName: 'SSC Consolidation',
temporaryPassword: 'Carrier-Temp-2',
loginUrl: `${APP_URL}/login`,
}),
['Carrier-Temp-2'],
],
[
'formulaire de contact',
() =>
templates.renderContactMessage({
firstName: 'Marie',
lastName: XSS,
email: 'marie@acme.test',
company: 'Acme Logistique',
phone: '+33 6 12 34 56 78',
subjectLabel: 'Demande de démonstration',
message: `Bonjour,\nNous expédions 40 conteneurs par mois.\n${XSS}`,
}),
['mailto:marie@acme.test', 'Acme Logistique'],
],
[
'virement a valider',
() =>
templates.renderBankTransferToValidate({
bookingNumber: 'XPD-2026-R9KE8U',
carrierName: 'SSC Consolidation',
routeDescription: 'FRLEH → EGEDK',
commissionAmount: '150,00 €',
adminUrl: `${APP_URL}/admin/bookings`,
}),
[`${APP_URL}/admin/bookings`, '150,00 €'],
],
[
'test SMTP',
() => templates.renderSmtpTest({ requestedBy: 'admin@xpeditis.com', sentAt: '14 sept. 2026' }),
['admin@xpeditis.com'],
],
];
describe('Emails Xpeditis — gabarit commun', () => {
beforeAll(() => setEmailValidationLevel('strict'));
afterAll(() => setEmailValidationLevel('soft'));
describe.each(cases)('%s', (_name, render, expectedContent) => {
let html: string;
beforeAll(async () => {
// En mode strict, toute erreur MJML fait echouer ce rendu.
html = await render();
});
it('compile en un document HTML complet', () => {
expect(html).toMatch(/^<!doctype html>/i);
});
it('declare la langue sur <html> et sur le contenu du <body>', () => {
expect(html).toMatch(/<html[^>]*lang="fr"[^>]*dir="ltr"/);
expect(html).toMatch(/<body[^>]*><div lang="fr" dir="ltr">/);
});
it('a un <title> propre et un logo avec texte alternatif', () => {
expect(html).toMatch(/<title>[^<]{8,}<\/title>/);
expect(html).toContain('alt="Xpeditis"');
expect(html).toContain(`${APP_URL}/assets/email/xpeditis-logo.png`);
});
it('contient un seul titre de niveau 1', () => {
expect(html.match(/<h1[\s>]/g)).toHaveLength(1);
});
it('affiche les informations attendues', () => {
for (const content of expectedContent) {
expect(html).toContain(content);
}
});
it("n'injecte jamais une donnee saisie sans l'echapper", () => {
expect(html).not.toContain('<script>');
});
it('ne laisse aucun marqueur de gabarit ni valeur manquante', () => {
expect(html).not.toMatch(/\{\{|\}\}|undefined|NaN|\[object Object\]/);
});
it('reste sous le seuil de coupure de Gmail (102 Ko)', () => {
expect(Buffer.byteLength(html, 'utf8')).toBeLessThan(102 * 1024);
});
});
});