diff --git a/src/app/api/referrals/route.test.ts b/src/app/api/referrals/route.test.ts index 3af21063..d7a0411b 100644 --- a/src/app/api/referrals/route.test.ts +++ b/src/app/api/referrals/route.test.ts @@ -191,6 +191,65 @@ describe("POST /api/referrals", () => { }); }); + it("should dedupe repeated emails in the same invite batch", async () => { + mockGetAuthContext.mockResolvedValue({ + user: { id: "user1" }, + supabase: mockSupabase, + }); + + let insertedRows: Array> = []; + const mockSelectChain = { + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { referral_code: "testuser", username: "testuser", full_name: "Test User" }, + error: null, + }), + }), + }; + const mockInsertChain = { + select: vi.fn().mockResolvedValue({ + data: [{ id: "ref1", referred_email: "friend@test.com", status: "pending" }], + error: null, + }), + }; + + mockSupabase.from.mockImplementation((table: string) => { + if (table === "profiles") return { select: () => mockSelectChain }; + if (table === "referrals") { + return { + insert: (rows: Array>) => { + insertedRows = rows; + return mockInsertChain; + }, + }; + } + return {}; + }); + + const res = await POST(makePostRequest({ + emails: ["Friend@Test.com", " friend@test.com "], + })); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.message).toContain("1 invite(s) created and sent"); + expect(insertedRows).toEqual([ + { + referrer_id: "user1", + referred_email: "friend@test.com", + referral_code: "testuser", + status: "pending", + }, + ]); + expect(mockSendEmail).toHaveBeenCalledTimes(1); + expect(mockSendEmail).toHaveBeenCalledWith({ + to: "friend@test.com", + subject: "Join ugig.net", + html: "

Join

", + text: "Join", + }); + }); + it("should keep created invites when email delivery fails", async () => { mockGetAuthContext.mockResolvedValue({ user: { id: "user1" }, diff --git a/src/app/api/referrals/route.ts b/src/app/api/referrals/route.ts index 50099975..37e56275 100644 --- a/src/app/api/referrals/route.ts +++ b/src/app/api/referrals/route.ts @@ -69,6 +69,10 @@ export async function POST(request: NextRequest) { ); } + const normalizedEmails = Array.from( + new Set(emails.map((email: string) => email.trim().toLowerCase())) + ); + // Spam throttling: max 50 invites per day, max 10 per hour const svc = createServiceClient(); const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); @@ -80,7 +84,7 @@ export async function POST(request: NextRequest) { .eq("referrer_id", user.id) .gte("created_at", oneHourAgo); - if ((hourlyCount ?? 0) + emails.length > 10) { + if ((hourlyCount ?? 0) + normalizedEmails.length > 10) { return NextResponse.json( { error: "Too many invites. Max 10 per hour." }, { status: 429 } @@ -93,7 +97,7 @@ export async function POST(request: NextRequest) { .eq("referrer_id", user.id) .gte("created_at", oneDayAgo); - if ((dailyCount ?? 0) + emails.length > 50) { + if ((dailyCount ?? 0) + normalizedEmails.length > 50) { return NextResponse.json( { error: "Daily invite limit reached. Max 50 per day." }, { status: 429 } @@ -101,7 +105,6 @@ export async function POST(request: NextRequest) { } // Prevent duplicate invites to same email - const normalizedEmails = emails.map((e: string) => e.trim().toLowerCase()); const { data: existingInvites } = await (svc as AnySupabase) .from("referrals") .select("referred_email")