Update features and dependencies
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { encrypt, decrypt } from "../encrypt";
|
||||
|
||||
describe("encrypt / decrypt", () => {
|
||||
it("round-trips a short string", () => {
|
||||
const plain = "hello world";
|
||||
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it("round-trips an empty string", () => {
|
||||
expect(decrypt(encrypt(""))).toBe("");
|
||||
});
|
||||
|
||||
it("round-trips a unicode string", () => {
|
||||
const plain = "café ☕ résumé";
|
||||
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it("round-trips a long string (API key-sized)", () => {
|
||||
const plain = "sk-proj-" + "a".repeat(128);
|
||||
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it("produces different ciphertexts for same input (random IV)", () => {
|
||||
const plain = "same input";
|
||||
const c1 = encrypt(plain);
|
||||
const c2 = encrypt(plain);
|
||||
expect(c1).not.toBe(c2);
|
||||
// but both decrypt to same value
|
||||
expect(decrypt(c1)).toBe(plain);
|
||||
expect(decrypt(c2)).toBe(plain);
|
||||
});
|
||||
|
||||
it("ciphertext format is iv:authTag:encrypted (3 hex segments)", () => {
|
||||
const ct = encrypt("test");
|
||||
const parts = ct.split(":");
|
||||
expect(parts).toHaveLength(3);
|
||||
parts.forEach((p) => expect(p).toMatch(/^[0-9a-f]+$/));
|
||||
});
|
||||
|
||||
it("throws on malformed ciphertext (wrong segments)", () => {
|
||||
expect(() => decrypt("notvalid")).toThrow("Invalid ciphertext format");
|
||||
expect(() => decrypt("a:b")).toThrow("Invalid ciphertext format");
|
||||
});
|
||||
|
||||
it("throws on tampered ciphertext (auth tag mismatch)", () => {
|
||||
const ct = encrypt("sensitive");
|
||||
const parts = ct.split(":");
|
||||
// flip a byte in the encrypted payload
|
||||
const tampered = parts[0] + ":" + parts[1] + ":" + "00" + parts[2]!.slice(2);
|
||||
expect(() => decrypt(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatQuantity, scaleQuantity } from "../fractions";
|
||||
|
||||
describe("formatQuantity", () => {
|
||||
it("returns '0' for 0", () => {
|
||||
expect(formatQuantity(0)).toBe("0");
|
||||
});
|
||||
|
||||
it("returns whole numbers as strings", () => {
|
||||
expect(formatQuantity(1)).toBe("1");
|
||||
expect(formatQuantity(4)).toBe("4");
|
||||
});
|
||||
|
||||
it("rounds near-integer values up", () => {
|
||||
expect(formatQuantity(0.97)).toBe("1");
|
||||
expect(formatQuantity(2.96)).toBe("3");
|
||||
});
|
||||
|
||||
it("formats known fractions with unicode symbols", () => {
|
||||
expect(formatQuantity(0.5)).toBe("½");
|
||||
expect(formatQuantity(0.25)).toBe("¼");
|
||||
expect(formatQuantity(0.75)).toBe("¾");
|
||||
expect(formatQuantity(0.333)).toBe("⅓");
|
||||
expect(formatQuantity(0.667)).toBe("⅔");
|
||||
expect(formatQuantity(0.125)).toBe("⅛");
|
||||
});
|
||||
|
||||
it("formats mixed numbers", () => {
|
||||
expect(formatQuantity(1.5)).toBe("1 ½");
|
||||
expect(formatQuantity(2.25)).toBe("2 ¼");
|
||||
expect(formatQuantity(3.75)).toBe("3 ¾");
|
||||
});
|
||||
|
||||
it("falls back to one decimal for values that don't match a fraction", () => {
|
||||
// 0.4375 is midpoint between ⅜ (0.375) and ½ (0.5), diff=0.0625 from both — exceeds 0.06 threshold
|
||||
expect(formatQuantity(0.4375)).toMatch(/^0\.\d$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleQuantity", () => {
|
||||
it("returns empty string for null/empty base quantity", () => {
|
||||
expect(scaleQuantity(null, 4, 2)).toBe("");
|
||||
expect(scaleQuantity("", 4, 2)).toBe("");
|
||||
});
|
||||
|
||||
it("returns base quantity unchanged for non-numeric strings", () => {
|
||||
expect(scaleQuantity("to taste", 4, 2)).toBe("to taste");
|
||||
});
|
||||
|
||||
it("scales down by half", () => {
|
||||
expect(scaleQuantity("4", 4, 2)).toBe("2");
|
||||
});
|
||||
|
||||
it("scales up by double", () => {
|
||||
expect(scaleQuantity("2", 2, 4)).toBe("4");
|
||||
});
|
||||
|
||||
it("handles base=0 gracefully (no division by zero)", () => {
|
||||
expect(scaleQuantity("3", 0, 4)).toBe("3");
|
||||
});
|
||||
|
||||
it("produces fraction symbols when result is fractional", () => {
|
||||
expect(scaleQuantity("1", 2, 1)).toBe("½");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const pipelineChain = vi.hoisted(() => ({
|
||||
zadd: vi.fn().mockReturnThis(),
|
||||
zremrangebyscore: vi.fn().mockReturnThis(),
|
||||
zcard: vi.fn().mockReturnThis(),
|
||||
expire: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../redis", () => ({
|
||||
getRedis: vi.fn(() => ({ pipeline: vi.fn(() => pipelineChain) })),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json: vi.fn((body: unknown, init?: { status?: number }) => ({ body, init, isNextResponse: true })),
|
||||
},
|
||||
}));
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
const { applyRateLimit } = await import("../rate-limit");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("applyRateLimit", () => {
|
||||
it("returns null when under the limit", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 3], null]);
|
||||
const result = await applyRateLimit("test-key", 10, 60);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 429 response when over the limit", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 11], null]);
|
||||
const result = await applyRateLimit("test-key", 10, 60);
|
||||
expect(result).not.toBeNull();
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.any(String) }),
|
||||
expect.objectContaining({ status: 429 })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when exactly at limit (count === limit is allowed)", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 10], null]);
|
||||
const result = await applyRateLimit("test-key", 10, 60);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("calls pipeline with zadd, zremrangebyscore, zcard, expire", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 1], null]);
|
||||
await applyRateLimit("my-key", 5, 30);
|
||||
expect(pipelineChain.zadd).toHaveBeenCalled();
|
||||
expect(pipelineChain.zremrangebyscore).toHaveBeenCalled();
|
||||
expect(pipelineChain.zcard).toHaveBeenCalled();
|
||||
expect(pipelineChain.expire).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const mockFindFirst = vi.fn();
|
||||
const mockFindMany = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
siteSettings: {
|
||||
findFirst: mockFindFirst,
|
||||
findMany: mockFindMany,
|
||||
},
|
||||
},
|
||||
insert: mockInsert,
|
||||
delete: mockDelete,
|
||||
},
|
||||
siteSettings: { key: "key", value: "value", isSecret: "is_secret", updatedAt: "updated_at", updatedById: "updated_by_id" },
|
||||
eq: vi.fn((a, b) => ({ a, b })),
|
||||
}));
|
||||
|
||||
const insertChain = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const deleteChain = {
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const { getSiteSetting, setSiteSetting, isSecretKey } = await import("../site-settings");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockInsert.mockReturnValue(insertChain);
|
||||
mockDelete.mockReturnValue(deleteChain);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
delete process.env["ANTHROPIC_API_KEY"];
|
||||
});
|
||||
|
||||
describe("isSecretKey", () => {
|
||||
it("marks API keys as secret", () => {
|
||||
expect(isSecretKey("OPENAI_API_KEY")).toBe(true);
|
||||
expect(isSecretKey("ANTHROPIC_API_KEY")).toBe(true);
|
||||
expect(isSecretKey("VAPID_PRIVATE_KEY")).toBe(true);
|
||||
});
|
||||
|
||||
it("marks non-secret settings as public", () => {
|
||||
expect(isSecretKey("OPENROUTER_DEFAULT_MODEL")).toBe(false);
|
||||
expect(isSecretKey("OLLAMA_BASE_URL")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSiteSetting", () => {
|
||||
it("returns DB value when present (plain)", async () => {
|
||||
mockFindFirst.mockResolvedValue({ value: "gpt-4o", isSecret: false });
|
||||
const val = await getSiteSetting("OPENROUTER_DEFAULT_MODEL");
|
||||
expect(val).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("decrypts DB value when secret", async () => {
|
||||
// Pre-encrypt something using the real encrypt function
|
||||
const { encrypt } = await import("../encrypt");
|
||||
const ciphertext = encrypt("sk-real-key");
|
||||
mockFindFirst.mockResolvedValue({ value: ciphertext, isSecret: true });
|
||||
|
||||
const val = await getSiteSetting("OPENAI_API_KEY");
|
||||
expect(val).toBe("sk-real-key");
|
||||
});
|
||||
|
||||
it("falls back to env var when no DB row", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
process.env["OPENAI_API_KEY"] = "sk-from-env";
|
||||
const val = await getSiteSetting("OPENAI_API_KEY");
|
||||
expect(val).toBe("sk-from-env");
|
||||
});
|
||||
|
||||
it("returns null when neither DB nor env is set", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
const val = await getSiteSetting("OPENAI_API_KEY");
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to env when DB throws", async () => {
|
||||
mockFindFirst.mockRejectedValue(new Error("DB error"));
|
||||
process.env["ANTHROPIC_API_KEY"] = "sk-anthropic-env";
|
||||
const val = await getSiteSetting("ANTHROPIC_API_KEY");
|
||||
expect(val).toBe("sk-anthropic-env");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSiteSetting", () => {
|
||||
it("deletes the row when value is null", async () => {
|
||||
await setSiteSetting("OPENAI_API_KEY", null, "admin-user");
|
||||
expect(mockDelete).toHaveBeenCalled();
|
||||
expect(mockInsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the row when value is empty string", async () => {
|
||||
await setSiteSetting("OPENAI_API_KEY", "", "admin-user");
|
||||
expect(mockDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inserts encrypted value for secret key", async () => {
|
||||
await setSiteSetting("OPENAI_API_KEY", "sk-test", "admin-user");
|
||||
expect(mockInsert).toHaveBeenCalled();
|
||||
const inserted = insertChain.values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(inserted.isSecret).toBe(true);
|
||||
// stored value should not be plain text
|
||||
expect(inserted.value).not.toBe("sk-test");
|
||||
expect(typeof inserted.value).toBe("string");
|
||||
});
|
||||
|
||||
it("inserts plain value for non-secret key", async () => {
|
||||
await setSiteSetting("OPENROUTER_DEFAULT_MODEL", "gpt-4o", "admin-user");
|
||||
const inserted = insertChain.values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(inserted.isSecret).toBe(false);
|
||||
expect(inserted.value).toBe("gpt-4o");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TierLimitError } from "../tiers";
|
||||
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: mockDb,
|
||||
tierDefinitions: { tier: "tier" },
|
||||
userUsage: {
|
||||
userId: "user_id",
|
||||
month: "month",
|
||||
aiCallsUsed: "ai_calls_used",
|
||||
recipeCount: "recipe_count",
|
||||
storageUsedMb: "storage_used_mb",
|
||||
},
|
||||
eq: vi.fn((col, val) => ({ col, val, op: "eq" })),
|
||||
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||
sql: vi.fn((strings, ...values) => ({ strings, values, op: "sql" })),
|
||||
}));
|
||||
|
||||
// Import after mock
|
||||
const { checkTierLimit, incrementUsage } = await import("../tiers");
|
||||
|
||||
function makeChain(finalValue: unknown) {
|
||||
const chain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(finalValue),
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeInsertChain() {
|
||||
const chain = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("TierLimitError", () => {
|
||||
it("has correct name and message", () => {
|
||||
const err = new TierLimitError("aiCall", "free");
|
||||
expect(err.name).toBe("TierLimitError");
|
||||
expect(err.message).toContain("aiCall");
|
||||
expect(err.message).toContain("free");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkTierLimit", () => {
|
||||
const tierDef = {
|
||||
tier: "free",
|
||||
maxRecipes: 10,
|
||||
aiCallsPerMonth: 5,
|
||||
storageMb: 100,
|
||||
maxPublicRecipes: 3,
|
||||
};
|
||||
|
||||
it("does not throw when usage is under limit", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 3, recipeCount: 2, storageUsedMb: 0 }]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws TierLimitError when aiCall limit reached", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 5, recipeCount: 0, storageUsedMb: 0 }]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
|
||||
});
|
||||
|
||||
it("throws TierLimitError when recipe limit reached", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 10, storageUsedMb: 0 }]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
|
||||
});
|
||||
|
||||
it("does not throw when no usage row exists (treats as zero)", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not throw when tier definition does not exist", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not throw for storage key (no limit enforced)", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 9999 }]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "storage")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementUsage", () => {
|
||||
it("calls insert with correct aiCall initial values", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "aiCall");
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user1",
|
||||
aiCallsUsed: 1,
|
||||
recipeCount: 0,
|
||||
storageUsedMb: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("calls insert with correct recipe initial values", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "recipe");
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipeCount: 1,
|
||||
aiCallsUsed: 0,
|
||||
storageUsedMb: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("respects custom amount", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "storage", 50);
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ storageUsedMb: 50 })
|
||||
);
|
||||
});
|
||||
|
||||
it("uses onConflictDoUpdate to increment (not overwrite)", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "aiCall");
|
||||
|
||||
expect(chain.onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
set: expect.objectContaining({ aiCallsUsed: expect.anything() }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user