Compare commits
11 Commits
8ba32e128c
...
3ed3af06ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed3af06ba
|
||
|
|
e9add95efa
|
||
|
|
83b274de79 | ||
|
|
264953453c | ||
|
|
56adbe7aa2
|
||
|
|
8ddbd1440f
|
||
|
|
19c4b7dc37
|
||
|
|
8e8e6bdf05
|
||
|
|
84b0c83409 | ||
|
|
81fcae545e
|
||
|
|
4bf41b64cf
|
6543
backend/package-lock.json
generated
Normal file
6543
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,24 +5,27 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"dev": "nodemon server.js",
|
||||
"test": "jest --config ../jest.config.js --runInBand --verbose --forceExit",
|
||||
"test:watch": "jest --config ../jest.config.js --runInBand --watch --forceExit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"csv-parse": "^5.5.3",
|
||||
"csv-stringify": "^6.4.5",
|
||||
"ws": "^8.14.0",
|
||||
"puppeteer": "^24.0.0"
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"puppeteer": "^24.0.0",
|
||||
"ws": "^8.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"supertest": "^6.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,40 @@ router.get('/:id/games', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get vote breakdown for a session
|
||||
router.get('/:id/votes', (req, res) => {
|
||||
try {
|
||||
const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(req.params.id);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const votes = db.prepare(`
|
||||
SELECT
|
||||
lv.game_id,
|
||||
g.title,
|
||||
g.pack_name,
|
||||
SUM(CASE WHEN lv.vote_type = 1 THEN 1 ELSE 0 END) AS upvotes,
|
||||
SUM(CASE WHEN lv.vote_type = -1 THEN 1 ELSE 0 END) AS downvotes,
|
||||
SUM(lv.vote_type) AS net_score,
|
||||
COUNT(*) AS total_votes
|
||||
FROM live_votes lv
|
||||
JOIN games g ON lv.game_id = g.id
|
||||
WHERE lv.session_id = ?
|
||||
GROUP BY lv.game_id
|
||||
ORDER BY net_score DESC
|
||||
`).all(req.params.id);
|
||||
|
||||
res.json({
|
||||
session_id: parseInt(req.params.id),
|
||||
votes,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Add game to session (admin only)
|
||||
router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,91 @@
|
||||
const express = require('express');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const db = require('../database');
|
||||
const { getWebSocketManager } = require('../utils/websocket-manager');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get vote history with filtering and pagination
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
let { session_id, game_id, username, vote_type, page, limit } = req.query;
|
||||
|
||||
page = parseInt(page) || 1;
|
||||
limit = Math.min(parseInt(limit) || 50, 100);
|
||||
if (page < 1) page = 1;
|
||||
if (limit < 1) limit = 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
if (session_id !== undefined) {
|
||||
const sid = parseInt(session_id);
|
||||
if (isNaN(sid)) {
|
||||
return res.status(400).json({ error: 'session_id must be an integer' });
|
||||
}
|
||||
where.push('lv.session_id = ?');
|
||||
params.push(sid);
|
||||
}
|
||||
|
||||
if (game_id !== undefined) {
|
||||
const gid = parseInt(game_id);
|
||||
if (isNaN(gid)) {
|
||||
return res.status(400).json({ error: 'game_id must be an integer' });
|
||||
}
|
||||
where.push('lv.game_id = ?');
|
||||
params.push(gid);
|
||||
}
|
||||
|
||||
if (username) {
|
||||
where.push('lv.username = ?');
|
||||
params.push(username);
|
||||
}
|
||||
|
||||
if (vote_type !== undefined) {
|
||||
if (vote_type !== 'up' && vote_type !== 'down') {
|
||||
return res.status(400).json({ error: 'vote_type must be "up" or "down"' });
|
||||
}
|
||||
where.push('lv.vote_type = ?');
|
||||
params.push(vote_type === 'up' ? 1 : -1);
|
||||
}
|
||||
|
||||
const whereClause = where.length > 0 ? 'WHERE ' + where.join(' AND ') : '';
|
||||
|
||||
const countResult = db.prepare(
|
||||
`SELECT COUNT(*) as total FROM live_votes lv ${whereClause}`
|
||||
).get(...params);
|
||||
|
||||
const total = countResult.total;
|
||||
const total_pages = Math.ceil(total / limit) || 0;
|
||||
|
||||
const votes = db.prepare(`
|
||||
SELECT
|
||||
lv.id,
|
||||
lv.session_id,
|
||||
lv.game_id,
|
||||
g.title AS game_title,
|
||||
g.pack_name,
|
||||
lv.username,
|
||||
CASE WHEN lv.vote_type = 1 THEN 'up' ELSE 'down' END AS vote_type,
|
||||
lv.timestamp,
|
||||
lv.created_at
|
||||
FROM live_votes lv
|
||||
JOIN games g ON lv.game_id = g.id
|
||||
${whereClause}
|
||||
ORDER BY lv.timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset);
|
||||
|
||||
res.json({
|
||||
votes,
|
||||
pagination: { page, limit, total, total_pages },
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Live vote endpoint - receives real-time votes from bot
|
||||
router.post('/live', authenticateToken, (req, res) => {
|
||||
try {
|
||||
@@ -43,7 +125,7 @@ router.post('/live', authenticateToken, (req, res) => {
|
||||
|
||||
// Get all games played in this session with timestamps
|
||||
const sessionGames = db.prepare(`
|
||||
SELECT sg.game_id, sg.played_at, g.title, g.upvotes, g.downvotes, g.popularity_score
|
||||
SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score
|
||||
FROM session_games sg
|
||||
JOIN games g ON sg.game_id = g.id
|
||||
WHERE sg.session_id = ?
|
||||
@@ -156,6 +238,33 @@ router.post('/live', authenticateToken, (req, res) => {
|
||||
WHERE id = ?
|
||||
`).get(matchedGame.game_id);
|
||||
|
||||
// Broadcast vote.received via WebSocket
|
||||
try {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('vote.received', {
|
||||
sessionId: activeSession.id,
|
||||
game: {
|
||||
id: updatedGame.id,
|
||||
title: updatedGame.title,
|
||||
pack_name: matchedGame.pack_name,
|
||||
},
|
||||
vote: {
|
||||
username: username,
|
||||
type: vote,
|
||||
timestamp: timestamp,
|
||||
},
|
||||
totals: {
|
||||
upvotes: updatedGame.upvotes,
|
||||
downvotes: updatedGame.downvotes,
|
||||
popularity_score: updatedGame.popularity_score,
|
||||
},
|
||||
}, activeSession.id);
|
||||
}
|
||||
} catch (wsError) {
|
||||
console.error('Error broadcasting vote.received event:', wsError);
|
||||
}
|
||||
|
||||
// Get session stats
|
||||
const sessionStats = db.prepare(`
|
||||
SELECT
|
||||
|
||||
@@ -12,9 +12,6 @@ const PORT = process.env.PORT || 5000;
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Bootstrap database with games
|
||||
bootstrapGames();
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', message: 'Jackbox Game Picker API is running' });
|
||||
@@ -50,8 +47,12 @@ const server = http.createServer(app);
|
||||
const wsManager = new WebSocketManager(server);
|
||||
setWebSocketManager(wsManager);
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
||||
});
|
||||
if (require.main === module) {
|
||||
bootstrapGames();
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { app, server };
|
||||
|
||||
@@ -15,6 +15,7 @@ Sessions represent a gaming night. Only one session can be active at a time. Gam
|
||||
| POST | `/api/sessions/{id}/close` | Bearer | Close a session |
|
||||
| DELETE | `/api/sessions/{id}` | Bearer | Delete a closed session |
|
||||
| GET | `/api/sessions/{id}/games` | No | List games in a session |
|
||||
| GET | `/api/sessions/{id}/votes` | No | Get per-game vote breakdown for a session |
|
||||
| POST | `/api/sessions/{id}/games` | Bearer | Add a game to a session |
|
||||
| POST | `/api/sessions/{id}/chat-import` | Bearer | Import chat log for vote processing |
|
||||
| GET | `/api/sessions/{id}/export` | Bearer | Export session (JSON or TXT) |
|
||||
@@ -369,6 +370,57 @@ curl "http://localhost:5000/api/sessions/5/games"
|
||||
|
||||
---
|
||||
|
||||
## GET /api/sessions/{id}/votes
|
||||
|
||||
Get per-game vote breakdown for a session. Aggregates votes from the `live_votes` table by game. Results ordered by `net_score` DESC.
|
||||
|
||||
### Authentication
|
||||
|
||||
None.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| id | integer | Session ID |
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": 5,
|
||||
"votes": [
|
||||
{
|
||||
"game_id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"pack_name": "Party Pack 7",
|
||||
"upvotes": 14,
|
||||
"downvotes": 3,
|
||||
"net_score": 11,
|
||||
"total_votes": 17
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Returns 200 with an empty `votes` array when the session has no votes.
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 404 | `{ "error": "Session not found" }` | Invalid session ID |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl "http://localhost:5000/api/sessions/5/votes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/sessions/{id}/games
|
||||
|
||||
Add a game to a session. Side effects: increments game `play_count`, sets previous `playing` games to `played` (skipped games stay skipped), triggers `game.added` webhook and WebSocket event, and auto-starts room monitor if `room_code` is provided.
|
||||
|
||||
@@ -6,10 +6,69 @@ Real-time popularity voting. Bots or integrations send votes during live gaming
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/votes` | None | Paginated vote history with filtering |
|
||||
| POST | `/api/votes/live` | Bearer | Submit a live vote (up/down) |
|
||||
|
||||
---
|
||||
|
||||
## GET /api/votes
|
||||
|
||||
Paginated vote history with filtering. Use query parameters to filter by session, game, username, or vote type.
|
||||
|
||||
### Authentication
|
||||
|
||||
None.
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| session_id | int | No | — | Filter by session ID |
|
||||
| game_id | int | No | — | Filter by game ID |
|
||||
| username | string | No | — | Filter by voter username |
|
||||
| vote_type | string | No | — | `"up"` or `"down"` |
|
||||
| page | int | No | 1 | Page number |
|
||||
| limit | int | No | 50 | Items per page (max 100) |
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
Results are ordered by `timestamp DESC`. The `vote_type` field is returned as `"up"` or `"down"` (not raw integers).
|
||||
|
||||
```json
|
||||
{
|
||||
"votes": [
|
||||
{
|
||||
"id": 891,
|
||||
"session_id": 5,
|
||||
"game_id": 42,
|
||||
"game_title": "Quiplash 3",
|
||||
"pack_name": "Party Pack 7",
|
||||
"username": "viewer123",
|
||||
"vote_type": "up",
|
||||
"timestamp": "2026-03-15T20:29:55.000Z",
|
||||
"created_at": "2026-03-15T20:29:56.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 50,
|
||||
"total": 237,
|
||||
"total_pages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 400 | `{ "error": "..." }` | Invalid `session_id`, `game_id`, or `vote_type` |
|
||||
| 200 | `{ "votes": [], "pagination": { "page": 1, "limit": 50, "total": 0, "total_pages": 0 } }` | No results match the filters |
|
||||
|
||||
---
|
||||
|
||||
## POST /api/votes/live
|
||||
|
||||
Submit a real-time up/down vote for the game currently being played. Automatically finds the active session and matches the vote to the correct game using the provided timestamp and session game intervals.
|
||||
@@ -40,6 +99,7 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
||||
- Matches the vote timestamp to the game being played at that time (uses interval between consecutive `played_at` timestamps).
|
||||
- Updates game `upvotes`, `downvotes`, and `popularity_score` atomically in a transaction.
|
||||
- **Deduplication:** Rejects votes from the same username within a 1-second window (409 Conflict).
|
||||
- Broadcasts a `vote.received` WebSocket event to all clients subscribed to the active session. See [WebSocket Protocol](../websocket.md#votereceived) for event payload.
|
||||
|
||||
### Response
|
||||
|
||||
|
||||
@@ -67,6 +67,17 @@ A bot sends individual votes during the stream. Each vote is processed immediate
|
||||
|
||||
See [Votes live endpoint](../endpoints/votes.md#post-apivoteslive).
|
||||
|
||||
**Real-time tracking:** Live votes also broadcast a `vote.received` WebSocket event to all clients subscribed to the active session. This enables stream overlays and bots to react to votes in real-time without polling. See [WebSocket vote.received](../websocket.md#votereceived).
|
||||
|
||||
---
|
||||
|
||||
## 3b. Querying Vote Data
|
||||
|
||||
Two endpoints expose vote data for reading:
|
||||
|
||||
- **`GET /api/sessions/{id}/votes`** — Per-game vote breakdown for a session. Returns aggregated `upvotes`, `downvotes`, `net_score`, and `total_votes` per game. See [Sessions votes endpoint](../endpoints/sessions.md#get-apisessionsidvotes).
|
||||
- **`GET /api/votes`** — Paginated global vote history with filtering by `session_id`, `game_id`, `username`, and `vote_type`. Returns individual vote records. See [Votes list endpoint](../endpoints/votes.md#get-apivotes).
|
||||
|
||||
---
|
||||
|
||||
## 4. Timestamp Matching Explained
|
||||
|
||||
@@ -979,6 +979,46 @@ paths:
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/sessions/{id}/votes:
|
||||
get:
|
||||
operationId: getSessionVotes
|
||||
summary: Get per-game vote breakdown for a session
|
||||
tags: [Sessions]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
description: Session ID
|
||||
responses:
|
||||
"200":
|
||||
description: Per-game vote aggregates for the session
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [session_id, votes]
|
||||
properties:
|
||||
session_id:
|
||||
type: integer
|
||||
votes:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
game_id: { type: integer }
|
||||
title: { type: string }
|
||||
pack_name: { type: string }
|
||||
upvotes: { type: integer }
|
||||
downvotes: { type: integer }
|
||||
net_score: { type: integer }
|
||||
total_votes: { type: integer }
|
||||
"404":
|
||||
description: Session not found
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
|
||||
/api/sessions/{id}/chat-import:
|
||||
post:
|
||||
operationId: importSessionChat
|
||||
@@ -1399,6 +1439,72 @@ paths:
|
||||
upvotes: { type: integer }
|
||||
downvotes: { type: integer }
|
||||
|
||||
/api/votes:
|
||||
get:
|
||||
operationId: listVotes
|
||||
summary: Paginated vote history with filtering
|
||||
tags: [Votes]
|
||||
parameters:
|
||||
- name: session_id
|
||||
in: query
|
||||
schema: { type: integer }
|
||||
description: Filter by session
|
||||
- name: game_id
|
||||
in: query
|
||||
schema: { type: integer }
|
||||
description: Filter by game
|
||||
- name: username
|
||||
in: query
|
||||
schema: { type: string }
|
||||
description: Filter by voter
|
||||
- name: vote_type
|
||||
in: query
|
||||
schema: { type: string, enum: [up, down] }
|
||||
description: Filter by direction
|
||||
- name: page
|
||||
in: query
|
||||
schema: { type: integer, default: 1 }
|
||||
description: Page number
|
||||
- name: limit
|
||||
in: query
|
||||
schema: { type: integer, default: 50, maximum: 100 }
|
||||
description: Results per page (max 100)
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated vote records
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [votes, pagination]
|
||||
properties:
|
||||
votes:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
session_id: { type: integer }
|
||||
game_id: { type: integer }
|
||||
game_title: { type: string }
|
||||
pack_name: { type: string }
|
||||
username: { type: string }
|
||||
vote_type: { type: string, enum: [up, down] }
|
||||
timestamp: { type: string, format: date-time }
|
||||
created_at: { type: string, format: date-time }
|
||||
pagination:
|
||||
type: object
|
||||
properties:
|
||||
page: { type: integer }
|
||||
limit: { type: integer }
|
||||
total: { type: integer }
|
||||
total_pages: { type: integer }
|
||||
"400":
|
||||
description: Invalid filter parameter
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
|
||||
/api/votes/live:
|
||||
post:
|
||||
operationId: recordLiveVote
|
||||
|
||||
@@ -6,6 +6,7 @@ The WebSocket API provides real-time updates for Jackbox gaming sessions. Use it
|
||||
|
||||
- Receive notifications when sessions start, end, or when games are added
|
||||
- Track player counts as they are updated
|
||||
- Receive live vote updates (upvotes/downvotes) as viewers vote
|
||||
- Avoid polling REST endpoints for session state changes
|
||||
|
||||
The WebSocket server runs on the same host and port as the HTTP API. Connect to `/api/sessions/live` to establish a live connection.
|
||||
@@ -129,6 +130,7 @@ Must be authenticated.
|
||||
| `game.added` | Game added to a session (broadcast to subscribers) |
|
||||
| `session.ended` | Session closed (broadcast to subscribers) |
|
||||
| `player-count.updated` | Player count changed (broadcast to subscribers) |
|
||||
| `vote.received` | Live vote recorded (broadcast to subscribers) |
|
||||
|
||||
---
|
||||
|
||||
@@ -217,6 +219,33 @@ All server-sent events use this envelope:
|
||||
}
|
||||
```
|
||||
|
||||
### vote.received
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** `POST /api/votes/live` (recording a live vote). Only fires for live votes, NOT chat-import.
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 5,
|
||||
"game": {
|
||||
"id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"pack_name": "Party Pack 7"
|
||||
},
|
||||
"vote": {
|
||||
"username": "viewer123",
|
||||
"type": "up",
|
||||
"timestamp": "2026-03-15T20:29:55.000Z"
|
||||
},
|
||||
"totals": {
|
||||
"upvotes": 14,
|
||||
"downvotes": 3,
|
||||
"popularity_score": 11
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Handling
|
||||
@@ -373,6 +402,10 @@ ws.onmessage = (event) => {
|
||||
console.log('Player count:', msg.data.playerCount, 'for game', msg.data.gameId);
|
||||
break;
|
||||
|
||||
case 'vote.received':
|
||||
console.log('Vote:', msg.data.vote.type, 'from', msg.data.vote.username, 'for', msg.data.game.title, '- totals:', msg.data.totals);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
case 'auth_error':
|
||||
console.error('Error:', msg.message);
|
||||
|
||||
196
docs/plans/2026-03-15-vote-tracking-api-design.md
Normal file
196
docs/plans/2026-03-15-vote-tracking-api-design.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Vote Tracking API Design
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the REST and WebSocket APIs so clients can track votes at both session and global levels. The primary consumer is a stream overlay (ticker-style display) that already has the admin JWT.
|
||||
|
||||
## Approach
|
||||
|
||||
**Approach B — Split by resource ownership.** Session-scoped vote data lives under the session resource. Global vote history lives under the vote resource. The WebSocket emits real-time events for live votes only.
|
||||
|
||||
## WebSocket: `vote.received` Event
|
||||
|
||||
**Trigger:** `POST /api/votes/live` — fires after the vote transaction succeeds, before the HTTP response. Only live votes emit this event; chat-import does not.
|
||||
|
||||
**Broadcast target:** Session subscribers via `broadcastEvent('vote.received', data, sessionId)`.
|
||||
|
||||
**Payload:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "vote.received",
|
||||
"timestamp": "2026-03-15T20:30:00.000Z",
|
||||
"data": {
|
||||
"sessionId": 5,
|
||||
"game": {
|
||||
"id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"pack_name": "Party Pack 7"
|
||||
},
|
||||
"vote": {
|
||||
"username": "viewer123",
|
||||
"type": "up",
|
||||
"timestamp": "2026-03-15T20:29:55.000Z"
|
||||
},
|
||||
"totals": {
|
||||
"upvotes": 14,
|
||||
"downvotes": 3,
|
||||
"popularity_score": 11
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation notes:**
|
||||
- `votes.js` needs access to the WebSocket manager singleton via `getWebSocketManager()`.
|
||||
- The existing session games JOIN needs to select `pack_name` from the `games` table.
|
||||
|
||||
## REST: `GET /api/sessions/:id/votes`
|
||||
|
||||
Per-game vote breakdown for a specific session.
|
||||
|
||||
**Location:** `backend/routes/sessions.js`
|
||||
|
||||
**Auth:** None (matches `GET /api/sessions/:id/games`).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": 5,
|
||||
"votes": [
|
||||
{
|
||||
"game_id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"pack_name": "Party Pack 7",
|
||||
"upvotes": 14,
|
||||
"downvotes": 3,
|
||||
"net_score": 11,
|
||||
"total_votes": 17
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Query:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
lv.game_id,
|
||||
g.title,
|
||||
g.pack_name,
|
||||
SUM(CASE WHEN lv.vote_type = 1 THEN 1 ELSE 0 END) AS upvotes,
|
||||
SUM(CASE WHEN lv.vote_type = -1 THEN 1 ELSE 0 END) AS downvotes,
|
||||
SUM(lv.vote_type) AS net_score,
|
||||
COUNT(*) AS total_votes
|
||||
FROM live_votes lv
|
||||
JOIN games g ON lv.game_id = g.id
|
||||
WHERE lv.session_id = ?
|
||||
GROUP BY lv.game_id
|
||||
ORDER BY net_score DESC
|
||||
```
|
||||
|
||||
**Error handling:**
|
||||
- Session not found → 404
|
||||
- Session exists, no votes → 200 with empty `votes` array
|
||||
|
||||
## REST: `GET /api/votes`
|
||||
|
||||
Paginated global vote history with flexible filtering.
|
||||
|
||||
**Location:** `backend/routes/votes.js`
|
||||
|
||||
**Auth:** None.
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `session_id` | integer | — | Filter by session |
|
||||
| `game_id` | integer | — | Filter by game |
|
||||
| `username` | string | — | Filter by voter |
|
||||
| `vote_type` | `up` or `down` | — | Filter by direction |
|
||||
| `page` | integer | 1 | Page number |
|
||||
| `limit` | integer | 50 | Results per page (max 100) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"votes": [
|
||||
{
|
||||
"id": 891,
|
||||
"session_id": 5,
|
||||
"game_id": 42,
|
||||
"game_title": "Quiplash 3",
|
||||
"pack_name": "Party Pack 7",
|
||||
"username": "viewer123",
|
||||
"vote_type": "up",
|
||||
"timestamp": "2026-03-15T20:29:55.000Z",
|
||||
"created_at": "2026-03-15T20:29:56.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 50,
|
||||
"total": 237,
|
||||
"total_pages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Design notes:**
|
||||
- `vote_type` returned as `"up"` / `"down"`, not raw `1` / `-1`.
|
||||
- `game_title` and `pack_name` included via JOIN.
|
||||
- Ordered by `timestamp DESC`.
|
||||
- `limit` capped at 100 server-side.
|
||||
|
||||
**Error handling:**
|
||||
- Invalid filter values → 400
|
||||
- No results → 200 with empty array and `total: 0`
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Phase 1: Regression tests (pre-implementation)
|
||||
|
||||
Written and passing before any code changes to lock down existing behavior.
|
||||
|
||||
**`tests/api/regression-votes-live.test.js`** — existing `POST /api/votes/live`:
|
||||
- Returns 200 with correct response shape (`success`, `session`, `game`, `vote`)
|
||||
- `game` includes `id`, `title`, `upvotes`, `downvotes`, `popularity_score`
|
||||
- Increments `upvotes`/`popularity_score` for upvote
|
||||
- Increments `downvotes`/decrements `popularity_score` for downvote
|
||||
- 400 for missing fields, invalid vote value, invalid timestamp
|
||||
- 404 when no active session or timestamp doesn't match a game
|
||||
- 409 for duplicate within 1-second window
|
||||
- 401 without JWT
|
||||
|
||||
**`tests/api/regression-games.test.js`** — game aggregate fields:
|
||||
- `GET /api/games` returns `upvotes`, `downvotes`, `popularity_score`
|
||||
- `GET /api/games/:id` returns same fields
|
||||
- Aggregates accurate after votes
|
||||
|
||||
**`tests/api/regression-sessions.test.js`** — session endpoints:
|
||||
- `GET /api/sessions/:id` returns session object
|
||||
- `GET /api/sessions/:id` returns 404 for nonexistent session
|
||||
- `GET /api/sessions/:id/games` returns game list with expected shape
|
||||
|
||||
**`tests/api/regression-websocket.test.js`** — existing WebSocket events:
|
||||
- Auth flow (auth → auth_success)
|
||||
- Subscribe/unsubscribe flow
|
||||
- `session.started` broadcast on session create
|
||||
- `session.ended` broadcast on session close
|
||||
- `game.added` broadcast on game add
|
||||
|
||||
### Phase 2: New feature tests (TDD — written before implementation)
|
||||
|
||||
- **`tests/api/votes-get.test.js`** — `GET /api/votes` history endpoint
|
||||
- **`tests/api/sessions-votes.test.js`** — `GET /api/sessions/:id/votes` breakdown
|
||||
- **`tests/api/votes-live-websocket.test.js`** — `vote.received` WebSocket event
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Write Phase 1 regression tests → run → all green
|
||||
2. Write Phase 2 feature tests → run → all red
|
||||
3. Implement features
|
||||
4. Run all tests → Phase 1 still green, Phase 2 now green
|
||||
1572
docs/plans/2026-03-15-vote-tracking-api.md
Normal file
1572
docs/plans/2026-03-15-vote-tracking-api.md
Normal file
File diff suppressed because it is too large
Load Diff
8
jest.config.js
Normal file
8
jest.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/tests'],
|
||||
setupFiles: ['<rootDir>/tests/jest.setup.js'],
|
||||
testMatch: ['**/*.test.js'],
|
||||
testTimeout: 10000,
|
||||
moduleDirectories: ['node_modules', '<rootDir>/backend/node_modules'],
|
||||
};
|
||||
62
tests/api/regression-games.test.js
Normal file
62
tests/api/regression-games.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const request = require('supertest');
|
||||
const { app } = require('../../backend/server');
|
||||
const { cleanDb, seedGame } = require('../helpers/test-utils');
|
||||
const db = require('../../backend/database');
|
||||
|
||||
describe('GET /api/games (regression)', () => {
|
||||
beforeEach(() => {
|
||||
cleanDb();
|
||||
});
|
||||
|
||||
test('returns games with vote fields', async () => {
|
||||
seedGame({
|
||||
title: 'Quiplash 3',
|
||||
upvotes: 10,
|
||||
downvotes: 3,
|
||||
popularity_score: 7,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/games');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'Quiplash 3',
|
||||
upvotes: 10,
|
||||
downvotes: 3,
|
||||
popularity_score: 7,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('GET /api/games/:id returns vote fields', async () => {
|
||||
const game = seedGame({
|
||||
title: 'Drawful 2',
|
||||
upvotes: 5,
|
||||
downvotes: 2,
|
||||
popularity_score: 3,
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/games/${game.id}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.upvotes).toBe(5);
|
||||
expect(res.body.downvotes).toBe(2);
|
||||
expect(res.body.popularity_score).toBe(3);
|
||||
});
|
||||
|
||||
test('vote aggregates update correctly after recording votes', async () => {
|
||||
const game = seedGame({ title: 'Fibbage 4' });
|
||||
|
||||
db.prepare('UPDATE games SET upvotes = upvotes + 1, popularity_score = popularity_score + 1 WHERE id = ?').run(game.id);
|
||||
db.prepare('UPDATE games SET upvotes = upvotes + 1, popularity_score = popularity_score + 1 WHERE id = ?').run(game.id);
|
||||
db.prepare('UPDATE games SET downvotes = downvotes + 1, popularity_score = popularity_score - 1 WHERE id = ?').run(game.id);
|
||||
|
||||
const res = await request(app).get(`/api/games/${game.id}`);
|
||||
|
||||
expect(res.body.upvotes).toBe(2);
|
||||
expect(res.body.downvotes).toBe(1);
|
||||
expect(res.body.popularity_score).toBe(1);
|
||||
});
|
||||
});
|
||||
71
tests/api/regression-sessions.test.js
Normal file
71
tests/api/regression-sessions.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const request = require('supertest');
|
||||
const { app } = require('../../backend/server');
|
||||
const { cleanDb, seedGame, seedSession, seedSessionGame } = require('../helpers/test-utils');
|
||||
|
||||
describe('GET /api/sessions (regression)', () => {
|
||||
beforeEach(() => {
|
||||
cleanDb();
|
||||
});
|
||||
|
||||
test('GET /api/sessions/:id returns session object', async () => {
|
||||
const session = seedSession({ is_active: 1, notes: 'Test session' });
|
||||
|
||||
const res = await request(app).get(`/api/sessions/${session.id}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: session.id,
|
||||
is_active: 1,
|
||||
notes: 'Test session',
|
||||
})
|
||||
);
|
||||
expect(res.body).toHaveProperty('games_played');
|
||||
});
|
||||
|
||||
test('GET /api/sessions/:id returns 404 for nonexistent session', async () => {
|
||||
const res = await request(app).get('/api/sessions/99999');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe('Session not found');
|
||||
});
|
||||
|
||||
test('GET /api/sessions/:id/games returns games with expected shape', async () => {
|
||||
const game = seedGame({
|
||||
title: 'Quiplash 3',
|
||||
pack_name: 'Party Pack 7',
|
||||
min_players: 3,
|
||||
max_players: 8,
|
||||
});
|
||||
const session = seedSession({ is_active: 1 });
|
||||
seedSessionGame(session.id, game.id, { status: 'playing' });
|
||||
|
||||
const res = await request(app).get(`/api/sessions/${session.id}/games`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
game_id: game.id,
|
||||
session_id: session.id,
|
||||
pack_name: 'Party Pack 7',
|
||||
title: 'Quiplash 3',
|
||||
min_players: 3,
|
||||
max_players: 8,
|
||||
status: 'playing',
|
||||
})
|
||||
);
|
||||
expect(res.body[0]).toHaveProperty('upvotes');
|
||||
expect(res.body[0]).toHaveProperty('downvotes');
|
||||
expect(res.body[0]).toHaveProperty('popularity_score');
|
||||
});
|
||||
|
||||
test('GET /api/sessions/:id/games returns empty array for session with no games', async () => {
|
||||
const session = seedSession({ is_active: 1 });
|
||||
|
||||
const res = await request(app).get(`/api/sessions/${session.id}/games`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
199
tests/api/regression-votes-live.test.js
Normal file
199
tests/api/regression-votes-live.test.js
Normal file
@@ -0,0 +1,199 @@
|
||||
const request = require('supertest');
|
||||
const { app } = require('../../backend/server');
|
||||
const { getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame } = require('../helpers/test-utils');
|
||||
|
||||
describe('POST /api/votes/live (regression)', () => {
|
||||
let game, session, sessionGame;
|
||||
const baseTime = '2026-03-15T20:00:00.000Z';
|
||||
|
||||
beforeEach(() => {
|
||||
cleanDb();
|
||||
game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||
session = seedSession({ is_active: 1 });
|
||||
sessionGame = seedSessionGame(session.id, game.id, {
|
||||
status: 'playing',
|
||||
played_at: baseTime,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns 200 with correct response shape for upvote', 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',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.session).toEqual(
|
||||
expect.objectContaining({ id: session.id })
|
||||
);
|
||||
expect(res.body.game).toEqual(
|
||||
expect.objectContaining({
|
||||
id: game.id,
|
||||
title: 'Quiplash 3',
|
||||
upvotes: 1,
|
||||
downvotes: 0,
|
||||
popularity_score: 1,
|
||||
})
|
||||
);
|
||||
expect(res.body.vote).toEqual({
|
||||
username: 'viewer1',
|
||||
type: 'up',
|
||||
timestamp: '2026-03-15T20:05:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('increments downvotes and decrements popularity_score for downvote', async () => {
|
||||
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',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.game.downvotes).toBe(1);
|
||||
expect(res.body.game.popularity_score).toBe(-1);
|
||||
});
|
||||
|
||||
test('returns 400 for missing username', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/votes/live')
|
||||
.set('Authorization', getAuthHeader())
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({ vote: 'up', timestamp: '2026-03-15T20:05:00.000Z' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Missing required fields/);
|
||||
});
|
||||
|
||||
test('returns 400 for missing vote', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/votes/live')
|
||||
.set('Authorization', getAuthHeader())
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({ username: 'viewer1', timestamp: '2026-03-15T20:05:00.000Z' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('returns 400 for missing timestamp', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/votes/live')
|
||||
.set('Authorization', getAuthHeader())
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({ username: 'viewer1', vote: 'up' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('returns 400 for invalid vote value', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/votes/live')
|
||||
.set('Authorization', getAuthHeader())
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
username: 'viewer1',
|
||||
vote: 'maybe',
|
||||
timestamp: '2026-03-15T20:05:00.000Z',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/vote must be either/);
|
||||
});
|
||||
|
||||
test('returns 400 for invalid timestamp format', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/votes/live')
|
||||
.set('Authorization', getAuthHeader())
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
username: 'viewer1',
|
||||
vote: 'up',
|
||||
timestamp: 'not-a-date',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Invalid timestamp/);
|
||||
});
|
||||
|
||||
test('returns 404 when no active session', async () => {
|
||||
cleanDb();
|
||||
seedGame({ title: 'Unused' });
|
||||
seedSession({ is_active: 0 });
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/No active session/);
|
||||
});
|
||||
|
||||
test('returns 404 when vote timestamp does not match any 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: '2020-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/does not match any game/);
|
||||
});
|
||||
|
||||
test('returns 409 for duplicate vote within 1 second', 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',
|
||||
});
|
||||
|
||||
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.500Z',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/Duplicate vote/);
|
||||
});
|
||||
|
||||
test('returns 401 without auth token', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/votes/live')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
username: 'viewer1',
|
||||
vote: 'up',
|
||||
timestamp: '2026-03-15T20:05:00.000Z',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
153
tests/api/regression-websocket.test.js
Normal file
153
tests/api/regression-websocket.test.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const WebSocket = require('ws');
|
||||
const request = require('supertest');
|
||||
const { app, server } = require('../../backend/server');
|
||||
const { getAuthToken, getAuthHeader, cleanDb, seedGame, seedSession } = require('../helpers/test-utils');
|
||||
|
||||
function connectWs() {
|
||||
return new WebSocket(`ws://localhost:${server.address().port}/api/sessions/live`);
|
||||
}
|
||||
|
||||
function waitForMessage(ws, type, timeoutMs = 3000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
|
||||
ws.on('message', function handler(data) {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === type) {
|
||||
clearTimeout(timeout);
|
||||
ws.removeListener('message', handler);
|
||||
resolve(msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function authenticateAndSubscribe(ws, sessionId) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||
await waitForMessage(ws, 'auth_success');
|
||||
ws.send(JSON.stringify({ type: 'subscribe', sessionId }));
|
||||
await waitForMessage(ws, 'subscribed');
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll((done) => {
|
||||
server.listen(0, () => done());
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
describe('WebSocket events (regression)', () => {
|
||||
beforeEach(() => {
|
||||
cleanDb();
|
||||
});
|
||||
|
||||
test('auth flow: auth -> auth_success', (done) => {
|
||||
const ws = connectWs();
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||
});
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'auth_success') {
|
||||
ws.close();
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('subscribe/unsubscribe flow', async () => {
|
||||
const session = seedSession({ is_active: 1 });
|
||||
const ws = connectWs();
|
||||
await new Promise((resolve) => ws.on('open', resolve));
|
||||
|
||||
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||
await waitForMessage(ws, 'auth_success');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id }));
|
||||
const subMsg = await waitForMessage(ws, 'subscribed');
|
||||
expect(subMsg.sessionId).toBe(session.id);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'unsubscribe', sessionId: session.id }));
|
||||
const unsubMsg = await waitForMessage(ws, 'unsubscribed');
|
||||
expect(unsubMsg.sessionId).toBe(session.id);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test('session.started broadcasts to all authenticated clients', async () => {
|
||||
const ws = connectWs();
|
||||
await new Promise((resolve) => ws.on('open', resolve));
|
||||
|
||||
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||
await waitForMessage(ws, 'auth_success');
|
||||
|
||||
const eventPromise = waitForMessage(ws, 'session.started');
|
||||
|
||||
await request(app)
|
||||
.post('/api/sessions')
|
||||
.set('Authorization', getAuthHeader())
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({ notes: 'Test session' });
|
||||
|
||||
const event = await eventPromise;
|
||||
expect(event.data.session).toEqual(
|
||||
expect.objectContaining({
|
||||
is_active: 1,
|
||||
notes: 'Test session',
|
||||
})
|
||||
);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test('session.ended broadcasts to session subscribers', async () => {
|
||||
const session = seedSession({ is_active: 1 });
|
||||
const ws = connectWs();
|
||||
await new Promise((resolve) => ws.on('open', resolve));
|
||||
|
||||
await authenticateAndSubscribe(ws, session.id);
|
||||
|
||||
const eventPromise = waitForMessage(ws, 'session.ended');
|
||||
|
||||
await request(app)
|
||||
.post(`/api/sessions/${session.id}/close`)
|
||||
.set('Authorization', getAuthHeader())
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
const event = await eventPromise;
|
||||
expect(event.data.session.id).toBe(session.id);
|
||||
expect(event.data.session.is_active).toBe(0);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test('game.added broadcasts to session subscribers', async () => {
|
||||
const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||
const session = seedSession({ is_active: 1 });
|
||||
const ws = connectWs();
|
||||
await new Promise((resolve) => ws.on('open', resolve));
|
||||
|
||||
await authenticateAndSubscribe(ws, session.id);
|
||||
|
||||
const eventPromise = waitForMessage(ws, 'game.added');
|
||||
|
||||
await request(app)
|
||||
.post(`/api/sessions/${session.id}/games`)
|
||||
.set('Authorization', getAuthHeader())
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({ game_id: game.id });
|
||||
|
||||
const event = await eventPromise;
|
||||
expect(event.data.game.title).toBe('Quiplash 3');
|
||||
expect(event.data.session.id).toBe(session.id);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
94
tests/api/sessions-votes.test.js
Normal file
94
tests/api/sessions-votes.test.js
Normal 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
166
tests/api/votes-get.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
166
tests/api/votes-live-websocket.test.js
Normal file
166
tests/api/votes-live-websocket.test.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const WebSocket = require('ws');
|
||||
const request = require('supertest');
|
||||
const { app, server } = require('../../backend/server');
|
||||
const { getAuthToken, getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame } = require('../helpers/test-utils');
|
||||
|
||||
function connectWs() {
|
||||
return new WebSocket(`ws://localhost:${server.address().port}/api/sessions/live`);
|
||||
}
|
||||
|
||||
function waitForMessage(ws, type, timeoutMs = 3000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
|
||||
ws.on('message', function handler(data) {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === type) {
|
||||
clearTimeout(timeout);
|
||||
ws.removeListener('message', handler);
|
||||
resolve(msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll((done) => {
|
||||
server.listen(0, () => done());
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
describe('vote.received WebSocket event', () => {
|
||||
const baseTime = '2026-03-15T20:00:00.000Z';
|
||||
|
||||
beforeEach(() => {
|
||||
cleanDb();
|
||||
});
|
||||
|
||||
test('broadcasts vote.received to session subscribers on live vote', async () => {
|
||||
const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||
const session = seedSession({ is_active: 1 });
|
||||
seedSessionGame(session.id, game.id, { status: 'playing', played_at: baseTime });
|
||||
|
||||
const ws = connectWs();
|
||||
await new Promise((resolve) => ws.on('open', resolve));
|
||||
|
||||
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||
await waitForMessage(ws, 'auth_success');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id }));
|
||||
await waitForMessage(ws, 'subscribed');
|
||||
|
||||
const eventPromise = waitForMessage(ws, 'vote.received');
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
const event = await eventPromise;
|
||||
|
||||
expect(event.data.sessionId).toBe(session.id);
|
||||
expect(event.data.game).toEqual({
|
||||
id: game.id,
|
||||
title: 'Quiplash 3',
|
||||
pack_name: 'Party Pack 7',
|
||||
});
|
||||
expect(event.data.vote).toEqual({
|
||||
username: 'viewer1',
|
||||
type: 'up',
|
||||
timestamp: '2026-03-15T20:05:00.000Z',
|
||||
});
|
||||
expect(event.data.totals).toEqual({
|
||||
upvotes: 1,
|
||||
downvotes: 0,
|
||||
popularity_score: 1,
|
||||
});
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test('does not broadcast on duplicate vote (409)', async () => {
|
||||
const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||
const session = seedSession({ is_active: 1 });
|
||||
seedSessionGame(session.id, game.id, { status: 'playing', played_at: baseTime });
|
||||
|
||||
const ws = connectWs();
|
||||
await new Promise((resolve) => ws.on('open', resolve));
|
||||
|
||||
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||
await waitForMessage(ws, 'auth_success');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id }));
|
||||
await waitForMessage(ws, 'subscribed');
|
||||
|
||||
// First vote succeeds - set up listener before POST to catch the event
|
||||
const firstEventPromise = waitForMessage(ws, 'vote.received');
|
||||
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',
|
||||
});
|
||||
await firstEventPromise;
|
||||
|
||||
// Duplicate vote (within 1 second)
|
||||
const dupRes = 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.500Z',
|
||||
});
|
||||
|
||||
expect(dupRes.status).toBe(409);
|
||||
|
||||
// Verify no vote.received event comes (wait briefly)
|
||||
const noEvent = await Promise.race([
|
||||
waitForMessage(ws, 'vote.received', 500).then(() => 'received').catch(() => 'timeout'),
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)),
|
||||
]);
|
||||
|
||||
expect(noEvent).toBe('timeout');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test('does not broadcast when no active session (404)', async () => {
|
||||
const ws = connectWs();
|
||||
await new Promise((resolve) => ws.on('open', resolve));
|
||||
|
||||
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||
await waitForMessage(ws, 'auth_success');
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
const noEvent = await Promise.race([
|
||||
waitForMessage(ws, 'vote.received', 500).then(() => 'received').catch(() => 'timeout'),
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)),
|
||||
]);
|
||||
|
||||
expect(noEvent).toBe('timeout');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
81
tests/helpers/test-utils.js
Normal file
81
tests/helpers/test-utils.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../../backend/database');
|
||||
|
||||
function getAuthToken() {
|
||||
return jwt.sign({ role: 'admin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||
}
|
||||
|
||||
function getAuthHeader() {
|
||||
return `Bearer ${getAuthToken()}`;
|
||||
}
|
||||
|
||||
function cleanDb() {
|
||||
db.exec('DELETE FROM live_votes');
|
||||
db.exec('DELETE FROM chat_logs');
|
||||
db.exec('DELETE FROM session_games');
|
||||
db.exec('DELETE FROM sessions');
|
||||
db.exec('DELETE FROM webhook_logs');
|
||||
db.exec('DELETE FROM webhooks');
|
||||
db.exec('DELETE FROM games');
|
||||
db.exec('DELETE FROM packs');
|
||||
}
|
||||
|
||||
function seedGame(overrides = {}) {
|
||||
const defaults = {
|
||||
pack_name: 'Party Pack 7',
|
||||
title: 'Quiplash 3',
|
||||
min_players: 3,
|
||||
max_players: 8,
|
||||
length_minutes: 15,
|
||||
has_audience: 1,
|
||||
family_friendly: 1,
|
||||
game_type: 'Writing',
|
||||
enabled: 1,
|
||||
upvotes: 0,
|
||||
downvotes: 0,
|
||||
popularity_score: 0,
|
||||
};
|
||||
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);
|
||||
return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
function seedSession(overrides = {}) {
|
||||
const defaults = { is_active: 1, notes: null };
|
||||
const s = { ...defaults, ...overrides };
|
||||
const result = db.prepare('INSERT INTO sessions (is_active, notes) VALUES (?, ?)').run(s.is_active, s.notes);
|
||||
return db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
function seedSessionGame(sessionId, gameId, overrides = {}) {
|
||||
const defaults = { status: 'playing', played_at: new Date().toISOString() };
|
||||
const sg = { ...defaults, ...overrides };
|
||||
const result = db.prepare(`
|
||||
INSERT INTO session_games (session_id, game_id, status, played_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(sessionId, gameId, sg.status, sg.played_at);
|
||||
return db.prepare('SELECT * FROM session_games WHERE id = ?').get(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
function seedVote(sessionId, gameId, username, voteType, timestamp) {
|
||||
const vt = voteType === 'up' ? 1 : -1;
|
||||
const ts = timestamp || new Date().toISOString();
|
||||
db.prepare(`
|
||||
INSERT INTO live_votes (session_id, game_id, username, vote_type, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(sessionId, gameId, username, vt, ts);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAuthToken,
|
||||
getAuthHeader,
|
||||
cleanDb,
|
||||
seedGame,
|
||||
seedSession,
|
||||
seedSessionGame,
|
||||
seedVote,
|
||||
db,
|
||||
};
|
||||
4
tests/jest.setup.js
Normal file
4
tests/jest.setup.js
Normal file
@@ -0,0 +1,4 @@
|
||||
process.env.DB_PATH = ':memory:';
|
||||
process.env.JWT_SECRET = 'test-jwt-secret-do-not-use-in-prod';
|
||||
process.env.ADMIN_KEY = 'test-admin-key';
|
||||
process.env.PORT = '0';
|
||||
Reference in New Issue
Block a user