diff --git a/src/app/api/referrals/route.dedupe.test.ts b/src/app/api/referrals/route.dedupe.test.ts new file mode 100644 index 00000000..8d32d4c5 --- /dev/null +++ b/src/app/api/referrals/route.dedupe.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { POST } from "./route"; + +const mocks = vi.hoisted(() => ({ + mockGetAuthContext: vi.fn(), + mockCreateServiceClient: vi.fn(), + mockReferralInviteEmail: vi.fn(), + mockSendEmail: vi.fn(), +})); + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: mocks.mockGetAuthContext, +})); + +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: mocks.mockCreateServiceClient, +})); + +vi.mock("@/lib/email", () => ({ + referralInviteEmail: mocks.mockReferralInviteEmail, + sendEmail: mocks.mockSendEmail, +})); + +function makePostRequest(body: Record) { + return new NextRequest("http://localhost/api/referrals", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +function setupSuccessfulInvite() { + const existingInviteLookup = vi.fn().mockResolvedValue({ data: [], error: null }); + const serviceClient = { + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + gte: vi.fn().mockResolvedValue({ count: 0, error: null }), + in: existingInviteLookup, + })), + })), + })), + }; + mocks.mockCreateServiceClient.mockReturnValue(serviceClient); + + const insertReferrals = vi.fn().mockReturnValue({ + select: vi.fn().mockResolvedValue({ + data: [{ id: "ref1", referred_email: "friend@test.com", status: "pending" }], + error: null, + }), + }); + const authSupabase = { + from: vi.fn((table: string) => { + if (table === "profiles") { + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + single: vi.fn().mockResolvedValue({ + data: { + referral_code: "testuser", + username: "testuser", + full_name: "Test User", + }, + error: null, + }), + })), + })), + }; + } + + if (table === "referrals") { + return { insert: insertReferrals }; + } + + return {}; + }), + }; + mocks.mockGetAuthContext.mockResolvedValue({ + user: { id: "user1" }, + supabase: authSupabase, + }); + + return { existingInviteLookup, insertReferrals }; +} + +describe("POST /api/referrals duplicate invite handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.mockReferralInviteEmail.mockReturnValue({ + subject: "Join ugig.net", + html: "

Join

", + text: "Join", + }); + mocks.mockSendEmail.mockResolvedValue({ success: true }); + }); + + it("deduplicates repeated emails within the same invite request", async () => { + const { existingInviteLookup, insertReferrals } = setupSuccessfulInvite(); + + const res = await POST( + makePostRequest({ + emails: [" Friend@Test.com ", "friend@test.com", "FRIEND@test.com"], + }) + ); + + expect(res.status).toBe(200); + expect(existingInviteLookup).toHaveBeenCalledWith("referred_email", ["friend@test.com"]); + expect(mocks.mockSendEmail).toHaveBeenCalledTimes(1); + expect(mocks.mockSendEmail).toHaveBeenCalledWith({ + to: "friend@test.com", + subject: "Join ugig.net", + html: "

Join

", + text: "Join", + }); + expect(insertReferrals).toHaveBeenCalledWith([ + { + referrer_id: "user1", + referred_email: "friend@test.com", + referral_code: "testuser", + status: "pending", + }, + ]); + }); + + it("applies the per-request cap after normalized duplicates are collapsed", async () => { + const { existingInviteLookup, insertReferrals } = setupSuccessfulInvite(); + + const res = await POST( + makePostRequest({ + emails: Array.from({ length: 21 }, () => " Friend@Test.com "), + }) + ); + + expect(res.status).toBe(200); + expect(existingInviteLookup).toHaveBeenCalledWith("referred_email", ["friend@test.com"]); + expect(mocks.mockSendEmail).toHaveBeenCalledTimes(1); + expect(insertReferrals).toHaveBeenCalledWith([ + { + referrer_id: "user1", + referred_email: "friend@test.com", + referral_code: "testuser", + status: "pending", + }, + ]); + }); +}); diff --git a/src/app/api/referrals/route.ts b/src/app/api/referrals/route.ts index b4bffb23..6a27788a 100644 --- a/src/app/api/referrals/route.ts +++ b/src/app/api/referrals/route.ts @@ -4,6 +4,8 @@ import { referralInviteEmail, sendEmail } from "@/lib/email"; import { createServiceClient } from "@/lib/supabase/service"; type AnySupabase = any; +const MAX_EMAIL_ENTRIES_PER_REQUEST = 200; +const MAX_INVITES_PER_REQUEST = 20; // GET /api/referrals - List my referrals export async function GET(request: NextRequest) { @@ -69,9 +71,9 @@ export async function POST(request: NextRequest) { ); } - if (emails.length > 20) { + if (emails.length > MAX_EMAIL_ENTRIES_PER_REQUEST) { return NextResponse.json( - { error: "Maximum 20 invites at a time" }, + { error: `Maximum ${MAX_EMAIL_ENTRIES_PER_REQUEST} email entries at a time` }, { status: 400 } ); } @@ -80,7 +82,9 @@ export async function POST(request: NextRequest) { // Only valid emails should count toward throttle limits const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const normalizedEmails = emails.map((e: string) => e.trim().toLowerCase()); - const validEmails = normalizedEmails.filter((e: string) => emailRegex.test(e)); + const validEmails = Array.from( + new Set(normalizedEmails.filter((e: string) => emailRegex.test(e))) + ); if (validEmails.length === 0) { return NextResponse.json( @@ -89,6 +93,13 @@ export async function POST(request: NextRequest) { ); } + if (validEmails.length > MAX_INVITES_PER_REQUEST) { + return NextResponse.json( + { error: `Maximum ${MAX_INVITES_PER_REQUEST} invites at a time` }, + { status: 400 } + ); + } + // Spam throttling: max 50 invites per day, max 10 per hour // Only count valid emails toward rate limits (#143) const svc = createServiceClient();