Skip to content
Merged
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
38 changes: 38 additions & 0 deletions src/app/api/affiliates/offers/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,44 @@ describe("POST /api/affiliates/offers", () => {
expect(insertData.title).not.toContain("<b>");
});

it("falls back to a non-empty slug when the title has no slug-safe characters", async () => {
mockGetAuthContext.mockResolvedValue({ user: { id: "user1" } });

const insertMock = vi.fn().mockReturnValue({
select: () => ({
single: () => Promise.resolve({
data: { id: "new-id", slug: "offer", title: "🔥🔥🔥" },
error: null,
}),
}),
});

mockFrom.mockReturnValue({
select: () => ({
eq: () => ({
single: () => Promise.resolve({ data: null, error: null }),
}),
}),
insert: insertMock,
});

const req = new NextRequest("http://localhost/api/affiliates/offers", {
method: "POST",
body: JSON.stringify({
title: "🔥🔥🔥",
description: "A description that is long enough for validation",
price_sats: 1000,
commission_type: "percentage",
commission_rate: 0.2,
}),
});

const res = await POST(req);
expect(res.status).toBe(201);
const insertData = insertMock.mock.calls[0][0];
expect(insertData.slug).toBe("offer");
});

it("rejects negative commission_flat_sats (#23)", async () => {
mockGetAuthContext.mockResolvedValue({ user: { id: "user1" } });

Expand Down
3 changes: 2 additions & 1 deletion src/app/api/affiliates/offers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ function parsePaginationParam(
}

function slugify(text: string): string {
return text
const slug = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80);
return slug || "offer";
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 All titles that produce no slug-safe characters (emoji, pure punctuation like "---", CJK-only strings pre-Unicode normalization, etc.) collapse to the same fallback "offer". Because the uniqueness check and the insert are not atomic, two concurrent requests that both generate "offer" will both pass the if (existing) guard and race to insert — one will hit a unique-constraint violation. For normal titles this race is rare since each user's title is different; here every non-slugifiable title funnels to the same word, so concurrent emoji-only offers will trigger it reliably. Adding a short random suffix to the fallback itself (before the collision check runs) makes all fallback slugs distinct by default and sidesteps the race entirely.

Suggested change
return slug || "offer";
return slug || `offer-${Math.random().toString(36).slice(2, 8)}`;

}

/**
Expand Down
Loading