diff --git a/backend/src/__tests__/socket-webrtc.test.js b/backend/src/__tests__/socket-webrtc.test.js new file mode 100644 index 0000000..94931a5 --- /dev/null +++ b/backend/src/__tests__/socket-webrtc.test.js @@ -0,0 +1,384 @@ +const { initializeSocket } = require('../socket'); +const { createServer } = require('http'); +const { Server } = require('socket.io'); +const Client = require('socket.io-client'); +const { generateToken } = require('../utils/auth'); +const { prisma } = require('../utils/db'); + +describe('Socket.IO WebRTC Signaling', () => { + let httpServer; + let io; + let clientSocket1; + let clientSocket2; + let token1; + let token2; + let testUser1; + let testUser2; + let testEvent; + let testMatch; + + beforeAll(async () => { + // Create test users + testUser1 = await prisma.user.create({ + data: { + username: 'webrtc_user1', + email: 'webrtc1@test.com', + passwordHash: 'hash', + emailVerified: true, + }, + }); + + testUser2 = await prisma.user.create({ + data: { + username: 'webrtc_user2', + email: 'webrtc2@test.com', + passwordHash: 'hash', + emailVerified: true, + }, + }); + + // Create test event + testEvent = await prisma.event.create({ + data: { + name: 'WebRTC Test Event', + location: 'Test Location', + startDate: new Date('2025-01-01'), + endDate: new Date('2025-01-02'), + }, + }); + + // Create chat room for the match + const chatRoom = await prisma.chatRoom.create({ + data: { + type: 'private', + }, + }); + + // Create test match + testMatch = await prisma.match.create({ + data: { + user1Id: testUser1.id, + user2Id: testUser2.id, + eventId: testEvent.id, + roomId: chatRoom.id, + status: 'accepted', + }, + }); + + // Generate tokens + token1 = generateToken({ userId: testUser1.id }); + token2 = generateToken({ userId: testUser2.id }); + + // Create HTTP server and Socket.IO + httpServer = createServer(); + io = initializeSocket(httpServer); + + await new Promise((resolve) => { + httpServer.listen(() => resolve()); + }); + }); + + afterAll(async () => { + await prisma.match.deleteMany({ + where: { + OR: [ + { user1Id: testUser1.id }, + { user2Id: testUser2.id }, + ], + }, + }); + + await prisma.user.deleteMany({ + where: { + id: { + in: [testUser1.id, testUser2.id], + }, + }, + }); + + await prisma.event.deleteMany({ + where: { + id: testEvent.id, + }, + }); + + io.close(); + httpServer.close(); + }); + + beforeEach((done) => { + const port = httpServer.address().port; + + // Connect first client + clientSocket1 = Client(`http://localhost:${port}`, { + auth: { token: token1 }, + transports: ['websocket'], + }); + + clientSocket1.on('connect', () => { + // Connect second client + clientSocket2 = Client(`http://localhost:${port}`, { + auth: { token: token2 }, + transports: ['websocket'], + }); + + clientSocket2.on('connect', () => { + done(); + }); + }); + }); + + afterEach(() => { + if (clientSocket1?.connected) clientSocket1.disconnect(); + if (clientSocket2?.connected) clientSocket2.disconnect(); + }); + + describe('WebRTC Offer', () => { + it('should relay WebRTC offer to the other user in match room', (done) => { + const offerData = { + type: 'offer', + sdp: 'mock-sdp-offer-data', + }; + + // User 1 and User 2 join match room + clientSocket1.emit('join_match_room', { matchId: testMatch.id }); + clientSocket2.emit('join_match_room', { matchId: testMatch.id }); + + // User 2 listens for offer + clientSocket2.on('webrtc_offer', (data) => { + expect(data.from).toBe(testUser1.id); + expect(data.offer).toEqual(offerData); + done(); + }); + + // User 1 sends offer + setTimeout(() => { + clientSocket1.emit('webrtc_offer', { + matchId: testMatch.id, + offer: offerData, + }); + }, 100); + }); + + it('should not relay offer to self', (done) => { + const offerData = { + type: 'offer', + sdp: 'mock-sdp-offer-data', + }; + + clientSocket1.emit('join_match_room', { matchId: testMatch.id }); + + let receivedOffer = false; + clientSocket1.on('webrtc_offer', () => { + receivedOffer = true; + }); + + clientSocket1.emit('webrtc_offer', { + matchId: testMatch.id, + offer: offerData, + }); + + setTimeout(() => { + expect(receivedOffer).toBe(false); + done(); + }, 200); + }); + + it('should require authorization for the match', async () => { + // Create a chat room for the unauthorized match + const unauthorizedRoom = await prisma.chatRoom.create({ + data: { + type: 'private', + }, + }); + + // Create a match that user1 is NOT part of + const unauthorizedMatch = await prisma.match.create({ + data: { + user1Id: testUser2.id, + user2Id: testUser2.id, // Both same user + eventId: testEvent.id, + roomId: unauthorizedRoom.id, + status: 'accepted', + }, + }); + + // Wait for error event + const errorPromise = new Promise((resolve) => { + clientSocket1.on('error', (error) => { + resolve(error); + }); + }); + + clientSocket1.emit('webrtc_offer', { + matchId: unauthorizedMatch.id, + offer: { type: 'offer', sdp: 'test' }, + }); + + const error = await errorPromise; + expect(error.message).toContain('authorized'); + + // Cleanup + await prisma.match.delete({ where: { id: unauthorizedMatch.id } }); + }); + }); + + describe('WebRTC Answer', () => { + it('should relay WebRTC answer to the other user in match room', (done) => { + const answerData = { + type: 'answer', + sdp: 'mock-sdp-answer-data', + }; + + clientSocket1.emit('join_match_room', { matchId: testMatch.id }); + clientSocket2.emit('join_match_room', { matchId: testMatch.id }); + + // User 1 listens for answer + clientSocket1.on('webrtc_answer', (data) => { + expect(data.from).toBe(testUser2.id); + expect(data.answer).toEqual(answerData); + done(); + }); + + // User 2 sends answer + setTimeout(() => { + clientSocket2.emit('webrtc_answer', { + matchId: testMatch.id, + answer: answerData, + }); + }, 100); + }); + }); + + describe('WebRTC ICE Candidate', () => { + it('should relay ICE candidate to the other user in match room', (done) => { + const candidateData = { + candidate: 'mock-ice-candidate', + sdpMid: 'data', + sdpMLineIndex: 0, + }; + + clientSocket1.emit('join_match_room', { matchId: testMatch.id }); + clientSocket2.emit('join_match_room', { matchId: testMatch.id }); + + // User 2 listens for ICE candidate + clientSocket2.on('webrtc_ice_candidate', (data) => { + expect(data.from).toBe(testUser1.id); + expect(data.candidate).toEqual(candidateData); + done(); + }); + + // User 1 sends ICE candidate + setTimeout(() => { + clientSocket1.emit('webrtc_ice_candidate', { + matchId: testMatch.id, + candidate: candidateData, + }); + }, 100); + }); + + it('should not relay ICE candidate to self', (done) => { + const candidateData = { + candidate: 'mock-ice-candidate', + sdpMid: 'data', + sdpMLineIndex: 0, + }; + + clientSocket1.emit('join_match_room', { matchId: testMatch.id }); + + let receivedCandidate = false; + clientSocket1.on('webrtc_ice_candidate', () => { + receivedCandidate = true; + }); + + clientSocket1.emit('webrtc_ice_candidate', { + matchId: testMatch.id, + candidate: candidateData, + }); + + setTimeout(() => { + expect(receivedCandidate).toBe(false); + done(); + }, 200); + }); + }); + + describe('WebRTC Full Flow', () => { + it('should successfully exchange offer, answer, and ICE candidates', (done) => { + const offerData = { type: 'offer', sdp: 'offer-sdp' }; + const answerData = { type: 'answer', sdp: 'answer-sdp' }; + const candidateData = { candidate: 'ice-candidate' }; + + let user1ReceivedAnswer = false; + let user1ReceivedCandidate = false; + let user2ReceivedOffer = false; + let user2ReceivedCandidate = false; + + clientSocket1.emit('join_match_room', { matchId: testMatch.id }); + clientSocket2.emit('join_match_room', { matchId: testMatch.id }); + + // User 1 listeners + clientSocket1.on('webrtc_answer', (data) => { + expect(data.from).toBe(testUser2.id); + user1ReceivedAnswer = true; + checkCompletion(); + }); + + clientSocket1.on('webrtc_ice_candidate', (data) => { + expect(data.from).toBe(testUser2.id); + user1ReceivedCandidate = true; + checkCompletion(); + }); + + // User 2 listeners + clientSocket2.on('webrtc_offer', (data) => { + expect(data.from).toBe(testUser1.id); + user2ReceivedOffer = true; + + // User 2 sends answer + clientSocket2.emit('webrtc_answer', { + matchId: testMatch.id, + answer: answerData, + }); + + // User 2 sends ICE candidate + clientSocket2.emit('webrtc_ice_candidate', { + matchId: testMatch.id, + candidate: candidateData, + }); + + checkCompletion(); + }); + + clientSocket2.on('webrtc_ice_candidate', (data) => { + expect(data.from).toBe(testUser1.id); + user2ReceivedCandidate = true; + checkCompletion(); + }); + + function checkCompletion() { + if ( + user1ReceivedAnswer && + user1ReceivedCandidate && + user2ReceivedOffer && + user2ReceivedCandidate + ) { + done(); + } + } + + // User 1 starts: send offer and ICE candidate + setTimeout(() => { + clientSocket1.emit('webrtc_offer', { + matchId: testMatch.id, + offer: offerData, + }); + + clientSocket1.emit('webrtc_ice_candidate', { + matchId: testMatch.id, + candidate: candidateData, + }); + }, 100); + }); + }); +}); diff --git a/frontend/src/components/__tests__/WebRTCWarning.test.jsx b/frontend/src/components/__tests__/WebRTCWarning.test.jsx new file mode 100644 index 0000000..9c56e9e --- /dev/null +++ b/frontend/src/components/__tests__/WebRTCWarning.test.jsx @@ -0,0 +1,195 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import WebRTCWarning from '../WebRTCWarning'; + +describe('WebRTCWarning Component', () => { + describe('Rendering', () => { + it('should not render when WebRTC is working correctly', () => { + const detection = { + supported: true, + hasIceCandidates: true, + error: null, + }; + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should render warning when RTCPeerConnection not supported', () => { + const detection = { + supported: false, + hasIceCandidates: false, + error: 'RTCPeerConnection not available', + }; + + render(); + + expect(screen.getByText('WebRTC Not Supported')).toBeInTheDocument(); + expect(screen.getByText(/does not support WebRTC/i)).toBeInTheDocument(); + }); + + it('should render warning when ICE candidates blocked', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: 'ICE candidates not generated', + }; + + render(); + + expect(screen.getByText('WebRTC Blocked')).toBeInTheDocument(); + expect(screen.getByText(/blocked by browser settings/i)).toBeInTheDocument(); + }); + + it('should show fix suggestions for unsupported browser', () => { + const detection = { + supported: false, + hasIceCandidates: false, + error: 'RTCPeerConnection not available', + }; + + render(); + + expect(screen.getByText(/Update your browser/i)).toBeInTheDocument(); + expect(screen.getByText(/Try using Chrome, Firefox, or Edge/i)).toBeInTheDocument(); + }); + + it('should show fix suggestions for blocked WebRTC', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: 'ICE candidates not generated', + }; + + render(); + + expect(screen.getByText(/privacy settings/i)).toBeInTheDocument(); + expect(screen.getByText(/VPN extensions/i)).toBeInTheDocument(); + expect(screen.getByText(/incognito/i)).toBeInTheDocument(); + }); + + it('should show fallback option message', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: 'ICE candidates not generated', + }; + + render(); + + expect(screen.getByText(/You can still send video links/i)).toBeInTheDocument(); + expect(screen.getByText(/Google Drive, Dropbox/i)).toBeInTheDocument(); + }); + }); + + describe('Dismiss Functionality', () => { + it('should call onDismiss callback when dismiss button clicked', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: 'blocked', + }; + const mockOnDismiss = jest.fn(); + + render(); + + const dismissButton = screen.getByLabelText('Dismiss warning'); + fireEvent.click(dismissButton); + + expect(mockOnDismiss).toHaveBeenCalledTimes(1); + }); + + it('should hide warning after dismissal', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: 'blocked', + }; + + const { container } = render(); + + // Should be visible initially + expect(screen.getByText('WebRTC Blocked')).toBeInTheDocument(); + + // Click dismiss + const dismissButton = screen.getByLabelText('Dismiss warning'); + fireEvent.click(dismissButton); + + // Should be hidden + expect(container.firstChild).toBeNull(); + }); + + it('should work without onDismiss callback', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: 'blocked', + }; + + render(); + + const dismissButton = screen.getByLabelText('Dismiss warning'); + + // Should not throw error + expect(() => fireEvent.click(dismissButton)).not.toThrow(); + }); + }); + + describe('Different Error Messages', () => { + it('should show generic error when error is unknown', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: 'Some unknown error', + }; + + render(); + + expect(screen.getByText('WebRTC Error')).toBeInTheDocument(); + expect(screen.getByText(/Some unknown error/i)).toBeInTheDocument(); + }); + + it('should handle null error gracefully', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: null, + }; + + render(); + + // Should still show warning for blocked ICE candidates + expect(screen.getByText('WebRTC Blocked')).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('should have accessible dismiss button', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: 'blocked', + }; + + render(); + + const dismissButton = screen.getByLabelText('Dismiss warning'); + expect(dismissButton).toBeInTheDocument(); + expect(dismissButton).toHaveAttribute('aria-label', 'Dismiss warning'); + }); + + it('should have proper heading structure', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: 'blocked', + }; + + render(); + + const heading = screen.getByRole('heading', { level: 3 }); + expect(heading).toHaveTextContent('WebRTC Blocked'); + }); + }); +}); diff --git a/frontend/src/utils/__tests__/webrtcDetection.test.js b/frontend/src/utils/__tests__/webrtcDetection.test.js new file mode 100644 index 0000000..b7225ad --- /dev/null +++ b/frontend/src/utils/__tests__/webrtcDetection.test.js @@ -0,0 +1,210 @@ +import { detectWebRTCSupport, getWebRTCErrorMessage, getWebRTCFixSuggestions } from '../webrtcDetection'; + +describe('WebRTC Detection', () => { + let mockRTCPeerConnection; + let mockCreateDataChannel; + let mockCreateOffer; + let mockSetLocalDescription; + let mockOnIceCandidate; + let mockClose; + + beforeEach(() => { + // Mock RTCPeerConnection + mockCreateDataChannel = jest.fn(); + mockCreateOffer = jest.fn(); + mockSetLocalDescription = jest.fn(); + mockClose = jest.fn(); + + mockRTCPeerConnection = jest.fn(function() { + this.createDataChannel = mockCreateDataChannel; + this.createOffer = mockCreateOffer; + this.setLocalDescription = mockSetLocalDescription; + this.close = mockClose; + this.onicecandidate = null; + }); + + global.RTCPeerConnection = mockRTCPeerConnection; + }); + + afterEach(() => { + jest.clearAllMocks(); + delete global.RTCPeerConnection; + }); + + describe('detectWebRTCSupport', () => { + it('should detect when RTCPeerConnection is not available', async () => { + delete global.RTCPeerConnection; + + const result = await detectWebRTCSupport(); + + expect(result.supported).toBe(false); + expect(result.hasIceCandidates).toBe(false); + expect(result.error).toContain('not available'); + }); + + it('should detect when ICE candidates are generated successfully', async () => { + // Mock successful ICE candidate generation + mockCreateOffer.mockResolvedValue({ type: 'offer', sdp: 'mock-sdp' }); + mockSetLocalDescription.mockResolvedValue(); + + const promise = detectWebRTCSupport(); + + // Simulate ICE candidate event after a short delay + setTimeout(() => { + const pc = mockRTCPeerConnection.mock.results[0].value; + if (pc.onicecandidate) { + // Simulate candidate + pc.onicecandidate({ candidate: { type: 'host', candidate: 'mock-candidate' } }); + // Simulate gathering complete + pc.onicecandidate({ candidate: null }); + } + }, 50); + + const result = await promise; + + expect(result.supported).toBe(true); + expect(result.hasIceCandidates).toBe(true); + expect(result.error).toBeNull(); + expect(mockCreateDataChannel).toHaveBeenCalledWith('test'); + expect(mockCreateOffer).toHaveBeenCalled(); + expect(mockSetLocalDescription).toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalled(); + }); + + it('should detect when ICE candidates are NOT generated (blocked)', async () => { + mockCreateOffer.mockResolvedValue({ type: 'offer', sdp: 'mock-sdp' }); + mockSetLocalDescription.mockResolvedValue(); + + const promise = detectWebRTCSupport(); + + // Simulate only gathering complete, no actual candidates + setTimeout(() => { + const pc = mockRTCPeerConnection.mock.results[0].value; + if (pc.onicecandidate) { + pc.onicecandidate({ candidate: null }); // Only null = gathering complete + } + }, 50); + + const result = await promise; + + expect(result.supported).toBe(true); + expect(result.hasIceCandidates).toBe(false); + expect(result.error).toContain('ICE candidates not generated'); + }); + + it('should handle timeout when no ICE events occur', async () => { + mockCreateOffer.mockResolvedValue({ type: 'offer', sdp: 'mock-sdp' }); + mockSetLocalDescription.mockResolvedValue(); + + // Don't trigger any ICE events - let it timeout + const result = await detectWebRTCSupport(); + + expect(result.supported).toBe(true); + expect(result.hasIceCandidates).toBe(false); + expect(mockClose).toHaveBeenCalled(); + }); + + it('should handle errors during detection', async () => { + mockCreateOffer.mockRejectedValue(new Error('Mock error')); + + const result = await detectWebRTCSupport(); + + expect(result.supported).toBe(true); + expect(result.hasIceCandidates).toBe(false); + expect(result.error).toContain('Mock error'); + }); + }); + + describe('getWebRTCErrorMessage', () => { + it('should return correct message when not supported', () => { + const detection = { + supported: false, + hasIceCandidates: false, + error: 'Not available', + }; + + const message = getWebRTCErrorMessage(detection); + + expect(message).toContain('does not support WebRTC'); + expect(message).toContain('modern browser'); + }); + + it('should return correct message when ICE candidates blocked', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: 'ICE candidates not generated', + }; + + const message = getWebRTCErrorMessage(detection); + + expect(message).toContain('WebRTC is blocked'); + expect(message).toContain('browser settings'); + }); + + it('should return success message when working correctly', () => { + const detection = { + supported: true, + hasIceCandidates: true, + error: null, + }; + + const message = getWebRTCErrorMessage(detection); + + expect(message).toContain('working correctly'); + }); + + it('should return generic error message when there is an unknown error', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: 'Unknown error occurred', + }; + + const message = getWebRTCErrorMessage(detection); + + expect(message).toContain('Unknown error occurred'); + }); + }); + + describe('getWebRTCFixSuggestions', () => { + it('should return browser update suggestions when not supported', () => { + const detection = { + supported: false, + hasIceCandidates: false, + error: null, + }; + + const suggestions = getWebRTCFixSuggestions(detection); + + expect(suggestions).toContain('Update your browser to the latest version'); + expect(suggestions).toContain('Try using Chrome, Firefox, or Edge'); + }); + + it('should return privacy settings suggestions when ICE candidates blocked', () => { + const detection = { + supported: true, + hasIceCandidates: false, + error: null, + }; + + const suggestions = getWebRTCFixSuggestions(detection); + + expect(suggestions.some(s => s.includes('privacy settings'))).toBe(true); + expect(suggestions.some(s => s.includes('VPN'))).toBe(true); + expect(suggestions.some(s => s.includes('incognito'))).toBe(true); + }); + + it('should return empty array when working correctly', () => { + const detection = { + supported: true, + hasIceCandidates: true, + error: null, + }; + + const suggestions = getWebRTCFixSuggestions(detection); + + expect(suggestions).toEqual([]); + }); + }); +});