Files
community-rule/tests/unit/rulesStakeholderMutationsRoute.test.ts
T
2026-05-22 13:30:47 -06:00

223 lines
7.2 KiB
TypeScript

import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const findFirstRuleMock = vi.fn();
const findFirstStakeholderMock = vi.fn();
const countMock = vi.fn();
const deleteMock = vi.fn();
const updateMock = vi.fn();
const createInviteMock = vi.fn();
const getSessionUserMock = vi.fn();
const getSessionPepperMock = vi.fn();
const sendInviteEmailMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => true,
getSessionPepper: () => getSessionPepperMock(),
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
publishedRule: {
findFirst: (...args: unknown[]) => findFirstRuleMock(...args),
},
ruleStakeholder: {
findFirst: (...args: unknown[]) => findFirstStakeholderMock(...args),
count: (...args: unknown[]) => countMock(...args),
delete: (...args: unknown[]) => deleteMock(...args),
update: (...args: unknown[]) => updateMock(...args),
},
},
}));
vi.mock("../../lib/server/session", () => ({
getSessionUser: () => getSessionUserMock(),
}));
vi.mock("../../lib/server/ruleStakeholderInviteOps", () => ({
createRuleStakeholderInviteAndSendMail: (...args: unknown[]) =>
createInviteMock(...args),
stakeholderInviteVerifyUrl: (origin: string, token: string) =>
`${origin}/api/invites/rule-stakeholder/verify?token=${encodeURIComponent(token)}`,
}));
vi.mock("../../lib/server/hash", () => ({
hashSessionToken: (token: string) => `hash-${token}`,
newSessionToken: () => "invite-token",
}));
vi.mock("../../lib/server/mail", () => ({
sendRuleStakeholderInviteEmail: (...args: unknown[]) =>
sendInviteEmailMock(...args),
}));
vi.mock("../../lib/server/rateLimit", () => ({
rateLimitKey: () => ({ ok: true }),
}));
import { DELETE } from "../../app/api/rules/[id]/stakeholders/[stakeholderId]/route";
import { POST as POST_RESEND } from "../../app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route";
import { POST } from "../../app/api/rules/[id]/stakeholders/route";
const owner = { id: "owner-1", email: "owner@example.com" };
const routeCtx = { params: Promise.resolve({ id: "rule-1" }) };
const memberCtx = {
params: Promise.resolve({ id: "rule-1", stakeholderId: "st-1" }),
};
beforeEach(() => {
findFirstRuleMock.mockReset();
findFirstStakeholderMock.mockReset();
countMock.mockReset();
deleteMock.mockReset();
updateMock.mockReset();
createInviteMock.mockReset();
getSessionUserMock.mockReset();
getSessionPepperMock.mockReset();
sendInviteEmailMock.mockReset();
getSessionUserMock.mockResolvedValue(owner);
getSessionPepperMock.mockReturnValue("pepper");
findFirstRuleMock.mockResolvedValue({ id: "rule-1", title: "My rule" });
createInviteMock.mockResolvedValue({ ok: true });
sendInviteEmailMock.mockResolvedValue(undefined);
updateMock.mockResolvedValue(undefined);
deleteMock.mockResolvedValue(undefined);
countMock.mockResolvedValue(0);
});
describe("POST /api/rules/[id]/stakeholders", () => {
it("returns 401 when unauthenticated", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
const res = await POST(
new NextRequest("https://x.test/api/rules/rule-1/stakeholders", {
method: "POST",
body: JSON.stringify({ email: "inv@example.com" }),
headers: { "content-type": "application/json" },
}),
routeCtx,
);
expect(res.status).toBe(401);
});
it("returns 400 when inviting the owner email", async () => {
const res = await POST(
new NextRequest("https://x.test/api/rules/rule-1/stakeholders", {
method: "POST",
body: JSON.stringify({ email: "Owner@Example.com" }),
headers: { "content-type": "application/json" },
}),
routeCtx,
);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe("validation_error");
});
it("creates an invite and returns 201", async () => {
findFirstStakeholderMock
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "st-new",
email: "inv@example.com",
invitedAt: new Date("2026-01-01T00:00:00Z"),
});
const res = await POST(
new NextRequest("https://x.test/api/rules/rule-1/stakeholders", {
method: "POST",
body: JSON.stringify({ email: "inv@example.com" }),
headers: { "content-type": "application/json" },
}),
routeCtx,
);
expect(res.status).toBe(201);
expect(createInviteMock).toHaveBeenCalled();
const body = (await res.json()) as {
stakeholder: { email: string; status: string };
};
expect(body.stakeholder.email).toBe("inv@example.com");
expect(body.stakeholder.status).toBe("pending");
});
});
describe("DELETE /api/rules/[id]/stakeholders/[stakeholderId]", () => {
it("returns 403 when the rule belongs to another user", async () => {
findFirstStakeholderMock.mockResolvedValueOnce({
id: "st-1",
rule: { userId: "other-user" },
});
const res = await DELETE(
new NextRequest(
"https://x.test/api/rules/rule-1/stakeholders/st-1",
{ method: "DELETE" },
),
memberCtx,
);
expect(res.status).toBe(403);
expect(deleteMock).not.toHaveBeenCalled();
});
it("deletes the stakeholder for the rule owner", async () => {
findFirstStakeholderMock.mockResolvedValueOnce({
id: "st-1",
rule: { userId: owner.id },
});
const res = await DELETE(
new NextRequest(
"https://x.test/api/rules/rule-1/stakeholders/st-1",
{ method: "DELETE" },
),
memberCtx,
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "st-1" } });
});
});
describe("POST /api/rules/[id]/stakeholders/[stakeholderId]/resend", () => {
it("returns 400 when the invite was already accepted", async () => {
findFirstStakeholderMock.mockResolvedValueOnce({
id: "st-1",
email: "inv@example.com",
inviteTokenHash: null,
inviteExpiresAt: null,
rule: { userId: owner.id, title: "My rule" },
});
const res = await POST_RESEND(
new NextRequest(
"https://x.test/api/rules/rule-1/stakeholders/st-1/resend",
{ method: "POST" },
),
memberCtx,
);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe("validation_error");
});
it("rotates the token and sends a new invite email", async () => {
findFirstStakeholderMock.mockResolvedValueOnce({
id: "st-1",
email: "inv@example.com",
inviteTokenHash: "old-hash",
inviteExpiresAt: new Date("2026-01-01T00:00:00Z"),
rule: { userId: owner.id, title: "My rule" },
});
const res = await POST_RESEND(
new NextRequest(
"https://x.test/api/rules/rule-1/stakeholders/st-1/resend",
{ method: "POST" },
),
memberCtx,
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
expect(updateMock).toHaveBeenCalled();
expect(sendInviteEmailMock).toHaveBeenCalledWith(
"inv@example.com",
expect.stringContaining("/api/invites/rule-stakeholder/verify?token="),
"My rule",
);
});
});