Skip to content

Commit eb5635e

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 150eb69 commit eb5635e

4 files changed

Lines changed: 475 additions & 385 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 ─────────────────────────────────────

0 commit comments

Comments
 (0)