feat: implement real-time chat with Socket.IO

Implemented WebSocket-based real-time messaging for both event rooms and private match chats using Socket.IO with comprehensive test coverage.

Backend changes:
- Installed socket.io@4.8.1 for WebSocket server
- Created Socket.IO server with JWT authentication middleware
- Implemented event room management (join/leave/messages)
- Added active users tracking with real-time updates
- Implemented private match room messaging
- Integrated Socket.IO with Express HTTP server
- Messages are persisted to PostgreSQL via Prisma
- Added 12 comprehensive unit tests (89.13% coverage)

Frontend changes:
- Installed socket.io-client for WebSocket connections
- Created socket service layer for connection management
- Updated EventChatPage with real-time messaging
- Updated MatchChatPage with real-time private chat
- Added connection status indicators (● Connected/Disconnected)
- Disabled message input when not connected

Infrastructure:
- Updated nginx config to proxy WebSocket connections at /socket.io
- Added Upgrade and Connection headers for WebSocket support
- Set long timeouts (7d) for persistent WebSocket connections

Key features:
- JWT-authenticated socket connections
- Room-based architecture for events and matches
- Real-time message broadcasting
- Active users list with automatic updates
- Automatic cleanup on disconnect
- Message persistence in database

Test coverage:
- 12 tests passing (authentication, event rooms, match rooms, disconnect, errors)
- Socket.IO module: 89.13% statements, 81.81% branches, 91.66% functions
- Overall coverage: 81.19%

Phase 1, Step 4 completed. Ready for Phase 2 (Core Features).
This commit is contained in:
Radosław Gierwiało
2025-11-12 22:42:15 +01:00
parent 3788274f73
commit 75cb4b16e7
11 changed files with 1472 additions and 63 deletions

View File

