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:
cottongin
2026-03-16 20:53:32 -04:00
parent 0d0d20161b
commit 35617268e9
4 changed files with 183 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
FROM node:18-alpine
FROM node:22-alpine
WORKDIR /app

View File

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

View File

@@ -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 () => {

View 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);
});
});