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:
cottongin
2026-04-05 04:27:58 -04:00
parent b2bb2989e9
commit ea6e8db90b
9 changed files with 322 additions and 61 deletions

View File

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