Skip to content

Commit c2b3df6

Browse files
committed
fix(teams): prevent duplicate slug allocation under concurrent creation
Replace non-deterministic random suffix generation with sequential numeric candidates (my-team → my-team-1 → my-team-2, capped at 10). Wrap team creation in a bounded retry loop (5 attempts) so P2002 constraint violations from concurrent inserts trigger re-allocation rather than surfacing as a 409. The database-level @unique constraint on Team.slug remains the authoritative guard; application logic now recovers gracefully when it fires. Adds slug utility tests (createSlug, generateUniqueSlug determinism and bounds) and team route tests for retry-on-race-condition and retry-exhaustion paths. Closes #499
1 parent 427ef34 commit c2b3df6

4 files changed

Lines changed: 182 additions & 66 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
3+
import { createSlug, generateUniqueSlug } from '../utils/slug';
4+
5+
describe('createSlug', () => {
6+
it('lowercases and trims input', () => {
7+
expect(createSlug(' Hello World ')).toBe('hello-world');
8+
});
9+
10+
it('replaces spaces with hyphens', () => {
11+
expect(createSlug('My Team Name')).toBe('my-team-name');
12+
});
13+
14+
it('strips non-alphanumeric characters', () => {
15+
expect(createSlug('DevCard @Core!')).toBe('devcard-core');
16+
});
17+
18+
it('collapses multiple hyphens', () => {
19+
expect(createSlug('a--b---c')).toBe('a-b-c');
20+
});
21+
22+
it('removes leading and trailing hyphens', () => {
23+
expect(createSlug('--team--')).toBe('team');
24+
});
25+
});
26+
27+
describe('generateUniqueSlug', () => {
28+
it('returns base slug when it is available', async () => {
29+
const slugExists = vi.fn().mockResolvedValue(false);
30+
const result = await generateUniqueSlug('My Team', slugExists);
31+
expect(result).toBe('my-team');
32+
expect(slugExists).toHaveBeenCalledOnce();
33+
});
34+
35+
it('returns sequential numeric suffix when base slug is taken', async () => {
36+
const slugExists = vi.fn()
37+
.mockResolvedValueOnce(true) // my-team taken
38+
.mockResolvedValueOnce(false); // my-team-1 free
39+
const result = await generateUniqueSlug('My Team', slugExists);
40+
expect(result).toBe('my-team-1');
41+
});
42+
43+
it('increments suffix deterministically until a free slot is found', async () => {
44+
const slugExists = vi.fn()
45+
.mockResolvedValueOnce(true) // my-team
46+
.mockResolvedValueOnce(true) // my-team-1
47+
.mockResolvedValueOnce(true) // my-team-2
48+
.mockResolvedValueOnce(false); // my-team-3 free
49+
const result = await generateUniqueSlug('My Team', slugExists);
50+
expect(result).toBe('my-team-3');
51+
});
52+
53+
it('throws when all 10 suffix candidates are taken', async () => {
54+
const slugExists = vi.fn().mockResolvedValue(true);
55+
await expect(generateUniqueSlug('My Team', slugExists)).rejects.toThrow(
56+
'Unable to generate unique slug',
57+
);
58+
expect(slugExists).toHaveBeenCalledTimes(11); // base + 10 suffixes
59+
});
60+
61+
it('produces consistent slugs across concurrent calls for different inputs', async () => {
62+
const takenSlugs = new Set<string>();
63+
const slugExists = vi.fn(async (slug: string) => takenSlugs.has(slug));
64+
65+
const [a, b] = await Promise.all([
66+
generateUniqueSlug('Alpha Team', slugExists),
67+
generateUniqueSlug('Beta Team', slugExists),
68+
]);
69+
70+
expect(a).toBe('alpha-team');
71+
expect(b).toBe('beta-team');
72+
expect(a).not.toBe(b);
73+
});
74+
});

apps/backend/src/__tests__/team.test.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import { Prisma, TeamRole } from '@prisma/client';
2+
import Fastify from 'fastify';
13
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2-
import Fastify, { FastifyInstance } from 'fastify';
3-
import { PrismaClient, TeamRole } from '@prisma/client';
4+
45
import { teamRoutes } from '../routes/team';
56

7+
import type { PrismaClient } from '@prisma/client';
8+
import type { FastifyInstance } from 'fastify';
9+
610
// ─── Shared mock data ─────────────────────────────────────────────────────────
711

