fix: upgrade Docker to Node 22, add vote persistence diagnostics and e2e tests
- Bump Dockerfile base image from node:18-alpine to node:22-alpine to fix build failure (better-sqlite3@12.8.0 requires Node 20+) - Add post-transaction verification logging in POST /api/votes/live to detect live_votes insertion failures in production - Add direct DB assertion to regression test for live_votes population - Add end-to-end integration tests covering the full vote flow: POST vote -> GET /api/sessions/:id/votes -> GET /api/votes -> direct DB Made-with: Cursor
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
FROM node:18-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
165
tests/api/votes-live-e2e.test.js
Normal file
165
tests/api/votes-live-e2e.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user