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, useTrialDaysRemaining, 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: 2, seatsLimit: 1, multiUsers: false, replyFromUserEmail: false, smsEnabled: false, }, activeInvoicesCount: 0, inGracePeriod: false, gracePeriodEndsAt: null, subscriptionStatus: null, inTrial: false, trialEndsAt: null, billingCycle: null, currentPeriodEnd: null, hasStripeCustomer: false, cancelAtPeriodEnd: 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: 2, 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 (1 < 2)", async () => { vi.spyOn(api, "get").mockResolvedValue( fakeState({ plan: "free", activeInvoicesCount: 1, inGracePeriod: false, }), ); const { result } = renderHook(() => useIsAtFreeLimit(), { wrapper: makeWrapper(), }); await waitFor(() => expect(api.get).toHaveBeenCalled()); await waitFor(() => expect(result.current).toBe(false)); }); it("retourne false pendant l'essai 14 j (inTrial=true bypass le quota)", async () => { vi.spyOn(api, "get").mockResolvedValue( fakeState({ plan: "free", activeInvoicesCount: 10, inGracePeriod: false, inTrial: true, trialEndsAt: new Date(Date.now() + 7 * 24 * 3600_000).toISOString(), subscriptionStatus: "trialing", }), ); const { result } = renderHook(() => useIsAtFreeLimit(), { wrapper: makeWrapper(), }); await waitFor(() => expect(api.get).toHaveBeenCalled()); await waitFor(() => expect(result.current).toBe(false)); }); }); describe("useTrialDaysRemaining", () => { it("retourne null hors essai", async () => { vi.spyOn(api, "get").mockResolvedValue( fakeState({ plan: "free", inTrial: false }), ); const { result } = renderHook(() => useTrialDaysRemaining(), { wrapper: makeWrapper(), }); await waitFor(() => expect(api.get).toHaveBeenCalled()); await waitFor(() => expect(result.current).toBe(null)); }); it("retourne le nombre de jours arrondi sup quand essai actif", async () => { const sevenDays = 7 * 24 * 3600 * 1000; vi.spyOn(api, "get").mockResolvedValue( fakeState({ plan: "free", inTrial: true, // 6.5 jours → arrondi sup = 7 trialEndsAt: new Date(Date.now() + sevenDays - 12 * 3600 * 1000).toISOString(), subscriptionStatus: "trialing", }), ); const { result } = renderHook(() => useTrialDaysRemaining(), { wrapper: makeWrapper(), }); await waitFor(() => expect(api.get).toHaveBeenCalled()); await waitFor(() => expect(result.current).toBe(7)); }); it("retourne 0 si trialEndsAt déjà passé (garde-fou)", async () => { vi.spyOn(api, "get").mockResolvedValue( fakeState({ plan: "free", inTrial: true, trialEndsAt: new Date(Date.now() - 1000).toISOString(), subscriptionStatus: "trialing", }), ); const { result } = renderHook(() => useTrialDaysRemaining(), { wrapper: makeWrapper(), }); await waitFor(() => expect(api.get).toHaveBeenCalled()); await waitFor(() => expect(result.current).toBe(0)); }); });