test(e2e): PR 2 — import OCR + plans + mailing Mailpit
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 <noreply@anthropic.com>
This commit is contained in:
parent
35ffde9d56
commit
0ecf8ad40f
98
e2e/tests/helpers/mailpit.ts
Normal file
98
e2e/tests/helpers/mailpit.ts
Normal file
@ -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<APIRequestContext> {
|
||||
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<void> {
|
||||
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<MailpitMessage[]> {
|
||||
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<MailpitMessage> {
|
||||
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<MailpitMessageDetail> {
|
||||
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
|
||||
}
|
||||
94
e2e/tests/import.spec.ts
Normal file
94
e2e/tests/import.spec.ts
Normal file
@ -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()
|
||||
})
|
||||
})
|
||||
107
e2e/tests/mailing.spec.ts
Normal file
107
e2e/tests/mailing.spec.ts
Normal file
@ -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
|
||||
})
|
||||
})
|
||||
72
e2e/tests/plans.spec.ts
Normal file
72
e2e/tests/plans.spec.ts
Normal file
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user