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
48 changes: 47 additions & 1 deletion apps/backend/src/__tests__/profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('PUT /api/profiles/me', () => {
expect(res.json().error).toBe('Validation failed');
});

it('should return 409 if username is already taken', async () => {
it('should return 409 if username is already taken (pre-check)', async () => {
mockPrisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
const app = await buildApp();
const res = await app.inject({
Expand All @@ -101,4 +101,50 @@ describe('PUT /api/profiles/me', () => {
expect(res.statusCode).toBe(409);
expect(res.json().error).toBe('Username already taken');
});

it('should return 409 when a concurrent request wins the unique constraint race (P2002)', async () => {
// Both requests pass the findFirst check; the DB unique constraint fires on
// the losing write — Prisma raises P2002.
mockPrisma.user.findFirst.mockResolvedValue(null);
const p2002 = Object.assign(new Error('Unique constraint failed'), { code: 'P2002' });
mockPrisma.user.update.mockRejectedValue(p2002);

const app = await buildApp();
const res = await app.inject({
method: 'PUT',
url: '/api/profiles/me',
payload: { username: 'raced-username' },
});

expect(res.statusCode).toBe(409);
expect(res.json().error).toBe('Username already taken');
});

it('should return 500 for unexpected database errors during update', async () => {
mockPrisma.user.findFirst.mockResolvedValue(null);
mockPrisma.user.update.mockRejectedValue(new Error('Connection refused'));

const app = await buildApp();
const res = await app.inject({
method: 'PUT',
url: '/api/profiles/me',
payload: { username: 'anyuser' },
});

expect(res.statusCode).toBe(500);
expect(res.json().error).toBe('Internal server error');
});

it('should not call findFirst when no username is provided in the update', async () => {
mockPrisma.user.update.mockResolvedValue({ ...mockUser, displayName: 'New Name' });
const app = await buildApp();
const res = await app.inject({
method: 'PUT',
url: '/api/profiles/me',
payload: { displayName: 'New Name' },
});

expect(res.statusCode).toBe(200);
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled();
});
});
71 changes: 51 additions & 20 deletions apps/backend/src/routes/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ import {
reorderLinksSchema,
} from '../utils/validators.js';

// ── Response types ────────────────────────────────────────────────────────────
// Declared explicitly so the API contract is visible without tracing through
// Prisma's generic return types. Follows the convention in public.ts.

type ProfileUpdateResponse = {
id: string;
email: string;
username: string;
displayName: string;
bio: string | null;
pronouns: string | null;
role: string | null;
company: string | null;
avatarUrl: string | null;
accentColor: string;
};

export async function profileRoutes(app: FastifyInstance) {
// All profile routes require auth
app.addHook('preHandler', app.authenticate);
Expand Down Expand Up @@ -50,9 +67,11 @@ export async function profileRoutes(app: FastifyInstance) {
return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() });
}

// Check username uniqueness if changing
// Note: For production, consider adding a timestamp/version field to handle
// race conditions where two users might try to claim the same username simultaneously.
// Fast-path uniqueness check. This read-before-write eliminates the common
// case (clearly taken username) without touching the write path, but it
// cannot prevent the race window between two concurrent requests that both
// pass this check simultaneously. The unique constraint on the DB is the
// authoritative guard — P2002 below is the definitive conflict signal.
if (parsed.data.username) {
const existing = await app.prisma.user.findFirst({
where: {
Expand All @@ -65,24 +84,36 @@ export async function profileRoutes(app: FastifyInstance) {
}
}

const updated = await app.prisma.user.update({
where: { id: userId },
data: parsed.data,
select: {
id: true,
email: true,
username: true,
displayName: true,
bio: true,
pronouns: true,
role: true,
company: true,
avatarUrl: true,
accentColor: true,
},
});
try {
const response: ProfileUpdateResponse = await app.prisma.user.update({
where: { id: userId },
data: parsed.data,
select: {
id: true,
email: true,
username: true,
displayName: true,
bio: true,
pronouns: true,
role: true,
company: true,
avatarUrl: true,
accentColor: true,
},
});

return updated;
return response;
} catch (err: any) {
// Unique constraint violation — two concurrent requests raced through the
// findFirst check above and both attempted the write. The DB constraint
// fires on the losing request; surface it as a deterministic 409 rather
// than leaking a raw Prisma error as a 500.
if (err?.code === 'P2002') {
return reply.status(409).send({ error: 'Username already taken' });
}
app.log.error({ err }, 'DB error in PUT /profiles/me');
return reply.status(500).send({ error: 'Internal server error' });
}
});

// ─── Add Platform Link ───
Expand Down