Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 85 additions & 17 deletions src/app/api/referrals/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
Expand Down Expand Up @@ -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);
Comment on lines +211 to 213
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Test assertion doesn't match route implementation

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.

expect(mockReferralInviteEmail).toHaveBeenCalledWith({
inviterName: "Test User",
Expand All @@ -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({
Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 502 path and mockInsert guard contradict the actual route

route.ts always calls supabase.from("referrals").insert(...) (line 164) before sending any email. It never returns 502; when email delivery fails it returns 200 with email_delivery_failed > 0. As a result this test will fail on two assertions: expect(res.status).toBe(502) and expect(mockInsert).not.toHaveBeenCalled(). The PR description documents an "emails-first" contract that does not exist in the current route.ts.


it("should return 400 for invalid emails only", async () => {
Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand Down
Loading