From 0ecf8ad40f9da9de77a9fea61e1cd8e5ac538492 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 18 May 2026 16:45:39 +0200 Subject: [PATCH] =?UTF-8?q?test(e2e):=20PR=202=20=E2=80=94=20import=20OCR?= =?UTF-8?q?=20+=20plans=20+=20mailing=20Mailpit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute 6 scénarios Playwright qui couvrent les surfaces utilisateur au-delà de l'auth/clients/factures : upload OCR via dropzone, listing des plans pré-fournis, et la chaîne mailing complète end-to-end. Import (2 tests) — import.spec.ts : - Upload PDF via setInputFiles : MockOcrProvider extrait depuis le filename → drafts créés → SPA redirige sur /factures/import/$batchId → on voit le filename listé dans les drafts - Empty state /factures/import : dropzone + bouton "Parcourir" visibles Plans (3 tests) — plans.spec.ts : - Les 4 plans pré-fournis (Standard, Rapide, Patient, Ferme) sont visibles dès le signup (provisionnés par provisionDefaultPlans) - Clic "Créer un plan" → arrive sur le wizard /plans/nouveau - Clic "Modifier" sur une PlanCard → page détail /plans/{slug} Mailing (1 test, le plus précieux) — mailing.spec.ts : - Helper helpers/mailpit.ts : clearMailpit, waitForMessageTo (poll 200ms / 10s timeout), getMessage (HTML + texte) - Scénario : signup → onboarding → créer client avec email → créer facture saisie manuelle → mark-paid → BullMQ worker enqueue payment-thanks → email envoyé via SMTP → Mailpit catche → test inspecte subject + body (numero, montant) - Valide la chaîne complète : SPA → API → DB → BullMQ → Worker → Mailpit, en moins de 5 s Total après cette PR : 26 scénarios Playwright verts en 55 s. Pré-requis runtime : - `pnpm dev:up` (Postgres + Redis + Mailpit + MinIO) - `pnpm e2e:setup` (création DB rubis_test_e2e + migrations) - Mailpit accessible sur :8025 (clearMailpit l'utilise en beforeEach) Note check-in flow : pas implémenté en E2E V1 — nécessite un endpoint test_e2e pour générer un CheckinTask + clear token. Le flow checkin est déjà couvert par les tests japa functional. À ajouter en PR 3 si besoin de validation end-to-end UI. Co-Authored-By: Claude Opus 4.7 --- e2e/tests/helpers/mailpit.ts | 98 ++++++++++++++++++++++++++++++++ e2e/tests/import.spec.ts | 94 ++++++++++++++++++++++++++++++ e2e/tests/mailing.spec.ts | 107 +++++++++++++++++++++++++++++++++++ e2e/tests/plans.spec.ts | 72 +++++++++++++++++++++++ 4 files changed, 371 insertions(+) create mode 100644 e2e/tests/helpers/mailpit.ts create mode 100644 e2e/tests/import.spec.ts create mode 100644 e2e/tests/mailing.spec.ts create mode 100644 e2e/tests/plans.spec.ts diff --git a/e2e/tests/helpers/mailpit.ts b/e2e/tests/helpers/mailpit.ts new file mode 100644 index 0000000..557ddee --- /dev/null +++ b/e2e/tests/helpers/mailpit.ts @@ -0,0 +1,98 @@ +import { request, type APIRequestContext } from '@playwright/test' + +/** + * Helpers pour interagir avec Mailpit (SMTP catcher local). + * + * Mailpit tourne sur http://localhost:8025 via docker-compose dev + * (cf. /docker-compose.dev.yml). L'API REST permet de : + * - Lister les messages reçus + * - Récupérer le contenu HTML/texte d'un message + * - Vider la boîte (utile en beforeEach E2E) + * + * Doc API : https://mailpit.axllent.org/docs/api-v1/ + */ + +const MAILPIT_URL = process.env.E2E_MAILPIT_URL ?? 'http://localhost:8025' + +let _ctx: APIRequestContext | null = null +async function ctx(): Promise { + if (_ctx) return _ctx + _ctx = await request.newContext({ baseURL: MAILPIT_URL }) + return _ctx +} + +export type MailpitMessage = { + ID: string + MessageID: string + Read: boolean + From: { Name: string; Address: string } + To: Array<{ Name: string; Address: string }> + Subject: string + Created: string + Snippet: string +} + +export type MailpitMessageDetail = MailpitMessage & { + Text: string + HTML: string + ReplyTo: Array<{ Name: string; Address: string }> +} + +/** + * Vide TOUS les messages Mailpit. À appeler en `beforeEach` E2E pour + * isoler les assertions email entre scénarios. + */ +export async function clearMailpit(): Promise { + const c = await ctx() + await c.delete('/api/v1/messages') +} + +/** + * Liste les messages reçus, plus récent en premier. + */ +export async function listMessages(): Promise { + const c = await ctx() + const r = await c.get('/api/v1/messages') + if (!r.ok()) throw new Error(`Mailpit list failed: ${r.status()}`) + const json = (await r.json()) as { messages: MailpitMessage[] } + return json.messages ?? [] +} + +/** + * Attend qu'au moins un message arrive pour l'adresse `to`, avec polling + * toutes les 200 ms (timeout 10 s par défaut). Renvoie le 1er match. + * + * Utile parce que les emails partent via BullMQ worker (async) — l'instant + * où on clique sur "Relancer maintenant" et l'instant où Mailpit reçoit + * peuvent être espacés de 1-2 s. + */ +export async function waitForMessageTo( + to: string, + opts: { timeoutMs?: number; subjectIncludes?: string } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 10_000 + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const messages = await listMessages() + const match = messages.find( + (m) => + m.To.some((t) => t.Address.toLowerCase() === to.toLowerCase()) && + (!opts.subjectIncludes || m.Subject.includes(opts.subjectIncludes)), + ) + if (match) return match + await new Promise((r) => setTimeout(r, 200)) + } + throw new Error( + `Mailpit : aucun message reçu pour ${to}${opts.subjectIncludes ? ` (subject contient "${opts.subjectIncludes}")` : ''} dans ${timeoutMs} ms`, + ) +} + +/** + * Récupère le détail d'un message (HTML + texte). + */ +export async function getMessage(id: string): Promise { + const c = await ctx() + const r = await c.get(`/api/v1/message/${id}`) + if (!r.ok()) throw new Error(`Mailpit get failed: ${r.status()}`) + return (await r.json()) as MailpitMessageDetail +} diff --git a/e2e/tests/import.spec.ts b/e2e/tests/import.spec.ts new file mode 100644 index 0000000..0c12a41 --- /dev/null +++ b/e2e/tests/import.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test' +import { join } from 'node:path' +import { resetDb } from './helpers/api' + +/** + * Scénarios import OCR via /factures/import. + * + * En mode `test_e2e`, le provider OCR est `mock` (cf. .env de l'API + * spawn par Playwright config), donc l'upload d'un PDF déclenche le + * MockOcrProvider qui génère des champs plausibles depuis le filename + * sans appel réseau. + * + * On utilise un vrai PDF du dossier fixtures (qui est ignoré par git + * mais existe en local) pour tester l'upload multipart réel. Si le + * dossier est vide (CI fresh), les tests sont skip. + */ + +const FIXTURE_PDF = join( + process.cwd(), + 'e2e', + 'fixtures', + 'invoices', + 'facture-pas-en-retard-echeance-30j-001.pdf', +) + +async function signupAndOnboard(page: import('@playwright/test').Page, tag = 'imp') { + const email = `${tag}+${Date.now()}@rubis.test` + await page.goto('/signup') + await page.getByLabel(/Prénom \/ Nom/i).fill('Test User') + await page.getByLabel(/Email professionnel/i).fill(email) + await page.getByLabel(/Mot de passe/i).fill('motdepasse-fort-123') + await page.getByRole('button', { name: /créer mon compte/i }).click() + await page.waitForURL(/\/onboarding\/compte/, { timeout: 10_000 }) + await page.getByRole('button', { name: /continuer/i }).click() + await page.waitForURL(/\/onboarding\/entreprise/) + await page.getByLabel(/nom de l'entreprise/i).fill('Atelier Test') + await page.getByRole('button', { name: /continuer/i }).click() + await page.waitForURL(/\/onboarding\/signature/) + await page.getByRole('button', { name: /terminer/i }).click() + await page.waitForURL('/', { timeout: 10_000 }) +} + +test.describe('Import factures via dropzone', () => { + test.beforeEach(async () => { + await resetDb() + }) + + test('upload PDF → drafts générés + redirection /import/$batchId', async ({ + page, + }) => { + // Skip si pas de fixture (CI fresh) + try { + // existsSync via Playwright fs + const { existsSync } = await import('node:fs') + if (!existsSync(FIXTURE_PDF)) { + test.skip(true, `Pas de PDF fixture (${FIXTURE_PDF}) — skip`) + } + } catch { + test.skip(true, 'fs indispo') + } + + await signupAndOnboard(page, 'imp-upload') + + await page.goto('/factures/import') + await expect( + page.getByRole('heading', { name: /importer.*plusieurs.*factures/i }), + ).toBeVisible() + + // Le file input est caché derrière le Dropzone — Playwright peut + // setInputFiles directement même sur un input hidden. + const fileInput = page.locator('input[type="file"]') + await fileInput.setInputFiles(FIXTURE_PDF) + + // Le SPA fait un POST multipart à /api/v1/invoices/upload puis + // navigate sur /factures/import/$batchId + await page.waitForURL(/\/factures\/import\/[a-f0-9-]+/, { timeout: 15_000 }) + + // Au moins 1 draft visible (le filename apparaît quelque part) + await expect( + page.getByText(/facture-pas-en-retard/).first(), + ).toBeVisible({ timeout: 5_000 }) + }) + + test('page /factures/import affiche le dropzone vide', async ({ page }) => { + await signupAndOnboard(page, 'imp-empty') + await page.goto('/factures/import') + // Texte du dropzone + await expect( + page.getByText(/PDF.*PNG.*JPG.*fichiers.*simultané/i), + ).toBeVisible() + // Bouton parcourir + await expect(page.getByRole('button', { name: /parcourir.*fichiers/i })).toBeVisible() + }) +}) diff --git a/e2e/tests/mailing.spec.ts b/e2e/tests/mailing.spec.ts new file mode 100644 index 0000000..2566375 --- /dev/null +++ b/e2e/tests/mailing.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from '@playwright/test' +import { resetDb } from './helpers/api' +import { clearMailpit, getMessage, waitForMessageTo } from './helpers/mailpit' + +/** + * Scénarios mailing — la chaîne complète passe par Mailpit (SMTP local + * sur :1025, UI/API :8025). + * + * Le scénario validé ici : créer une facture, la marquer encaissée → + * BullMQ worker enqueue le job `payment-thanks` → email "Merci, paiement + * bien reçu" envoyé au client final → Mailpit catche → on inspecte + * subject + body via l'API Mailpit. + * + * Pré-requis : + * - Mailpit container up (`pnpm dev:up`) + * - Redis container up (BullMQ workers démarrent au boot API) + * + * Latence typique : ~2 s entre le clic mark-paid et Mailpit (job BullMQ + * + render React Email + envoi SMTP). On poll Mailpit jusqu'à 10 s. + */ + +async function signupAndOnboard( + page: import('@playwright/test').Page, + tag = 'mail', +) { + const email = `${tag}+${Date.now()}@rubis.test` + await page.goto('/signup') + await page.getByLabel(/Prénom \/ Nom/i).fill('Test User') + await page.getByLabel(/Email professionnel/i).fill(email) + await page.getByLabel(/Mot de passe/i).fill('motdepasse-fort-123') + await page.getByRole('button', { name: /créer mon compte/i }).click() + await page.waitForURL(/\/onboarding\/compte/, { timeout: 10_000 }) + await page.getByRole('button', { name: /continuer/i }).click() + await page.waitForURL(/\/onboarding\/entreprise/) + await page.getByLabel(/nom de l'entreprise/i).fill('Atelier Mail') + await page.getByRole('button', { name: /continuer/i }).click() + await page.waitForURL(/\/onboarding\/signature/) + await page.getByRole('button', { name: /terminer/i }).click() + await page.waitForURL('/', { timeout: 10_000 }) +} + +test.describe('Mailing — payment thanks via Mailpit', () => { + test.beforeEach(async () => { + await resetDb() + await clearMailpit() + }) + + test('mark-paid déclenche l\'email "Merci, paiement bien reçu" au client', async ({ + page, + }) => { + const clientEmail = `client-mail-${Date.now()}@example.test` + + await signupAndOnboard(page, 'mail-thanks') + + // 1. Créer un client avec l'email qu'on va surveiller dans Mailpit + await page.goto('/clients') + await page.getByRole('button', { name: /nouveau client/i }).first().click() + await page.getByLabel(/^Nom du client$/i).fill('Boulangerie Mail Test') + await page.getByLabel(/^Email du contact$/i).fill(clientEmail) + await page.getByRole('button', { name: /^créer le client$/i }).click() + await expect(page.getByText('Boulangerie Mail Test').first()).toBeVisible({ + timeout: 5_000, + }) + + // 2. Créer une facture saisie manuelle + await page.getByRole('button', { name: /^saisir$/i }).first().click() + await expect(page.getByRole('dialog')).toBeVisible() + + const combobox = page.getByPlaceholder(/rechercher.*créer un client/i) + await combobox.fill('Boulangerie Mail') + await expect( + page.getByRole('option', { name: /Boulangerie Mail Test/i }), + ).toBeVisible({ timeout: 5_000 }) + await page.getByRole('option', { name: /Boulangerie Mail Test/i }).click() + + await page.getByLabel(/N° de facture/i).fill('F-MAIL-001') + await page.getByLabel(/Montant TTC/i).fill('1240') + + await page.getByLabel(/Plan de relance/i).click() + await page.getByRole('option').first().click() + + await page.getByRole('button', { name: /^créer la facture$/i }).click() + + // 3. Aller sur la liste, ouvrir le détail de la facture + await page.goto('/factures') + await expect(page.getByText('F-MAIL-001').first()).toBeVisible({ timeout: 5_000 }) + await page.getByText('F-MAIL-001').first().click() + + // 4. Cliquer "Marquer encaissée" + await page.getByRole('button', { name: /marquer encaissée/i }).click() + // Toast de confirmation + await expect(page.getByText(/encaissée.*rubis/i)).toBeVisible({ timeout: 5_000 }) + + // 5. Attendre l'email Mailpit au client + const msg = await waitForMessageTo(clientEmail, { + subjectIncludes: 'F-MAIL-001', + timeoutMs: 15_000, + }) + expect(msg.From.Address).toBeTruthy() + + // 6. Vérifier le contenu : le body mentionne le numero + montant + const detail = await getMessage(msg.ID) + expect(detail.Subject).toMatch(/paiement|reçu|F-MAIL-001/i) + expect(detail.Text + detail.HTML).toMatch(/F-MAIL-001/) + expect(detail.Text + detail.HTML).toMatch(/1\s*240/) // "1 240 €" ou variation + }) +}) diff --git a/e2e/tests/plans.spec.ts b/e2e/tests/plans.spec.ts new file mode 100644 index 0000000..15e488d --- /dev/null +++ b/e2e/tests/plans.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '@playwright/test' +import { resetDb } from './helpers/api' + +/** + * Scénarios plans de relance. + * + * On valide la list view (4 plans pré-fournis après signup) + la + * navigation vers le wizard de création. Le wizard 4 étapes lui-même + * (avec génération IA des emails) est testé par les tests japa + * functional pour le détail métier, et reste hors scope E2E V1. + */ + +async function signupAndOnboard(page: import('@playwright/test').Page, tag = 'pl') { + const email = `${tag}+${Date.now()}@rubis.test` + await page.goto('/signup') + await page.getByLabel(/Prénom \/ Nom/i).fill('Test User') + await page.getByLabel(/Email professionnel/i).fill(email) + await page.getByLabel(/Mot de passe/i).fill('motdepasse-fort-123') + await page.getByRole('button', { name: /créer mon compte/i }).click() + await page.waitForURL(/\/onboarding\/compte/, { timeout: 10_000 }) + await page.getByRole('button', { name: /continuer/i }).click() + await page.waitForURL(/\/onboarding\/entreprise/) + await page.getByLabel(/nom de l'entreprise/i).fill('Atelier Test') + await page.getByRole('button', { name: /continuer/i }).click() + await page.waitForURL(/\/onboarding\/signature/) + await page.getByRole('button', { name: /terminer/i }).click() + await page.waitForURL('/', { timeout: 10_000 }) +} + +test.describe('Plans de relance', () => { + test.beforeEach(async () => { + await resetDb() + }) + + test('les 4 plans pré-fournis sont visibles après signup', async ({ page }) => { + await signupAndOnboard(page, 'pl-default') + await page.goto('/plans') + + await expect( + page.getByRole('heading', { name: /plans de.*relance/i }), + ).toBeVisible() + + // Les 4 plans pré-fournis ont des slugs `standard-30j`, `rapide-15j`, + // `patient-60j`, `ferme-7j`. Leurs noms d'affichage utilisent les mots + // "Standard", "Rapide", "Patient", "Ferme". + await expect(page.getByText(/standard/i).first()).toBeVisible() + await expect(page.getByText(/rapide/i).first()).toBeVisible() + await expect(page.getByText(/patient/i).first()).toBeVisible() + await expect(page.getByText(/ferme/i).first()).toBeVisible() + + // La carte "+ Créer un plan" est aussi visible + await expect(page.getByRole('link', { name: /créer un plan/i })).toBeVisible() + }) + + test('clic "Créer un plan" → arrive sur le wizard', async ({ page }) => { + await signupAndOnboard(page, 'pl-wizard') + await page.goto('/plans') + await page.getByRole('link', { name: /créer un plan/i }).click() + await expect(page).toHaveURL(/\/plans\/nouveau/, { timeout: 5_000 }) + }) + + test('clic "Modifier" sur un plan → page détail du plan', async ({ page }) => { + await signupAndOnboard(page, 'pl-detail') + await page.goto('/plans') + // Chaque PlanCard a un lien "Modifier" → /plans/$slug. On clique sur + // le premier (qui correspondra à un des 4 plans pré-fournis). + await page.getByRole('link', { name: /^modifier/i }).first().click() + await expect(page).toHaveURL(/\/plans\/(standard|rapide|patient|ferme)/, { + timeout: 5_000, + }) + }) +})