diff --git a/apps/backend/src/__tests__/profiles.test.ts b/apps/backend/src/__tests__/profiles.test.ts index 0633b841..0f61f728 100644 --- a/apps/backend/src/__tests__/profiles.test.ts +++ b/apps/backend/src/__tests__/profiles.test.ts @@ -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', @@ -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, }, }; @@ -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); @@ -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); @@ -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', @@ -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(); const res = await app.inject({ method: 'PUT', @@ -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({ @@ -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({ @@ -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', @@ -147,6 +167,103 @@ describe('PUT /api/profiles/me', () => { }); expect(res.statusCode).toBe(200); - expect(mockPrisma.user.findFirst).not.toHaveBeenCalled(); + expect(mockUserFindFirst).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); + +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' } }); + }); +});