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([]);
+ });
+ });
+});