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>
180 lines
5.6 KiB
TypeScript
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();
|
|
});
|
|
});
|