Files
jackboxpartypack-gamepicker/docs/plans/2026-03-15-vote-tracking-api.md
cottongin 81fcae545e docs: vote tracking API implementation plan
TDD plan with 9 tasks: test infrastructure, regression tests
(4 files), and 3 feature implementations with full test code.

Made-with: Cursor
2026-03-15 18:24:50 -04:00

1573 lines
46 KiB
Markdown

# 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: ['<rootDir>/tests'],
setupFiles: ['<rootDir>/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"
```