import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const createMock = vi.fn(); const updateMock = vi.fn(); const sendInboxMock = vi.fn(); const sendConfirmationMock = vi.fn(); const isDatabaseConfiguredMock = vi.fn(); const rateLimitKeyMock = vi.hoisted(() => vi.fn( (_key: string, _minIntervalMs: number): { ok: true } | { ok: false; retryAfterMs: number } => ({ ok: true, }), ), ); vi.mock("../../lib/server/db", () => ({ prisma: { workshopInquiry: { create: (...args: unknown[]) => createMock(...args), update: (...args: unknown[]) => updateMock(...args), }, }, })); vi.mock("../../lib/server/mail", () => ({ sendWorkshopInquiryNotification: (...args: unknown[]) => sendInboxMock(...args), sendWorkshopInquiryConfirmation: (...args: unknown[]) => sendConfirmationMock(...args), })); vi.mock("../../lib/server/env", () => ({ isDatabaseConfigured: () => isDatabaseConfiguredMock(), })); vi.mock("../../lib/server/rateLimit", () => ({ rateLimitKey: (key: string, minIntervalMs: number) => rateLimitKeyMock(key, minIntervalMs), })); import { POST } from "../../app/api/workshop-signup/route"; const startedAt = Date.now() - 30_000; function payload(overrides: Record = {}) { return { challenges: ["big-change"], challengeOther: "", unspokenQuestion: "Who decides when we merge?", organizationName: "Northside Credit Union", organizationKind: "credit-union", memberCount: "under-1000", contactName: "Ada Lovelace", email: "Ada@Example.com", role: "board", sourcePage: "/workshops", sourceUrl: "https://communityrule.com/workshops", startedAt, company_website: "", ...overrides, }; } describe("POST /api/workshop-signup", () => { beforeEach(() => { createMock.mockReset(); updateMock.mockReset(); sendInboxMock.mockReset(); sendConfirmationMock.mockReset(); rateLimitKeyMock.mockReset(); isDatabaseConfiguredMock.mockReset(); createMock.mockResolvedValue({ id: "inq-1" }); updateMock.mockResolvedValue({}); sendInboxMock.mockResolvedValue(undefined); sendConfirmationMock.mockResolvedValue(undefined); rateLimitKeyMock.mockImplementation(() => ({ ok: true as const })); isDatabaseConfiguredMock.mockReturnValue(true); process.env.WORKSHOP_INQUIRY_TO = "workshops@example.com"; }); afterEach(() => { delete process.env.WORKSHOP_INQUIRY_TO; delete process.env.NEXT_PUBLIC_WORKSHOP_SCHEDULING_URL; }); it("saves the row, then emails the inbox, then confirms", async () => { const order: string[] = []; createMock.mockImplementation(async () => { order.push("save"); return { id: "inq-1" }; }); sendInboxMock.mockImplementation(async () => { order.push("inbox"); }); sendConfirmationMock.mockImplementation(async () => { order.push("confirmation"); }); const res = await POST( new NextRequest("https://x.test/api/workshop-signup", { method: "POST", body: JSON.stringify(payload()), }), undefined, ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true, status: "sent", largeOrganization: false, }); expect(order).toEqual(["save", "inbox", "confirmation"]); expect(createMock.mock.calls[0][0].data.email).toBe("ada@example.com"); expect(createMock.mock.calls[0][0].data.routeSuggestion).toBe( "Send the one-page brief, then offer a call.", ); }); it("shows the confirmation and saves nothing when the honeypot is filled", async () => { const res = await POST( new NextRequest("https://x.test/api/workshop-signup", { method: "POST", body: JSON.stringify(payload({ company_website: "https://spam.example" })), }), undefined, ); expect(res.status).toBe(200); expect(createMock).not.toHaveBeenCalled(); expect(sendInboxMock).not.toHaveBeenCalled(); }); it("returns the form errors and keeps the answers", async () => { const res = await POST( new NextRequest("https://x.test/api/workshop-signup", { method: "POST", body: JSON.stringify(payload({ email: "not-an-email", organizationName: "Northside" })), }), undefined, ); expect(res.status).toBe(400); const body = await res.json(); expect(body.status).toBe("invalid"); expect(body.fieldErrors.email).toBe("invalid"); expect(body.values.organizationName).toBe("Northside"); expect(createMock).not.toHaveBeenCalled(); expect(sendInboxMock).not.toHaveBeenCalled(); }); it("keeps the row and skips a thrown inbox email", async () => { sendInboxMock.mockRejectedValue(new Error("smtp down")); const res = await POST( new NextRequest("https://x.test/api/workshop-signup", { method: "POST", body: JSON.stringify(payload({ memberCount: "1000-10000" })), }), undefined, ); expect(res.status).toBe(200); expect(createMock).toHaveBeenCalledTimes(1); expect(sendConfirmationMock).toHaveBeenCalledTimes(1); const body = await res.json(); expect(body.largeOrganization).toBe(true); expect(JSON.stringify(body)).not.toMatch(/price|\$/); }); it("sends nothing when the row cannot be saved", async () => { createMock.mockRejectedValue(new Error("db down")); const res = await POST( new NextRequest("https://x.test/api/workshop-signup", { method: "POST", body: JSON.stringify(payload()), }), undefined, ); expect(res.status).toBe(502); expect(sendInboxMock).not.toHaveBeenCalled(); expect(sendConfirmationMock).not.toHaveBeenCalled(); }); });