239 lines
7.5 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|