From 5419d5104b036b4be9a6abb5a0383b2533287115 Mon Sep 17 00:00:00 2001 From: forgou37 Date: Sat, 30 May 2026 08:50:12 +0300 Subject: [PATCH] fix(referrals): align test expectations with current route implementation (#348) Two test cases had stale assertions that no longer matched the route: 1. 'should create referrals for valid emails' expected 'created and sent' but route returns 'sent successfully'. 2. 'should keep created invites when email delivery fails' expected 200 but route returns 502 when all deliveries fail (emails-first strategy only inserts DB records for successfully delivered emails). Updated both tests to match actual behavior and renamed the second test to 'returns 502 when all email deliveries fail' to accurately describe it. --- src/app/api/referrals/route.test.ts | 102 +++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 17 deletions(-) diff --git a/src/app/api/referrals/route.test.ts b/src/app/api/referrals/route.test.ts index 177ff375..2aa55b6d 100644 --- a/src/app/api/referrals/route.test.ts +++ b/src/app/api/referrals/route.test.ts @@ -55,6 +55,30 @@ function makeServiceClientWithNoExistingReferrals() { }; } +function makeServiceClientWithExistingReferrals(existingEmails: string[]) { + let referralsQueryCount = 0; + + return { + from: vi.fn(() => ({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + gte: vi.fn().mockImplementation(() => { + referralsQueryCount += 1; + if (referralsQueryCount <= 2) { + return Promise.resolve({ count: 0, error: null }); + } + return Promise.resolve({ data: [], error: null }); + }), + in: vi.fn().mockResolvedValue({ + data: existingEmails.map((referred_email) => ({ referred_email })), + error: null, + }), + }), + }), + })), + }; +} + function makeGetRequest() { return new NextRequest("http://localhost/api/referrals", { method: "GET" }); } @@ -174,10 +198,18 @@ describe("POST /api/referrals", () => { return {}; }); + mockSendEmail.mockResolvedValue({ success: true }); + mockReferralInviteEmail.mockReturnValue({ + subject: "Join ugig.net", + html: "

Join

", + text: "Join", + }); + const res = await POST(makePostRequest({ emails: ["friend@test.com"] })); expect(res.status).toBe(200); const body = await res.json(); - expect(body.message).toContain("1 invite(s) created and sent"); + // Route returns "X invite(s) sent successfully" for all-successful sends + expect(body.message).toContain("1 invite(s) sent successfully"); expect(body.email_delivery_failed).toBe(0); expect(mockReferralInviteEmail).toHaveBeenCalledWith({ inviterName: "Test User", @@ -191,12 +223,20 @@ describe("POST /api/referrals", () => { }); }); - it("should keep created invites when email delivery fails", async () => { + it("returns 502 when all email deliveries fail", async () => { + // Route sends emails FIRST, then only inserts DB records for successfully + // delivered emails. When all fail, it returns 502 rather than inserting + // pending records that can never be confirmed. mockGetAuthContext.mockResolvedValue({ user: { id: "user1" }, supabase: mockSupabase, }); - mockSendEmail.mockResolvedValueOnce({ success: false, error: "resend failed" }); + mockSendEmail.mockResolvedValue({ success: false, error: "resend failed" }); + mockReferralInviteEmail.mockReturnValue({ + subject: "Join ugig.net", + html: "

Join

", + text: "Join", + }); const mockSelectChain = { eq: vi.fn().mockReturnValue({ @@ -206,28 +246,19 @@ describe("POST /api/referrals", () => { }), }), }; - 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: () => mockInsertChain }; + if (table === "referrals") return { insert: mockInsert }; return {}; }); const res = await POST(makePostRequest({ emails: ["friend@test.com"] })); - expect(res.status).toBe(200); + // All deliveries failed -> 502, no DB records inserted + expect(res.status).toBe(502); const body = await res.json(); - expect(body.message).toContain("1 email(s) failed to send"); - expect(body.email_delivery_failed).toBe(1); - expect(mockReferralInviteEmail).toHaveBeenCalledWith({ - inviterName: "testuser", - referralCode: "testuser", - }); + expect(body.error).toContain("Failed to send invitation emails"); + expect(mockInsert).not.toHaveBeenCalled(); }); it("should return 400 for invalid emails only", async () => { @@ -242,6 +273,36 @@ describe("POST /api/referrals", () => { expect(body.error).toContain("No valid email"); }); + it("normalizes duplicate invite checks before inserting or sending", async () => { + mockCreateServiceClient.mockReturnValue(makeServiceClientWithExistingReferrals(["friend@test.com"])); + mockGetAuthContext.mockResolvedValue({ + user: { id: "user1" }, + supabase: mockSupabase, + }); + + const mockSelectChain = { + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { referral_code: "testuser", username: "testuser", full_name: "Test User" }, + error: null, + }), + }), + }; + + mockSupabase.from.mockImplementation((table: string) => { + if (table === "profiles") return { select: () => mockSelectChain }; + if (table === "referrals") return { insert: mockInsert }; + return {}; + }); + + const res = await POST(makePostRequest({ emails: [" Friend@Test.com "] })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("already been invited"); + expect(mockInsert).not.toHaveBeenCalled(); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); + // Regression test for #143: Invalid emails should return 400 before rate-limit check it("returns 400 for all-invalid emails, NOT 429 rate-limit (#143)", async () => { mockGetAuthContext.mockResolvedValue({ @@ -286,6 +347,13 @@ describe("POST /api/referrals", () => { return {}; }); + mockSendEmail.mockResolvedValue({ success: true }); + mockReferralInviteEmail.mockReturnValue({ + subject: "Join ugig.net", + html: "

Join

", + text: "Join", + }); + // Send 9 invalid + 1 valid email. With old code, emails.length=10 would count // toward rate limit. With fix, only validEmails.length=1 counts. // This should succeed since only 1 valid email is within the limit of 10/hour