157 lines
5.5 KiB
TypeScript
157 lines
5.5 KiB
TypeScript
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<string, string> }[] = [];
|
|
|
|
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<string, string>) ?? {},
|
|
});
|
|
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<string, string>) ?? {},
|
|
});
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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);
|
|
});
|
|
});
|