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

46 KiB

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:

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

cd backend && npm install --save-dev jest supertest

Step 3: Add test script to backend/package.json

Add to "scripts":

"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)

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

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

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:

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

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

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

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

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

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

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

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.

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

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

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:

// 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

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

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', ...):

// 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

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

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):

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:

SELECT sg.game_id, sg.played_at, g.title, g.upvotes, g.downvotes, g.popularity_score

to:

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:

    // 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

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

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:

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

git add -A
git commit -m "chore: final cleanup after vote tracking API implementation"