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 = { 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 = ''; 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[]]> = [ [ '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(/^/i); }); it('declare la langue sur et sur le contenu du ', () => { expect(html).toMatch(/]*lang="fr"[^>]*dir="ltr"/); expect(html).toMatch(/]*>
/); }); it('a un 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); }); }); });