import { describe, it, expect, vi, beforeEach } from "vitest"; import crypto from "crypto"; const mockWebhookSelect = vi.fn(); const mockInsert = vi.fn(); const mockInsertValues = vi.fn().mockResolvedValue(undefined); vi.mock("@epicure/db", () => ({ db: { select: vi.fn(() => ({ from: vi.fn(() => ({ where: mockWebhookSelect, })), })), insert: vi.fn(() => ({ values: mockInsertValues })), }, webhooks: {}, webhookDeliveries: {}, eq: vi.fn(), and: vi.fn(), })); let fetchCalls: { url: string; body: string; headers: Record }[] = []; global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => { const body = (init?.body as string) ?? ""; fetchCalls.push({ url: url as string, body, headers: (init?.headers as Record) ?? {}, }); return new Response(null, { status: 200 }); }); const { dispatchWebhook } = await import("../webhooks"); beforeEach(() => { fetchCalls = []; vi.clearAllMocks(); mockInsert.mockReturnValue({ values: mockInsertValues }); global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => { const body = (init?.body as string) ?? ""; fetchCalls.push({ url: url as string, body, headers: (init?.headers as Record) ?? {}, }); return new Response(null, { status: 200 }); }); }); describe("dispatchWebhook", () => { it("does not fetch when no matching webhooks", async () => { mockWebhookSelect.mockResolvedValue([]); await dispatchWebhook("user1", "recipe.created", { id: "r1" }); expect(global.fetch).not.toHaveBeenCalled(); }); it("sends POST to webhook URL with event and payload", async () => { mockWebhookSelect.mockResolvedValue([ { id: "wh1", url: "https://example.com/hook", secret: "mysecret", events: [], active: true }, ]); await dispatchWebhook("user1", "recipe.created", { id: "r1", title: "Test" }); expect(global.fetch).toHaveBeenCalledOnce(); const call = fetchCalls[0]!; expect(call.url).toBe("https://example.com/hook"); const parsed = JSON.parse(call.body) as { event: string; payload: unknown }; expect(parsed.event).toBe("recipe.created"); expect(parsed.payload).toMatchObject({ id: "r1", title: "Test" }); }); it("includes valid HMAC-SHA256 signature header", async () => { const secret = "test-webhook-secret"; mockWebhookSelect.mockResolvedValue([ { id: "wh1", url: "https://example.com/hook", secret, events: [], active: true }, ]); await dispatchWebhook("user1", "recipe.updated", { id: "r2" }); const call = fetchCalls[0]!; const sigHeader = call.headers["X-Epicure-Signature"]; expect(sigHeader).toBeTruthy(); expect(sigHeader).toMatch(/^sha256=/); const expectedSig = "sha256=" + crypto.createHmac("sha256", secret).update(call.body).digest("hex"); expect(sigHeader).toBe(expectedSig); }); it("sends X-Epicure-Event header", async () => { mockWebhookSelect.mockResolvedValue([ { id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true }, ]); await dispatchWebhook("user1", "comment.added", {}); expect(fetchCalls[0]!.headers["X-Epicure-Event"]).toBe("comment.added"); }); it("filters webhooks by event subscription", async () => { mockWebhookSelect.mockResolvedValue([ { id: "wh1", url: "https://a.com/hook", secret: "s", events: ["recipe.deleted"], active: true }, { id: "wh2", url: "https://b.com/hook", secret: "s", events: [], active: true }, ]); await dispatchWebhook("user1", "recipe.created", {}); // wh1 only subscribes to recipe.deleted, wh2 subscribes to all (empty = all) expect(global.fetch).toHaveBeenCalledOnce(); expect(fetchCalls[0]!.url).toBe("https://b.com/hook"); }); it("records delivery success in DB", async () => { const { db } = await import("@epicure/db"); mockWebhookSelect.mockResolvedValue([ { id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true }, ]); await dispatchWebhook("user1", "recipe.created", {}); expect(db.insert).toHaveBeenCalled(); const insertedRow = mockInsertValues.mock.calls[0]![0] as Record; expect(insertedRow.success).toBe(true); expect(insertedRow.event).toBe("recipe.created"); }); it("records delivery failure when fetch throws", async () => { const { db } = await import("@epicure/db"); global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); mockWebhookSelect.mockResolvedValue([ { id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true }, ]); await dispatchWebhook("user1", "recipe.created", {}); expect(db.insert).toHaveBeenCalled(); const insertedRow = mockInsertValues.mock.calls[0]![0] as Record; expect(insertedRow.success).toBe(false); expect(insertedRow.statusCode).toBe(0); }); it("still dispatches remaining hooks if one fails (allSettled)", async () => { global.fetch = vi.fn() .mockRejectedValueOnce(new Error("Timeout")) .mockResolvedValueOnce(new Response(null, { status: 200 })); mockWebhookSelect.mockResolvedValue([ { id: "wh1", url: "https://fail.com/hook", secret: "s", events: [], active: true }, { id: "wh2", url: "https://ok.com/hook", secret: "s", events: [], active: true }, ]); await expect(dispatchWebhook("user1", "recipe.created", {})).resolves.toBeUndefined(); expect(global.fetch).toHaveBeenCalledTimes(2); }); });