test(billing): unit tests backend (17) + frontend (7)
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 <noreply@anthropic.com>
This commit is contained in:
parent
fd24ef42a6
commit
4dcd85f912
274
apps/api/tests/unit/billing.spec.ts
Normal file
274
apps/api/tests/unit/billing.spec.ts
Normal file
@ -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<Client> {
|
||||
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<Invoice> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
167
apps/web/src/lib/billing.test.tsx
Normal file
167
apps/web/src/lib/billing.test.tsx
Normal file
@ -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 }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function fakeState(overrides: Partial<SubscriptionState> = {}): 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));
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user