From 830f08edba834286d17ec1bbd762ef065400302b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Fri, 14 Nov 2025 23:12:08 +0100 Subject: [PATCH] test: add comprehensive test suite for Matches & Ratings API - Created matches.test.js with 24 tests covering: * Match creation and validation * Match listing and filtering * Match acceptance workflow * Match deletion * Rating creation and validation * User ratings display - Fixed Jest ES module issues: * Added mock for jsdom to bypass parse5 compatibility * Added mock for dompurify for test environment * Updated package.json with moduleNameMapper Test results: 19/24 passing (79%) Remaining: 5 tests need investigation --- backend/package.json | 6 +- backend/src/__mocks__/dompurify.js | 11 + backend/src/__mocks__/jsdom.js | 50 +++ backend/src/__tests__/matches.test.js | 478 ++++++++++++++++++++++++++ 4 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 backend/src/__mocks__/dompurify.js create mode 100644 backend/src/__mocks__/jsdom.js create mode 100644 backend/src/__tests__/matches.test.js diff --git a/backend/package.json b/backend/package.json index 8bfc61e..c22bce7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -53,7 +53,11 @@ ], "testMatch": [ "**/__tests__/**/*.test.js" - ] + ], + "moduleNameMapper": { + "^jsdom$": "/src/__mocks__/jsdom.js", + "^dompurify$": "/src/__mocks__/dompurify.js" + } }, "prisma": { "seed": "node prisma/seed.js" diff --git a/backend/src/__mocks__/dompurify.js b/backend/src/__mocks__/dompurify.js new file mode 100644 index 0000000..65d05ee --- /dev/null +++ b/backend/src/__mocks__/dompurify.js @@ -0,0 +1,11 @@ +// Mock DOMPurify for Jest tests +// Returns a sanitize function that just returns the input (for testing) + +module.exports = () => ({ + sanitize: (dirty) => { + if (typeof dirty !== 'string') return ''; + // For tests, just return the string as-is (no actual sanitization) + // In real code, this would strip HTML tags + return dirty; + }, +}); diff --git a/backend/src/__mocks__/jsdom.js b/backend/src/__mocks__/jsdom.js new file mode 100644 index 0000000..6a9a02a --- /dev/null +++ b/backend/src/__mocks__/jsdom.js @@ -0,0 +1,50 @@ +// Mock JSDOM for Jest tests +// This avoids ES module issues with parse5 + +class MockWindow { + constructor() { + this.document = { + createElement: (tag) => ({ + setAttribute: () => {}, + getAttribute: () => null, + tagName: tag.toUpperCase(), + attributes: {}, + childNodes: [], + appendChild: () => {}, + }), + createTextNode: (text) => ({ textContent: text }), + implementation: { + createHTMLDocument: () => ({ + body: { + innerHTML: '', + appendChild: () => {}, + }, + }), + }, + }; + + // Add global constructors that DOMPurify needs + this.Element = class Element {}; + this.DocumentFragment = class DocumentFragment {}; + this.HTMLTemplateElement = class HTMLTemplateElement {}; + this.Node = class Node { + static ELEMENT_NODE = 1; + static TEXT_NODE = 3; + }; + this.NodeFilter = { + SHOW_ALL: 0xffffffff, + SHOW_ELEMENT: 0x1, + SHOW_TEXT: 0x4, + }; + } +} + +class MockJSDOM { + constructor() { + this.window = new MockWindow(); + } +} + +module.exports = { + JSDOM: MockJSDOM, +}; diff --git a/backend/src/__tests__/matches.test.js b/backend/src/__tests__/matches.test.js new file mode 100644 index 0000000..fe2785a --- /dev/null +++ b/backend/src/__tests__/matches.test.js @@ -0,0 +1,478 @@ +const request = require('supertest'); +const app = require('../app'); +const { prisma } = require('../utils/db'); +const { hashPassword, generateToken } = require('../utils/auth'); + +// Test data +let testUser1, testUser2, testEvent, testToken1, testToken2; +let testMatch; + +// Setup test data +beforeAll(async () => { + // Clean up + await prisma.rating.deleteMany({}); + await prisma.message.deleteMany({}); + await prisma.match.deleteMany({}); + await prisma.chatRoom.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', + }, + }); + + // Generate tokens + testToken1 = generateToken({ userId: testUser1.id }); + testToken2 = generateToken({ userId: testUser2.id }); + + // Create test event + testEvent = await prisma.event.create({ + data: { + name: 'Test Dance Festival', + slug: 'test-dance-festival', + location: 'Test City', + startDate: new Date('2025-06-01'), + endDate: new Date('2025-06-03'), + description: 'Test event', + worldsdcId: '12345', + }, + }); + + // Add users as participants + await prisma.eventParticipant.createMany({ + data: [ + { userId: testUser1.id, eventId: testEvent.id }, + { userId: testUser2.id, eventId: testEvent.id }, + ], + }); +}); + +afterAll(async () => { + // Clean up + await prisma.rating.deleteMany({}); + await prisma.message.deleteMany({}); + await prisma.match.deleteMany({}); + await prisma.chatRoom.deleteMany({}); + await prisma.eventParticipant.deleteMany({}); + await prisma.event.deleteMany({}); + await prisma.user.deleteMany({}); + await prisma.$disconnect(); +}); + +describe('Matches API Tests', () => { + describe('POST /api/matches', () => { + it('should create a match request successfully', async () => { + const response = await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${testToken1}`) + .send({ + targetUserId: testUser2.id, + eventSlug: testEvent.slug, + }) + .expect('Content-Type', /json/) + .expect(201); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('slug'); + expect(response.body.data).toHaveProperty('status', 'pending'); + expect(response.body.data.slug).toMatch(/^[a-z0-9]+$/); // CUID format + + // Save for later tests + testMatch = response.body.data; + }); + + it('should reject match request without authentication', async () => { + const response = await request(app) + .post('/api/matches') + .send({ + targetUserId: testUser2.id, + eventSlug: testEvent.slug, + }) + .expect('Content-Type', /json/) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should reject match request to yourself', async () => { + const response = await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${testToken1}`) + .send({ + targetUserId: testUser1.id, // Same as authenticated user + eventSlug: testEvent.slug, + }) + .expect('Content-Type', /json/) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('yourself'); + }); + + it('should reject match request with invalid event', async () => { + const response = await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${testToken1}`) + .send({ + targetUserId: testUser2.id, + eventSlug: 'non-existent-event', + }) + .expect('Content-Type', /json/) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + }); + }); + + describe('GET /api/matches', () => { + it('should list all matches for authenticated user', async () => { + const response = await request(app) + .get('/api/matches') + .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); + + const match = response.body.data[0]; + expect(match).toHaveProperty('slug'); + expect(match).toHaveProperty('partner'); + expect(match).toHaveProperty('event'); + expect(match).toHaveProperty('status'); + }); + + it('should filter matches by event slug', async () => { + const response = await request(app) + .get(`/api/matches?eventSlug=${testEvent.slug}`) + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toBeInstanceOf(Array); + + if (response.body.data.length > 0) { + expect(response.body.data[0].event.slug).toBe(testEvent.slug); + } + }); + + it('should filter matches by status', async () => { + const response = await request(app) + .get('/api/matches?status=pending') + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toBeInstanceOf(Array); + + response.body.data.forEach(match => { + expect(match.status).toBe('pending'); + }); + }); + }); + + describe('GET /api/matches/:slug', () => { + it('should get match details by slug', async () => { + const response = await request(app) + .get(`/api/matches/${testMatch.slug}`) + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('slug', testMatch.slug); + expect(response.body.data).toHaveProperty('partner'); + expect(response.body.data).toHaveProperty('event'); + expect(response.body.data).toHaveProperty('hasRated', false); + expect(response.body.data).toHaveProperty('isInitiator'); + }); + + it('should reject access to match by non-participant', async () => { + // Create third user + const testUser3 = await prisma.user.create({ + data: { + username: 'outsider', + email: 'outsider@example.com', + passwordHash: await hashPassword('password123'), + emailVerified: true, + }, + }); + + const testToken3 = generateToken({ userId: testUser3.id }); + + const response = await request(app) + .get(`/api/matches/${testMatch.slug}`) + .set('Authorization', `Bearer ${testToken3}`) + .expect('Content-Type', /json/) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + + // Clean up + await prisma.user.delete({ where: { id: testUser3.id } }); + }); + + it('should return 404 for non-existent match slug', async () => { + const response = await request(app) + .get('/api/matches/nonexistentslug123') + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + }); + }); + + describe('PUT /api/matches/:slug/accept', () => { + it('should accept a match request', async () => { + const response = await request(app) + .put(`/api/matches/${testMatch.slug}/accept`) + .set('Authorization', `Bearer ${testToken2}`) // User 2 accepts + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('status', 'accepted'); + expect(response.body.data).toHaveProperty('roomId'); + }); + + it('should reject accepting already accepted match', async () => { + const response = await request(app) + .put(`/api/matches/${testMatch.slug}/accept`) + .set('Authorization', `Bearer ${testToken2}`) + .expect('Content-Type', /json/) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('already accepted'); + }); + + it('should reject accepting match by initiator', async () => { + // Create new match for this test + const newMatchRes = await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${testToken1}`) + .send({ + targetUserId: testUser2.id, + eventSlug: testEvent.slug, + }); + + const newMatch = newMatchRes.body.data; + + const response = await request(app) + .put(`/api/matches/${newMatch.slug}/accept`) + .set('Authorization', `Bearer ${testToken1}`) // Initiator tries to accept + .expect('Content-Type', /json/) + .expect(403); + + expect(response.body).toHaveProperty('success', false); + + // Clean up + await prisma.match.delete({ where: { slug: newMatch.slug } }); + }); + }); + + describe('GET /api/matches/:slug/messages', () => { + it('should get match messages (empty initially)', async () => { + const response = await request(app) + .get(`/api/matches/${testMatch.slug}/messages`) + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toBeInstanceOf(Array); + }); + }); + + describe('DELETE /api/matches/:slug', () => { + it('should delete/reject a match request', async () => { + // Create new match for deletion test + const newMatchRes = await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${testToken1}`) + .send({ + targetUserId: testUser2.id, + eventSlug: testEvent.slug, + }); + + const newMatch = newMatchRes.body.data; + + const response = await request(app) + .delete(`/api/matches/${newMatch.slug}`) + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('message'); + + // Verify deletion + const match = await prisma.match.findUnique({ + where: { slug: newMatch.slug }, + }); + expect(match).toBeNull(); + }); + }); +}); + +describe('Ratings API Tests', () => { + describe('POST /api/matches/:slug/ratings', () => { + it('should create a rating successfully', async () => { + const response = await request(app) + .post(`/api/matches/${testMatch.slug}/ratings`) + .set('Authorization', `Bearer ${testToken1}`) + .send({ + score: 5, + comment: 'Great partner!', + wouldCollaborateAgain: true, + }) + .expect('Content-Type', /json/) + .expect(201); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('message'); + expect(response.body.data.rating).toHaveProperty('score', 5); + expect(response.body.data.rating).toHaveProperty('comment', 'Great partner!'); + expect(response.body.data.rating).toHaveProperty('wouldCollaborateAgain', true); + }); + + it('should reject duplicate rating from same user', async () => { + const response = await request(app) + .post(`/api/matches/${testMatch.slug}/ratings`) + .set('Authorization', `Bearer ${testToken1}`) // Same user tries again + .send({ + score: 4, + comment: 'Second rating', + wouldCollaborateAgain: false, + }) + .expect('Content-Type', /json/) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('already rated'); + }); + + it('should reject rating with invalid score', async () => { + const response = await request(app) + .post(`/api/matches/${testMatch.slug}/ratings`) + .set('Authorization', `Bearer ${testToken2}`) + .send({ + score: 6, // Invalid (must be 1-5) + comment: 'Test', + wouldCollaborateAgain: true, + }) + .expect('Content-Type', /json/) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should create rating without comment (optional)', async () => { + const response = await request(app) + .post(`/api/matches/${testMatch.slug}/ratings`) + .set('Authorization', `Bearer ${testToken2}`) + .send({ + score: 4, + wouldCollaborateAgain: true, + }) + .expect('Content-Type', /json/) + .expect(201); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data.rating).toHaveProperty('score', 4); + expect(response.body.data.rating).toHaveProperty('comment', null); + }); + + it('should auto-complete match when both users rate', async () => { + // Both users have now rated, check match status + const matchRes = await request(app) + .get(`/api/matches/${testMatch.slug}`) + .set('Authorization', `Bearer ${testToken1}`) + .expect(200); + + expect(matchRes.body.data.status).toBe('completed'); + }); + + it('should update hasRated flag after rating', async () => { + const response = await request(app) + .get(`/api/matches/${testMatch.slug}`) + .set('Authorization', `Bearer ${testToken1}`) + .expect(200); + + expect(response.body.data.hasRated).toBe(true); + }); + }); + + describe('GET /api/users/:username/ratings', () => { + it('should get user ratings', async () => { + const response = await request(app) + .get(`/api/users/${testUser2.username}/ratings`) + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('username', testUser2.username); + expect(response.body.data).toHaveProperty('averageRating'); + expect(response.body.data).toHaveProperty('ratingsCount'); + expect(response.body.data).toHaveProperty('ratings'); + expect(response.body.data.ratings).toBeInstanceOf(Array); + + if (response.body.data.ratings.length > 0) { + const rating = response.body.data.ratings[0]; + expect(rating).toHaveProperty('score'); + expect(rating).toHaveProperty('rater'); + expect(rating).toHaveProperty('event'); + expect(rating).toHaveProperty('createdAt'); + } + }); + + it('should calculate average rating correctly', async () => { + const response = await request(app) + .get(`/api/users/${testUser2.username}/ratings`) + .set('Authorization', `Bearer ${testToken1}`) + .expect(200); + + const { averageRating, ratingsCount } = response.body.data; + + if (ratingsCount > 0) { + expect(parseFloat(averageRating)).toBeGreaterThan(0); + expect(parseFloat(averageRating)).toBeLessThanOrEqual(5); + } + }); + + it('should return 404 for non-existent user', async () => { + const response = await request(app) + .get('/api/users/nonexistentuser/ratings') + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + }); + }); +});