diff --git a/backend/Dockerfile b/backend/Dockerfile index b5bab68..f2a3aab 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:22-alpine WORKDIR /app diff --git a/backend/routes/votes.js b/backend/routes/votes.js index f93522c..5eaebb1 100644 --- a/backend/routes/votes.js +++ b/backend/routes/votes.js @@ -231,6 +231,16 @@ router.post('/live', authenticateToken, (req, res) => { processVote(); + // Verify the live_votes row was persisted (diagnostic for production debugging) + const voteCheck = db.prepare( + 'SELECT id FROM live_votes WHERE session_id = ? AND game_id = ? AND username = ? AND timestamp = ?' + ).get(activeSession.id, matchedGame.game_id, username, timestamp); + if (!voteCheck) { + console.error('[votes] CRITICAL: live_votes INSERT committed but row not found', { + session_id: activeSession.id, game_id: matchedGame.game_id, username, timestamp, + }); + } + // Get updated game stats const updatedGame = db.prepare(` SELECT id, title, upvotes, downvotes, popularity_score diff --git a/tests/api/regression-votes-live.test.js b/tests/api/regression-votes-live.test.js index a337053..37b6c18 100644 --- a/tests/api/regression-votes-live.test.js +++ b/tests/api/regression-votes-live.test.js @@ -1,6 +1,6 @@ const request = require('supertest'); const { app } = require('../../backend/server'); -const { getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame } = require('../helpers/test-utils'); +const { getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame, db } = require('../helpers/test-utils'); describe('POST /api/votes/live (regression)', () => { let game, session, sessionGame; @@ -46,6 +46,12 @@ describe('POST /api/votes/live (regression)', () => { type: 'up', timestamp: '2026-03-15T20:05:00.000Z', }); + + const voteRow = db.prepare('SELECT * FROM live_votes WHERE username = ?').get('viewer1'); + expect(voteRow).toBeDefined(); + expect(voteRow.session_id).toBe(session.id); + expect(voteRow.game_id).toBe(game.id); + expect(voteRow.vote_type).toBe(1); }); test('increments downvotes and decrements popularity_score for downvote', async () => { diff --git a/tests/api/votes-live-e2e.test.js b/tests/api/votes-live-e2e.test.js new file mode 100644 index 0000000..212dbfb --- /dev/null +++ b/tests/api/votes-live-e2e.test.js @@ -0,0 +1,165 @@ +const request = require('supertest'); +const { app } = require('../../backend/server'); +const { getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame, db } = require('../helpers/test-utils'); + +describe('POST /api/votes/live -> read-back (end-to-end)', () => { + let game1, game2, session; + const baseTime = '2026-03-15T20:00:00.000Z'; + const game2Time = '2026-03-15T20:30:00.000Z'; + + 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, { status: 'played', played_at: baseTime }); + seedSessionGame(session.id, game2.id, { status: 'playing', played_at: game2Time }); + }); + + test('vote via POST populates live_votes table (direct DB check)', async () => { + const res = await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'up', + timestamp: '2026-03-15T20:35:00.000Z', + }); + + expect(res.status).toBe(200); + + const row = db.prepare( + 'SELECT * FROM live_votes WHERE session_id = ? AND game_id = ? AND username = ?' + ).get(session.id, game2.id, 'viewer1'); + + expect(row).toBeDefined(); + expect(row.session_id).toBe(session.id); + expect(row.game_id).toBe(game2.id); + expect(row.username).toBe('viewer1'); + expect(row.vote_type).toBe(1); + expect(row.timestamp).toBe('2026-03-15T20:35:00.000Z'); + }); + + test('vote via POST is visible in GET /api/sessions/:id/votes', async () => { + await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'up', + timestamp: '2026-03-15T20:35:00.000Z', + }); + + await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer2', + vote: 'down', + timestamp: '2026-03-15T20:36:00.000Z', + }); + + 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(1); + + const gameVotes = res.body.votes[0]; + expect(gameVotes.game_id).toBe(game2.id); + expect(gameVotes.title).toBe('Drawful 2'); + expect(gameVotes.upvotes).toBe(1); + expect(gameVotes.downvotes).toBe(1); + expect(gameVotes.net_score).toBe(0); + expect(gameVotes.total_votes).toBe(2); + }); + + test('vote via POST is visible in GET /api/votes', async () => { + await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'up', + timestamp: '2026-03-15T20:35:00.000Z', + }); + + const res = await request(app).get(`/api/votes?session_id=${session.id}`); + + expect(res.status).toBe(200); + expect(res.body.votes).toHaveLength(1); + expect(res.body.votes[0].username).toBe('viewer1'); + expect(res.body.votes[0].vote_type).toBe('up'); + expect(res.body.votes[0].game_id).toBe(game2.id); + expect(res.body.votes[0].game_title).toBe('Drawful 2'); + expect(res.body.votes[0].session_id).toBe(session.id); + }); + + test('votes for different games in same session are tracked separately', async () => { + await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer1', + vote: 'up', + timestamp: '2026-03-15T20:10:00.000Z', + }); + + await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: 'viewer2', + vote: 'down', + timestamp: '2026-03-15T20:35:00.000Z', + }); + + const res = await request(app).get(`/api/sessions/${session.id}/votes`); + + expect(res.status).toBe(200); + expect(res.body.votes).toHaveLength(2); + + const q3 = res.body.votes.find((v) => v.game_id === game1.id); + expect(q3.upvotes).toBe(1); + expect(q3.downvotes).toBe(0); + + const d2 = res.body.votes.find((v) => v.game_id === game2.id); + expect(d2.upvotes).toBe(0); + expect(d2.downvotes).toBe(1); + }); + + test('live_votes row count matches number of accepted votes', async () => { + const timestamps = [ + '2026-03-15T20:35:00.000Z', + '2026-03-15T20:36:05.000Z', + '2026-03-15T20:37:10.000Z', + ]; + + for (let i = 0; i < timestamps.length; i++) { + const res = await request(app) + .post('/api/votes/live') + .set('Authorization', getAuthHeader()) + .set('Content-Type', 'application/json') + .send({ + username: `viewer${i}`, + vote: i % 2 === 0 ? 'up' : 'down', + timestamp: timestamps[i], + }); + expect(res.status).toBe(200); + } + + const count = db.prepare( + 'SELECT COUNT(*) as cnt FROM live_votes WHERE session_id = ?' + ).get(session.id); + expect(count.cnt).toBe(3); + + const sessionVotes = await request(app).get(`/api/sessions/${session.id}/votes`); + expect(sessionVotes.body.votes[0].total_votes).toBe(3); + }); +});