Files
community-rule/tests/unit/rulesPublishPostRoute.test.ts
T
adilalloandCursor 950b3224ac feat: let guests publish a CommunityRule and claim it on this browser later
Finalize no longer requires a magic link. Guest rows stay off the catalog until sign-in on the same browser attaches ownership, and the login modal kebab no longer acts as a second close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 11:01:10 -06:00

260 lines
7.6 KiB
TypeScript

import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const getSessionUserMock = vi.fn();
const transactionMock = vi.fn();
const publishedRuleCreateMock = vi.fn();
const publishedRuleDeleteMock = vi.fn();
const sendInviteMock = vi.fn();
const rateLimitKeyMock = vi.fn(() => ({ ok: true as const }));
const issueGuestRuleClaimCookieMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => true,
getSessionPepper: () => "test-pepper",
}));
vi.mock("../../lib/server/session", () => ({
getSessionUser: () => getSessionUserMock(),
}));
vi.mock("../../lib/server/rateLimit", () => ({
rateLimitKey: (...args: unknown[]) => rateLimitKeyMock(...args),
}));
vi.mock("../../lib/server/mail", () => ({
sendRuleStakeholderInviteEmail: (...args: unknown[]) =>
sendInviteMock(...args),
}));
vi.mock("../../lib/server/hash", () => ({
newSessionToken: () => "x".repeat(32),
hashSessionToken: (t: string) => `hashed-${t}`,
hashRuleClaimToken: (t: string) => `claim-${t}`,
}));
vi.mock("../../lib/server/guestRuleClaim", () => ({
issueGuestRuleClaimCookie: (...args: unknown[]) =>
issueGuestRuleClaimCookieMock(...args),
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
$transaction: (fn: (tx: unknown) => Promise<unknown>) =>
transactionMock(fn),
publishedRule: {
create: (...args: unknown[]) => publishedRuleCreateMock(...args),
delete: (...args: unknown[]) => publishedRuleDeleteMock(...args),
},
ruleStakeholder: {
create: vi.fn().mockResolvedValue({}),
},
},
}));
import { POST } from "../../app/api/rules/route";
beforeEach(() => {
getSessionUserMock.mockReset();
transactionMock.mockReset();
publishedRuleCreateMock.mockReset();
publishedRuleDeleteMock.mockReset();
sendInviteMock.mockReset();
rateLimitKeyMock.mockReset();
issueGuestRuleClaimCookieMock.mockReset();
rateLimitKeyMock.mockReturnValue({ ok: true as const });
getSessionUserMock.mockResolvedValue({
id: "user-1",
email: "owner@example.com",
});
});
describe("POST /api/rules", () => {
it("creates rule without transaction when there are no stakeholder emails", async () => {
publishedRuleCreateMock.mockResolvedValueOnce({
id: "rule-solo",
title: "T",
summary: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
});
const res = await POST(
new NextRequest("https://x.test/api/rules", {
method: "POST",
body: JSON.stringify({
title: "T",
summary: null,
document: {},
}),
}),
undefined,
);
expect(res.status).toBe(200);
expect(transactionMock).not.toHaveBeenCalled();
expect(publishedRuleCreateMock).toHaveBeenCalledTimes(1);
expect(
(
publishedRuleCreateMock.mock.calls[0]?.[0] as {
data: { claimTokenHash?: unknown };
}
).data.claimTokenHash,
).toBeUndefined();
expect(issueGuestRuleClaimCookieMock).not.toHaveBeenCalled();
expect(sendInviteMock).not.toHaveBeenCalled();
});
it("uses a transaction and sends stakeholder invites", async () => {
const created = {
id: "rule-new",
title: "Published title",
summary: null,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
transactionMock.mockImplementation(
async (fn: (tx: { publishedRule: { create: typeof vi.fn }; ruleStakeholder: { create: typeof vi.fn } }) => Promise<unknown>) => {
const tx = {
publishedRule: {
create: vi.fn().mockResolvedValue(created),
},
ruleStakeholder: {
create: vi.fn().mockResolvedValue({}),
},
};
return fn(tx);
},
);
sendInviteMock.mockResolvedValue(undefined);
const res = await POST(
new NextRequest("https://x.test/api/rules", {
method: "POST",
body: JSON.stringify({
title: "Published title",
summary: null,
document: {},
stakeholderEmails: ["stakeholder@example.com"],
}),
}),
undefined,
);
expect(res.status).toBe(200);
expect(transactionMock).toHaveBeenCalledTimes(1);
expect(sendInviteMock).toHaveBeenCalledTimes(1);
expect(sendInviteMock.mock.calls[0][0]).toBe("stakeholder@example.com");
expect(String(sendInviteMock.mock.calls[0][1])).toContain(
"/api/invites/rule-stakeholder/verify?token=",
);
expect(publishedRuleCreateMock).not.toHaveBeenCalled();
});
it("rolls back publish when mail fails", async () => {
const created = {
id: "rule-new",
title: "Published title",
summary: null,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
transactionMock.mockImplementation(
async (fn: (tx: { publishedRule: { create: typeof vi.fn }; ruleStakeholder: { create: typeof vi.fn } }) => Promise<unknown>) => {
const tx = {
publishedRule: {
create: vi.fn().mockResolvedValue(created),
},
ruleStakeholder: {
create: vi.fn().mockResolvedValue({}),
},
};
return fn(tx);
},
);
sendInviteMock.mockRejectedValueOnce(new Error("smtp down"));
publishedRuleDeleteMock.mockResolvedValueOnce({});
const res = await POST(
new NextRequest("https://x.test/api/rules", {
method: "POST",
body: JSON.stringify({
title: "Published title",
summary: null,
document: {},
stakeholderEmails: ["stakeholder@example.com"],
}),
}),
undefined,
);
expect(res.status).toBe(502);
expect(publishedRuleDeleteMock).toHaveBeenCalledWith({
where: { id: "rule-new" },
});
});
it("creates an unlisted rule when there is no session", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
publishedRuleCreateMock.mockResolvedValueOnce({
id: "rule-guest",
title: "Guest rule",
summary: null,
createdAt: new Date("2026-01-03T00:00:00.000Z"),
});
const res = await POST(
new NextRequest("https://x.test/api/rules", {
method: "POST",
headers: { "x-forwarded-for": "203.0.113.10" },
body: JSON.stringify({
title: "Guest rule",
summary: null,
document: {},
stakeholderEmails: ["invitee@example.com"],
}),
}),
undefined,
);
expect(res.status).toBe(200);
expect(rateLimitKeyMock).toHaveBeenCalledWith(
"publish-anonymous-ip:203.0.113.10",
60_000,
);
expect(publishedRuleCreateMock).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: null,
title: "Guest rule",
claimTokenHash: `claim-${"x".repeat(32)}`,
}),
});
expect(issueGuestRuleClaimCookieMock).toHaveBeenCalledWith("x".repeat(32));
expect(transactionMock).not.toHaveBeenCalled();
expect(sendInviteMock).not.toHaveBeenCalled();
const body = (await res.json()) as { rule: { id: string } };
expect(body.rule.id).toBe("rule-guest");
});
it("rate-limits anonymous publish by IP", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
rateLimitKeyMock.mockReturnValueOnce({
ok: false as const,
retryAfterMs: 12_000,
});
const res = await POST(
new NextRequest("https://x.test/api/rules", {
method: "POST",
body: JSON.stringify({
title: "Guest rule",
summary: null,
document: {},
}),
}),
undefined,
);
expect(res.status).toBe(429);
expect(publishedRuleCreateMock).not.toHaveBeenCalled();
expect(issueGuestRuleClaimCookieMock).not.toHaveBeenCalled();
});
});