812
const MOCK_OWNER_ID = 'user-uuid-001';
@@ -92,7 +96,7 @@ const prismaMock = {
9296

9397
// ─── App factory ──────────────────────────────────────────────────────────────
9498

95-
let mockJwtVerify = vi.fn();
99+
const mockJwtVerify = vi.fn();
96100

97101
async function buildApp(): Promise<FastifyInstance> {
98102
const app = Fastify({ logger: false });
@@ -118,7 +122,7 @@ async function createTeam(
118122
app: FastifyInstance,
119123
body: Record<string, unknown>,
120124
authenticated = true,
121-
) {
125+
): Promise<ReturnType<typeof app.inject>> {
122126
return app.inject({
123127
method: 'POST',
124128
url: '/',
@@ -220,6 +224,46 @@ describe('Teams API', () => {
220224
expect(res.statusCode).toBe(500);
221225
expect(res.json()).toMatchObject({ error: 'Failed to create team' });
222226
});
227+
228+
it('201 — retries and succeeds when first attempt loses slug race to concurrent request', async () => {
229+
// First generateUniqueSlug: base slug appears available
230+
prismaMock.team.findUnique.mockResolvedValueOnce(null);
231+
// First $transaction: P2002 — another request inserted first
232+
prismaMock.$transaction.mockRejectedValueOnce(
233+
new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { code: 'P2002', clientVersion: '0' }),
234+
);
235+
// Second generateUniqueSlug: base slug now taken, devcard-core-1 is free
236+
prismaMock.team.findUnique.mockResolvedValueOnce(MOCK_TEAM); // devcard-core taken
237+
prismaMock.team.findUnique.mockResolvedValueOnce(null); // devcard-core-1 free
238+
// Second $transaction: succeeds with suffix slug
239+
prismaMock.$transaction.mockImplementationOnce(async (cb: any) => {
240+
return cb({
241+
team: { create: vi.fn().mockResolvedValue({ ...MOCK_TEAM, slug: 'devcard-core-1' }) },
242+
teamMember: { create: vi.fn().mockResolvedValue({}) },
243+
});
244+
});
245+
246+
const res = await createTeam(app, validBody);
247+
248+
expect(res.statusCode).toBe(201);
249+
expect(res.json().slug).toBe('devcard-core-1');
250+
});
251+
252+
it('409 — exhausts all retry attempts when DB rejects every slug with P2002', async () => {
253+
const p2002 = new Prisma.PrismaClientKnownRequestError(
254+
'Unique constraint failed on the fields: (`slug`)',
255+
{ code: 'P2002', clientVersion: '0' },
256+
);
257+
// Slug always appears available at the application level
258+
prismaMock.team.findUnique.mockResolvedValue(null);
259+
// DB always rejects with P2002 (concurrent inserts won every race)
260+
prismaMock.$transaction.mockRejectedValue(p2002);
261+
262+
const res = await createTeam(app, validBody);
263+
264+
expect(res.statusCode).toBe(409);
265+
expect(prismaMock.$transaction).toHaveBeenCalledTimes(5);
266+
});
223267
});
224268

225269
// ── GET /:slug — public team profile ─────────────────────────────────────

apps/backend/src/routes/team.ts

Lines changed: 38 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ type TeamProfile = {
2424
members: TeamMember[];
2525
}
2626

27-
export async function teamRoutes(app:FastifyInstance){
27+
export async function teamRoutes(app: FastifyInstance): Promise<void> {
2828
app.post('/', async(request:FastifyRequest<{
2929
Body: {name: string, description? : string, avatarUrl?: string }
3030
}>, reply: FastifyReply) => {
3131
let decoded;
3232
try {
3333
decoded = await request.jwtVerify() as any;
34-
} catch (error) {
34+
} catch (_error) {
3535
return reply.status(401).send({error : 'Unauthorized'})
3636
}
3737

@@ -42,55 +42,45 @@ export async function teamRoutes(app:FastifyInstance){
4242
};
4343
const {name , description , avatarUrl} = parsed.data;
4444

45-
const finalSlug = await generateUniqueSlug(name, async(slug) => {
46-
const existing = await app.prisma.team.findUnique({where: {slug }})
45+
const MAX_CREATE_ATTEMPTS = 5;
4746

48-
return !!existing
49-
})
47+
for (let attempt = 0; attempt < MAX_CREATE_ATTEMPTS; attempt++) {
48+
let finalSlug: string;
49+
try {
50+
finalSlug = await generateUniqueSlug(name, async (slug) => {
51+
const existing = await app.prisma.team.findUnique({ where: { slug } });
52+
return !!existing;
53+
});
54+
} catch {
55+
return reply.status(409).send({ error: 'Unable to generate a unique team slug' });
56+
}
5057

51-
try {
58+
try {
5259
const team = await app.prisma.$transaction(async (tx) => {
53-
const team = await tx.team.create({
54-
data: {
55-
name,
56-
slug: finalSlug,
57-
description,
58-
avatarUrl,
59-
ownerId: userId,
60-
}
61-
})
62-
63-
await tx.teamMember.create({
64-
data: {
65-
teamId : team.id,
66-
userId,
67-
role: TeamRole.OWNER,
68-
joinedAt: new Date(),
69-
}
70-
})
71-
return team
72-
})
73-
return reply.status(201).send(team)
74-
75-
}catch (error) {
60+
const created = await tx.team.create({
61+
data: { name, slug: finalSlug, description, avatarUrl, ownerId: userId },
62+
});
63+
await tx.teamMember.create({
64+
data: { teamId: created.id, userId, role: TeamRole.OWNER, joinedAt: new Date() },
65+
});
66+
return created;
67+
});
68+
return reply.status(201).send(team);
69+
} catch (error) {
7670
if (error instanceof Prisma.PrismaClientKnownRequestError) {
77-
switch (error.code) {
78-
case 'P2002':
79-
return reply.status(409).send({
80-
error: 'Team slug already exists'
81-
});
82-
83-
case 'P2003':
84-
return reply.status(400).send({
85-
error: 'Invalid organizer'
86-
});
87-
}
71+
if (error.code === 'P2002') {
72+
continue;
73+
}
74+
if (error.code === 'P2003') {
75+
return reply.status(400).send({ error: 'Invalid organizer' });
76+
}
8877
}
8978
app.log.error('Failed to create a team');
90-
return reply.status(500).send({
91-
error: 'Failed to create team'
92-
});
79+
return reply.status(500).send({ error: 'Failed to create team' });
80+
}
9381
}
82+
83+
return reply.status(409).send({ error: 'Unable to allocate a unique team slug' });
9484
})
9585

9686
app.get('/:slug', async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => {
@@ -164,7 +154,7 @@ export async function teamRoutes(app:FastifyInstance){
164154
let decoded;
165155
try {
166156
decoded = await request.jwtVerify() as any;
167-
} catch (error) {
157+
} catch (_error) {
168158
return reply.status(401).send({error : 'Unauthorized'})
169159
}
170160
const userId = decoded.id;
@@ -231,7 +221,7 @@ export async function teamRoutes(app:FastifyInstance){
231221
let decoded;
232222
try {
233223
decoded = await request.jwtVerify() as any
234-
} catch (error) {
224+
} catch (_error) {
235225
return reply.status(401).send({error : 'Unauthorized'})
236226
}
237227
const paramsSlug = request.params.slug
@@ -299,7 +289,7 @@ export async function teamRoutes(app:FastifyInstance){
299289
let decoded;
300290
try {
301291
decoded = await request.jwtVerify() as any
302-
} catch (error) {
292+
} catch (_error) {
303293
return reply.status(401).send({error : 'Unauthorized'})
304294
}
305295
const userId = decoded.id;
@@ -347,7 +337,7 @@ export async function teamRoutes(app:FastifyInstance){
347337
let decoded;
348338
try {
349339
decoded = await request.jwtVerify() as any
350-
} catch (error) {
340+
} catch (_error) {
351341
return reply.status(401).send({error : 'Unauthorized'})
352342
}
353343
const userId = decoded.id;

apps/backend/src/utils/slug.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
1-
export function createSlug(name:string){
2-
return name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]+/g, '').replace(/-+/g, '-').replace(/^-+|-+$/g, '')
1+
export function createSlug(name: string): string {
2+
return name
3+
.toLowerCase()
4+
.trim()
5+
.replace(/\s+/g, '-')
6+
.replace(/[^a-z0-9-]+/g, '')
7+
.replace(/-+/g, '-')
8+
.replace(/^-+|-+$/g, '');
39
}
410

5-
export async function generateUniqueSlug(name: string,
6-
slugExists: (slug: string) => Promise<boolean>
7-
){
8-
const cleanSlug = createSlug(name)
9-
let finalSlug = cleanSlug;
10-
while(true){
11-
const exists = await slugExists(finalSlug)
11+
const MAX_SLUG_ATTEMPTS = 10;
1212

13-
if(!exists) break;
13+
export async function generateUniqueSlug(
14+
name: string,
15+
slugExists: (slug: string) => Promise<boolean>,
16+
): Promise<string> {
17+
const baseSlug = createSlug(name);
1418

15-
const randomSuffix = Math.random().toString(36).substring(2,6);
16-
finalSlug = `${cleanSlug}-${randomSuffix}`
17-
}
18-
return finalSlug;
19+
if (!(await slugExists(baseSlug))) { return baseSlug; }
20+
21+
for (let i = 1; i <= MAX_SLUG_ATTEMPTS; i++) {
22+
const candidate = `${baseSlug}-${i}`;
23+
if (!(await slugExists(candidate))) { return candidate; }
24+
}
25+
26+
throw new Error(`Unable to generate unique slug for "${name}" after ${MAX_SLUG_ATTEMPTS} attempts`);
1927
}

0 commit comments

Comments
 (0)