From e40f417caa35c1d6b4bc89863667a3f453e2eacc Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 18 May 2026 17:55:02 +0200 Subject: [PATCH] =?UTF-8?q?test(e2e):=20PR=203=20=E2=80=94=20billing=20sta?= =?UTF-8?q?tes=20+=20quota=20+=20dashboard=20+=20settings=20(+=20fix=20isD?= =?UTF-8?q?irty)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Couvre les surfaces transverses post auth/clients/factures : - billing-states (3) : transitions webhook trial→active, past_due, cancel - billing-quota (2) : Free limite à 2 factures actives, 3e bloquée + toast remonté avec message API (UX bug : onError du dialog masquait l'erreur) - dashboard (2) : zéros au start, +rubis et activity feed après mark-paid - settings (2) : sections visibles + persistence Prénom/Nom après reload Bug isDirty détecté par TDD sur settings : AccountForm/OrganizationForm/ SignatureForm lisaient form.state.isDirty *hors* d'un form.Subscribe, donc le bouton Save ne réagissait jamais aux changements (texte figé sur "Aucune modification"). Fix : wrap le bouton dans form.Subscribe selector=isDirty, même pattern que ManualInvoiceDialog. 36 tests Playwright vert, ~1m20. Co-Authored-By: Claude Opus 4.7 --- .../factures/ManualInvoiceDialog.tsx | 11 +- .../src/components/settings/AccountForm.tsx | 24 +-- .../components/settings/OrganizationForm.tsx | 24 +-- .../src/components/settings/SignatureForm.tsx | 24 +-- e2e/tests/billing-quota.spec.ts | 151 ++++++++++++++++++ e2e/tests/billing-states.spec.ts | 141 ++++++++++++++++ e2e/tests/dashboard.spec.ts | 85 ++++++++++ e2e/tests/settings.spec.ts | 74 +++++++++ 8 files changed, 502 insertions(+), 32 deletions(-) create mode 100644 e2e/tests/billing-quota.spec.ts create mode 100644 e2e/tests/billing-states.spec.ts create mode 100644 e2e/tests/dashboard.spec.ts create mode 100644 e2e/tests/settings.spec.ts diff --git a/apps/web/src/components/factures/ManualInvoiceDialog.tsx b/apps/web/src/components/factures/ManualInvoiceDialog.tsx index 05d0fb8..50a2c2f 100644 --- a/apps/web/src/components/factures/ManualInvoiceDialog.tsx +++ b/apps/web/src/components/factures/ManualInvoiceDialog.tsx @@ -131,8 +131,15 @@ export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogP toast.success("Facture créée. + 1 rubis."); onOpenChange(false); }, - onError: () => { - toast.error("Création impossible. Vérifiez les champs."); + onError: (err) => { + // Remonte le message de l'API si on l'a (ex. quota Free atteint, + // duplicate, validation). Sinon toast générique. + // Le `ApiError` de @/lib/api expose `.message` directement. + const message = + err instanceof Error && err.message + ? err.message + : "Création impossible. Vérifiez les champs."; + toast.error(message); }, }); diff --git a/apps/web/src/components/settings/AccountForm.tsx b/apps/web/src/components/settings/AccountForm.tsx index 1fe0fd2..18fa812 100644 --- a/apps/web/src/components/settings/AccountForm.tsx +++ b/apps/web/src/components/settings/AccountForm.tsx @@ -118,16 +118,20 @@ export function AccountForm() { )} -
- -
+ state.isDirty}> + {(isDirty) => ( +
+ +
+ )} +
); } diff --git a/apps/web/src/components/settings/OrganizationForm.tsx b/apps/web/src/components/settings/OrganizationForm.tsx index b2ad9ec..6b398a7 100644 --- a/apps/web/src/components/settings/OrganizationForm.tsx +++ b/apps/web/src/components/settings/OrganizationForm.tsx @@ -175,16 +175,20 @@ export function OrganizationForm() { )} -
- -
+ state.isDirty}> + {(isDirty) => ( +
+ +
+ )} +
); } diff --git a/apps/web/src/components/settings/SignatureForm.tsx b/apps/web/src/components/settings/SignatureForm.tsx index 9ff11f3..2b6ca61 100644 --- a/apps/web/src/components/settings/SignatureForm.tsx +++ b/apps/web/src/components/settings/SignatureForm.tsx @@ -102,16 +102,20 @@ export function SignatureForm() { )} -
- -
+ state.isDirty}> + {(isDirty) => ( +
+ +
+ )} +
); } diff --git a/e2e/tests/billing-quota.spec.ts b/e2e/tests/billing-quota.spec.ts new file mode 100644 index 0000000..41170ba --- /dev/null +++ b/e2e/tests/billing-quota.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from '@playwright/test' +import { resetDb } from './helpers/api' + +/** + * Scénario quota Free 2 factures (cf. ADR-023). + * + * Nouveau user → org en Free pure (pas de grace_period_ends_at posé sur + * les nouvelles orgs, cf. billing.ts §contexte). À la 3e facture active, + * la création doit être bloquée par `canCreateInvoices` → 402 + * plan_limit_reached côté API. + * + * On valide ici la chaîne : SPA → API → DB. Les tests unit billing.spec.ts + * couvrent les variations détaillées (delta, statuts). + */ + +async function signupAndOnboard(page: import('@playwright/test').Page) { + const email = `quota+${Date.now()}@rubis.test` + await page.goto('/signup') + await page.getByLabel(/Prénom \/ Nom/i).fill('Quota Test') + 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 Quota') + 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 }) +} + +async function createInvoiceForNewClient( + page: import('@playwright/test').Page, + opts: { clientName: string; clientEmail: string; numero: string; amount: string }, +) { + 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(opts.clientName) + // Une fois 1er client créé, il apparaît dans le dropdown — on prend + // soit l'option "Créer le client" pour un nouveau, soit l'option + // matchée pour un existant. + const createOption = page.getByRole('button', { + name: new RegExp(`créer le client.*${opts.clientName}`, 'i'), + }) + const existingOption = page.getByRole('option', { + name: new RegExp(opts.clientName, 'i'), + }) + + // Race : on prend ce qui apparaît en premier + await Promise.race([ + createOption.waitFor({ state: 'visible', timeout: 3_000 }).then(() => createOption.click()), + existingOption.waitFor({ state: 'visible', timeout: 3_000 }).then(() => existingOption.click()), + ]) + + // Le champ email apparaît si c'est un nouveau client + const emailField = page.getByLabel(/email du client/i) + if (await emailField.isVisible().catch(() => false)) { + await emailField.fill(opts.clientEmail) + } + + await page.getByLabel(/N° de facture/i).fill(opts.numero) + await page.getByLabel(/Montant TTC/i).fill(opts.amount) + 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() +} + +test.describe('Billing — quota Free 2 factures', () => { + test.beforeEach(async () => { + await resetDb() + }) + + test('Free post-grace : 2 factures OK, la 3e est bloquée avec message clair', async ({ + page, + }) => { + await signupAndOnboard(page) + + // 1re facture OK + await createInvoiceForNewClient(page, { + clientName: 'Client Quota A', + clientEmail: 'a@quota.test', + numero: 'F-QUOTA-1', + amount: '100', + }) + await expect(page.getByText(/facture créée/i)).toBeVisible({ timeout: 5_000 }) + + // 2e OK (on a maintenant 2 factures actives sur 2) + await createInvoiceForNewClient(page, { + clientName: 'Client Quota B', + clientEmail: 'b@quota.test', + numero: 'F-QUOTA-2', + amount: '200', + }) + await expect(page.getByText(/facture créée/i).first()).toBeVisible({ + timeout: 5_000, + }) + + // 3e doit être bloquée (Free 2 max, post-grace) + await createInvoiceForNewClient(page, { + clientName: 'Client Quota C', + clientEmail: 'c@quota.test', + numero: 'F-QUOTA-3', + amount: '300', + }) + + // Toast d'erreur "Limite atteinte : 2 factures actives sur le plan Free" + await expect( + page.getByText(/limite.*atteinte|2 factures.*Free|passez pro/i).first(), + ).toBeVisible({ timeout: 5_000 }) + + // La facture F-QUOTA-3 ne doit PAS apparaître dans /factures + await page.goto('/factures') + await expect(page.getByText('F-QUOTA-1').first()).toBeVisible() + await expect(page.getByText('F-QUOTA-2').first()).toBeVisible() + await expect(page.getByText('F-QUOTA-3')).not.toBeVisible() + }) + + test('Banner "Limite Free atteinte" apparaît sur /factures à 2/2', async ({ + page, + }) => { + await signupAndOnboard(page) + + // Créer 2 factures pour atteindre la limite + await createInvoiceForNewClient(page, { + clientName: 'Banner A', + clientEmail: 'banner-a@quota.test', + numero: 'F-BAN-1', + amount: '100', + }) + await expect(page.getByText(/facture créée/i)).toBeVisible({ timeout: 5_000 }) + + await createInvoiceForNewClient(page, { + clientName: 'Banner B', + clientEmail: 'banner-b@quota.test', + numero: 'F-BAN-2', + amount: '200', + }) + await expect(page.getByText(/facture créée/i).first()).toBeVisible({ + timeout: 5_000, + }) + + // Sur /factures, le banner doit afficher "Limite Free atteinte" + await page.goto('/factures') + await expect( + page.getByText(/limite Free atteinte|Passer Pro/i).first(), + ).toBeVisible({ timeout: 5_000 }) + }) +}) diff --git a/e2e/tests/billing-states.spec.ts b/e2e/tests/billing-states.spec.ts new file mode 100644 index 0000000..d966d4d --- /dev/null +++ b/e2e/tests/billing-states.spec.ts @@ -0,0 +1,141 @@ +import { test, expect } from '@playwright/test' +import { fireStripeWebhook, getLastOrg, resetDb } from './helpers/api' + +/** + * Scénarios billing — transitions de subscription côté DB après les + * différents webhooks Stripe (au-delà de l'essai 14 j déjà couvert + * dans billing-trial.spec.ts). + * + * On teste les états observables : + * - subscription.updated trialing → active (J+14 paiement OK) + * - subscription.updated trialing → past_due (paiement échoué) + * - subscription.deleted → org bascule en Free + * - invoice.payment_failed → past_due + * + * Le rendu SPA du status est testé séparément par les tests vitest + * (useSubscription / SubscriptionState). + */ + +async function signupAndStartTrial(page: import('@playwright/test').Page, tag: string) { + const email = `${tag}+${Date.now()}@rubis.test` + await page.goto('/signup') + await page.getByLabel(/Prénom \/ Nom/i).fill('Test') + 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 }) + + // Onboarding pour avoir un user complet + await page.getByRole('button', { name: /continuer/i }).click() + await page.waitForURL(/\/onboarding\/entreprise/) + await page.getByLabel(/nom de l'entreprise/i).fill('Atelier Billing') + 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 }) + + // Démarrer l'essai 14 j (pose stripeCustomerId + envoie redirect) + await page.goto('/onboarding/billing') + await page.getByRole('button', { name: /démarrer mon essai 14 jours/i }).click() + await page.waitForURL(/\/onboarding\/compte\?trial=started/, { timeout: 10_000 }) + + // Webhook checkout.completed → org en trialing avec sub_e2e_mock posé + const org = await getLastOrg() + await fireStripeWebhook({ + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_e2e_mock', + subscription: 'sub_e2e_mock', + metadata: { organization_id: org!.id }, + }, + }, + }) + return org! +} + +test.describe('Billing — transitions de subscription via webhooks', () => { + test.beforeEach(async () => { + await resetDb() + }) + + test('trial → active : org reste pro avec status active', async ({ page }) => { + const orgBefore = await signupAndStartTrial(page, 'bill-active') + expect((await getLastOrg())?.subscription_status).toBe('trialing') + + // Stripe envoie customer.subscription.updated (status='active') à J+14 + await fireStripeWebhook({ + type: 'customer.subscription.updated', + data: { + object: { + id: 'sub_e2e_mock', + customer: 'cus_e2e_mock', + status: 'active', + items: { + data: [ + { + id: 'si_e2e', + price: { id: 'price_pro_monthly_e2e', lookup_key: 'rubis_pro_monthly' }, + current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 3600, + }, + ], + }, + cancel_at_period_end: false, + metadata: { organization_id: orgBefore.id }, + }, + }, + }) + + const after = await getLastOrg() + expect(after?.subscription_status).toBe('active') + expect(after?.plan).toBe('pro') + }) + + test('invoice.payment_failed → org passe en past_due (le plan reste Pro)', async ({ + page, + }) => { + await signupAndStartTrial(page, 'bill-pastdue') + + await fireStripeWebhook({ + type: 'invoice.payment_failed', + data: { + object: { + id: 'in_e2e_failed', + customer: 'cus_e2e_mock', + status: 'open', + }, + }, + }) + + const after = await getLastOrg() + expect(after?.subscription_status).toBe('past_due') + // Important : le plan reste Pro (Stripe smart retries = 7 j de tolérance) + expect(after?.plan).toBe('pro') + }) + + test('subscription.deleted → org bascule en Free, trial_ends_at conservé', async ({ + page, + }) => { + const orgBefore = await signupAndStartTrial(page, 'bill-deleted') + const trialEndsAtBefore = (await getLastOrg())?.trial_ends_at + + await fireStripeWebhook({ + type: 'customer.subscription.deleted', + data: { + object: { + id: 'sub_e2e_mock', + customer: 'cus_e2e_mock', + }, + }, + }) + + const after = await getLastOrg() + expect(after?.id).toBe(orgBefore.id) + expect(after?.plan).toBe('free') + expect(after?.subscription_status).toBe('canceled') + expect(after?.stripe_subscription_id).toBeNull() + // trial_ends_at conservé pour l'historique (empêche un re-trial) + expect(after?.trial_ends_at).toBeTruthy() + expect(after?.trial_ends_at).toBe(trialEndsAtBefore) + }) +}) diff --git a/e2e/tests/dashboard.spec.ts b/e2e/tests/dashboard.spec.ts new file mode 100644 index 0000000..321e620 --- /dev/null +++ b/e2e/tests/dashboard.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '@playwright/test' +import { resetDb } from './helpers/api' + +/** + * Scénarios dashboard : KPIs à zéro au start, bump après actions. + * + * Les calculs des KPIs sont testés par les tests japa functional + * (dashboard.spec.ts) — ici on valide la chaîne SPA → API → render + * sur la page d'accueil. + */ + +async function signupAndOnboard(page: import('@playwright/test').Page) { + const email = `dash+${Date.now()}@rubis.test` + await page.goto('/signup') + await page.getByLabel(/Prénom \/ Nom/i).fill('Dashboard Test') + 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 Dashboard') + 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('Dashboard', () => { + test.beforeEach(async () => { + await resetDb() + }) + + test('zéros au start : 0 rubis, "Pas encore d\'activité"', async ({ page }) => { + await signupAndOnboard(page) + await page.goto('/') + + // RubisHero : "0 rubis gagnés ce mois" + await expect(page.getByText(/0\s+rubis/i).first()).toBeVisible({ timeout: 10_000 }) + + // Activity feed vide + await expect( + page.getByText(/pas encore d'activité|importez votre première facture/i).first(), + ).toBeVisible() + }) + + test('après mark-paid : rubis += 1 + activity contient l\'event', async ({ + page, + }) => { + await signupAndOnboard(page) + + // 1. Créer client + facture + await page.getByRole('button', { name: /^saisir$/i }).first().click() + const combobox = page.getByPlaceholder(/rechercher.*créer un client/i) + await combobox.fill('Dashboard Client') + await page.getByRole('button', { name: /créer le client.*dashboard client/i }).click() + await page.getByLabel(/email du client/i).fill('client@dashboard.test') + await page.getByLabel(/N° de facture/i).fill('F-DASH-001') + await page.getByLabel(/Montant TTC/i).fill('500') + 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() + await expect(page.getByText(/facture créée/i)).toBeVisible({ timeout: 5_000 }) + + // 2. Aller sur la facture, mark-paid + await page.goto('/factures') + await page.getByText('F-DASH-001').first().click() + await page.getByRole('button', { name: /marquer encaissée/i }).click() + await expect(page.getByText(/encaissée.*rubis/i)).toBeVisible({ timeout: 5_000 }) + + // 3. Sur le dashboard, rubis count = 1 (création) + 1 (mark-paid) = 2 rubis + // Note : la création d'une facture saisie manuelle pose rubisEarned=1 + // et le mark-paid bump encore +1 + await page.goto('/') + // Le compteur RubisHero affiche "X rubis gagnés" et la sidebar aussi + await expect(page.getByText(/[1-2]\s+rubis/i).first()).toBeVisible({ + timeout: 10_000, + }) + + // 4. Activity feed contient l'event "Facture F-DASH-001 marquée encaissée" + await expect(page.getByText(/F-DASH-001/).first()).toBeVisible({ + timeout: 5_000, + }) + }) +}) diff --git a/e2e/tests/settings.spec.ts b/e2e/tests/settings.spec.ts new file mode 100644 index 0000000..d032bc1 --- /dev/null +++ b/e2e/tests/settings.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test' +import { resetDb } from './helpers/api' + +/** + * Scénarios /parametres : forms account + organization + signature. + * + * Chaque form est isolé (son propre Save button). On valide la + * persistence après reload page — pas juste l'UI optimiste. + */ + +async function signupAndOnboard(page: import('@playwright/test').Page) { + const email = `set+${Date.now()}@rubis.test` + await page.goto('/signup') + await page.getByLabel(/Prénom \/ Nom/i).fill('Settings Test') + 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 Settings') + 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('Settings', () => { + test.beforeEach(async () => { + await resetDb() + }) + + test('page /parametres affiche les sections principales', async ({ page }) => { + await signupAndOnboard(page) + await page.goto('/parametres') + + await expect(page.getByRole('heading', { name: /paramètres/i })).toBeVisible() + + // Les sections AccountForm, OrganizationForm, SignatureForm sont + // toutes visibles (rendues par /parametres). Le scroll est long mais + // tous les labels sont dans le DOM dès le mount. + await expect(page.getByText(/^compte$/i).first()).toBeVisible() + await expect(page.getByText(/^entreprise$/i).first()).toBeVisible() + await expect(page.getByText(/signature email/i).first()).toBeVisible() + }) + + test('modifier "Prénom et nom" → toast + persistence après reload', async ({ + page, + }) => { + await signupAndOnboard(page) + await page.goto('/parametres') + + const fullNameInput = page.getByLabel(/^Prénom et nom$/i) + await expect(fullNameInput).toBeVisible({ timeout: 5_000 }) + + // L'initial du onboarding était "Settings Test" + await expect(fullNameInput).toHaveValue('Settings Test') + + await fullNameInput.fill('Pierre Modifié') + + // Le bouton passe de "Aucune modification" à "Enregistrer" + const saveButton = page.getByRole('button', { name: /^enregistrer$/i }).first() + await saveButton.click() + + // Confirmation : le bouton revient à "Aucune modification" après save + await expect( + page.getByRole('button', { name: /aucune modification/i }).first(), + ).toBeVisible({ timeout: 5_000 }) + + // Reload : la valeur persiste + await page.reload() + await expect(page.getByLabel(/^Prénom et nom$/i)).toHaveValue('Pierre Modifié') + }) +})