import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; const findUniqueMock = vi.fn(); const updateMock = vi.fn(); const sendInviteMock = vi.fn(); const getSessionPepperMock = vi.fn(() => "pepper"); const rateLimitKeyMock = vi.fn( ( _key: string, _minIntervalMs: number, ): { ok: true } | { ok: false; retryAfterMs: number } => ({ ok: true }), ); vi.mock("../../lib/server/env", () => ({ isDatabaseConfigured: () => true, getSessionPepper: () => getSessionPepperMock(), })); vi.mock("../../lib/server/db", () => ({ prisma: { ruleStakeholder: { findUnique: (...args: unknown[]) => findUniqueMock(...args), update: (...args: unknown[]) => updateMock(...args), }, }, })); vi.mock("../../lib/server/hash", () => ({ hashSessionToken: (token: string) => `hash-${token}`, newSessionToken: () => "new-token", })); vi.mock("../../lib/server/mail", () => ({ sendRuleStakeholderInviteEmail: (...args: unknown[]) => sendInviteMock(...args), })); vi.mock("../../lib/server/rateLimit", () => ({ rateLimitKey: (key: string, minIntervalMs: number) => rateLimitKeyMock(key, minIntervalMs), })); import { RULE_STAKEHOLDER_INVITER_FALLBACK } from "../../lib/server/ruleStakeholderInviteMail"; import { POST } from "../../app/api/invites/rule-stakeholder/resend/route"; beforeEach(() => { findUniqueMock.mockReset(); updateMock.mockReset(); sendInviteMock.mockReset(); getSessionPepperMock.mockReset(); rateLimitKeyMock.mockReset(); getSessionPepperMock.mockReturnValue("pepper"); rateLimitKeyMock.mockReturnValue({ ok: true as const }); updateMock.mockResolvedValue({}); sendInviteMock.mockResolvedValue(undefined); }); describe("POST /api/invites/rule-stakeholder/resend", () => { it("returns ok without sending when the token does not match", async () => { findUniqueMock.mockResolvedValueOnce(null); const res = await POST( new NextRequest("https://x.test/api/invites/rule-stakeholder/resend", { method: "POST", body: JSON.stringify({ token: "a".repeat(12), email: "inv@example.com", }), headers: { "content-type": "application/json" }, }), undefined, ); expect(res.status).toBe(200); expect(sendInviteMock).not.toHaveBeenCalled(); }); it("rotates the token and sends mail when email matches", async () => { findUniqueMock.mockResolvedValueOnce({ id: "st-1", email: "inv@example.com", inviteTokenHash: "hash-old", inviteExpiresAt: new Date(Date.now() - 60_000), invitedBy: { email: "owner@example.com" }, rule: { title: "Juniper Street Food Share", summary: null, document: {}, user: { email: "owner@example.com" }, }, }); const res = await POST( new NextRequest("https://x.test/api/invites/rule-stakeholder/resend", { method: "POST", body: JSON.stringify({ token: "a".repeat(12), email: "inv@example.com", }), headers: { "content-type": "application/json" }, }), undefined, ); expect(res.status).toBe(200); expect(updateMock).toHaveBeenCalled(); expect(sendInviteMock).toHaveBeenCalledWith( expect.objectContaining({ to: "inv@example.com", communityName: "Juniper Street Food Share", inviterEmail: "owner@example.com", }), ); }); it("does not use a fake mailbox when the inviter and owner have no email", async () => { findUniqueMock.mockResolvedValueOnce({ id: "st-1", email: "inv@example.com", inviteTokenHash: "hash-old", inviteExpiresAt: new Date(Date.now() - 60_000), invitedBy: null, rule: { title: "Juniper Street Food Share", summary: null, document: {}, user: { email: null }, }, }); const res = await POST( new NextRequest("https://x.test/api/invites/rule-stakeholder/resend", { method: "POST", body: JSON.stringify({ token: "a".repeat(12), email: "inv@example.com", }), headers: { "content-type": "application/json" }, }), undefined, ); expect(res.status).toBe(200); expect(sendInviteMock).toHaveBeenCalledWith( expect.objectContaining({ inviterEmail: RULE_STAKEHOLDER_INVITER_FALLBACK, }), ); }); });