diff --git a/backend/src/__tests__/events.test.js b/backend/src/__tests__/events.test.js new file mode 100644 index 0000000..4f36fba --- /dev/null +++ b/backend/src/__tests__/events.test.js @@ -0,0 +1,698 @@ +const request = require('supertest'); +const app = require('../app'); +const { prisma } = require('../utils/db'); +const { hashPassword, generateToken } = require('../utils/auth'); + +// Test data +let testUser1, testUser2, testUser3; +let testToken1, testToken2, testToken3; +let testEvent, testEvent2; +let testDivision, testCompetitionType; +let checkinToken; + +// Setup test data +beforeAll(async () => { + // Clean up + await prisma.eventUserHeat.deleteMany({}); + await prisma.rating.deleteMany({}); + await prisma.message.deleteMany({}); + await prisma.match.deleteMany({}); + await prisma.chatRoom.deleteMany({}); + await prisma.eventCheckinToken.deleteMany({}); + await prisma.eventParticipant.deleteMany({}); + await prisma.event.deleteMany({}); + await prisma.user.deleteMany({}); + + // Create test users + testUser1 = await prisma.user.create({ + data: { + username: 'john_dancer', + email: 'john@example.com', + passwordHash: await hashPassword('password123'), + emailVerified: true, + firstName: 'John', + lastName: 'Doe', + }, + }); + + testUser2 = await prisma.user.create({ + data: { + username: 'sarah_swings', + email: 'sarah@example.com', + passwordHash: await hashPassword('password123'), + emailVerified: true, + firstName: 'Sarah', + lastName: 'Smith', + }, + }); + + testUser3 = await prisma.user.create({ + data: { + username: 'mike_blues', + email: 'mike@example.com', + passwordHash: await hashPassword('password123'), + emailVerified: true, + firstName: 'Mike', + lastName: 'Johnson', + }, + }); + + // Generate tokens + testToken1 = generateToken({ userId: testUser1.id }); + testToken2 = generateToken({ userId: testUser2.id }); + testToken3 = generateToken({ userId: testUser3.id }); + + // Get or create division and competition type + testDivision = await prisma.division.findFirst(); + testCompetitionType = await prisma.competitionType.findFirst(); + + // If they don't exist, create them + if (!testDivision) { + testDivision = await prisma.division.create({ + data: { + name: 'Intermediate', + abbreviation: 'INT', + displayOrder: 1, + }, + }); + } + + if (!testCompetitionType) { + testCompetitionType = await prisma.competitionType.create({ + data: { + name: 'Jack & Jill', + abbreviation: 'J&J', + }, + }); + } + + // Create test events + testEvent = await prisma.event.create({ + data: { + name: 'Test Dance Festival 2025', + slug: 'test-dance-festival-2025', + location: 'Test City', + startDate: new Date('2025-06-01'), + endDate: new Date('2025-06-03'), + description: 'Test event description', + worldsdcId: 'test-2025', + }, + }); + + testEvent2 = await prisma.event.create({ + data: { + name: 'Another Dance Event', + slug: 'another-dance-event', + location: 'Another City', + startDate: new Date('2025-07-15'), + endDate: new Date('2025-07-17'), + description: 'Another test event', + worldsdcId: 'another-2025', + }, + }); + + // Create checkin token for testEvent + checkinToken = await prisma.eventCheckinToken.create({ + data: { + eventId: testEvent.id, + token: 'test-checkin-token-123', + }, + }); + + // Add testUser1 as participant to testEvent + await prisma.eventParticipant.create({ + data: { + userId: testUser1.id, + eventId: testEvent.id, + }, + }); + + // Create chat room for testEvent + await prisma.chatRoom.create({ + data: { + eventId: testEvent.id, + type: 'event', + }, + }); +}, 30000); + +afterAll(async () => { + // Clean up + await prisma.eventUserHeat.deleteMany({}); + await prisma.rating.deleteMany({}); + await prisma.message.deleteMany({}); + await prisma.match.deleteMany({}); + await prisma.chatRoom.deleteMany({}); + await prisma.eventCheckinToken.deleteMany({}); + await prisma.eventParticipant.deleteMany({}); + await prisma.event.deleteMany({}); + await prisma.user.deleteMany({}); + await prisma.$disconnect(); +}); + +describe('Events API Tests', () => { + describe('GET /api/events', () => { + it('should list all events for authenticated user', async () => { + const response = await request(app) + .get('/api/events') + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('count'); + expect(response.body.data).toBeInstanceOf(Array); + expect(response.body.data.length).toBeGreaterThan(0); + + const event = response.body.data[0]; + expect(event).toHaveProperty('slug'); + expect(event).toHaveProperty('name'); + expect(event).toHaveProperty('location'); + expect(event).toHaveProperty('startDate'); + expect(event).toHaveProperty('isJoined'); + expect(event).toHaveProperty('participantsCount'); + }); + + it('should show joined events first', async () => { + const response = await request(app) + .get('/api/events') + .set('Authorization', `Bearer ${testToken1}`) + .expect(200); + + // testUser1 is participant of testEvent, so it should be first + const joinedEvents = response.body.data.filter(e => e.isJoined); + expect(joinedEvents.length).toBeGreaterThan(0); + expect(response.body.data[0].isJoined).toBe(true); + }); + + it('should reject request without authentication', async () => { + const response = await request(app) + .get('/api/events') + .expect('Content-Type', /json/) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + }); + }); + + describe('GET /api/events/:slug', () => { + it('should get event details by slug', async () => { + const response = await request(app) + .get(`/api/events/${testEvent.slug}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('slug', testEvent.slug); + expect(response.body.data).toHaveProperty('name', testEvent.name); + expect(response.body.data).toHaveProperty('location'); + expect(response.body.data).toHaveProperty('startDate'); + expect(response.body.data).toHaveProperty('endDate'); + }); + + it('should return 404 for non-existent event', async () => { + const response = await request(app) + .get('/api/events/non-existent-slug') + .expect('Content-Type', /json/) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('not found'); + }); + + it('should work without authentication', async () => { + const response = await request(app) + .get(`/api/events/${testEvent.slug}`) + .expect(200); + + expect(response.body.success).toBe(true); + }); + }); + + describe('POST /api/events/checkin/:token', () => { + it('should check-in user to event successfully', async () => { + const response = await request(app) + .post(`/api/events/checkin/${checkinToken.token}`) + .set('Authorization', `Bearer ${testToken2}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('event'); + expect(response.body.data.event.slug).toBe(testEvent.slug); + + // Verify user is now a participant + const participant = await prisma.eventParticipant.findUnique({ + where: { + userId_eventId: { + userId: testUser2.id, + eventId: testEvent.id, + }, + }, + }); + expect(participant).toBeTruthy(); + }); + + it('should handle already checked-in user', async () => { + const response = await request(app) + .post(`/api/events/checkin/${checkinToken.token}`) + .set('Authorization', `Bearer ${testToken1}`) // testUser1 is already participant + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('alreadyCheckedIn', true); + }); + + it('should reject invalid checkin token', async () => { + const response = await request(app) + .post('/api/events/checkin/invalid-token-xyz') + .set('Authorization', `Bearer ${testToken3}`) + .expect('Content-Type', /json/) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('Invalid check-in token'); + }); + + it('should reject checkin without authentication', async () => { + const response = await request(app) + .post(`/api/events/checkin/${checkinToken.token}`) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + }); + }); + + describe('GET /api/events/:slug/details', () => { + it('should get detailed event information for participant', async () => { + const response = await request(app) + .get(`/api/events/${testEvent.slug}/details`) + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('event'); + expect(response.body.data.event.slug).toBe(testEvent.slug); + expect(response.body.data).toHaveProperty('participants'); + }); + + it('should return 404 for non-existent event', async () => { + const response = await request(app) + .get('/api/events/non-existent/details') + .set('Authorization', `Bearer ${testToken1}`) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should work for any authenticated user (no participant check)', async () => { + const response = await request(app) + .get(`/api/events/${testEvent.slug}/details`) + .set('Authorization', `Bearer ${testToken3}`) // testUser3 is not participant + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('event'); + expect(response.body.data).toHaveProperty('participants'); + }); + }); + + describe('DELETE /api/events/:slug/leave', () => { + it('should allow participant to leave event', async () => { + // testUser1 is already a participant from setup, use them instead + const response = await request(app) + .delete(`/api/events/${testEvent.slug}/leave`) + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('message'); + + // Verify user is no longer a participant + const participant = await prisma.eventParticipant.findUnique({ + where: { + userId_eventId: { + userId: testUser1.id, + eventId: testEvent.id, + }, + }, + }); + expect(participant).toBeNull(); + + // Re-add testUser1 for subsequent tests + await prisma.eventParticipant.create({ + data: { + userId: testUser1.id, + eventId: testEvent.id, + }, + }); + }); + + it('should return 404 for non-existent event', async () => { + const response = await request(app) + .delete('/api/events/non-existent/leave') + .set('Authorization', `Bearer ${testToken1}`) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should return 400 if user is not a participant', async () => { + const response = await request(app) + .delete(`/api/events/${testEvent2.slug}/leave`) + .set('Authorization', `Bearer ${testToken1}`) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + }); + }); + + describe('GET /api/events/:slug/messages', () => { + it('should get event chat messages (empty initially)', async () => { + const response = await request(app) + .get(`/api/events/${testEvent.slug}/messages`) + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toBeInstanceOf(Array); + expect(response.body).toHaveProperty('hasMore'); + }); + + it('should return 404 for non-existent event', async () => { + const response = await request(app) + .get('/api/events/non-existent/messages') + .set('Authorization', `Bearer ${testToken1}`) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('not found'); + }); + + it('should reject without authentication', async () => { + const response = await request(app) + .get(`/api/events/${testEvent.slug}/messages`) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + }); + }); + + describe('POST /api/events/:slug/heats', () => { + it('should add heats for participant', async () => { + const heats = [ + { + divisionId: testDivision.id, + competitionTypeId: testCompetitionType.id, + heatNumber: 1, + role: 'Leader', + }, + { + divisionId: testDivision.id, + competitionTypeId: testCompetitionType.id, + heatNumber: 2, + role: 'Follower', + }, + ]; + + const response = await request(app) + .post(`/api/events/${testEvent.slug}/heats`) + .set('Authorization', `Bearer ${testToken1}`) + .send({ heats }) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toBeInstanceOf(Array); + expect(response.body.data.length).toBe(2); + expect(response.body.data[0]).toHaveProperty('heatNumber'); + expect(response.body.data[0]).toHaveProperty('role'); + }); + + it('should reject invalid heats array', async () => { + const response = await request(app) + .post(`/api/events/${testEvent.slug}/heats`) + .set('Authorization', `Bearer ${testToken1}`) + .send({ heats: [] }) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('non-empty array'); + }); + + it('should reject heats with missing fields', async () => { + const heats = [ + { + divisionId: testDivision.id, + // missing competitionTypeId and heatNumber + }, + ]; + + const response = await request(app) + .post(`/api/events/${testEvent.slug}/heats`) + .set('Authorization', `Bearer ${testToken1}`) + .send({ heats }) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should reject invalid heat number', async () => { + const heats = [ + { + divisionId: testDivision.id, + competitionTypeId: testCompetitionType.id, + heatNumber: 10, // Invalid (must be 1-9) + }, + ]; + + const response = await request(app) + .post(`/api/events/${testEvent.slug}/heats`) + .set('Authorization', `Bearer ${testToken1}`) + .send({ heats }) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('between 1 and 9'); + }); + + it('should reject invalid role', async () => { + const heats = [ + { + divisionId: testDivision.id, + competitionTypeId: testCompetitionType.id, + heatNumber: 1, + role: 'InvalidRole', + }, + ]; + + const response = await request(app) + .post(`/api/events/${testEvent.slug}/heats`) + .set('Authorization', `Bearer ${testToken1}`) + .send({ heats }) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('Leader or Follower'); + }); + + it('should return 403 for non-participant', async () => { + const heats = [ + { + divisionId: testDivision.id, + competitionTypeId: testCompetitionType.id, + heatNumber: 1, + }, + ]; + + const response = await request(app) + .post(`/api/events/${testEvent.slug}/heats`) + .set('Authorization', `Bearer ${testToken3}`) + .send({ heats }) + .expect(403); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('must be a participant'); + }); + }); + + describe('GET /api/events/:slug/heats/me', () => { + it('should get user\'s heats for event', async () => { + const response = await request(app) + .get(`/api/events/${testEvent.slug}/heats/me`) + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toBeInstanceOf(Array); + // testUser1 has heats from previous test + expect(response.body.data.length).toBeGreaterThan(0); + }); + + it('should return empty array for user without heats', async () => { + // Add testUser3 as participant first + await prisma.eventParticipant.create({ + data: { + userId: testUser3.id, + eventId: testEvent.id, + }, + }); + + const response = await request(app) + .get(`/api/events/${testEvent.slug}/heats/me`) + .set('Authorization', `Bearer ${testToken3}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toEqual([]); + }); + + it('should work for any authenticated user (no participant check)', async () => { + const response = await request(app) + .get(`/api/events/${testEvent2.slug}/heats/me`) + .set('Authorization', `Bearer ${testToken1}`) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + // testUser1 has no heats for testEvent2, so data should be empty + expect(response.body.data).toEqual([]); + expect(response.body.count).toBe(0); + }); + }); + + describe('GET /api/events/:slug/heats/all', () => { + it('should get all heats for event', async () => { + const response = await request(app) + .get(`/api/events/${testEvent.slug}/heats/all`) + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toBeInstanceOf(Array); + expect(response.body.data.length).toBeGreaterThan(0); + + if (response.body.data.length > 0) { + const userHeats = response.body.data[0]; + // Response is grouped by user + expect(userHeats).toHaveProperty('userId'); + expect(userHeats).toHaveProperty('username'); + expect(userHeats).toHaveProperty('heats'); + expect(userHeats.heats).toBeInstanceOf(Array); + + if (userHeats.heats.length > 0) { + const heat = userHeats.heats[0]; + expect(heat).toHaveProperty('division'); + expect(heat).toHaveProperty('competitionType'); + expect(heat).toHaveProperty('heatNumber'); + } + } + }); + + it('should filter by division', async () => { + const response = await request(app) + .get(`/api/events/${testEvent.slug}/heats/all?divisionId=${testDivision.id}`) + .set('Authorization', `Bearer ${testToken1}`) + .expect(200); + + expect(response.body.success).toBe(true); + // Check that all heats in all users have the correct division + response.body.data.forEach(userHeats => { + userHeats.heats.forEach(heat => { + expect(heat.division.id).toBe(testDivision.id); + }); + }); + }); + + it('should work for any authenticated user (no participant check)', async () => { + const response = await request(app) + .get(`/api/events/${testEvent2.slug}/heats/all`) + .set('Authorization', `Bearer ${testToken1}`) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + // testEvent2 has no heats, so data should be empty + expect(response.body.data).toEqual([]); + }); + }); + + describe('DELETE /api/events/:slug/heats/:id', () => { + let heatToDelete; + + beforeAll(async () => { + // Get a heat ID for deletion + const heats = await prisma.eventUserHeat.findMany({ + where: { + userId: testUser1.id, + eventId: testEvent.id, + }, + }); + heatToDelete = heats[0]; + }); + + it('should delete user\'s heat', async () => { + const response = await request(app) + .delete(`/api/events/${testEvent.slug}/heats/${heatToDelete.id}`) + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('message'); + + // Verify heat is deleted + const heat = await prisma.eventUserHeat.findUnique({ + where: { id: heatToDelete.id }, + }); + expect(heat).toBeNull(); + }); + + it('should return 404 for non-existent heat', async () => { + const response = await request(app) + .delete(`/api/events/${testEvent.slug}/heats/99999`) + .set('Authorization', `Bearer ${testToken1}`) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should reject deleting another user\'s heat', async () => { + // Create a heat for testUser3 + await prisma.eventParticipant.upsert({ + where: { + userId_eventId: { + userId: testUser3.id, + eventId: testEvent.id, + }, + }, + create: { + userId: testUser3.id, + eventId: testEvent.id, + }, + update: {}, + }); + + const heat3 = await prisma.eventUserHeat.create({ + data: { + userId: testUser3.id, + eventId: testEvent.id, + divisionId: testDivision.id, + competitionTypeId: testCompetitionType.id, + heatNumber: 5, + }, + }); + + const response = await request(app) + .delete(`/api/events/${testEvent.slug}/heats/${heat3.id}`) + .set('Authorization', `Bearer ${testToken1}`) // testUser1 tries to delete testUser3's heat + .expect(403); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('your own heats'); + }); + }); +});