rubis/e2e/tests/factures.spec.ts
ordinarthur 1e2eecdeba
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 40s
fix(invoices): dialog facture demande l'email du client à la volée
Bug UX rapporté par Arthur : quand on crée une facture pour un nouveau
client via le combobox du dialog "+ Saisir" (option "Créer le client
« X »"), aucun champ email n'apparaissait. Au submit, l'API renvoyait
422 client_email_required mais le toast affiché était générique
("Création impossible. Vérifiez les champs.") sans guidance sur le
champ manquant. L'utilisateur se retrouvait coincé sans savoir
qu'il devait quitter le dialog et créer le client d'abord via /clients.

Friction quotidienne, jamais visible en démo (où on sélectionne un
client existant).

Fix :
  - Nouveau champ `clientEmail` (string, default "") dans FormValues
  - Validator zod email + max 254 char
  - Render conditionnel via form.Subscribe : visible UNIQUEMENT quand
    `clientId === null && clientName.trim().length >= 2` (= création
    à la volée). Disparait dès que l'user sélectionne un client
    existant ou vide le combobox.
  - Validation finale : requis seulement si clientId null
  - mutationFn envoie `clientEmail || null` au backend uniquement en
    mode création à la volée (pour client existant, l'email est déjà
    en DB)

TDD : le test E2E "client inconnu — fournit l'email" a été écrit AVANT
le fix, échouait sur `getByLabel(/email du client/i) not found` (champ
inexistant), passe maintenant en 3.9 s. Empêche la régression future.

Le test vérifie aussi la chaîne complète :
  - Facture créée + apparaît dans /factures
  - Client créé en même temps + apparaît dans /clients avec son email
    visible

État après ce commit : 27/27 tests Playwright verts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:17:12 +02:00

164 lines
6.9 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { resetDb } from './helpers/api'
/**
* Scénarios facture — saisie manuelle via dialog.
*
* Le dialog combine combobox client async, DatePicker custom, Radix Select
* (échéance + plan), Input montant. On vise le happy path UI ;
* les edge cases (numéro unique, quota Free 2, cross-org, payload
* incomplet) sont déjà couverts par les tests japa functional/unit.
*/
async function signupAndOnboard(page: import('@playwright/test').Page, tag = 'fac') {
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 })
return { email }
}
async function createClientViaUI(
page: import('@playwright/test').Page,
name: string,
email: string,
) {
await page.goto('/clients')
await page.getByRole('button', { name: /nouveau client/i }).first().click()
await page.getByLabel(/^Nom du client$/i).fill(name)
await page.getByLabel(/^Email du contact$/i).fill(email)
await page.getByRole('button', { name: /^créer le client$/i }).click()
await expect(page.getByText(name).first()).toBeVisible({ timeout: 5_000 })
}
test.describe('Factures — saisie manuelle via dialog', () => {
test.beforeEach(async () => {
await resetDb()
})
test('crée une facture liée à un client existant + apparaît dans /factures', async ({
page,
}) => {
await signupAndOnboard(page, 'fac-manual')
// 1. Prérequis : un client en DB
await createClientViaUI(page, 'Boulangerie Test', 'compta@boulangerie.test')
// 2. Ouvrir le dialog "+ Saisir" depuis le header desktop
await page.getByRole('button', { name: /^saisir$/i }).first().click()
await expect(page.getByRole('dialog')).toBeVisible()
// 3. Combobox client — taper le nom puis sélectionner l'option dans
// la dropdown (recherche async côté API).
const combobox = page.getByPlaceholder(/rechercher.*créer un client/i)
await combobox.fill('Boulangerie')
// Attendre que l'option remontée par l'API apparaisse
await expect(page.getByRole('option', { name: /Boulangerie Test/i })).toBeVisible({
timeout: 5_000,
})
await page.getByRole('option', { name: /Boulangerie Test/i }).click()
// 4. Numéro de facture
await page.getByLabel(/N° de facture/i).fill('F-E2E-001')
// 5. Montant TTC (en euros, le form convertit en cents)
await page.getByLabel(/Montant TTC/i).fill('1240')
// 6. Plan (Radix Select) — on prend le 1er disponible
await page.getByLabel(/Plan de relance/i).click()
// L'item Radix s'affiche via Portal — on cible par rôle option
await page.getByRole('option').first().click()
// 7. Submit
await page.getByRole('button', { name: /^créer la facture$/i }).click()
// 8. Le dialog se ferme + redirection vers /factures (route handler du form)
// ou bien rester sur la page courante. On navigue manuellement pour
// vérifier la persistence.
await page.goto('/factures')
await expect(page.getByText('F-E2E-001').first()).toBeVisible({ timeout: 10_000 })
await expect(page.getByText('Boulangerie Test').first()).toBeVisible()
})
test('liste vide → empty state visible', async ({ page }) => {
await signupAndOnboard(page, 'fac-empty')
await page.goto('/factures')
// Empty state avec un texte parlant
await expect(
page.getByText(/aucune facture|pas encore.*facture/i).first(),
).toBeVisible({ timeout: 5_000 })
})
test('crée une facture avec un client inconnu (à la volée) — fournit l\'email', async ({
page,
}) => {
// Cas user réel : pas envie de quitter le dialog de création de facture
// pour aller créer le client d'abord. Le combobox propose "Créer le
// client X" → on clique, on remplit l'email du client juste après, on
// soumet la facture. Le client est créé en DB en même temps que la
// facture, en un seul flow.
//
// Avant le fix UX (2026-05-18) : le dialog ne demandait PAS l'email
// du client → POST /invoices renvoyait 422 client_email_required →
// toast générique "Création impossible. Vérifiez les champs." → l'user
// ne pouvait pas comprendre ni s'en sortir sans fermer le dialog. Ce
// test l'a démontré et a guidé le fix.
await signupAndOnboard(page, 'fac-newclient')
// Pas de client préexistant dans la DB.
await page.getByRole('button', { name: /^saisir$/i }).first().click()
await expect(page.getByRole('dialog')).toBeVisible()
// Taper un nom de client inconnu → la dropdown propose "Créer le client".
const combobox = page.getByPlaceholder(/rechercher.*créer un client/i)
await combobox.fill('Café du Coin Test')
// L'option "Créer le client « Café du Coin Test »" apparaît.
const createOption = page.getByRole('button', {
name: /créer le client.*café du coin test/i,
})
await expect(createOption).toBeVisible({ timeout: 3_000 })
await createOption.click()
// Après le fix, un champ "Email du client" devient visible dans le
// dialog (conditionnel sur `clientId === null && clientName non vide`).
const emailField = page.getByLabel(/email du client/i)
await expect(emailField).toBeVisible({ timeout: 3_000 })
await emailField.fill('contact@cafeducoin.test')
// Reste du form
await page.getByLabel(/N° de facture/i).fill('F-NEWCLI-001')
await page.getByLabel(/Montant TTC/i).fill('850')
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()
// Toast success + dialog fermé
await expect(page.getByText(/facture créée/i)).toBeVisible({ timeout: 5_000 })
// La facture est visible dans /factures avec le nouveau client
await page.goto('/factures')
await expect(page.getByText('F-NEWCLI-001').first()).toBeVisible({
timeout: 5_000,
})
await expect(page.getByText(/café du coin test/i).first()).toBeVisible()
// Le client a aussi été créé en DB → visible sur /clients
await page.goto('/clients')
await expect(page.getByText(/café du coin test/i).first()).toBeVisible()
await expect(page.getByText('contact@cafeducoin.test').first()).toBeVisible()
})
})