diff --git a/backend/src/__tests__/users.test.js b/backend/src/__tests__/users.test.js new file mode 100644 index 0000000..baae1c2 --- /dev/null +++ b/backend/src/__tests__/users.test.js @@ -0,0 +1,416 @@ +const request = require('supertest'); +const app = require('../app'); +const { prisma } = require('../utils/db'); +const { hashPassword, generateToken } = require('../utils/auth'); + +// Test data +let testUser1, testUser2; +let testToken1, testToken2; + +// 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', + wsdcId: '12345', + country: 'USA', + city: 'New York', + youtubeUrl: 'https://youtube.com/@john', + instagramUrl: 'https://instagram.com/john', + }, + }); + + 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 }); +}, 30000); + +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('User Profiles API Tests', () => { + describe('GET /api/users/me', () => { + it('should get current user profile', async () => { + const response = await request(app) + .get('/api/users/me') + .set('Authorization', `Bearer ${testToken1}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('id', testUser1.id); + expect(response.body.data).toHaveProperty('username', testUser1.username); + expect(response.body.data).toHaveProperty('email', testUser1.email); + expect(response.body.data).toHaveProperty('firstName', testUser1.firstName); + expect(response.body.data).toHaveProperty('lastName', testUser1.lastName); + expect(response.body.data).toHaveProperty('wsdcId', testUser1.wsdcId); + expect(response.body.data).toHaveProperty('country', testUser1.country); + expect(response.body.data).toHaveProperty('city', testUser1.city); + expect(response.body.data).toHaveProperty('youtubeUrl', testUser1.youtubeUrl); + expect(response.body.data).toHaveProperty('instagramUrl', testUser1.instagramUrl); + expect(response.body.data).toHaveProperty('stats'); + expect(response.body.data.stats).toHaveProperty('matchesCount'); + expect(response.body.data.stats).toHaveProperty('ratingsCount'); + expect(response.body.data.stats).toHaveProperty('rating'); + }); + + it('should not include password in response', async () => { + const response = await request(app) + .get('/api/users/me') + .set('Authorization', `Bearer ${testToken1}`) + .expect(200); + + expect(response.body.data).not.toHaveProperty('passwordHash'); + expect(response.body.data).not.toHaveProperty('password'); + }); + + it('should reject request without authentication', async () => { + const response = await request(app) + .get('/api/users/me') + .expect('Content-Type', /json/) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + }); + }); + + describe('PATCH /api/users/me', () => { + it('should update user profile successfully', async () => { + const updates = { + firstName: 'Johnny', + lastName: 'Dancer', + country: 'United States', + city: 'Los Angeles', + youtubeUrl: 'https://youtube.com/@johnny', + }; + + const response = await request(app) + .patch('/api/users/me') + .set('Authorization', `Bearer ${testToken1}`) + .send(updates) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('message'); + expect(response.body.data).toHaveProperty('user'); + expect(response.body.data.user.firstName).toBe(updates.firstName); + expect(response.body.data.user.lastName).toBe(updates.lastName); + expect(response.body.data.user.country).toBe(updates.country); + expect(response.body.data.user.city).toBe(updates.city); + expect(response.body.data.user.youtubeUrl).toBe(updates.youtubeUrl); + expect(response.body.data).toHaveProperty('token'); // Should return new token + }); + + it('should update individual fields', async () => { + const response = await request(app) + .patch('/api/users/me') + .set('Authorization', `Bearer ${testToken1}`) + .send({ city: 'San Francisco' }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.user.city).toBe('San Francisco'); + }); + + it('should allow clearing optional fields', async () => { + const response = await request(app) + .patch('/api/users/me') + .set('Authorization', `Bearer ${testToken1}`) + .send({ wsdcId: '' }) // Clear WSDC ID + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.user.wsdcId).toBeNull(); + }); + + it('should handle email change and require verification', async () => { + const newEmail = 'newemail@example.com'; + + const response = await request(app) + .patch('/api/users/me') + .set('Authorization', `Bearer ${testToken1}`) + .send({ email: newEmail }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.emailChanged).toBe(true); + expect(response.body.data.user.email).toBe(newEmail); + expect(response.body.data.user.emailVerified).toBe(false); + expect(response.body.message).toContain('verify'); + + // Restore original email for other tests + await prisma.user.update({ + where: { id: testUser1.id }, + data: { + email: 'john@example.com', + emailVerified: true, + }, + }); + }); + + it('should reject duplicate email', async () => { + const response = await request(app) + .patch('/api/users/me') + .set('Authorization', `Bearer ${testToken1}`) + .send({ email: testUser2.email }) // Try to use testUser2's email + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('already registered'); + }); + + it('should reject invalid URLs', async () => { + const response = await request(app) + .patch('/api/users/me') + .set('Authorization', `Bearer ${testToken1}`) + .send({ youtubeUrl: 'not-a-valid-url' }) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should reject request without authentication', async () => { + const response = await request(app) + .patch('/api/users/me') + .send({ firstName: 'Test' }) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + }); + }); + + describe('PATCH /api/users/me/password', () => { + it('should change password successfully', async () => { + const response = await request(app) + .patch('/api/users/me/password') + .set('Authorization', `Bearer ${testToken2}`) + .send({ + currentPassword: 'password123', + newPassword: 'newPassword456!', + }) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('message'); + expect(response.body.message).toContain('successfully'); + + // Verify can login with new password + const loginResponse = await request(app) + .post('/api/auth/login') + .send({ + email: testUser2.email, + password: 'newPassword456!', + }) + .expect(200); + + expect(loginResponse.body.success).toBe(true); + + // Change password back for other tests + await prisma.user.update({ + where: { id: testUser2.id }, + data: { passwordHash: await hashPassword('password123') }, + }); + }); + + it('should reject incorrect current password', async () => { + const response = await request(app) + .patch('/api/users/me/password') + .set('Authorization', `Bearer ${testToken1}`) + .send({ + currentPassword: 'wrongPassword', + newPassword: 'newPassword123!', + }) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('incorrect'); + }); + + it('should reject missing current password', async () => { + const response = await request(app) + .patch('/api/users/me/password') + .set('Authorization', `Bearer ${testToken1}`) + .send({ + newPassword: 'newPassword123!', + }) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should reject missing new password', async () => { + const response = await request(app) + .patch('/api/users/me/password') + .set('Authorization', `Bearer ${testToken1}`) + .send({ + currentPassword: 'password123', + }) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should reject weak new password', async () => { + const response = await request(app) + .patch('/api/users/me/password') + .set('Authorization', `Bearer ${testToken1}`) + .send({ + currentPassword: 'password123', + newPassword: '123', // Too short + }) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should reject request without authentication', async () => { + const response = await request(app) + .patch('/api/users/me/password') + .send({ + currentPassword: 'password123', + newPassword: 'newPassword123!', + }) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + }); + }); + + describe('GET /api/users/:username', () => { + it('should get user profile by username', async () => { + const response = await request(app) + .get(`/api/users/${testUser1.username}`) + .set('Authorization', `Bearer ${testToken2}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('username', testUser1.username); + expect(response.body.data).toHaveProperty('firstName'); + expect(response.body.data).toHaveProperty('lastName'); + expect(response.body.data).toHaveProperty('stats'); + expect(response.body.data.stats).toHaveProperty('matchesCount'); + expect(response.body.data.stats).toHaveProperty('ratingsCount'); + expect(response.body.data.stats).toHaveProperty('rating'); + }); + + it('should not include email in public profile', async () => { + const response = await request(app) + .get(`/api/users/${testUser1.username}`) + .set('Authorization', `Bearer ${testToken2}`) + .expect(200); + + expect(response.body.data).not.toHaveProperty('email'); + expect(response.body.data).not.toHaveProperty('emailVerified'); + }); + + it('should not include password in public profile', async () => { + const response = await request(app) + .get(`/api/users/${testUser1.username}`) + .set('Authorization', `Bearer ${testToken2}`) + .expect(200); + + expect(response.body.data).not.toHaveProperty('passwordHash'); + expect(response.body.data).not.toHaveProperty('password'); + }); + + it('should return 404 for non-existent username', async () => { + const response = await request(app) + .get('/api/users/nonexistentuser123') + .set('Authorization', `Bearer ${testToken1}`) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + expect(response.body.error).toContain('not found'); + }); + + it('should reject request without authentication', async () => { + const response = await request(app) + .get(`/api/users/${testUser1.username}`) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should allow viewing own profile via username', async () => { + const response = await request(app) + .get(`/api/users/${testUser1.username}`) + .set('Authorization', `Bearer ${testToken1}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.username).toBe(testUser1.username); + }); + }); + + describe('GET /api/users/:username/ratings', () => { + it('should get user ratings', async () => { + const response = await request(app) + .get(`/api/users/${testUser1.username}/ratings`) + .set('Authorization', `Bearer ${testToken2}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('username', testUser1.username); + expect(response.body.data).toHaveProperty('averageRating'); + expect(response.body.data).toHaveProperty('ratingsCount'); + expect(response.body.data).toHaveProperty('ratings'); + expect(Array.isArray(response.body.data.ratings)).toBe(true); + }); + + it('should return 404 for non-existent username', async () => { + const response = await request(app) + .get('/api/users/nonexistentuser/ratings') + .set('Authorization', `Bearer ${testToken1}`) + .expect(404); + + expect(response.body).toHaveProperty('success', false); + }); + + it('should reject request without authentication', async () => { + const response = await request(app) + .get(`/api/users/${testUser1.username}/ratings`) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + }); + }); +});