diff --git a/docs/plans/2026-03-15-vote-tracking-api.md b/docs/plans/2026-03-15-vote-tracking-api.md new file mode 100644 index 0000000..3f18c65 --- /dev/null +++ b/docs/plans/2026-03-15-vote-tracking-api.md @@ -0,0 +1,1572 @@ +# 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" +```