-
Notifications
You must be signed in to change notification settings - Fork 32
fix(referrals): align test expectations with current route implementation #349
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: "<p>Join</p>", | ||
| 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: "<p>Join</p>", | ||
| 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(); | ||
| }); | ||
|
Comment on lines
256
to
262
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| 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: "<p>Join</p>", | ||
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
route.ts(line 184) returns the string"X invite(s) created and sent"on full success — not"X invite(s) sent successfully". The route inserts DB records first (lines 164–171) and then sends emails (lines 173–179); there is no "emails-first" strategy in the current implementation. This assertion will fail as written.