Skip to content
Closed
Show file tree
Hide file tree
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
59 changes: 59 additions & 0 deletions src/app/api/referrals/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = [];
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<Record<string, unknown>>) => {
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: "<p>Join</p>",
text: "Join",
});
Comment on lines +244 to +250

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Test relies on cross-describe mock state

The assertion that mockSendEmail was called with subject, html, and text only works because mockReferralInviteEmail.mockReturnValue(...) is set in the GET describe's beforeEach and vi.clearAllMocks() (used in the POST describe's beforeEach) does not clear mock return values — only call records. If the GET block is ever removed, reordered, or the POST beforeEach is upgraded to vi.resetAllMocks(), this assertion will silently pass with mockSendEmail receiving { to: "friend@test.com" } (no subject/html/text). Add mockReferralInviteEmail.mockReturnValue({ subject: "Join ugig.net", html: "<p>Join</p>", text: "Join" }) inside this test or the POST beforeEach.

});

it("should keep created invites when email delivery fails", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "user1" },
Expand Down
9 changes: 6 additions & 3 deletions src/app/api/referrals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 }
Expand All @@ -93,15 +97,14 @@ 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 }
);
}

// 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")
Expand Down
Loading