diff --git a/backend/routes/votes.js b/backend/routes/votes.js index 21a6cfb..f93522c 100644 --- a/backend/routes/votes.js +++ b/backend/routes/votes.js @@ -1,6 +1,7 @@ const express = require('express'); const { authenticateToken } = require('../middleware/auth'); const db = require('../database'); +const { getWebSocketManager } = require('../utils/websocket-manager'); const router = express.Router(); @@ -124,7 +125,7 @@ router.post('/live', authenticateToken, (req, res) => { // Get all games played in this session with timestamps const sessionGames = db.prepare(` - SELECT sg.game_id, sg.played_at, g.title, g.upvotes, g.downvotes, g.popularity_score + SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score FROM session_games sg JOIN games g ON sg.game_id = g.id WHERE sg.session_id = ? @@ -237,6 +238,33 @@ router.post('/live', authenticateToken, (req, res) => { WHERE id = ? `).get(matchedGame.game_id); + // Broadcast vote.received via WebSocket + try { + const wsManager = getWebSocketManager(); + if (wsManager) { + wsManager.broadcastEvent('vote.received', { + sessionId: activeSession.id, + game: { + id: updatedGame.id, + title: updatedGame.title, + pack_name: matchedGame.pack_name, + }, + vote: { + username: username, + type: vote, + timestamp: timestamp, + }, + totals: { + upvotes: updatedGame.upvotes, + downvotes: updatedGame.downvotes, + popularity_score: updatedGame.popularity_score, + }, + }, activeSession.id); + } + } catch (wsError) { + console.error('Error broadcasting vote.received event:', wsError); + } + // Get session stats const sessionStats = db.prepare(` SELECT diff --git a/tests/api/votes-live-websocket.test.js b/tests/api/votes-live-websocket.test.js new file mode 100644 index 0000000..c0e61ac --- /dev/null +++ b/tests/api/votes-live-websocket.test.js @@ -0,0 +1,166 @@ +const WebSocket = require('ws'); +const request = require('supertest'); +const { app, server } = require('../../backend/server'); +const { getAuthToken, getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame } = require('../helpers/test-utils'); + +function connectWs() { + return new WebSocket(`ws://localhost:${server.address().port}/api/sessions/live`); +} + +function waitForMessage(ws, type, timeoutMs = 3000) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs); + ws.on('message', function handler(data) { + const msg = JSON.parse(data.toString()); + if (msg.type === type) { + clearTimeout(timeout); + ws.removeListener('message', handler); + resolve(msg); + } + }); + }); +} + +beforeAll((done) => { + server.listen(0, () => done()); +}); + +afterAll((done) => { + server.close(done); +}); + +describe('vote.received WebSocket event', () => { + const baseTime = '2026-03-15T20:00:00.000Z'; + + beforeEach(() => { + cleanDb(); + }); + + test('broadcasts vote.received to session subscribers on live vote', async () => { + const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' }); + const session = seedSession({ is_active: 1 }); + seedSessionGame(session.id, game.id, { status: 'playing', played_at: baseTime }); + + const ws = connectWs(); + await new Promise((resolve) => ws.on('open', resolve)); + + ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() })); + await waitForMessage(ws, 'auth_success'); + + ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id })); + await waitForMessage(ws, 'subscribed'); + + const eventPromise = waitForMessage(ws, 'vote.received'); + + await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'up', + timestamp: '2026-03-15T20:05:00.000Z', + }); + + const event = await eventPromise; + + expect(event.data.sessionId).toBe(session.id); + expect(event.data.game).toEqual({ + id: game.id, + title: 'Quiplash 3', + pack_name: 'Party Pack 7', + }); + expect(event.data.vote).toEqual({ + username: 'viewer1', + type: 'up', + timestamp: '2026-03-15T20:05:00.000Z', + }); + expect(event.data.totals).toEqual({ + upvotes: 1, + downvotes: 0, + popularity_score: 1, + }); + + ws.close(); + }); + + test('does not broadcast on duplicate vote (409)', async () => { + const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' }); + const session = seedSession({ is_active: 1 }); + seedSessionGame(session.id, game.id, { status: 'playing', played_at: baseTime }); + + const ws = connectWs(); + await new Promise((resolve) => ws.on('open', resolve)); + + ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() })); + await waitForMessage(ws, 'auth_success'); + + ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id })); + await waitForMessage(ws, 'subscribed'); + + // First vote succeeds - set up listener before POST to catch the event + const firstEventPromise = waitForMessage(ws, 'vote.received'); + await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'up', + timestamp: '2026-03-15T20:05:00.000Z', + }); + await firstEventPromise; + + // Duplicate vote (within 1 second) + const dupRes = await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'down', + timestamp: '2026-03-15T20:05:00.500Z', + }); + + expect(dupRes.status).toBe(409); + + // Verify no vote.received event comes (wait briefly) + const noEvent = await Promise.race([ + waitForMessage(ws, 'vote.received', 500).then(() => 'received').catch(() => 'timeout'), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)), + ]); + + expect(noEvent).toBe('timeout'); + + ws.close(); + }); + + test('does not broadcast when no active session (404)', async () => { + const ws = connectWs(); + await new Promise((resolve) => ws.on('open', resolve)); + + ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() })); + await waitForMessage(ws, 'auth_success'); + + const res = await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'up', + timestamp: '2026-03-15T20:05:00.000Z', + }); + + expect(res.status).toBe(404); + + const noEvent = await Promise.race([ + waitForMessage(ws, 'vote.received', 500).then(() => 'received').catch(() => 'timeout'), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)), + ]); + + expect(noEvent).toBe('timeout'); + + ws.close(); + }); +});