# Vote Tracking API Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add real-time vote tracking via WebSocket and REST endpoints so clients can see per-session vote breakdowns, global vote history, and live vote events. **Architecture:** Approach B — session-scoped vote data under `/api/sessions/:id/votes`, global vote history under `/api/votes`, and a `vote.received` WebSocket event from `POST /api/votes/live`. Two-phase TDD: regression tests first (green), then feature tests (red→green). **Tech Stack:** Express, better-sqlite3, ws, jest, supertest --- ## Task 1: Test Infrastructure Setup **Files:** - Modify: `backend/server.js` (guard bootstrap/listen, add exports) - Modify: `backend/package.json` (add jest + supertest) - Create: `jest.config.js` - Create: `tests/jest.setup.js` - Create: `tests/helpers/test-utils.js` ### Step 1: Refactor `backend/server.js` for testability Move `bootstrapGames()` and `server.listen()` inside a `require.main` guard so importing the module in tests doesn't start the server or bootstrap data. Export `app` and `server`. Replace the end of `backend/server.js` (lines 15-56) so it becomes: ```js require('dotenv').config(); const express = require('express'); const http = require('http'); const cors = require('cors'); const { bootstrapGames } = require('./bootstrap'); const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager'); const app = express(); const PORT = process.env.PORT || 5000; // Middleware app.use(cors()); app.use(express.json()); // Health check app.get('/health', (req, res) => { res.json({ status: 'ok', message: 'Jackbox Game Picker API is running' }); }); // Routes const authRoutes = require('./routes/auth'); const gamesRoutes = require('./routes/games'); const sessionsRoutes = require('./routes/sessions'); const statsRoutes = require('./routes/stats'); const pickerRoutes = require('./routes/picker'); const votesRoutes = require('./routes/votes'); const webhooksRoutes = require('./routes/webhooks'); app.use('/api/auth', authRoutes); app.use('/api/games', gamesRoutes); app.use('/api/sessions', sessionsRoutes); app.use('/api/stats', statsRoutes); app.use('/api', pickerRoutes); app.use('/api/votes', votesRoutes); app.use('/api/webhooks', webhooksRoutes); // Error handling middleware app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ error: 'Something went wrong!', message: err.message }); }); // Create HTTP server and attach WebSocket const server = http.createServer(app); // Initialize WebSocket Manager const wsManager = new WebSocketManager(server); setWebSocketManager(wsManager); if (require.main === module) { bootstrapGames(); server.listen(PORT, '0.0.0.0', () => { console.log(`Server is running on port ${PORT}`); console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`); }); } module.exports = { app, server }; ``` Key change: `bootstrapGames()` and `server.listen()` only run when `server.js` is executed directly (`node server.js`), not when imported by tests. `app` and `server` are exported for test use. ### Step 2: Install test dependencies ```bash cd backend && npm install --save-dev jest supertest ``` ### Step 3: Add test script to `backend/package.json` Add to `"scripts"`: ```json "test": "jest --runInBand --verbose", "test:watch": "jest --runInBand --watch" ``` `--runInBand` ensures sequential execution (avoids SQLite concurrency issues). ### Step 4: Create `jest.config.js` (project root) ```js module.exports = { testEnvironment: 'node', roots: ['/tests'], setupFiles: ['/tests/jest.setup.js'], testMatch: ['**/*.test.js'], testTimeout: 10000, }; ``` ### Step 5: Create `tests/jest.setup.js` ```js process.env.DB_PATH = ':memory:'; process.env.JWT_SECRET = 'test-jwt-secret-do-not-use-in-prod'; process.env.ADMIN_KEY = 'test-admin-key'; process.env.PORT = '0'; ``` These env vars are set BEFORE any module is loaded. `DB_PATH=:memory:` gives each test worker an in-memory SQLite database. `dotenv` (loaded by server.js) won't overwrite existing env vars. ### Step 6: Create `tests/helpers/test-utils.js` ```js const jwt = require('jsonwebtoken'); const db = require('../../backend/database'); function getAuthToken() { return jwt.sign({ role: 'admin' }, process.env.JWT_SECRET, { expiresIn: '1h' }); } function getAuthHeader() { return `Bearer ${getAuthToken()}`; } function cleanDb() { db.exec('DELETE FROM live_votes'); db.exec('DELETE FROM chat_logs'); db.exec('DELETE FROM session_games'); db.exec('DELETE FROM sessions'); db.exec('DELETE FROM webhook_logs'); db.exec('DELETE FROM webhooks'); db.exec('DELETE FROM games'); db.exec('DELETE FROM packs'); } function seedGame(overrides = {}) { const defaults = { pack_name: 'Party Pack 7', title: 'Quiplash 3', min_players: 3, max_players: 8, length_minutes: 15, has_audience: 1, family_friendly: 1, game_type: 'Writing', enabled: 1, upvotes: 0, downvotes: 0, popularity_score: 0, }; const g = { ...defaults, ...overrides }; const result = db.prepare(` INSERT INTO games (pack_name, title, min_players, max_players, length_minutes, has_audience, family_friendly, game_type, enabled, upvotes, downvotes, popularity_score) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(g.pack_name, g.title, g.min_players, g.max_players, g.length_minutes, g.has_audience, g.family_friendly, g.game_type, g.enabled, g.upvotes, g.downvotes, g.popularity_score); return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid); } function seedSession(overrides = {}) { const defaults = { is_active: 1, notes: null }; const s = { ...defaults, ...overrides }; const result = db.prepare('INSERT INTO sessions (is_active, notes) VALUES (?, ?)').run(s.is_active, s.notes); return db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid); } function seedSessionGame(sessionId, gameId, overrides = {}) { const defaults = { status: 'playing', played_at: new Date().toISOString() }; const sg = { ...defaults, ...overrides }; const result = db.prepare(` INSERT INTO session_games (session_id, game_id, status, played_at) VALUES (?, ?, ?, ?) `).run(sessionId, gameId, sg.status, sg.played_at); return db.prepare('SELECT * FROM session_games WHERE id = ?').get(result.lastInsertRowid); } function seedVote(sessionId, gameId, username, voteType, timestamp) { const vt = voteType === 'up' ? 1 : -1; const ts = timestamp || new Date().toISOString(); db.prepare(` INSERT INTO live_votes (session_id, game_id, username, vote_type, timestamp) VALUES (?, ?, ?, ?, ?) `).run(sessionId, gameId, username, vt, ts); } module.exports = { getAuthToken, getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame, seedVote, db, }; ``` ### Step 7: Verify setup with a smoke test Create a temporary `tests/api/smoke.test.js`: ```js const request = require('supertest'); const { app } = require('../../backend/server'); describe('Smoke test', () => { test('GET /health returns ok', async () => { const res = await request(app).get('/health'); expect(res.status).toBe(200); expect(res.body.status).toBe('ok'); }); }); ``` Run: `cd backend && npx jest --runInBand --verbose` Expected: 1 test passing. Delete the smoke test after verifying. ### Step 8: Commit ```bash git add -A git commit -m "test: add jest/supertest infrastructure and make server.js testable" ``` --- ## Task 2: Regression Tests — POST /api/votes/live **Files:** - Create: `tests/api/regression-votes-live.test.js` ### Step 1: Write regression tests ```js const request = require('supertest'); const { app } = require('../../backend/server'); const { getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame } = require('../helpers/test-utils'); describe('POST /api/votes/live (regression)', () => { let game, session, sessionGame; const baseTime = '2026-03-15T20:00:00.000Z'; beforeEach(() => { cleanDb(); game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' }); session = seedSession({ is_active: 1 }); sessionGame = seedSessionGame(session.id, game.id, { status: 'playing', played_at: baseTime, }); }); test('returns 200 with correct response shape for upvote', async () => { const res = await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ username: 'viewer1', vote: 'up', timestamp: '2026-03-15T20:05:00.000Z', }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.session).toEqual( expect.objectContaining({ id: session.id }) ); expect(res.body.game).toEqual( expect.objectContaining({ id: game.id, title: 'Quiplash 3', upvotes: 1, downvotes: 0, popularity_score: 1, }) ); expect(res.body.vote).toEqual({ username: 'viewer1', type: 'up', timestamp: '2026-03-15T20:05:00.000Z', }); }); test('increments downvotes and decrements popularity_score for downvote', async () => { const res = await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ username: 'viewer1', vote: 'down', timestamp: '2026-03-15T20:05:00.000Z', }); expect(res.status).toBe(200); expect(res.body.game.downvotes).toBe(1); expect(res.body.game.popularity_score).toBe(-1); }); test('returns 400 for missing username', async () => { const res = await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ vote: 'up', timestamp: '2026-03-15T20:05:00.000Z' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/Missing required fields/); }); test('returns 400 for missing vote', async () => { const res = await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ username: 'viewer1', timestamp: '2026-03-15T20:05:00.000Z' }); expect(res.status).toBe(400); }); test('returns 400 for missing timestamp', async () => { const res = await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ username: 'viewer1', vote: 'up' }); expect(res.status).toBe(400); }); test('returns 400 for invalid vote value', async () => { const res = await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ username: 'viewer1', vote: 'maybe', timestamp: '2026-03-15T20:05:00.000Z', }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/vote must be either/); }); test('returns 400 for invalid timestamp format', async () => { const res = await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ username: 'viewer1', vote: 'up', timestamp: 'not-a-date', }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/Invalid timestamp/); }); test('returns 404 when no active session', async () => { cleanDb(); seedGame({ title: 'Unused' }); seedSession({ is_active: 0 }); const res = await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ username: 'viewer1', vote: 'up', timestamp: '2026-03-15T20:05:00.000Z', }); expect(res.status).toBe(404); expect(res.body.error).toMatch(/No active session/); }); test('returns 404 when vote timestamp does not match any game', async () => { const res = await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ username: 'viewer1', vote: 'up', timestamp: '2020-01-01T00:00:00.000Z', }); expect(res.status).toBe(404); expect(res.body.error).toMatch(/does not match any game/); }); test('returns 409 for duplicate vote within 1 second', async () => { await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ username: 'viewer1', vote: 'up', timestamp: '2026-03-15T20:05:00.000Z', }); const res = await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ username: 'viewer1', vote: 'down', timestamp: '2026-03-15T20:05:00.500Z', }); expect(res.status).toBe(409); expect(res.body.error).toMatch(/Duplicate vote/); }); test('returns 401 without auth token', async () => { const res = await request(app) .post('/api/votes/live') .send({ username: 'viewer1', vote: 'up', timestamp: '2026-03-15T20:05:00.000Z', }); expect(res.status).toBe(401); }); }); ``` ### Step 2: Run tests to verify green Run: `cd backend && npx jest --runInBand --verbose tests/api/regression-votes-live.test.js` Expected: All tests PASS. ### Step 3: Commit ```bash git add tests/api/regression-votes-live.test.js git commit -m "test: regression tests for POST /api/votes/live" ``` --- ## Task 3: Regression Tests — GET /api/games **Files:** - Create: `tests/api/regression-games.test.js` ### Step 1: Write regression tests ```js const request = require('supertest'); const { app } = require('../../backend/server'); const { cleanDb, seedGame } = require('../helpers/test-utils'); const db = require('../../backend/database'); describe('GET /api/games (regression)', () => { beforeEach(() => { cleanDb(); }); test('returns games with vote fields', async () => { seedGame({ title: 'Quiplash 3', upvotes: 10, downvotes: 3, popularity_score: 7, }); const res = await request(app).get('/api/games'); expect(res.status).toBe(200); expect(res.body).toHaveLength(1); expect(res.body[0]).toEqual( expect.objectContaining({ title: 'Quiplash 3', upvotes: 10, downvotes: 3, popularity_score: 7, }) ); }); test('GET /api/games/:id returns vote fields', async () => { const game = seedGame({ title: 'Drawful 2', upvotes: 5, downvotes: 2, popularity_score: 3, }); const res = await request(app).get(`/api/games/${game.id}`); expect(res.status).toBe(200); expect(res.body.upvotes).toBe(5); expect(res.body.downvotes).toBe(2); expect(res.body.popularity_score).toBe(3); }); test('vote aggregates update correctly after recording votes', async () => { const game = seedGame({ title: 'Fibbage 4' }); db.prepare('UPDATE games SET upvotes = upvotes + 1, popularity_score = popularity_score + 1 WHERE id = ?').run(game.id); db.prepare('UPDATE games SET upvotes = upvotes + 1, popularity_score = popularity_score + 1 WHERE id = ?').run(game.id); db.prepare('UPDATE games SET downvotes = downvotes + 1, popularity_score = popularity_score - 1 WHERE id = ?').run(game.id); const res = await request(app).get(`/api/games/${game.id}`); expect(res.body.upvotes).toBe(2); expect(res.body.downvotes).toBe(1); expect(res.body.popularity_score).toBe(1); }); }); ``` ### Step 2: Run tests to verify green Run: `cd backend && npx jest --runInBand --verbose tests/api/regression-games.test.js` Expected: All tests PASS. ### Step 3: Commit ```bash git add tests/api/regression-games.test.js git commit -m "test: regression tests for GET /api/games vote fields" ``` --- ## Task 4: Regression Tests — GET /api/sessions **Files:** - Create: `tests/api/regression-sessions.test.js` ### Step 1: Write regression tests ```js const request = require('supertest'); const { app } = require('../../backend/server'); const { cleanDb, seedGame, seedSession, seedSessionGame } = require('../helpers/test-utils'); describe('GET /api/sessions (regression)', () => { beforeEach(() => { cleanDb(); }); test('GET /api/sessions/:id returns session object', async () => { const session = seedSession({ is_active: 1, notes: 'Test session' }); const res = await request(app).get(`/api/sessions/${session.id}`); expect(res.status).toBe(200); expect(res.body).toEqual( expect.objectContaining({ id: session.id, is_active: 1, notes: 'Test session', }) ); expect(res.body).toHaveProperty('games_played'); }); test('GET /api/sessions/:id returns 404 for nonexistent session', async () => { const res = await request(app).get('/api/sessions/99999'); expect(res.status).toBe(404); expect(res.body.error).toBe('Session not found'); }); test('GET /api/sessions/:id/games returns games with expected shape', async () => { const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7', min_players: 3, max_players: 8, }); const session = seedSession({ is_active: 1 }); seedSessionGame(session.id, game.id, { status: 'playing' }); const res = await request(app).get(`/api/sessions/${session.id}/games`); expect(res.status).toBe(200); expect(res.body).toHaveLength(1); expect(res.body[0]).toEqual( expect.objectContaining({ game_id: game.id, session_id: session.id, pack_name: 'Party Pack 7', title: 'Quiplash 3', min_players: 3, max_players: 8, status: 'playing', }) ); expect(res.body[0]).toHaveProperty('upvotes'); expect(res.body[0]).toHaveProperty('downvotes'); expect(res.body[0]).toHaveProperty('popularity_score'); }); test('GET /api/sessions/:id/games returns empty array for session with no games', async () => { const session = seedSession({ is_active: 1 }); const res = await request(app).get(`/api/sessions/${session.id}/games`); expect(res.status).toBe(200); expect(res.body).toEqual([]); }); }); ``` ### Step 2: Run tests to verify green Run: `cd backend && npx jest --runInBand --verbose tests/api/regression-sessions.test.js` Expected: All tests PASS. ### Step 3: Commit ```bash git add tests/api/regression-sessions.test.js git commit -m "test: regression tests for GET /api/sessions endpoints" ``` --- ## Task 5: Regression Tests — WebSocket Events **Files:** - Create: `tests/api/regression-websocket.test.js` ### Step 1: Write regression tests These tests need a real HTTP server (for WebSocket) and use the `ws` client library. The server is started on port 0 (random) and closed after tests. ```js const WebSocket = require('ws'); const request = require('supertest'); const { app, server } = require('../../backend/server'); const { getAuthToken, getAuthHeader, cleanDb, seedGame, seedSession } = require('../helpers/test-utils'); let baseUrl; 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); } }); }); } function authenticateAndSubscribe(ws, sessionId) { return new Promise(async (resolve, reject) => { try { ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() })); await waitForMessage(ws, 'auth_success'); ws.send(JSON.stringify({ type: 'subscribe', sessionId })); await waitForMessage(ws, 'subscribed'); resolve(); } catch (err) { reject(err); } }); } beforeAll((done) => { server.listen(0, () => { baseUrl = `http://localhost:${server.address().port}`; done(); }); }); afterAll((done) => { server.close(done); }); describe('WebSocket events (regression)', () => { beforeEach(() => { cleanDb(); }); test('auth flow: auth -> auth_success', (done) => { const ws = connectWs(); ws.on('open', () => { ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() })); }); ws.on('message', (data) => { const msg = JSON.parse(data.toString()); if (msg.type === 'auth_success') { ws.close(); done(); } }); }); test('subscribe/unsubscribe flow', async () => { const session = seedSession({ is_active: 1 }); 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 })); const subMsg = await waitForMessage(ws, 'subscribed'); expect(subMsg.sessionId).toBe(session.id); ws.send(JSON.stringify({ type: 'unsubscribe', sessionId: session.id })); const unsubMsg = await waitForMessage(ws, 'unsubscribed'); expect(unsubMsg.sessionId).toBe(session.id); ws.close(); }); test('session.started broadcasts to all authenticated clients', 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 eventPromise = waitForMessage(ws, 'session.started'); await request(app) .post('/api/sessions') .set('Authorization', getAuthHeader()) .send({ notes: 'Test session' }); const event = await eventPromise; expect(event.data.session).toEqual( expect.objectContaining({ is_active: 1, notes: 'Test session', }) ); ws.close(); }); test('session.ended broadcasts to session subscribers', async () => { const session = seedSession({ is_active: 1 }); const ws = connectWs(); await new Promise((resolve) => ws.on('open', resolve)); await authenticateAndSubscribe(ws, session.id); const eventPromise = waitForMessage(ws, 'session.ended'); await request(app) .post(`/api/sessions/${session.id}/close`) .set('Authorization', getAuthHeader()); const event = await eventPromise; expect(event.data.session.id).toBe(session.id); expect(event.data.session.is_active).toBe(0); ws.close(); }); test('game.added broadcasts to session subscribers', async () => { const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' }); const session = seedSession({ is_active: 1 }); const ws = connectWs(); await new Promise((resolve) => ws.on('open', resolve)); await authenticateAndSubscribe(ws, session.id); const eventPromise = waitForMessage(ws, 'game.added'); await request(app) .post(`/api/sessions/${session.id}/games`) .set('Authorization', getAuthHeader()) .send({ game_id: game.id }); const event = await eventPromise; expect(event.data.game.title).toBe('Quiplash 3'); expect(event.data.session.id).toBe(session.id); ws.close(); }); }); ``` ### Step 2: Run tests to verify green Run: `cd backend && npx jest --runInBand --verbose tests/api/regression-websocket.test.js` Expected: All tests PASS. ### Step 3: Commit ```bash git add tests/api/regression-websocket.test.js git commit -m "test: regression tests for WebSocket events" ``` --- ## Task 6: Feature — GET /api/sessions/:id/votes **Files:** - Create: `tests/api/sessions-votes.test.js` - Modify: `backend/routes/sessions.js` (add route after `GET /:id/games`) ### Step 1: Write the failing test ```js const request = require('supertest'); const { app } = require('../../backend/server'); const { cleanDb, seedGame, seedSession, seedSessionGame, seedVote } = require('../helpers/test-utils'); describe('GET /api/sessions/:id/votes', () => { beforeEach(() => { cleanDb(); }); test('returns per-game vote breakdown for a session', async () => { const game1 = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' }); const game2 = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' }); const session = seedSession({ is_active: 1 }); seedSessionGame(session.id, game1.id); seedSessionGame(session.id, game2.id); seedVote(session.id, game1.id, 'user1', 'up'); seedVote(session.id, game1.id, 'user2', 'up'); seedVote(session.id, game1.id, 'user3', 'down'); seedVote(session.id, game2.id, 'user1', 'down'); const res = await request(app).get(`/api/sessions/${session.id}/votes`); expect(res.status).toBe(200); expect(res.body.session_id).toBe(session.id); expect(res.body.votes).toHaveLength(2); const q3 = res.body.votes.find((v) => v.game_id === game1.id); expect(q3.title).toBe('Quiplash 3'); expect(q3.pack_name).toBe('Party Pack 7'); expect(q3.upvotes).toBe(2); expect(q3.downvotes).toBe(1); expect(q3.net_score).toBe(1); expect(q3.total_votes).toBe(3); const d2 = res.body.votes.find((v) => v.game_id === game2.id); expect(d2.upvotes).toBe(0); expect(d2.downvotes).toBe(1); expect(d2.net_score).toBe(-1); expect(d2.total_votes).toBe(1); }); test('returns empty votes array when session has no votes', async () => { const session = seedSession({ is_active: 1 }); const res = await request(app).get(`/api/sessions/${session.id}/votes`); expect(res.status).toBe(200); expect(res.body.session_id).toBe(session.id); expect(res.body.votes).toEqual([]); }); test('returns 404 for nonexistent session', async () => { const res = await request(app).get('/api/sessions/99999/votes'); expect(res.status).toBe(404); expect(res.body.error).toBe('Session not found'); }); test('only includes votes from the requested session', async () => { const game = seedGame({ title: 'Quiplash 3' }); const session1 = seedSession({ is_active: 0 }); const session2 = seedSession({ is_active: 1 }); seedSessionGame(session1.id, game.id); seedSessionGame(session2.id, game.id); seedVote(session1.id, game.id, 'user1', 'up'); seedVote(session1.id, game.id, 'user2', 'up'); seedVote(session2.id, game.id, 'user3', 'down'); const res = await request(app).get(`/api/sessions/${session1.id}/votes`); expect(res.body.votes).toHaveLength(1); expect(res.body.votes[0].upvotes).toBe(2); expect(res.body.votes[0].downvotes).toBe(0); }); test('results are ordered by net_score descending', async () => { const game1 = seedGame({ title: 'Good Game' }); const game2 = seedGame({ title: 'Bad Game' }); const session = seedSession({ is_active: 1 }); seedSessionGame(session.id, game1.id); seedSessionGame(session.id, game2.id); seedVote(session.id, game2.id, 'user1', 'down'); seedVote(session.id, game2.id, 'user2', 'down'); seedVote(session.id, game1.id, 'user1', 'up'); const res = await request(app).get(`/api/sessions/${session.id}/votes`); expect(res.body.votes[0].title).toBe('Good Game'); expect(res.body.votes[1].title).toBe('Bad Game'); }); }); ``` ### Step 2: Run test to verify it fails Run: `cd backend && npx jest --runInBand --verbose tests/api/sessions-votes.test.js` Expected: FAIL — route does not exist (404 from Express, or the `/:id` route matches and returns a session object instead of votes). ### Step 3: Implement — add route to `backend/routes/sessions.js` Add the following route AFTER the `GET /:id/games` route (after line 255 in current file). Insert between the `GET /:id/games` handler and the `POST /:id/games` handler: ```js // Get vote breakdown for a session router.get('/:id/votes', (req, res) => { try { const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(req.params.id); if (!session) { return res.status(404).json({ error: 'Session not found' }); } const votes = db.prepare(` SELECT lv.game_id, g.title, g.pack_name, SUM(CASE WHEN lv.vote_type = 1 THEN 1 ELSE 0 END) AS upvotes, SUM(CASE WHEN lv.vote_type = -1 THEN 1 ELSE 0 END) AS downvotes, SUM(lv.vote_type) AS net_score, COUNT(*) AS total_votes FROM live_votes lv JOIN games g ON lv.game_id = g.id WHERE lv.session_id = ? GROUP BY lv.game_id ORDER BY net_score DESC `).all(req.params.id); res.json({ session_id: parseInt(req.params.id), votes, }); } catch (error) { res.status(500).json({ error: error.message }); } }); ``` ### Step 4: Run test to verify it passes Run: `cd backend && npx jest --runInBand --verbose tests/api/sessions-votes.test.js` Expected: All tests PASS. ### Step 5: Run regression tests to verify no breakage Run: `cd backend && npx jest --runInBand --verbose tests/api/regression-sessions.test.js` Expected: All tests PASS. ### Step 6: Commit ```bash git add tests/api/sessions-votes.test.js backend/routes/sessions.js git commit -m "feat: add GET /api/sessions/:id/votes endpoint" ``` --- ## Task 7: Feature — GET /api/votes **Files:** - Create: `tests/api/votes-get.test.js` - Modify: `backend/routes/votes.js` (add GET / route) ### Step 1: Write the failing test ```js const request = require('supertest'); const { app } = require('../../backend/server'); const { cleanDb, seedGame, seedSession, seedSessionGame, seedVote } = require('../helpers/test-utils'); describe('GET /api/votes', () => { let game1, game2, session; beforeEach(() => { cleanDb(); game1 = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' }); game2 = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' }); session = seedSession({ is_active: 1 }); seedSessionGame(session.id, game1.id); seedSessionGame(session.id, game2.id); }); test('returns all votes with pagination metadata', async () => { seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z'); seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z'); const res = await request(app).get('/api/votes'); expect(res.status).toBe(200); expect(res.body.votes).toHaveLength(2); expect(res.body.pagination).toEqual({ page: 1, limit: 50, total: 2, total_pages: 1, }); }); test('returns vote_type as "up"/"down" not raw integers', async () => { seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z'); seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z'); const res = await request(app).get('/api/votes'); const types = res.body.votes.map((v) => v.vote_type); expect(types).toContain('up'); expect(types).toContain('down'); expect(types).not.toContain(1); expect(types).not.toContain(-1); }); test('includes game_title and pack_name via join', async () => { seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z'); const res = await request(app).get('/api/votes'); expect(res.body.votes[0].game_title).toBe('Quiplash 3'); expect(res.body.votes[0].pack_name).toBe('Party Pack 7'); }); test('filters by session_id', async () => { const session2 = seedSession({ is_active: 0 }); seedSessionGame(session2.id, game1.id); seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z'); seedVote(session2.id, game1.id, 'user2', 'up', '2026-03-15T21:01:00.000Z'); const res = await request(app).get(`/api/votes?session_id=${session.id}`); expect(res.body.votes).toHaveLength(1); expect(res.body.votes[0].session_id).toBe(session.id); expect(res.body.pagination.total).toBe(1); }); test('filters by game_id', async () => { seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z'); seedVote(session.id, game2.id, 'user2', 'down', '2026-03-15T20:02:00.000Z'); const res = await request(app).get(`/api/votes?game_id=${game1.id}`); expect(res.body.votes).toHaveLength(1); expect(res.body.votes[0].game_id).toBe(game1.id); }); test('filters by username', async () => { seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z'); seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z'); const res = await request(app).get('/api/votes?username=user1'); expect(res.body.votes).toHaveLength(1); expect(res.body.votes[0].username).toBe('user1'); }); test('filters by vote_type', async () => { seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z'); seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z'); const res = await request(app).get('/api/votes?vote_type=up'); expect(res.body.votes).toHaveLength(1); expect(res.body.votes[0].vote_type).toBe('up'); }); test('combines multiple filters', async () => { seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z'); seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z'); seedVote(session.id, game2.id, 'user1', 'up', '2026-03-15T20:03:00.000Z'); const res = await request(app).get( `/api/votes?game_id=${game1.id}&username=user1` ); expect(res.body.votes).toHaveLength(1); expect(res.body.votes[0].username).toBe('user1'); expect(res.body.votes[0].game_id).toBe(game1.id); }); test('respects page and limit', async () => { for (let i = 0; i < 5; i++) { seedVote(session.id, game1.id, `user${i}`, 'up', `2026-03-15T20:0${i}:00.000Z`); } const res = await request(app).get('/api/votes?page=2&limit=2'); expect(res.body.votes).toHaveLength(2); expect(res.body.pagination).toEqual({ page: 2, limit: 2, total: 5, total_pages: 3, }); }); test('caps limit at 100', async () => { seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z'); const res = await request(app).get('/api/votes?limit=500'); expect(res.body.pagination.limit).toBe(100); }); test('returns 200 with empty array when no votes match', async () => { const res = await request(app).get('/api/votes?username=nonexistent'); expect(res.status).toBe(200); expect(res.body.votes).toEqual([]); expect(res.body.pagination.total).toBe(0); }); test('returns 400 for invalid session_id', async () => { const res = await request(app).get('/api/votes?session_id=abc'); expect(res.status).toBe(400); }); test('returns 400 for invalid vote_type', async () => { const res = await request(app).get('/api/votes?vote_type=maybe'); expect(res.status).toBe(400); }); test('orders by timestamp descending', async () => { seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z'); seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:05:00.000Z'); const res = await request(app).get('/api/votes'); const timestamps = res.body.votes.map((v) => v.timestamp); expect(timestamps[0]).toBe('2026-03-15T20:05:00.000Z'); expect(timestamps[1]).toBe('2026-03-15T20:01:00.000Z'); }); }); ``` ### Step 2: Run test to verify it fails Run: `cd backend && npx jest --runInBand --verbose tests/api/votes-get.test.js` Expected: FAIL — route does not exist. ### Step 3: Implement — add GET route to `backend/routes/votes.js` Add BEFORE the existing `router.post('/live', ...)`: ```js // Get vote history with filtering and pagination router.get('/', (req, res) => { try { let { session_id, game_id, username, vote_type, page, limit } = req.query; page = parseInt(page) || 1; limit = Math.min(parseInt(limit) || 50, 100); if (page < 1) page = 1; if (limit < 1) limit = 50; const offset = (page - 1) * limit; const where = []; const params = []; if (session_id !== undefined) { const sid = parseInt(session_id); if (isNaN(sid)) { return res.status(400).json({ error: 'session_id must be an integer' }); } where.push('lv.session_id = ?'); params.push(sid); } if (game_id !== undefined) { const gid = parseInt(game_id); if (isNaN(gid)) { return res.status(400).json({ error: 'game_id must be an integer' }); } where.push('lv.game_id = ?'); params.push(gid); } if (username) { where.push('lv.username = ?'); params.push(username); } if (vote_type !== undefined) { if (vote_type !== 'up' && vote_type !== 'down') { return res.status(400).json({ error: 'vote_type must be "up" or "down"' }); } where.push('lv.vote_type = ?'); params.push(vote_type === 'up' ? 1 : -1); } const whereClause = where.length > 0 ? 'WHERE ' + where.join(' AND ') : ''; const countResult = db.prepare( `SELECT COUNT(*) as total FROM live_votes lv ${whereClause}` ).get(...params); const total = countResult.total; const total_pages = Math.ceil(total / limit) || 0; const votes = db.prepare(` SELECT lv.id, lv.session_id, lv.game_id, g.title AS game_title, g.pack_name, lv.username, CASE WHEN lv.vote_type = 1 THEN 'up' ELSE 'down' END AS vote_type, lv.timestamp, lv.created_at FROM live_votes lv JOIN games g ON lv.game_id = g.id ${whereClause} ORDER BY lv.timestamp DESC LIMIT ? OFFSET ? `).all(...params, limit, offset); res.json({ votes, pagination: { page, limit, total, total_pages }, }); } catch (error) { res.status(500).json({ error: error.message }); } }); ``` ### Step 4: Run test to verify it passes Run: `cd backend && npx jest --runInBand --verbose tests/api/votes-get.test.js` Expected: All tests PASS. ### Step 5: Run regression tests Run: `cd backend && npx jest --runInBand --verbose tests/api/regression-votes-live.test.js` Expected: All PASS (no breakage to existing POST /api/votes/live). ### Step 6: Commit ```bash git add tests/api/votes-get.test.js backend/routes/votes.js git commit -m "feat: add GET /api/votes endpoint with filtering and pagination" ``` --- ## Task 8: Feature — vote.received WebSocket Event **Files:** - Create: `tests/api/votes-live-websocket.test.js` - Modify: `backend/routes/votes.js` (add WebSocket broadcast + pack_name to query) ### Step 1: Write the failing test ```js 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()) .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 await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .send({ username: 'viewer1', vote: 'up', timestamp: '2026-03-15T20:05:00.000Z', }); // Consume the first vote.received event await waitForMessage(ws, 'vote.received'); // Duplicate vote (within 1 second) const dupRes = await request(app) .post('/api/votes/live') .set('Authorization', getAuthHeader()) .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'), 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()) .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'), new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)), ]); expect(noEvent).toBe('timeout'); ws.close(); }); }); ``` ### Step 2: Run test to verify it fails Run: `cd backend && npx jest --runInBand --verbose tests/api/votes-live-websocket.test.js` Expected: FAIL — first test times out waiting for `vote.received` (event is never sent). ### Step 3: Implement — add WebSocket broadcast to `backend/routes/votes.js` Two changes in `backend/routes/votes.js`: **3a.** Add import at top of file (after existing requires): ```js const { getWebSocketManager } = require('../utils/websocket-manager'); ``` **3b.** Add `g.pack_name` to the session games query (the one that fetches games for timestamp matching). Change: ```js SELECT sg.game_id, sg.played_at, g.title, g.upvotes, g.downvotes, g.popularity_score ``` to: ```js SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score ``` **3c.** After `processVote()` and the `updatedGame` query (after line 157, before the `res.json(...)` call), add the WebSocket broadcast: ```js // 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); } ``` ### Step 4: Run test to verify it passes Run: `cd backend && npx jest --runInBand --verbose tests/api/votes-live-websocket.test.js` Expected: All tests PASS. ### Step 5: Run regression tests Run: `cd backend && npx jest --runInBand --verbose tests/api/regression-votes-live.test.js tests/api/regression-websocket.test.js` Expected: All PASS (no breakage to existing vote or WebSocket behavior). ### Step 6: Commit ```bash git add tests/api/votes-live-websocket.test.js backend/routes/votes.js git commit -m "feat: add vote.received WebSocket event on live votes" ``` --- ## Task 9: Final Verification ### Step 1: Run ALL tests ```bash cd backend && npx jest --runInBand --verbose ``` Expected: ALL tests PASS — regression tests (Phase 1) confirm no breakage, feature tests (Phase 2) confirm new functionality works. ### Step 2: Manual sanity check (optional) Start the server and verify: ```bash cd backend && node server.js ``` 1. `GET /api/sessions/:id/votes` — returns expected breakdown 2. `GET /api/votes` — returns paginated history 3. WebSocket `vote.received` — connect, subscribe, post a vote, observe event ### Step 3: Final commit if any cleanup needed ```bash git add -A git commit -m "chore: final cleanup after vote tracking API implementation" ```