@@ -15,12 +15,14 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-validator": "^7.3.0",
"jsonwebtoken": "^9.0.2"
"jsonwebtoken": "^9.0.2",
"socket.io": "^4.8.1"
},
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"prisma": "^5.8.0",
"socket.io-client": "^4.8.1",
"supertest": "^6.3.3"
}
},
@@ -1077,6 +1079,12 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1122,6 +1130,15 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -1163,7 +1180,6 @@
"version": "24.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -1417,6 +1433,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.27",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.27.tgz",
@@ -2040,6 +2065,106 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
"integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-client/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -4520,6 +4645,157 @@
"node": ">=8"
}
},
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
"license": "MIT",
"dependencies": {
"debug": "~4.3.4",
"ws": "~8.17.1"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-adapter/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-client/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -4834,7 +5110,6 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
@@ -4978,6 +5253,36 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -29,12 +29,14 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-validator": "^7.3.0",
"jsonwebtoken": "^9.0.2"
"jsonwebtoken": "^9.0.2",
"socket.io": "^4.8.1"
},
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"prisma": "^5.8.0",
"socket.io-client": "^4.8.1",
"supertest": "^6.3.3"
},
"jest": {

View File

@@ -0,0 +1,514 @@
const http = require('http');
const { Server } = require('socket.io');
const Client = require('socket.io-client');
const { initializeSocket } = require('../socket');
const { generateToken } = require('../utils/auth');
const { prisma } = require('../utils/db');
describe('Socket.IO Server', () => {
let httpServer;
let io;
let serverSocket;
let clientSocket;
let testUser;
let testToken;
const port = 3001;
beforeAll(async () => {
// Create test user
testUser = await prisma.user.create({
data: {
username: 'sockettest',
email: 'sockettest@example.com',
passwordHash: 'hashedpassword',
avatar: 'https://example.com/avatar.jpg',
},
});
testToken = generateToken({ userId: testUser.id });
// Create HTTP server and initialize Socket.IO
httpServer = http.createServer();
io = initializeSocket(httpServer);
httpServer.listen(port);
// Wait for server to be ready
await new Promise((resolve) => {
httpServer.once('listening', resolve);
});
});
afterAll(async () => {
// Cleanup test user
await prisma.user.delete({
where: { id: testUser.id },
});
// Close server and client
if (clientSocket) clientSocket.close();
io.close();
httpServer.close();
});
afterEach(() => {
if (clientSocket && clientSocket.connected) {
clientSocket.close();
}
});
describe('Authentication', () => {
test('should reject connection without token', (done) => {
clientSocket = Client(`http://localhost:${port}`);
clientSocket.on('connect_error', (error) => {
expect(error.message).toBe('Authentication required');
done();
});
clientSocket.on('connect', () => {
done(new Error('Should not connect without token'));
});
});
test('should reject connection with invalid token', (done) => {
clientSocket = Client(`http://localhost:${port}`, {
auth: { token: 'invalid-token' },
});
clientSocket.on('connect_error', (error) => {
expect(error.message).toBe('Invalid token');
done();
});
});
test('should accept connection with valid token', (done) => {
clientSocket = Client(`http://localhost:${port}`, {
auth: { token: testToken },
});
clientSocket.on('connect', () => {
expect(clientSocket.connected).toBe(true);
done();
});
clientSocket.on('connect_error', (error) => {
done(error);
});
});
});
describe('Event Rooms', () => {
let testEvent;
let testChatRoom;
beforeAll(async () => {
// Create test event and chat room
testEvent = await prisma.event.create({
data: {
name: 'Test Event',
location: 'Test Location',
startDate: new Date('2025-12-01'),
endDate: new Date('2025-12-03'),
},
});
testChatRoom = await prisma.chatRoom.create({
data: {
eventId: testEvent.id,
type: 'event',
},
});
});
afterAll(async () => {
// Cleanup test data
await prisma.chatRoom.delete({
where: { id: testChatRoom.id },
});
await prisma.event.delete({
where: { id: testEvent.id },
});
});
test('should join event room successfully', (done) => {
clientSocket = Client(`http://localhost:${port}`, {
auth: { token: testToken },
});
clientSocket.on('connect', () => {
clientSocket.emit('join_event_room', { eventId: testEvent.id });
});
clientSocket.on('active_users', (users) => {
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBeGreaterThan(0);
const currentUser = users.find(u => u.userId === testUser.id);
expect(currentUser).toBeDefined();
expect(currentUser.username).toBe(testUser.username);
done();
});
clientSocket.on('error', (error) => {
done(error);
});
});
test('should receive user_joined notification', (done) => {
const client1 = Client(`http://localhost:${port}`, {
auth: { token: testToken },
});
client1.on('connect', () => {
client1.emit('join_event_room', { eventId: testEvent.id });
// Create second user and join the same room
const client2Token = generateToken({ userId: testUser.id });
const client2 = Client(`http://localhost:${port}`, {
auth: { token: client2Token },
});
client1.on('user_joined', (userData) => {
expect(userData.userId).toBe(testUser.id);
expect(userData.username).toBe(testUser.username);
client1.close();
client2.close();
done();
});
client2.on('connect', () => {
client2.emit('join_event_room', { eventId: testEvent.id });
});
});
});
test('should send and receive event messages', (done) => {
clientSocket = Client(`http://localhost:${port}`, {
auth: { token: testToken },
});
const messageContent = 'Test message content';
clientSocket.on('connect', () => {
clientSocket.emit('join_event_room', { eventId: testEvent.id });
clientSocket.on('active_users', () => {
// Wait for active_users, then send message
clientSocket.emit('send_event_message', {
eventId: testEvent.id,
content: messageContent,
});
});
});
clientSocket.on('event_message', async (message) => {
expect(message.content).toBe(messageContent);
expect(message.userId).toBe(testUser.id);
expect(message.username).toBe(testUser.username);
expect(message.type).toBe('text');
expect(message.createdAt).toBeDefined();
// Verify message was saved to database
const dbMessage = await prisma.message.findUnique({
where: { id: message.id },
});
expect(dbMessage).toBeDefined();
expect(dbMessage.content).toBe(messageContent);
// Cleanup
await prisma.message.delete({ where: { id: message.id } });
done();
});
clientSocket.on('error', (error) => {
done(error);
});
});
test('should leave event room and update active users', (done) => {
clientSocket = Client(`http://localhost:${port}`, {
auth: { token: testToken },
});
clientSocket.on('connect', () => {
clientSocket.emit('join_event_room', { eventId: testEvent.id });
clientSocket.on('active_users', (users) => {
if (users.length > 0) {
// User joined, now leave
clientSocket.emit('leave_event_room', { eventId: testEvent.id });
setTimeout(() => {
done();
}, 100);
}
});
});
});
});
describe('Match Rooms', () => {
let testUser2;
let testMatch;
let testMatchRoom;
let testEvent;
beforeAll(async () => {
// Create test event for match
testEvent = await prisma.event.create({
data: {
name: 'Match Test Event',
location: 'Test Location',
startDate: new Date('2025-12-01'),
endDate: new Date('2025-12-03'),
},
});
// Create second user with timestamp to avoid conflicts
const timestamp = Date.now();
testUser2 = await prisma.user.create({
data: {
username: `sockettest2_${timestamp}`,
email: `sockettest2_${timestamp}@example.com`,
passwordHash: 'hashedpassword',
avatar: 'https://example.com/avatar2.jpg',
},
});
// Create match
testMatch = await prisma.match.create({
data: {
status: 'active',
user1: { connect: { id: testUser.id } },
user2: { connect: { id: testUser2.id } },
event: { connect: { id: testEvent.id } },
},
});
// Create match chat room
testMatchRoom = await prisma.chatRoom.create({
data: {
type: 'private',
},
});
// Link room to match
await prisma.match.update({
where: { id: testMatch.id },
data: { roomId: testMatchRoom.id },
});
});
afterAll(async () => {
// Cleanup
await prisma.match.delete({
where: { id: testMatch.id },
});
await prisma.chatRoom.delete({
where: { id: testMatchRoom.id },
});
await prisma.user.delete({
where: { id: testUser2.id },
});
await prisma.event.delete({
where: { id: testEvent.id },
});
});
test('should join match room successfully', (done) => {
clientSocket = Client(`http://localhost:${port}`, {
auth: { token: testToken },
});
clientSocket.on('connect', () => {
clientSocket.emit('join_match_room', { matchId: testMatch.id });
// Just verify no error occurs
setTimeout(() => {
expect(clientSocket.connected).toBe(true);
done();
}, 100);
});
clientSocket.on('error', (error) => {
done(error);
});
});
test('should send and receive match messages', (done) => {
clientSocket = Client(`http://localhost:${port}`, {
auth: { token: testToken },
});
const messageContent = 'Private match message';
clientSocket.on('connect', () => {
clientSocket.emit('join_match_room', { matchId: testMatch.id });
setTimeout(() => {
clientSocket.emit('send_match_message', {
matchId: testMatch.id,
content: messageContent,
});
}, 100);
});
clientSocket.on('match_message', async (message) => {
expect(message.content).toBe(messageContent);
expect(message.userId).toBe(testUser.id);
expect(message.username).toBe(testUser.username);
expect(message.type).toBe('text');
// Verify message was saved
const dbMessage = await prisma.message.findUnique({
where: { id: message.id },
});
expect(dbMessage).toBeDefined();
expect(dbMessage.roomId).toBe(testMatchRoom.id);
// Cleanup
await prisma.message.delete({ where: { id: message.id } });
done();
});
clientSocket.on('error', (error) => {
done(error);
});
});
test('should handle match room not found error', (done) => {
clientSocket = Client(`http://localhost:${port}`, {
auth: { token: testToken },
});
clientSocket.on('connect', () => {
clientSocket.emit('join_match_room', { matchId: 99999 });
setTimeout(() => {
clientSocket.emit('send_match_message', {
matchId: 99999,
content: 'Test',
});
}, 100);
});
clientSocket.on('error', (error) => {
expect(error.message).toBe('Match room not found');
done();
});
});
});
describe('Disconnect Handling', () => {
let testEvent;
let testChatRoom;
beforeAll(async () => {
testEvent = await prisma.event.create({
data: {
name: 'Disconnect Test Event',
location: 'Test Location',
startDate: new Date('2025-12-01'),
endDate: new Date('2025-12-03'),
},
});
testChatRoom = await prisma.chatRoom.create({
data: {
eventId: testEvent.id,
type: 'event',
},
});
});
afterAll(async () => {
await prisma.chatRoom.delete({
where: { id: testChatRoom.id },
});
await prisma.event.delete({
where: { id: testEvent.id },
});
});
test('should handle disconnect and update active users', (done) => {
const client1 = Client(`http://localhost:${port}`, {
auth: { token: testToken },
});
const client2Token = generateToken({ userId: testUser.id });
const client2 = Client(`http://localhost:${port}`, {
auth: { token: client2Token },
});
let client1Connected = false;
let client2Connected = false;
client1.on('connect', () => {
client1Connected = true;
client1.emit('join_event_room', { eventId: testEvent.id });
});
client2.on('connect', () => {
client2Connected = true;
client2.emit('join_event_room', { eventId: testEvent.id });
});
client2.on('user_left', (userData) => {
expect(userData.userId).toBe(testUser.id);
expect(userData.username).toBe(testUser.username);
client2.close();
done();
});
// Wait for both to join, then disconnect client1
setTimeout(() => {
if (client1Connected && client2Connected) {
client1.close();
}
}, 500);
});
});
describe('Error Handling', () => {
let testEvent;
beforeAll(async () => {
testEvent = await prisma.event.create({
data: {
name: 'Error Test Event',
location: 'Test Location',
startDate: new Date('2025-12-01'),
endDate: new Date('2025-12-03'),
},
});
});
afterAll(async () => {
await prisma.event.delete({
where: { id: testEvent.id },
});
});
test('should handle chat room not found error', (done) => {
clientSocket = Client(`http://localhost:${port}`, {
auth: { token: testToken },
});
clientSocket.on('connect', () => {
clientSocket.emit('join_event_room', { eventId: testEvent.id });
setTimeout(() => {
clientSocket.emit('send_event_message', {
eventId: testEvent.id,
content: 'Test message',
});
}, 100);
});
clientSocket.on('error', (error) => {
expect(error.message).toBe('Chat room not found');
done();
});
});
});
});

View File

@@ -1,6 +1,8 @@
require('dotenv').config();
const http = require('http');
const app = require('./app');
const { testConnection, disconnect } = require('./utils/db');
const { initializeSocket } = require('./socket');
const PORT = process.env.PORT || 3000;
@@ -8,7 +10,13 @@ async function startServer() {
// Test database connection
await testConnection();
const server = app.listen(PORT, '0.0.0.0', () => {
// Create HTTP server
const server = http.createServer(app);
// Initialize Socket.IO
initializeSocket(server);
server.listen(PORT, '0.0.0.0', () => {
console.log('=================================');
console.log('🚀 spotlight.cam Backend Started');
console.log('=================================');

270
backend/src/socket/index.js Normal file
View File

@@ -0,0 +1,270 @@
const { Server } = require('socket.io');
const { verifyToken } = require('../utils/auth');
const { prisma } = require('../utils/db');
// Track active users in each event room
const activeUsers = new Map(); // eventId -> Set of { socketId, userId, username, avatar }
function initializeSocket(httpServer) {
const io = new Server(httpServer, {
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:8080',
credentials: true,
},
});
// Authentication middleware for Socket.IO
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication required'));
}
const decoded = verifyToken(token);
if (!decoded) {
return next(new Error('Invalid token'));
}
// Get user from database
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
username: true,
email: true,
avatar: true,
},
});
if (!user) {
return next(new Error('User not found'));
}
socket.user = user;
next();
} catch (error) {
console.error('Socket auth error:', error);
next(new Error('Authentication failed'));
}
});
io.on('connection', (socket) => {
console.log(`✅ User connected: ${socket.user.username} (${socket.id})`);
// Join event room
socket.on('join_event_room', async ({ eventId }) => {
try {
const roomName = `event_${eventId}`;
socket.join(roomName);
socket.currentEventRoom = roomName;
socket.currentEventId = eventId;
// Add user to active users
if (!activeUsers.has(eventId)) {
activeUsers.set(eventId, new Set());
}
const userInfo = {
socketId: socket.id,
userId: socket.user.id,
username: socket.user.username,
avatar: socket.user.avatar,
};
activeUsers.get(eventId).add(JSON.stringify(userInfo));
console.log(`👤 ${socket.user.username} joined event room ${eventId}`);
// Broadcast updated active users list
const users = Array.from(activeUsers.get(eventId)).map(u => JSON.parse(u));
io.to(roomName).emit('active_users', users);
// Notify room about new user
socket.to(roomName).emit('user_joined', {
userId: socket.user.id,
username: socket.user.username,
avatar: socket.user.avatar,
});
} catch (error) {
console.error('Join event room error:', error);
socket.emit('error', { message: 'Failed to join room' });
}
});
// Leave event room
socket.on('leave_event_room', ({ eventId }) => {
const roomName = `event_${eventId}`;
socket.leave(roomName);
// Remove from active users
if (activeUsers.has(eventId)) {
const users = activeUsers.get(eventId);
const userInfo = JSON.stringify({
socketId: socket.id,
userId: socket.user.id,
username: socket.user.username,
avatar: socket.user.avatar,
});
users.delete(userInfo);
// Broadcast updated list
const updatedUsers = Array.from(users).map(u => JSON.parse(u));
io.to(roomName).emit('active_users', updatedUsers);
}
console.log(`👤 ${socket.user.username} left event room ${eventId}`);
});
// Send message to event room
socket.on('send_event_message', async ({ eventId, content }) => {
try {
const roomName = `event_${eventId}`;
// Save message to database
const chatRoom = await prisma.chatRoom.findFirst({
where: {
eventId: parseInt(eventId),
type: 'event',
},
});
if (!chatRoom) {
return socket.emit('error', { message: 'Chat room not found' });
}
const message = await prisma.message.create({
data: {
roomId: chatRoom.id,
userId: socket.user.id,
content,
type: 'text',
},
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
},
},
},
});
// Broadcast message to room
io.to(roomName).emit('event_message', {
id: message.id,
roomId: message.roomId,
userId: message.user.id,
username: message.user.username,
avatar: message.user.avatar,
content: message.content,
type: message.type,
createdAt: message.createdAt,
});
console.log(`💬 Message in event ${eventId} from ${socket.user.username}`);
} catch (error) {
console.error('Send message error:', error);
socket.emit('error', { message: 'Failed to send message' });
}
});
// Join private match room
socket.on('join_match_room', ({ matchId }) => {
const roomName = `match_${matchId}`;
socket.join(roomName);
socket.currentMatchRoom = roomName;
console.log(`👥 ${socket.user.username} joined match room ${matchId}`);
});
// Send message to match room
socket.on('send_match_message', async ({ matchId, content }) => {
try {
const roomName = `match_${matchId}`;
// Get match and its room
const match = await prisma.match.findUnique({
where: { id: parseInt(matchId) },
include: { room: true },
});
if (!match || !match.room) {
return socket.emit('error', { message: 'Match room not found' });
}
// Save message
const message = await prisma.message.create({
data: {
roomId: match.room.id,
userId: socket.user.id,
content,
type: 'text',
},
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
},
},
},
});
// Broadcast to match room
io.to(roomName).emit('match_message', {
id: message.id,
roomId: message.roomId,
userId: message.user.id,
username: message.user.username,
avatar: message.user.avatar,
content: message.content,
type: message.type,
createdAt: message.createdAt,
});
console.log(`💬 Private message in match ${matchId} from ${socket.user.username}`);
} catch (error) {
console.error('Send match message error:', error);
socket.emit('error', { message: 'Failed to send message' });
}
});
// Handle disconnection
socket.on('disconnect', () => {
console.log(`❌ User disconnected: ${socket.user.username} (${socket.id})`);
// Remove from active users in all event rooms
if (socket.currentEventId) {
const eventId = socket.currentEventId;
if (activeUsers.has(eventId)) {
const users = activeUsers.get(eventId);
const userInfo = JSON.stringify({
socketId: socket.id,
userId: socket.user.id,
username: socket.user.username,
avatar: socket.user.avatar,
});
users.delete(userInfo);
// Broadcast updated list
const updatedUsers = Array.from(users).map(u => JSON.parse(u));
io.to(socket.currentEventRoom).emit('active_users', updatedUsers);
// Notify about user leaving
socket.to(socket.currentEventRoom).emit('user_left', {
userId: socket.user.id,
username: socket.user.username,
});
}
}
});
});
console.log('🔌 Socket.IO initialized');
return io;
}
module.exports = { initializeSocket };