diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index 091ceb1..1b1f279 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -254,6 +254,40 @@ router.get('/:id/games', (req, res) => { } }); +// 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 }); + } +}); + // Add game to session (admin only) router.post('/:id/games', authenticateToken, (req, res) => { try { diff --git a/backend/routes/votes.js b/backend/routes/votes.js index 5efeeae..21a6cfb 100644 --- a/backend/routes/votes.js +++ b/backend/routes/votes.js @@ -4,6 +4,87 @@ const db = require('../database'); const router = express.Router(); +// 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 }); + } +}); + // Live vote endpoint - receives real-time votes from bot router.post('/live', authenticateToken, (req, res) => { try { diff --git a/tests/api/sessions-votes.test.js b/tests/api/sessions-votes.test.js new file mode 100644 index 0000000..ebd1bd7 --- /dev/null +++ b/tests/api/sessions-votes.test.js @@ -0,0 +1,94 @@ +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'); + }); +}); diff --git a/tests/api/votes-get.test.js b/tests/api/votes-get.test.js new file mode 100644 index 0000000..23260ab --- /dev/null +++ b/tests/api/votes-get.test.js @@ -0,0 +1,166 @@ +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'); + }); +});