feat: add GET /api/votes endpoint with filtering and pagination

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-15 19:00:00 -04:00
parent 264953453c
commit 83b274de79
4 changed files with 375 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
const request = require('supertest');
const { app } = require('../../backend/server');
const { cleanDb, seedGame, seedSession, seedSessionGame, seedVote } = require('../helpers/test-utils');
describe('GET /api/sessions/:id/votes', () => {
beforeEach(() => {
cleanDb();
});
test('returns per-game vote breakdown for a session', async () => {
const game1 = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
const game2 = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
const session = seedSession({ is_active: 1 });
seedSessionGame(session.id, game1.id);
seedSessionGame(session.id, game2.id);
seedVote(session.id, game1.id, 'user1', 'up');
seedVote(session.id, game1.id, 'user2', 'up');
seedVote(session.id, game1.id, 'user3', 'down');
seedVote(session.id, game2.id, 'user1', 'down');
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(2);
const q3 = res.body.votes.find((v) => v.game_id === game1.id);
expect(q3.title).toBe('Quiplash 3');
expect(q3.pack_name).toBe('Party Pack 7');
expect(q3.upvotes).toBe(2);
expect(q3.downvotes).toBe(1);
expect(q3.net_score).toBe(1);
expect(q3.total_votes).toBe(3);
const d2 = res.body.votes.find((v) => v.game_id === game2.id);
expect(d2.upvotes).toBe(0);
expect(d2.downvotes).toBe(1);
expect(d2.net_score).toBe(-1);
expect(d2.total_votes).toBe(1);
});
test('returns empty votes array when session has no votes', async () => {
const session = seedSession({ is_active: 1 });
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).toEqual([]);
});
test('returns 404 for nonexistent session', async () => {
const res = await request(app).get('/api/sessions/99999/votes');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Session not found');
});
test('only includes votes from the requested session', async () => {
const game = seedGame({ title: 'Quiplash 3' });
const session1 = seedSession({ is_active: 0 });
const session2 = seedSession({ is_active: 1 });
seedSessionGame(session1.id, game.id);
seedSessionGame(session2.id, game.id);
seedVote(session1.id, game.id, 'user1', 'up');
seedVote(session1.id, game.id, 'user2', 'up');
seedVote(session2.id, game.id, 'user3', 'down');
const res = await request(app).get(`/api/sessions/${session1.id}/votes`);
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].upvotes).toBe(2);
expect(res.body.votes[0].downvotes).toBe(0);
});
test('results are ordered by net_score descending', async () => {
const game1 = seedGame({ title: 'Good Game' });
const game2 = seedGame({ title: 'Bad Game' });
const session = seedSession({ is_active: 1 });
seedSessionGame(session.id, game1.id);
seedSessionGame(session.id, game2.id);
seedVote(session.id, game2.id, 'user1', 'down');
seedVote(session.id, game2.id, 'user2', 'down');
seedVote(session.id, game1.id, 'user1', 'up');
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
expect(res.body.votes[0].title).toBe('Good Game');
expect(res.body.votes[1].title).toBe('Bad Game');
});
});

166
tests/api/votes-get.test.js Normal file
View File

@@ -0,0 +1,166 @@
const request = require('supertest');
const { app } = require('../../backend/server');
const { cleanDb, seedGame, seedSession, seedSessionGame, seedVote } = require('../helpers/test-utils');
describe('GET /api/votes', () => {
let game1, game2, session;
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);
seedSessionGame(session.id, game2.id);
});
test('returns all votes with pagination metadata', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
const res = await request(app).get('/api/votes');
expect(res.status).toBe(200);
expect(res.body.votes).toHaveLength(2);
expect(res.body.pagination).toEqual({
page: 1,
limit: 50,
total: 2,
total_pages: 1,
});
});
test('returns vote_type as "up"/"down" not raw integers', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
const res = await request(app).get('/api/votes');
const types = res.body.votes.map((v) => v.vote_type);
expect(types).toContain('up');
expect(types).toContain('down');
expect(types).not.toContain(1);
expect(types).not.toContain(-1);
});
test('includes game_title and pack_name via join', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
const res = await request(app).get('/api/votes');
expect(res.body.votes[0].game_title).toBe('Quiplash 3');
expect(res.body.votes[0].pack_name).toBe('Party Pack 7');
});
test('filters by session_id', async () => {
const session2 = seedSession({ is_active: 0 });
seedSessionGame(session2.id, game1.id);
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session2.id, game1.id, 'user2', 'up', '2026-03-15T21:01:00.000Z');
const res = await request(app).get(`/api/votes?session_id=${session.id}`);
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].session_id).toBe(session.id);
expect(res.body.pagination.total).toBe(1);
});
test('filters by game_id', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game2.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
const res = await request(app).get(`/api/votes?game_id=${game1.id}`);
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].game_id).toBe(game1.id);
});
test('filters by username', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
const res = await request(app).get('/api/votes?username=user1');
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].username).toBe('user1');
});
test('filters by vote_type', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
const res = await request(app).get('/api/votes?vote_type=up');
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].vote_type).toBe('up');
});
test('combines multiple filters', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
seedVote(session.id, game2.id, 'user1', 'up', '2026-03-15T20:03:00.000Z');
const res = await request(app).get(
`/api/votes?game_id=${game1.id}&username=user1`
);
expect(res.body.votes).toHaveLength(1);
expect(res.body.votes[0].username).toBe('user1');
expect(res.body.votes[0].game_id).toBe(game1.id);
});
test('respects page and limit', async () => {
for (let i = 0; i < 5; i++) {
seedVote(session.id, game1.id, `user${i}`, 'up', `2026-03-15T20:0${i}:00.000Z`);
}
const res = await request(app).get('/api/votes?page=2&limit=2');
expect(res.body.votes).toHaveLength(2);
expect(res.body.pagination).toEqual({
page: 2,
limit: 2,
total: 5,
total_pages: 3,
});
});
test('caps limit at 100', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
const res = await request(app).get('/api/votes?limit=500');
expect(res.body.pagination.limit).toBe(100);
});
test('returns 200 with empty array when no votes match', async () => {
const res = await request(app).get('/api/votes?username=nonexistent');
expect(res.status).toBe(200);
expect(res.body.votes).toEqual([]);
expect(res.body.pagination.total).toBe(0);
});
test('returns 400 for invalid session_id', async () => {
const res = await request(app).get('/api/votes?session_id=abc');
expect(res.status).toBe(400);
});
test('returns 400 for invalid vote_type', async () => {
const res = await request(app).get('/api/votes?vote_type=maybe');
expect(res.status).toBe(400);
});
test('orders by timestamp descending', async () => {
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:05:00.000Z');
const res = await request(app).get('/api/votes');
const timestamps = res.body.votes.map((v) => v.timestamp);
expect(timestamps[0]).toBe('2026-03-15T20:05:00.000Z');
expect(timestamps[1]).toBe('2026-03-15T20:01:00.000Z');
});
});