test(e2e): PR 3 — billing states + quota + dashboard + settings (+ fix isDirty)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 28s

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 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-18 17:55:02 +02:00
parent 1e2eecdeba
commit e40f417caa
8 changed files with 502 additions and 32 deletions

View File

@ -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);
},
});

View File

@ -118,16 +118,20 @@ export function AccountForm() {
)}
</form.Field>
<div className="flex justify-end">
<Button
type="submit"
size="sm"
loading={updateMutation.isPending}
disabled={!form.state.isDirty}
>
{form.state.isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
</div>
<form.Subscribe selector={(state) => state.isDirty}>
{(isDirty) => (
<div className="flex justify-end">
<Button
type="submit"
size="sm"
loading={updateMutation.isPending}
disabled={!isDirty}
>
{isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
</div>
)}
</form.Subscribe>
</form>
);
}

View File

@ -175,16 +175,20 @@ export function OrganizationForm() {
)}
</form.Field>
<div className="flex justify-end">
<Button
type="submit"
size="sm"
loading={updateMutation.isPending}
disabled={!form.state.isDirty}
>
{form.state.isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
</div>
<form.Subscribe selector={(state) => state.isDirty}>
{(isDirty) => (
<div className="flex justify-end">
<Button
type="submit"
size="sm"
loading={updateMutation.isPending}
disabled={!isDirty}
>
{isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
</div>
)}
</form.Subscribe>
</form>
);
}

View File

@ -102,16 +102,20 @@ export function SignatureForm() {
)}
</form.Field>
<div className="flex justify-end">
<Button
type="submit"
size="sm"
loading={updateMutation.isPending}
disabled={!form.state.isDirty}
>
{form.state.isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
</div>
<form.Subscribe selector={(state) => state.isDirty}>
{(isDirty) => (
<div className="flex justify-end">
<Button
type="submit"
size="sm"
loading={updateMutation.isPending}
disabled={!isDirty}
>
{isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
</div>
)}
</form.Subscribe>
</form>
);
}

View File

@ -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 })
})
})

View File

@ -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)
})
})

View File

@ -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,
})
})
})

View File

@ -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é')
})
})