feat: ticker symbol voting for live votes
- Add ticker column to games table with migration - Bootstrap tickers from tickers.json config on startup - POST /api/votes/live accepts optional ticker field for direct game lookup (bypasses timestamp-interval matching) - Ticker votes work for any game, not just session games - Update API docs and add e2e tests for ticker voting - Version bump to 0.6.5 Made-with: Cursor
This commit is contained in:
@@ -163,3 +163,104 @@ describe('POST /api/votes/live -> read-back (end-to-end)', () => {
|
||||
expect(sessionVotes.body.votes[0].total_votes).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/votes/live — ticker voting', () => {
|
||||
let session, tickerGame, sessionGame;
|
||||
const baseTime = '2026-03-15T20:00:00.000Z';
|
||||
|
||||
beforeEach(() => {
|
||||
cleanDb();
|
||||
tickerGame = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7', ticker: 'QPL3' });
|
||||
sessionGame = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
|
||||
session = seedSession({ is_active: 1 });
|
||||
seedSessionGame(session.id, sessionGame.id, { status: 'playing', played_at: baseTime });
|
||||
});
|
||||
|
||||
test('vote with valid ticker resolves to the correct game', 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:05:00.000Z',
|
||||
ticker: 'QPL3',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.game.id).toBe(tickerGame.id);
|
||||
expect(res.body.game.title).toBe('Quiplash 3');
|
||||
expect(res.body.vote.ticker).toBe('QPL3');
|
||||
|
||||
const row = db.prepare(
|
||||
'SELECT * FROM live_votes WHERE game_id = ? AND username = ?'
|
||||
).get(tickerGame.id, 'viewer1');
|
||||
expect(row).toBeDefined();
|
||||
expect(row.vote_type).toBe(1);
|
||||
});
|
||||
|
||||
test('ticker vote works for a game not in the active session', async () => {
|
||||
const outsideGame = seedGame({ title: 'Fibbage XL', pack_name: 'Party Pack 1', ticker: 'FBXL' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/votes/live')
|
||||
.set('Authorization', getAuthHeader())
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
username: 'viewer1',
|
||||
vote: 'down',
|
||||
timestamp: '2026-03-15T20:05:00.000Z',
|
||||
ticker: 'FBXL',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.game.id).toBe(outsideGame.id);
|
||||
expect(res.body.game.title).toBe('Fibbage XL');
|
||||
});
|
||||
|
||||
test('unknown ticker returns 404', 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:05:00.000Z',
|
||||
ticker: 'NOPE',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/Unknown ticker 'NOPE'/);
|
||||
});
|
||||
|
||||
test('ticker vote updates game scores', 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:05:00.000Z',
|
||||
ticker: 'QPL3',
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/api/votes/live')
|
||||
.set('Authorization', getAuthHeader())
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
username: 'viewer2',
|
||||
vote: 'down',
|
||||
timestamp: '2026-03-15T20:06:05.000Z',
|
||||
ticker: 'QPL3',
|
||||
});
|
||||
|
||||
const game = db.prepare('SELECT upvotes, downvotes, popularity_score FROM games WHERE id = ?').get(tickerGame.id);
|
||||
expect(game.upvotes).toBe(1);
|
||||
expect(game.downvotes).toBe(1);
|
||||
expect(game.popularity_score).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,12 +34,13 @@ function seedGame(overrides = {}) {
|
||||
upvotes: 0,
|
||||
downvotes: 0,
|
||||
popularity_score: 0,
|
||||
ticker: null,
|
||||
};
|
||||
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);
|
||||
INSERT INTO games (pack_name, title, min_players, max_players, length_minutes, has_audience, family_friendly, game_type, enabled, upvotes, downvotes, popularity_score, ticker)
|
||||
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, g.ticker);
|
||||
return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user