TDD plan with 9 tasks: test infrastructure, regression tests (4 files), and 3 feature implementations with full test code. Made-with: Cursor
1573 lines
46 KiB
Markdown
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"
|
|
```
|