diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts index 44806af1..8c4ae4b0 100644 --- a/apps/backend/src/__tests__/event.test.ts +++ b/apps/backend/src/__tests__/event.test.ts @@ -1,8 +1,10 @@ +import { type PrismaClient, Prisma } from '@prisma/client'; +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import Fastify, { FastifyInstance } from 'fastify'; -import { PrismaClient } from '@prisma/client'; + import { eventRoutes } from '../routes/event'; + // ─── Shared mock data ──────────────────────────────────────────────────────── const MOCK_USER_ID = 'user-uuid-001'; @@ -64,7 +66,7 @@ const prismaMock = { // // This mirrors the real app setup without touching a real DB or real JWT keys. -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ logger: false }); @@ -97,7 +99,7 @@ async function createEvent( app: FastifyInstance, body: Record, authenticated = true, -) { +): Promise>> { return app.inject({ method: 'POST', url: '/api/events', @@ -355,9 +357,10 @@ describe('Events API', () => { it('409 — returns 409 when user already joined the event', async () => { prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); // Prisma unique constraint error - const uniqueError = Object.assign(new Error('Unique constraint'), { - code: 'P2002', - }); + const uniqueError = new Prisma.PrismaClientKnownRequestError( + 'Unique constraint failed', + { code: 'P2002', clientVersion: '6.0.0' }, + ); prismaMock.eventAttendee.create.mockRejectedValue(uniqueError); const res = await app.inject({ @@ -440,9 +443,10 @@ describe('Events API', () => { it('404 — returns 404 when user was never an attendee (P2025)', async () => { prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); // Prisma record-not-found error - const notFoundError = Object.assign(new Error('Record not found'), { - code: 'P2025', - }); + const notFoundError = new Prisma.PrismaClientKnownRequestError( + 'Record not found', + { code: 'P2025', clientVersion: '6.0.0' }, + ); prismaMock.eventAttendee.delete.mockRejectedValue(notFoundError); const res = await app.inject({ @@ -476,7 +480,13 @@ describe('Events API', () => { /** Builds a raw EventAttendee row as Prisma returns it (with nested user) */ function makeAttendeeRow( profile: typeof MOCK_USER_PROFILE | typeof MOCK_OTHER_USER_PROFILE, - ) { + ): { + id: string; + userId: string; + eventId: string; + joinedAt: Date; + user: typeof profile; + } { return { id: `attendee-${profile.id}`, userId: profile.id, diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 8d7bc566..6ac3db30 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,8 +1,10 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { createEventSchema, joinEventSchema} from '../validations/event.validation.js'; +import { Prisma } from '@prisma/client'; -import {generateUniqueSlug} from '../utils/slug.js' +import { getErrorMessage } from '../utils/error.util'; +import { generateUniqueSlug } from '../utils/slug'; +import { createEventSchema } from '../validations/event.validation'; +import type { FastifyInstance } from 'fastify'; type EventDetails = { id: string; @@ -15,8 +17,8 @@ type EventDetails = { startDate: Date; endDate: Date; createdAt: Date; - attendeesCount: number -} + attendeesCount: number; +}; type AttendeePublicProfile = { id: string; @@ -27,17 +29,16 @@ type AttendeePublicProfile = { company: string | null; avatarUrl: string | null; accentColor: string; -} - +}; type PaginatedAttendeesResponse = { attendees: AttendeePublicProfile[]; pagination: { page: number; limit: number; - total: number; + total: number; }; -} +}; type EventWithAttendees = { _count: { @@ -55,231 +56,220 @@ type EventWithAttendees = { accentColor: string; }; }[]; -} +}; -export async function eventRoutes(app:FastifyInstance) { - app.post('/', { preHandler: [async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }] }, async (request: FastifyRequest<{ +export async function eventRoutes( + app: FastifyInstance, +): Promise { + app.post<{ Body: { - name: string, - description?: string, - startDate: string, - location: string, - endDate: string, - isPublic?: boolean - }}>, reply: FastifyReply) => { - const userId = (request.user as any).id; - const parsed = createEventSchema.safeParse(request.body); - if(!parsed.success){ - return reply.status(400).send({error: 'Bad request'}) + name: string; + description?: string; + startDate: string; + location: string; + endDate: string; + isPublic?: boolean; + }; + }>('/', { preHandler: [async (request, reply) => { await app.authenticate(request, reply); }] }, + async (request, reply) => { + const userId = request.user.id; + const parsed = createEventSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Bad request' }); } - - const {name, description, startDate, endDate, isPublic ,location} = parsed.data - let finalSlug = await generateUniqueSlug(name, async(slug) => { - const existing = await app.prisma.event.findUnique({where: {slug : slug}}) - - return !!existing - }) + const { name, description, startDate, endDate, isPublic, location } = parsed.data; + + const finalSlug = await generateUniqueSlug(name, async (slug) => { + const existing = await app.prisma.event.findUnique({ where: { slug } }); + return !!existing; + }); - const startDateObj = new Date(startDate); - const endDateObj = new Date(endDate); + const startDateObj = new Date(startDate); + const endDateObj = new Date(endDate); try { const newEvent = await app.prisma.event.create({ data: { - name, - description, - slug: finalSlug, - location: location, - startDate: startDateObj, - endDate: endDateObj, - isPublic: isPublic ?? true, - organizerId: userId - } - }) - - return reply.status(201).send(newEvent); - } catch (error) { - app.log.error('Failed to create event'); - return reply.status(500).send({error: 'Failed to create event'}) - } - - }) - - //Returns event details and attendees count - app.get('/:slug', async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - const details = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug, - }, - include: { - _count: { - select: { - attendees: true - } + name, + description, + slug: finalSlug, + location, + startDate: startDateObj, + endDate: endDateObj, + isPublic: isPublic ?? true, + organizerId: userId, }, - organizer: { - select: { - username: true, - displayName: true - } - } - } - }) - if(!details){ - return reply.status(404).send({error: 'Event not found'}) + }); + return reply.status(201).send(newEvent); + } catch { + app.log.error('Failed to create event'); + return reply.status(500).send({ error: 'Failed to create event' }); } + }); - const response: EventDetails = { - id: details.id, - name: details.name, - slug: details.slug, - description: details.description, - location: details.location, - organizerUsername: details.organizer.username, - organizerDisplayName: details.organizer.displayName, - startDate: details.startDate, - endDate: details.endDate, - createdAt: details.createdAt, - attendeesCount: details._count.attendees - } - - return response; - }) - - app.post('/:slug/join', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const userId = (request.user as any).id; - const paramsSlug = request.params.slug; + // Returns event details and attendees count + app.get<{ Params: { slug: string } }>( + '/:slug', + async (request, reply) => { + const paramsSlug = request.params.slug; + const details = await app.prisma.event.findUnique({ + where: { slug: paramsSlug }, + include: { + _count: { select: { attendees: true } }, + organizer: { select: { username: true, displayName: true } }, + }, + }); - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug + if (!details) { + return reply.status(404).send({ error: 'Event not found' }); } - }) - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } + const response: EventDetails = { + id: details.id, + name: details.name, + slug: details.slug, + description: details.description, + location: details.location, + organizerUsername: details.organizer.username, + organizerDisplayName: details.organizer.displayName, + startDate: details.startDate, + endDate: details.endDate, + createdAt: details.createdAt, + attendeesCount: details._count.attendees, + }; - try { - await app.prisma.eventAttendee.create({ - data: { - eventId: event.id, - userId: userId, - joinedAt: new Date() - } - }) + return response; + }, + ); - return reply.status(201).send({message: 'User joined successfully'}) - } catch (error:any) { - if(error.code === "P2002" ){ - return reply.status(409).send({error: 'Already joined'}) - } - app.log.error((error as Error).message); - return reply.status(500).send({error: 'Failed to join'}) - } - - }) + app.post<{ Params: { slug: string } }>( + '/:slug/join', + { preHandler: [async (request, reply) => { await app.authenticate(request, reply); }] }, + async (request, reply) => { + const userId = request.user.id; + const paramsSlug = request.params.slug; - app.delete('/:slug/leave', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const userId = (request.user as any).id; - const paramsSlug = request.params.slug; + const event = await app.prisma.event.findUnique({ where: { slug: paramsSlug } }); + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug + try { + await app.prisma.eventAttendee.create({ + data: { + eventId: event.id, + userId, + joinedAt: new Date(), + }, + }); + return reply.status(201).send({ message: 'User joined successfully' }); + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return reply.status(409).send({ error: 'Already joined' }); + } + app.log.error(getErrorMessage(error)); + return reply.status(500).send({ error: 'Failed to join' }); } - }) + }, + ); - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } + app.delete<{ Params: { slug: string } }>( + '/:slug/leave', + { preHandler: [async (request, reply) => { await app.authenticate(request, reply); }] }, + async (request, reply) => { + const userId = request.user.id; + const paramsSlug = request.params.slug; - try { - await app.prisma.eventAttendee.delete({ - where: { - userId_eventId: { - userId: userId, - eventId: event.id - } - } - }) - return reply.status(204).send({message: 'User left'}) - } catch (error:any) { - if(error.code === 'P2025'){ - return reply.status(404).send({error: 'User not found'}) + const event = await app.prisma.event.findUnique({ where: { slug: paramsSlug } }); + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); } - app.log.error((error as Error).message) - return reply.status(500).send({error: 'Failed to leave'}) - } - }) - app.get('/:slug/attendees', async(request: FastifyRequest<{Params: {slug: string}, Querystring: {page?:string; limit?: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - const page = Math.max(1, Number(request.query.page) || 1); - const limit = Math.min(50, Number(request.query.limit) || 10); - const skip = (page - 1) * limit - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - }, - include: { - _count: { - select: { attendees: true } - }, - attendees : { - include: { - user: { - select: { - id: true, - username: true, - displayName:true, - bio: true, - pronouns: true, - company: true, - avatarUrl: true, - accentColor: true - } - } - }, - skip, - take: limit, - orderBy: {joinedAt: 'desc'} + try { + await app.prisma.eventAttendee.delete({ + where: { + userId_eventId: { userId, eventId: event.id }, + }, + }); + return reply.status(204).send({ message: 'User left' }); + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + return reply.status(404).send({ error: 'User not found' }); } - }, - })as EventWithAttendees | null; + app.log.error(getErrorMessage(error)); + return reply.status(500).send({ error: 'Failed to leave' }); + } + }, + ); - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } + app.get<{ + Params: { slug: string }; + Querystring: { page?: string; limit?: string }; + }>( + '/:slug/attendees', + async (request, reply) => { + const paramsSlug = request.params.slug; + const page = Math.max(1, Number(request.query.page) || 1); + const limit = Math.min(50, Number(request.query.limit) || 10); + const skip = (page - 1) * limit; - - const attendees = event.attendees.map((attendee: EventWithAttendees['attendees'][number]) => ({ - id: attendee.user.id, - username: attendee.user.username, - displayName: attendee.user.displayName, - bio: attendee.user.bio, - pronouns: attendee.user.pronouns, - company: attendee.user.company, - avatarUrl: attendee.user.avatarUrl, - accentColor: attendee.user.accentColor, - })); + const event = (await app.prisma.event.findUnique({ + where: { slug: paramsSlug }, + include: { + _count: { select: { attendees: true } }, + attendees: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + bio: true, + pronouns: true, + company: true, + avatarUrl: true, + accentColor: true, + }, + }, + }, + skip, + take: limit, + orderBy: { joinedAt: 'desc' }, + }, + }, + })) as EventWithAttendees | null; - const response: PaginatedAttendeesResponse = { - attendees, - pagination: { - page, - limit, - total : event._count.attendees, + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); } - } - return response; - }) + const attendees = event.attendees.map( + (attendee: EventWithAttendees['attendees'][number]) => ( + { + id: attendee.user.id, + username: attendee.user.username, + displayName: attendee.user.displayName, + bio: attendee.user.bio, + pronouns: attendee.user.pronouns, + company: attendee.user.company, + avatarUrl: attendee.user.avatarUrl, + accentColor: attendee.user.accentColor, + }), + ); + + const response: PaginatedAttendeesResponse = { + attendees, + pagination: { page, limit, total: event._count.attendees }, + }; + + return response; + }, + ); } \ No newline at end of file