From 4dcd85f912ef7969df1056e1804b6af714b062be Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Thu, 7 May 2026 16:43:40 +0200 Subject: [PATCH] test(billing): unit tests backend (17) + frontend (7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (`apps/api/tests/unit/billing.spec.ts`) — 17 tests : - PLAN_CAPS sanity : Free 5 invoices/1 user, Pro illimité, Business 5 sièges - countActiveInvoices : • compte les 4 statuts actifs (pending, awaiting, in_relance, litigation) • exclut paid + cancelled • isolation par org (ne fuit pas entre orgs) - canCreateInvoices : • Free + grace period → autorisé même à 50+ actives • Free post-grace + 4 actives + delta=1 → autorisé (≤ limite) • Free post-grace + 5 actives + delta=1 → BLOQUÉ + bonne raison/limit/current • Free post-grace + 3 actives + delta=3 → BLOQUÉ (over par batch) • Pro + Business → toujours autorisé • paid n'occupe pas de slot (5 paid + delta=5 → autorisé) - getOrgSubscriptionState : • inGracePeriod=true quand date future • inGracePeriod=false quand date passée • Pro reflète subscription_status / billing_cycle / current_period_end • activeInvoicesCount inclut bien les 4 statuts Frontend (`apps/web/src/lib/billing.test.tsx`) — 7 tests : - useSubscription : appelle /billing/subscription, retourne le state - useIsAtFreeLimit : • false en loading • false sur Pro avec 200 factures • false en grace period même si activeCount > limit (12) • true sur Free post-grace + activeCount = limit (5/5) • true sur Free post-grace + activeCount > limit (8/5) • false sur Free post-grace + activeCount < limit (4/5) Setup : - vitest.config.ts : ajout de `env: {VITE_API_URL, ...}` pour stub les variables exigées par src/lib/env.ts au chargement (sinon plante au boot des tests). - Mock vi.spyOn(api, "get") pour éviter les vraies requêtes HTTP. - QueryClient avec retry:false pour fail-fast. Co-Authored-By: Claude Opus 4.7 --- apps/api/tests/unit/billing.spec.ts | 274 ++++++++++++++++++++++++++++ apps/web/src/lib/billing.test.tsx | 167 +++++++++++++++++ apps/web/vitest.config.ts | 7 + 3 files changed, 448 insertions(+) create mode 100644 apps/api/tests/unit/billing.spec.ts create mode 100644 apps/web/src/lib/billing.test.tsx diff --git a/apps/api/tests/unit/billing.spec.ts b/apps/api/tests/unit/billing.spec.ts new file mode 100644 index 0000000..858349a --- /dev/null +++ b/apps/api/tests/unit/billing.spec.ts @@ -0,0 +1,274 @@ +import { test } from '@japa/runner' +import testUtils from '@adonisjs/core/services/test_utils' +import { DateTime } from 'luxon' + +import { + PLAN_CAPS, + canCreateInvoices, + countActiveInvoices, + getOrgSubscriptionState, +} from '#services/billing' +import Organization from '#models/organization' +import Client from '#models/client' +import Invoice from '#models/invoice' +import { createTestUser } from '../helpers/auth.js' + +/** + * Tests unitaires sur la logique de plans + enforcement. + * On utilise une DB transaction par test (auto-rollback) pour isoler chaque + * scénario sans pollution croisée. Pas de mock de DB — on teste la vraie + * logique SQL `whereIn(status, ACTIVE_STATUSES)`. + */ + +const ACTIVE_STATUSES = ['pending', 'awaiting_user_confirmation', 'in_relance', 'litigation'] as const +const INACTIVE_STATUSES = ['paid', 'cancelled'] as const + +async function makeClientFor(org: Organization): Promise { + return Client.create({ + organizationId: org.id, + name: `Client ${Math.random().toString(36).slice(2, 8)}`, + email: `client-${Math.random().toString(36).slice(2, 8)}@spec.test`, + contactFirstName: null, + contactLastName: null, + phone: null, + address: null, + siret: null, + notes: null, + }) +} + +async function makeInvoice( + org: Organization, + client: Client, + status: Invoice['status'] +): Promise { + const issue = DateTime.utc().minus({ days: 30 }) + return Invoice.create({ + organizationId: org.id, + clientId: client.id, + planId: null, + numero: `F-${Math.random().toString(36).slice(2, 10)}`, + amountTtcCents: 100_00, + issueDate: issue, + dueDate: issue.plus({ days: 30 }), + paidAt: status === 'paid' ? DateTime.utc() : null, + status, + pdfStorageKey: null, + rubisEarned: 0, + notes: null, + }) +} + +// --------------------------------------------------------------------------- +// PLAN_CAPS — sanity check sur les caps de chaque plan +// --------------------------------------------------------------------------- + +test.group('billing — PLAN_CAPS', () => { + test('Free : 5 factures max, 1 user, pas de multi-users', ({ assert }) => { + assert.equal(PLAN_CAPS.free.activeInvoicesLimit, 5) + assert.equal(PLAN_CAPS.free.seatsLimit, 1) + assert.isFalse(PLAN_CAPS.free.multiUsers) + assert.isFalse(PLAN_CAPS.free.replyFromUserEmail) + }) + + test('Pro : factures illimitées, 1 user', ({ assert }) => { + assert.isNull(PLAN_CAPS.pro.activeInvoicesLimit) + assert.equal(PLAN_CAPS.pro.seatsLimit, 1) + assert.isFalse(PLAN_CAPS.pro.multiUsers) + }) + + test('Business : illimité + 5 sièges + reply-from-user', ({ assert }) => { + assert.isNull(PLAN_CAPS.business.activeInvoicesLimit) + assert.equal(PLAN_CAPS.business.seatsLimit, 5) + assert.isTrue(PLAN_CAPS.business.multiUsers) + assert.isTrue(PLAN_CAPS.business.replyFromUserEmail) + }) +}) + +// --------------------------------------------------------------------------- +// countActiveInvoices — compte les statuts pending/awaiting/in_relance/litigation +// --------------------------------------------------------------------------- + +test.group('billing — countActiveInvoices', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('compte les 4 statuts actifs', async ({ assert }) => { + const { org } = await createTestUser() + const client = await makeClientFor(org) + for (const status of ACTIVE_STATUSES) { + await makeInvoice(org, client, status) + } + const n = await countActiveInvoices(org.id) + assert.equal(n, ACTIVE_STATUSES.length) + }) + + test('exclut les statuts inactifs (paid, cancelled)', async ({ assert }) => { + const { org } = await createTestUser() + const client = await makeClientFor(org) + for (const status of INACTIVE_STATUSES) { + await makeInvoice(org, client, status) + } + const n = await countActiveInvoices(org.id) + assert.equal(n, 0) + }) + + test('isolation par org : ne compte pas les factures d\'une autre org', async ({ assert }) => { + const { org: orgA } = await createTestUser() + const { org: orgB } = await createTestUser() + const clientB = await makeClientFor(orgB) + await makeInvoice(orgB, clientB, 'pending') + await makeInvoice(orgB, clientB, 'in_relance') + const n = await countActiveInvoices(orgA.id) + assert.equal(n, 0) + }) +}) + +// --------------------------------------------------------------------------- +// canCreateInvoices — règle d'enforcement principale +// --------------------------------------------------------------------------- + +test.group('billing — canCreateInvoices', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('Free + grace period active → autorisé sans limite', async ({ assert }) => { + const { org } = await createTestUser() + org.plan = 'free' + org.gracePeriodEndsAt = DateTime.utc().plus({ months: 2 }) + await org.save() + const client = await makeClientFor(org) + // 50 factures actives, on devrait quand même pouvoir en ajouter + for (let i = 0; i < 50; i++) await makeInvoice(org, client, 'pending') + const result = await canCreateInvoices(org.id, 1) + assert.isTrue(result.allowed) + }) + + test('Free post-grace + 4 actives → on peut en ajouter 1 (4+1 ≤ 5)', async ({ assert }) => { + const { org } = await createTestUser() + org.plan = 'free' + org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 }) + await org.save() + const client = await makeClientFor(org) + for (let i = 0; i < 4; i++) await makeInvoice(org, client, 'pending') + const result = await canCreateInvoices(org.id, 1) + assert.isTrue(result.allowed) + }) + + test('Free post-grace + 5 actives → bloqué (5+1 > 5)', async ({ assert }) => { + const { org } = await createTestUser() + org.plan = 'free' + org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 }) + await org.save() + const client = await makeClientFor(org) + for (let i = 0; i < 5; i++) await makeInvoice(org, client, 'in_relance') + const result = await canCreateInvoices(org.id, 1) + assert.isFalse(result.allowed) + if (!result.allowed) { + assert.equal(result.reason, 'free_limit_active_invoices') + assert.equal(result.limit, 5) + assert.equal(result.current, 5) + } + }) + + test('Free post-grace + 3 actives + delta=3 → bloqué (3+3 > 5)', async ({ assert }) => { + const { org } = await createTestUser() + org.plan = 'free' + org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 }) + await org.save() + const client = await makeClientFor(org) + for (let i = 0; i < 3; i++) await makeInvoice(org, client, 'pending') + const result = await canCreateInvoices(org.id, 3) + assert.isFalse(result.allowed) + }) + + test('Pro → toujours autorisé peu importe le compteur', async ({ assert }) => { + const { org } = await createTestUser() + org.plan = 'pro' + org.gracePeriodEndsAt = DateTime.utc().minus({ months: 6 }) + await org.save() + const client = await makeClientFor(org) + for (let i = 0; i < 200; i++) await makeInvoice(org, client, 'in_relance') + const result = await canCreateInvoices(org.id, 1) + assert.isTrue(result.allowed) + }) + + test('Business → toujours autorisé', async ({ assert }) => { + const { org } = await createTestUser() + org.plan = 'business' + await org.save() + const client = await makeClientFor(org) + for (let i = 0; i < 100; i++) await makeInvoice(org, client, 'in_relance') + const result = await canCreateInvoices(org.id, 1) + assert.isTrue(result.allowed) + }) + + test('Free post-grace : factures paid ne consomment pas de slot', async ({ assert }) => { + const { org } = await createTestUser() + org.plan = 'free' + org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 }) + await org.save() + const client = await makeClientFor(org) + // 5 paid + 0 actives = encore 5 slots dispos + for (let i = 0; i < 5; i++) await makeInvoice(org, client, 'paid') + const result = await canCreateInvoices(org.id, 5) + assert.isTrue(result.allowed) + }) +}) + +// --------------------------------------------------------------------------- +// getOrgSubscriptionState — shape & cohérence +// --------------------------------------------------------------------------- + +test.group('billing — getOrgSubscriptionState', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('Free + grace active → inGracePeriod=true', async ({ assert }) => { + const { org } = await createTestUser() + org.plan = 'free' + org.gracePeriodEndsAt = DateTime.utc().plus({ months: 2 }) + await org.save() + const state = await getOrgSubscriptionState(org.id) + assert.equal(state.plan, 'free') + assert.isTrue(state.inGracePeriod) + assert.equal(state.activeInvoicesCount, 0) + assert.equal(state.caps.activeInvoicesLimit, 5) + assert.isFalse(state.hasStripeCustomer) + }) + + test('Free post-grace → inGracePeriod=false', async ({ assert }) => { + const { org } = await createTestUser() + org.plan = 'free' + org.gracePeriodEndsAt = DateTime.utc().minus({ days: 1 }) + await org.save() + const state = await getOrgSubscriptionState(org.id) + assert.isFalse(state.inGracePeriod) + }) + + test('Pro avec subscription → reflète status et period_end Stripe', async ({ assert }) => { + const { org } = await createTestUser() + org.plan = 'pro' + org.subscriptionStatus = 'active' + org.billingCycle = 'monthly' + org.currentPeriodEnd = DateTime.utc().plus({ days: 23 }) + org.stripeCustomerId = 'cus_test_123' + await org.save() + const state = await getOrgSubscriptionState(org.id) + assert.equal(state.plan, 'pro') + assert.equal(state.subscriptionStatus, 'active') + assert.equal(state.billingCycle, 'monthly') + assert.isNotNull(state.currentPeriodEnd) + assert.isTrue(state.hasStripeCustomer) + assert.isFalse(state.inGracePeriod) // les payants ne sont jamais "en grâce" + }) + + test('Compteur actif inclut bien les 4 statuts', async ({ assert }) => { + const { org } = await createTestUser() + const client = await makeClientFor(org) + for (const status of ACTIVE_STATUSES) { + await makeInvoice(org, client, status) + } + await makeInvoice(org, client, 'paid') + await makeInvoice(org, client, 'cancelled') + const state = await getOrgSubscriptionState(org.id) + assert.equal(state.activeInvoicesCount, ACTIVE_STATUSES.length) + }) +}) diff --git a/apps/web/src/lib/billing.test.tsx b/apps/web/src/lib/billing.test.tsx new file mode 100644 index 0000000..1064abf --- /dev/null +++ b/apps/web/src/lib/billing.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +import { + useIsAtFreeLimit, + useSubscription, + type SubscriptionState, +} from "@/lib/billing"; +import { api } from "@/lib/api"; + +/** + * Tests unitaires sur la logique billing côté SPA. + * + * - useSubscription : query hook qui appelle /api/v1/billing/subscription + * - useIsAtFreeLimit : helper qui dérive un booléen pour bloquer l'UI + * + * On mock `api.get` pour éviter de toucher la network. Le wrapper QueryClient + * a `retry: false` pour fail-fast en cas de mock manquant. + */ + +function makeWrapper() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +function fakeState(overrides: Partial = {}): SubscriptionState { + return { + plan: "free", + caps: { + activeInvoicesLimit: 5, + seatsLimit: 1, + multiUsers: false, + replyFromUserEmail: false, + smsEnabled: false, + }, + activeInvoicesCount: 0, + inGracePeriod: false, + gracePeriodEndsAt: null, + subscriptionStatus: null, + billingCycle: null, + currentPeriodEnd: null, + hasStripeCustomer: false, + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("useSubscription", () => { + it("appelle l'endpoint /billing/subscription et renvoie le state", async () => { + const state = fakeState({ plan: "pro", inGracePeriod: false }); + vi.spyOn(api, "get").mockResolvedValue(state); + + const { result } = renderHook(() => useSubscription(), { + wrapper: makeWrapper(), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.plan).toBe("pro"); + expect(api.get).toHaveBeenCalledWith("/api/v1/billing/subscription"); + }); +}); + +describe("useIsAtFreeLimit", () => { + it("retourne false en l'absence de données (loading)", () => { + vi.spyOn(api, "get").mockImplementation(() => new Promise(() => {})); // jamais résolu + + const { result } = renderHook(() => useIsAtFreeLimit(), { + wrapper: makeWrapper(), + }); + expect(result.current).toBe(false); + }); + + it("retourne false sur un plan Pro même avec beaucoup de factures", async () => { + vi.spyOn(api, "get").mockResolvedValue( + fakeState({ + plan: "pro", + caps: { + activeInvoicesLimit: null, + seatsLimit: 1, + multiUsers: false, + replyFromUserEmail: false, + smsEnabled: false, + }, + activeInvoicesCount: 200, + }), + ); + + const { result } = renderHook(() => useIsAtFreeLimit(), { + wrapper: makeWrapper(), + }); + await waitFor(() => expect(api.get).toHaveBeenCalled()); + // Petit délai pour laisser le state propagate après la mock resolve + await waitFor(() => expect(result.current).toBe(false)); + }); + + it("retourne false en grace period même si activeCount > limite", async () => { + vi.spyOn(api, "get").mockResolvedValue( + fakeState({ + plan: "free", + activeInvoicesCount: 12, + inGracePeriod: true, + gracePeriodEndsAt: "2099-01-01T00:00:00.000Z", + }), + ); + + const { result } = renderHook(() => useIsAtFreeLimit(), { + wrapper: makeWrapper(), + }); + await waitFor(() => expect(api.get).toHaveBeenCalled()); + await waitFor(() => expect(result.current).toBe(false)); + }); + + it("retourne true sur Free post-grace + activeCount >= limit", async () => { + vi.spyOn(api, "get").mockResolvedValue( + fakeState({ + plan: "free", + activeInvoicesCount: 5, + inGracePeriod: false, + }), + ); + + const { result } = renderHook(() => useIsAtFreeLimit(), { + wrapper: makeWrapper(), + }); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it("retourne true même si activeCount > limit (over)", async () => { + vi.spyOn(api, "get").mockResolvedValue( + fakeState({ + plan: "free", + activeInvoicesCount: 8, + inGracePeriod: false, + }), + ); + + const { result } = renderHook(() => useIsAtFreeLimit(), { + wrapper: makeWrapper(), + }); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it("retourne false sur Free post-grace mais activeCount < limit (4 < 5)", async () => { + vi.spyOn(api, "get").mockResolvedValue( + fakeState({ + plan: "free", + activeInvoicesCount: 4, + inGracePeriod: false, + }), + ); + + const { result } = renderHook(() => useIsAtFreeLimit(), { + wrapper: makeWrapper(), + }); + await waitFor(() => expect(api.get).toHaveBeenCalled()); + await waitFor(() => expect(result.current).toBe(false)); + }); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index f09eaad..3be5fff 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -17,5 +17,12 @@ export default defineConfig({ environment: "jsdom", setupFiles: ["./src/test/setup.ts"], include: ["src/**/*.{test,spec}.{ts,tsx}"], + // Env stubs : src/lib/env.ts valide les VITE_* au chargement et plante + // sinon. On fournit des valeurs no-op pour les tests (l'API est mockée). + env: { + VITE_API_URL: "http://localhost:3333", + VITE_PUBLIC_LANDING_URL: "http://localhost:5173", + VITE_USE_MOCKS: "false", + }, }, });