import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; const findFirstStakeholderMock = vi.fn(); const createRuleMock = vi.fn(); const getSessionUserMock = vi.fn(); const rateLimitKeyMock = vi.fn( ( _key: string, _minIntervalMs: number, ): { ok: true } | { ok: false; retryAfterMs: number } => ({ ok: true }), ); vi.mock("../../lib/server/env", () => ({ isDatabaseConfigured: () => true, })); vi.mock("../../lib/server/session", () => ({ getSessionUser: () => getSessionUserMock(), })); vi.mock("../../lib/server/rateLimit", () => ({ rateLimitKey: (key: string, minIntervalMs: number) => rateLimitKeyMock(key, minIntervalMs), })); vi.mock("../../lib/server/db", () => ({ prisma: { ruleStakeholder: { findFirst: (...args: unknown[]) => findFirstStakeholderMock(...args), }, publishedRule: { create: (...args: unknown[]) => createRuleMock(...args), }, }, })); import { POST } from "../../app/api/rules/[id]/make-copy/route"; const viewer = { id: "user-2", email: "reader@example.com" }; const routeCtx = { params: Promise.resolve({ id: "rule-1" }) }; beforeEach(() => { findFirstStakeholderMock.mockReset(); createRuleMock.mockReset(); getSessionUserMock.mockReset(); rateLimitKeyMock.mockReset(); rateLimitKeyMock.mockReturnValue({ ok: true as const }); getSessionUserMock.mockResolvedValue(viewer); }); describe("POST /api/rules/[id]/make-copy", () => { it("returns 401 when unauthenticated", async () => { getSessionUserMock.mockResolvedValueOnce(null); const res = await POST( new NextRequest("https://x.test/api/rules/rule-1/make-copy", { method: "POST", body: JSON.stringify({ title: "New group" }), headers: { "content-type": "application/json" }, }), routeCtx, ); expect(res.status).toBe(401); }); it("returns 404 when the viewer is not an invited reader", async () => { findFirstStakeholderMock.mockResolvedValueOnce(null); const res = await POST( new NextRequest("https://x.test/api/rules/rule-1/make-copy", { method: "POST", body: JSON.stringify({ title: "New group" }), headers: { "content-type": "application/json" }, }), routeCtx, ); expect(res.status).toBe(404); expect(createRuleMock).not.toHaveBeenCalled(); }); it("copies cards and text, not the original name or description", async () => { findFirstStakeholderMock.mockResolvedValueOnce({ id: "st-1", rule: { id: "rule-1", title: "Juniper Street Food Share", userId: "owner-1", document: { title: "Juniper Street Food Share", communityContext: "A neighborhood share.", communityAvatarUrl: "https://example.test/a.png", sections: [ { categoryName: "Values", entries: [ { title: "Care", body: "Juniper Street Food Share cares.", }, ], }, ], }, }, }); createRuleMock.mockResolvedValueOnce({ id: "rule-copy", title: "New group", summary: null, createdAt: new Date("2026-01-01T00:00:00Z"), updatedAt: new Date("2026-01-01T00:00:00Z"), }); const res = await POST( new NextRequest("https://x.test/api/rules/rule-1/make-copy", { method: "POST", body: JSON.stringify({ title: "New group" }), headers: { "content-type": "application/json" }, }), routeCtx, ); expect(res.status).toBe(200); expect(createRuleMock).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: viewer.id, title: "New group", summary: null, document: expect.not.objectContaining({ title: "Juniper Street Food Share", communityContext: "A neighborhood share.", communityAvatarUrl: "https://example.test/a.png", }), }), }); const body = (await res.json()) as { rule: { id: string; copiedFromTitle: string; copiedNameHit: { entryTitle: string } | null; }; }; expect(body.rule.id).toBe("rule-copy"); expect(body.rule.copiedFromTitle).toBe("Juniper Street Food Share"); expect(body.rule.copiedNameHit?.entryTitle).toBe("Care"); }); });