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