223 lines
7.2 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|