feat(billing): TD-13 webhook idempotency + Stripe Customer Portal + doc cleanup
- Table stripe_webhook_events + helpers isEventProcessed/markEventProcessed - POST /stripe/customer-portal (auth + stripe_customer_id check) - ARCHITECTURE-backend.md: suppression POST /plans/upgrade (duplication doc) - TD-13 fermé dans TECH_DEBT-backend.md - Tests: 261 → 278 verts (+17)
This commit is contained in:
parent
ec0598d122
commit
6671bac347
10 changed files with 891 additions and 324 deletions
101
src/lib/__tests__/stripeWebhookEvents.test.ts
Normal file
101
src/lib/__tests__/stripeWebhookEvents.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const { fromMock, selectMock, eqMock, maybeSingleMock, insertMock } =
|
||||
vi.hoisted(() => ({
|
||||
fromMock: vi.fn(),
|
||||
selectMock: vi.fn(),
|
||||
eqMock: vi.fn(),
|
||||
maybeSingleMock: vi.fn(),
|
||||
insertMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../supabase", () => ({
|
||||
supabase: { from: fromMock },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fromMock.mockReset();
|
||||
selectMock.mockReset();
|
||||
eqMock.mockReset();
|
||||
maybeSingleMock.mockReset();
|
||||
insertMock.mockReset();
|
||||
|
||||
fromMock.mockImplementation((table: string) => {
|
||||
if (table !== "stripe_webhook_events") return {};
|
||||
return {
|
||||
select: selectMock,
|
||||
insert: insertMock,
|
||||
};
|
||||
});
|
||||
selectMock.mockReturnValue({ eq: eqMock });
|
||||
eqMock.mockReturnValue({ maybeSingle: maybeSingleMock });
|
||||
});
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("isEventProcessed", () => {
|
||||
it("retourne true quand l'event est déjà journalisé", async () => {
|
||||
maybeSingleMock.mockResolvedValue({ data: { id: "evt_123" }, error: null });
|
||||
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||
const result = await isEventProcessed("evt_123");
|
||||
expect(result).toBe(true);
|
||||
expect(eqMock).toHaveBeenCalledWith("id", "evt_123");
|
||||
});
|
||||
|
||||
it("retourne false quand l'event est absent", async () => {
|
||||
maybeSingleMock.mockResolvedValue({ data: null, error: null });
|
||||
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||
const result = await isEventProcessed("evt_456");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("retourne false sur erreur de lecture (privilégie disponibilité)", async () => {
|
||||
maybeSingleMock.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: "DB unreachable" },
|
||||
});
|
||||
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||
const result = await isEventProcessed("evt_789");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("retourne false pour un eventId vide sans toucher Supabase", async () => {
|
||||
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||
const result = await isEventProcessed("");
|
||||
expect(result).toBe(false);
|
||||
expect(fromMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("markEventProcessed", () => {
|
||||
it("insère l'event quand il n'existe pas", async () => {
|
||||
insertMock.mockResolvedValue({ error: null });
|
||||
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||
await markEventProcessed("evt_new");
|
||||
expect(insertMock).toHaveBeenCalledWith({ id: "evt_new" });
|
||||
});
|
||||
|
||||
it("avale silencieusement un conflit unique (livraison concurrente)", async () => {
|
||||
insertMock.mockResolvedValue({
|
||||
error: { code: "23505", message: "duplicate key" },
|
||||
});
|
||||
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||
await expect(markEventProcessed("evt_dup")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("ne throw pas sur erreur DB inattendue (webhook doit toujours répondre 200)", async () => {
|
||||
insertMock.mockResolvedValue({
|
||||
error: { code: "08006", message: "connection failure" },
|
||||
});
|
||||
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||
await expect(markEventProcessed("evt_fail")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("no-op pour un eventId vide", async () => {
|
||||
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||
await markEventProcessed("");
|
||||
expect(insertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue