Files
community-rule/tests/unit/workshopSignupPostRoute.test.ts
T
adilalloandCursor c0ba3a3798 Add the Workshops page and keep each sign-up on the site.
The form is built once to the settled field set, and a valid send is saved before anyone is emailed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:56:49 -07:00

180 lines
5.6 KiB
TypeScript

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<string, unknown> = {}) {
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();
});
});