feat: add vote.received WebSocket event on live votes
Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const db = require('../database');
|
const db = require('../database');
|
||||||
|
const { getWebSocketManager } = require('../utils/websocket-manager');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
|
|
||||||
// Get all games played in this session with timestamps
|
// Get all games played in this session with timestamps
|
||||||
const sessionGames = db.prepare(`
|
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
|
FROM session_games sg
|
||||||
JOIN games g ON sg.game_id = g.id
|
JOIN games g ON sg.game_id = g.id
|
||||||
WHERE sg.session_id = ?
|
WHERE sg.session_id = ?
|
||||||
@@ -237,6 +238,33 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(matchedGame.game_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
|
// Get session stats
|
||||||
const sessionStats = db.prepare(`
|
const sessionStats = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
166
tests/api/votes-live-websocket.test.js
Normal file
166
tests/api/votes-live-websocket.test.js
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user