Skip to content

Commit a313856

Browse files
Deduplicate referral invite emails
1 parent 60b24a1 commit a313856

2 files changed

Lines changed: 122 additions & 1 deletion

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { NextRequest } from "next/server";
3+
import { POST } from "./route";
4+
5+
const mocks = vi.hoisted(() => ({
6+
mockGetAuthContext: vi.fn(),
7+
mockCreateServiceClient: vi.fn(),
8+
mockReferralInviteEmail: vi.fn(),
9+
mockSendEmail: vi.fn(),
10+
}));
11+
12+
vi.mock("@/lib/auth/get-user", () => ({
13+
getAuthContext: mocks.mockGetAuthContext,
14+
}));
15+
16+
vi.mock("@/lib/supabase/service", () => ({
17+
createServiceClient: mocks.mockCreateServiceClient,
18+
}));
19+
20+
vi.mock("@/lib/email", () => ({
21+
referralInviteEmail: mocks.mockReferralInviteEmail,
22+
sendEmail: mocks.mockSendEmail,
23+
}));
24+
25+
function makePostRequest(body: Record<string, unknown>) {
26+
return new NextRequest("http://localhost/api/referrals", {
27+
method: "POST",
28+
body: JSON.stringify(body),
29+
headers: { "Content-Type": "application/json" },
30+
});
31+
}
32+
33+
describe("POST /api/referrals duplicate invite handling", () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks();
36+
mocks.mockReferralInviteEmail.mockReturnValue({
37+
subject: "Join ugig.net",
38+
html: "<p>Join</p>",
39+
text: "Join",
40+
});
41+
mocks.mockSendEmail.mockResolvedValue({ success: true });
42+
});
43+
44+
it("deduplicates repeated emails within the same invite request", async () => {
45+
const existingInviteLookup = vi.fn().mockResolvedValue({ data: [], error: null });
46+
const serviceClient = {
47+
from: vi.fn(() => ({
48+
select: vi.fn(() => ({
49+
eq: vi.fn(() => ({
50+
gte: vi.fn().mockResolvedValue({ count: 0, error: null }),
51+
in: existingInviteLookup,
52+
})),
53+
})),
54+
})),
55+
};
56+
mocks.mockCreateServiceClient.mockReturnValue(serviceClient);
57+
58+
const insertReferrals = vi.fn().mockReturnValue({
59+
select: vi.fn().mockResolvedValue({
60+
data: [{ id: "ref1", referred_email: "friend@test.com", status: "pending" }],
61+
error: null,
62+
}),
63+
});
64+
const authSupabase = {
65+
from: vi.fn((table: string) => {
66+
if (table === "profiles") {
67+
return {
68+
select: vi.fn(() => ({
69+
eq: vi.fn(() => ({
70+
single: vi.fn().mockResolvedValue({
71+
data: {
72+
referral_code: "testuser",
73+
username: "testuser",
74+
full_name: "Test User",
75+
},
76+
error: null,
77+
}),
78+
})),
79+
})),
80+
};
81+
}
82+
83+
if (table === "referrals") {
84+
return { insert: insertReferrals };
85+
}
86+
87+
return {};
88+
}),
89+
};
90+
mocks.mockGetAuthContext.mockResolvedValue({
91+
user: { id: "user1" },
92+
supabase: authSupabase,
93+
});
94+
95+
const res = await POST(
96+
makePostRequest({
97+
emails: [" Friend@Test.com ", "friend@test.com", "FRIEND@test.com"],
98+
})
99+
);
100+
101+
expect(res.status).toBe(200);
102+
expect(existingInviteLookup).toHaveBeenCalledWith("referred_email", ["friend@test.com"]);
103+
expect(mocks.mockSendEmail).toHaveBeenCalledTimes(1);
104+
expect(mocks.mockSendEmail).toHaveBeenCalledWith({
105+
to: "friend@test.com",
106+
subject: "Join ugig.net",
107+
html: "<p>Join</p>",
108+
text: "Join",
109+
});
110+
expect(insertReferrals).toHaveBeenCalledWith([
111+
{
112+
referrer_id: "user1",
113+
referred_email: "friend@test.com",
114+
referral_code: "testuser",
115+
status: "pending",
116+
},
117+
]);
118+
});
119+
});

src/app/api/referrals/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ export async function POST(request: NextRequest) {
8080
// Only valid emails should count toward throttle limits
8181
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
8282
const normalizedEmails = emails.map((e: string) => e.trim().toLowerCase());
83-
const validEmails = normalizedEmails.filter((e: string) => emailRegex.test(e));
83+
const validEmails = Array.from(
84+
new Set(normalizedEmails.filter((e: string) => emailRegex.test(e)))
85+
);
8486

8587
if (validEmails.length === 0) {
8688
return NextResponse.json(

0 commit comments

Comments
 (0)