Skip to content
Merged
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
155 changes: 136 additions & 19 deletions apps/backend/src/__tests__/profiles.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Prisma, type PrismaClient } from '@prisma/client';
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, it, expect, beforeEach, vi } from 'vitest';

import { profileRoutes } from '../routes/profiles.js';

import type { PrismaClient } from '@prisma/client';

const mockUser = {
id: 'user-123',
email: 'test@example.com',
Expand All @@ -22,11 +21,27 @@ const mockUser = {
providerId: 'gh-123',
};

const mockUserFindUnique = vi.fn();
const mockUserFindFirst = vi.fn();
const mockUserUpdate = vi.fn();
const mockPlatformLinkAggregate = vi.fn();
const mockPlatformLinkCreate = vi.fn();
const mockPlatformLinkFindFirst = vi.fn();
const mockPlatformLinkUpdate = vi.fn();
const mockPlatformLinkDelete = vi.fn();

const mockPrisma = {
user: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
findUnique: mockUserFindUnique,
findFirst: mockUserFindFirst,
update: mockUserUpdate,
},
platformLink: {
aggregate: mockPlatformLinkAggregate,
create: mockPlatformLinkCreate,
findFirst: mockPlatformLinkFindFirst,
update: mockPlatformLinkUpdate,
delete: mockPlatformLinkDelete,
},
};

Expand All @@ -45,7 +60,7 @@ describe('GET /api/profiles/me', () => {
beforeEach(() => vi.clearAllMocks());

it('should return user profile with displayName', async () => {
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
mockUserFindUnique.mockResolvedValue(mockUser);
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/profiles/me' });
expect(res.statusCode).toBe(200);
Expand All @@ -57,7 +72,7 @@ describe('GET /api/profiles/me', () => {
});

it('should return 404 if user not found', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
mockUserFindUnique.mockResolvedValue(null);
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/profiles/me' });
expect(res.statusCode).toBe(404);
Expand All @@ -69,8 +84,8 @@ describe('PUT /api/profiles/me', () => {
beforeEach(() => vi.clearAllMocks());

it('should update profile and return updated data', async () => {
mockPrisma.user.findFirst.mockResolvedValue(null);
mockPrisma.user.update.mockResolvedValue({ ...mockUser, displayName: 'Updated Name' });
mockUserFindFirst.mockResolvedValue(null);
mockUserUpdate.mockResolvedValue({ ...mockUser, displayName: 'Updated Name' });
const app = await buildApp();
const res = await app.inject({
method: 'PUT',
Expand All @@ -90,10 +105,11 @@ describe('PUT /api/profiles/me', () => {
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('Validation failed');
expect(mockUserUpdate).not.toHaveBeenCalled();
});

it('should return 409 if username is already taken (pre-check)', async () => {
mockPrisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
it('should return 409 if username is already taken', async () => {
mockUserFindFirst.mockResolvedValue({ id: 'other-user' });
const app = await buildApp();
Comment thread
dangzitou marked this conversation as resolved.
const res = await app.inject({
method: 'PUT',
Expand All @@ -102,14 +118,18 @@ describe('PUT /api/profiles/me', () => {
});
expect(res.statusCode).toBe(409);
expect(res.json().error).toBe('Username already taken');
expect(mockUserUpdate).not.toHaveBeenCalled();
});

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);
mockUserFindFirst.mockResolvedValue(null);
const p2002 = new Prisma.PrismaClientKnownRequestError('Unique constraint failed', {
code: 'P2002',
clientVersion: 'test',
});
mockUserUpdate.mockRejectedValue(p2002);

const app = await buildApp();
const res = await app.inject({
Expand All @@ -123,8 +143,8 @@ describe('PUT /api/profiles/me', () => {
});

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

const app = await buildApp();
const res = await app.inject({
Expand All @@ -138,7 +158,7 @@ describe('PUT /api/profiles/me', () => {
});

it('should not call findFirst when no username is provided in the update', async () => {
mockPrisma.user.update.mockResolvedValue({ ...mockUser, displayName: 'New Name' });
mockUserUpdate.mockResolvedValue({ ...mockUser, displayName: 'New Name' });
const app = await buildApp();
const res = await app.inject({
method: 'PUT',
Expand All @@ -147,6 +167,103 @@ describe('PUT /api/profiles/me', () => {
});

expect(res.statusCode).toBe(200);
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled();
expect(mockUserFindFirst).not.toHaveBeenCalled();
});
});
});

describe('Platform link routes', () => {
beforeEach(() => vi.clearAllMocks());

it('should return 400 for invalid link create body', async () => {
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/profiles/me/links',
payload: { platform: '', username: '' },
});

expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('Validation failed');
expect(mockPlatformLinkCreate).not.toHaveBeenCalled();
});

it('should create a platform link with a valid body', async () => {
const createdLink = {
id: 'link-123',
userId: 'user-123',
platform: 'github',
username: 'octocat',
url: 'https://github.com/octocat',
displayOrder: 2,
};

mockPlatformLinkAggregate.mockResolvedValue({ _max: { displayOrder: 1 } });
mockPlatformLinkCreate.mockResolvedValue(createdLink);

const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/profiles/me/links',
payload: { platform: 'github', username: 'octocat' },
});

expect(res.statusCode).toBe(201);
expect(res.json()).toEqual(createdLink);
expect(mockPlatformLinkAggregate).toHaveBeenCalledWith({
where: { userId: 'user-123' },
_max: { displayOrder: true },
});
expect(mockPlatformLinkCreate).toHaveBeenCalledWith({
data: {
userId: 'user-123',
platform: 'github',
username: 'octocat',
url: expect.stringContaining('octocat'),
displayOrder: 2,
},
});
});

it('should return 404 when updating a link that does not exist', async () => {
mockPlatformLinkFindFirst.mockResolvedValue(null);

const app = await buildApp();
const res = await app.inject({
method: 'PUT',
url: '/api/profiles/me/links/link-404',
payload: { platform: 'github', username: 'octocat' },
});

expect(res.statusCode).toBe(404);
expect(res.json().error).toBe('Link not found');
expect(mockPlatformLinkFindFirst).toHaveBeenCalledWith({
where: { id: 'link-404', userId: 'user-123' },
});
expect(mockPlatformLinkUpdate).not.toHaveBeenCalled();
});

it('should delete an existing platform link', async () => {
mockPlatformLinkFindFirst.mockResolvedValue({
id: 'link-123',
userId: 'user-123',
platform: 'github',
username: 'octocat',
url: 'https://github.com/octocat',
displayOrder: 0,
});
mockPlatformLinkDelete.mockResolvedValue({ id: 'link-123' });

const app = await buildApp();
const res = await app.inject({
method: 'DELETE',
url: '/api/profiles/me/links/link-123',
});

expect(res.statusCode).toBe(204);
expect(res.body).toBe('');
expect(mockPlatformLinkFindFirst).toHaveBeenCalledWith({
where: { id: 'link-123', userId: 'user-123' },
});
expect(mockPlatformLinkDelete).toHaveBeenCalledWith({ where: { id: 'link-123' } });
});
});