diff --git a/db/migrations/009_perf_indexes.sql b/db/migrations/009_perf_indexes.sql new file mode 100644 index 0000000..66c345b --- /dev/null +++ b/db/migrations/009_perf_indexes.sql @@ -0,0 +1,20 @@ +-- Performance indexes for hot read paths. +-- +-- ledger_entries(user_id): every balance lookup runs +-- SELECT SUM(delta) FROM ledger_entries WHERE user_id = $1 +-- The existing ux_ledger_market_user_reason index leads with market_id, so it +-- can't serve a user_id lookup. Without this, balance is a sequential scan. +-- +-- markets(group_id): the leaderboard resolves a group's markets with +-- WHERE group_id = $1 +-- and the markets-list endpoint filters the same way. markets had no index here. +-- +-- Tables are small at current scale, so a plain (non-CONCURRENT) build is fine +-- and keeps the migration transactional like the others. If these tables grow +-- large, rebuild these with CREATE INDEX CONCURRENTLY outside a transaction. +BEGIN; + +CREATE INDEX IF NOT EXISTS ix_ledger_entries_user_id ON ledger_entries (user_id); +CREATE INDEX IF NOT EXISTS ix_markets_group_id ON markets (group_id); + +COMMIT; diff --git a/package.json b/package.json index 95261f0..25aa1c4 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "migrate": "node --env-file-if-exists=.env.local server/migrations/run_migrations.js", "test": "node --env-file-if-exists=.env.local test/resolve.integration.test.js", "test:db": "node --env-file-if-exists=.env.local test/database-url.test.js", - "test:dburl": "node --env-file-if-exists=.env.local test/database-url.test.js" + "test:dburl": "node --env-file-if-exists=.env.local test/database-url.test.js", + "test:perf": "vitest run", + "test:perf:watch": "vitest" }, "dependencies": { "@aws-sdk/client-s3": "^3.1039.0", @@ -22,5 +24,9 @@ "react": "18.3.1", "react-dom": "18.3.1", "remotion": "^4.0.454" + }, + "devDependencies": { + "dotenv": "^17.4.2", + "vitest": "^4.1.7" } } diff --git a/pages/api/groups/[id]/leaderboard.js b/pages/api/groups/[id]/leaderboard.js index 8824390..798dc7b 100644 --- a/pages/api/groups/[id]/leaderboard.js +++ b/pages/api/groups/[id]/leaderboard.js @@ -1,6 +1,6 @@ import { getUserFromRequest } from '../../../../lib/auth'; import { applyCors } from '../../../../server/cors'; -import { query } from '../../../../server/db'; +import { getLeaderboard } from '../../../../server/queries/leaderboard'; export default async function handler(req, res) { if (applyCors(req, res)) return; @@ -16,70 +16,8 @@ export default async function handler(req, res) { if (!groupId) return res.status(400).json({ error: 'group id is required' }); try { - const { rows: memberRows } = await query( - 'SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1', - [groupId, user.id] - ); - if (memberRows.length === 0) { - return res.status(403).json({ error: 'forbidden' }); - } - - const { rows: marketRows } = await query( - 'SELECT id FROM markets WHERE group_id = $1', - [groupId] - ); - const marketIds = marketRows.map((row) => row.id).filter(Boolean); - - let ledgerRows = []; - if (marketIds.length > 0) { - const result = await query( - 'SELECT user_id, market_id, delta, reason, created_at FROM ledger_entries WHERE market_id = ANY($1)', - [marketIds] - ); - ledgerRows = result.rows; - } - - const scores = new Map(); - const history = new Map(); - for (const row of ledgerRows) { - scores.set(row.user_id, (scores.get(row.user_id) || 0) + (row.delta || 0)); - if (!history.has(row.user_id)) history.set(row.user_id, []); - history.get(row.user_id).push({ - delta: row.delta || 0, - reason: row.reason, - created_at: row.created_at, - }); - } - - const { rows: groupMemberRows } = await query( - 'SELECT user_id FROM group_members WHERE group_id = $1', - [groupId] - ); - - const scoredUserIds = [...new Set(groupMemberRows.map((row) => row.user_id).filter(Boolean))]; - if (scoredUserIds.length === 0) return res.status(200).json([]); - - const { rows: userRows } = await query( - 'SELECT id, email, display_name, starting_points FROM users WHERE id = ANY($1)', - [scoredUserIds] - ); - const userMap = new Map(userRows.map((u) => [u.id, u])); - - const leaderboard = [...scores.entries()] - .map(([userId, score]) => { - const u = userMap.get(userId); - const display_name = u?.display_name ?? (u?.email ? u.email.split('@')[0] : userId); - const balance = (u?.starting_points ?? 2000) + score; - const recent = (history.get(userId) || []) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) - .slice(0, 5); - const trendWindow = recent.slice(0, 3).reduce((sum, row) => sum + row.delta, 0); - const trend = trendWindow > 0 ? 'up' : (trendWindow < 0 ? 'down' : 'flat'); - return { user_id: userId, display_name, score: balance, raw_delta: score, last_deltas: recent, trend }; - }) - .sort((left, right) => right.score - left.score); - - return res.status(200).json(leaderboard); + const result = await getLeaderboard(groupId, user.id); + return res.status(result.status).json(result.body); } catch (err) { console.error('leaderboard error', err); return res.status(500).json({ error: 'internal server error' }); diff --git a/pages/api/markets/[id]/index.js b/pages/api/markets/[id]/index.js index a7164db..4f919ac 100644 --- a/pages/api/markets/[id]/index.js +++ b/pages/api/markets/[id]/index.js @@ -1,6 +1,6 @@ import { applyCors } from '../../../../server/cors'; -import { query } from '../../../../server/db'; import { getUserFromRequest } from '../../../../lib/auth'; +import { getMarketDetail } from '../../../../server/queries/marketDetail'; export default async function handler(req, res) { if (applyCors(req, res)) return; @@ -12,80 +12,9 @@ export default async function handler(req, res) { const user = await getUserFromRequest(req); if (!user) return res.status(401).json({ error: 'unauthorized' }); - const marketId = req.query.id; - try { - const { rows: marketRows } = await query( - `SELECT id, group_id, creator_id, title, type, state, resolve_by, resolution, created_at - FROM markets WHERE id = $1 LIMIT 1`, - [marketId] - ); - const market = marketRows[0]; - if (!market) { - return res.status(404).json({ error: 'market not found' }); - } - - const { rows: memberRows } = await query( - 'SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1', - [market.group_id, user.id] - ); - if (memberRows.length === 0) { - return res.status(403).json({ error: 'forbidden' }); - } - - const { rows: predictionRows } = await query( - 'SELECT choice FROM predictions WHERE market_id = $1', - [marketId] - ); - - const { rows: mySettlementRows } = await query( - 'SELECT delta, reason, created_at FROM ledger_entries WHERE market_id = $1 AND user_id = $2', - [marketId, user.id] - ); - - const { rows: myPredictionRows } = await query( - 'SELECT stake_points, choice, created_at FROM predictions WHERE market_id = $1 AND user_id = $2 LIMIT 1', - [marketId, user.id] - ); - const myPredictionRow = myPredictionRows[0] || null; - - const { rows: userRows } = await query( - 'SELECT starting_points FROM users WHERE id = $1 LIMIT 1', - [user.id] - ); - const userRow = userRows[0]; - - const { rows: allLedgerRows } = await query( - 'SELECT delta FROM ledger_entries WHERE user_id = $1', - [user.id] - ); - - const predictionCount = predictionRows.length; - const yesCount = predictionRows.filter((row) => row.choice === true).length; - const noCount = predictionRows.filter((row) => row.choice === false).length; - - const settlementBreakdown = {}; - let settlementDelta = 0; - for (const row of mySettlementRows) { - settlementBreakdown[row.reason] = (settlementBreakdown[row.reason] || 0) + (row.delta || 0); - settlementDelta += row.delta || 0; - } - const userBalance = (userRow?.starting_points ?? 2000) + allLedgerRows.reduce((sum, row) => sum + (row.delta || 0), 0); - - return res.status(200).json({ - market: { - ...market, - prediction_count: predictionCount, - yes_count: yesCount, - no_count: noCount, - my_settlement: { - total_delta: settlementDelta, - breakdown: settlementBreakdown, - }, - my_prediction: myPredictionRow, - my_balance: userBalance, - }, - }); + const result = await getMarketDetail(req.query.id, user.id); + return res.status(result.status).json(result.body); } catch (e) { console.error('market detail error', e); return res.status(500).json({ error: 'internal' }); diff --git a/pages/api/markets/[id]/predictions.js b/pages/api/markets/[id]/predictions.js index e012396..af22c89 100644 --- a/pages/api/markets/[id]/predictions.js +++ b/pages/api/markets/[id]/predictions.js @@ -1,6 +1,7 @@ import { getUserFromRequest } from '../../../../lib/auth'; import { applyCors } from '../../../../server/cors'; import { query } from '../../../../server/db'; +import { getUserBalance } from '../../../../server/queries/balance'; import { getIdempotentResponse, storeIdempotentResponse } from '../../../../server/idempotency'; async function loadMarketWithMembership(marketId, userId) { @@ -51,19 +52,6 @@ async function listPredictions(marketId, userId) { return { status: 200, body: enriched }; } -async function getUserBalance(userId) { - const { rows: userRows } = await query( - 'SELECT starting_points FROM users WHERE id = $1 LIMIT 1', - [userId] - ); - const { rows: ledgerRows } = await query( - 'SELECT delta FROM ledger_entries WHERE user_id = $1', - [userId] - ); - const ledgerTotal = ledgerRows.reduce((sum, row) => sum + (row.delta || 0), 0); - return (userRows[0]?.starting_points ?? 2000) + ledgerTotal; -} - async function createPrediction(marketId, userId, userEmail, choice, stakePoints) { const marketStatus = await loadMarketWithMembership(marketId, userId); if (marketStatus.notFound) return { status: 404, body: { error: 'market not found' } }; diff --git a/server/queries/balance.js b/server/queries/balance.js new file mode 100644 index 0000000..102ed85 --- /dev/null +++ b/server/queries/balance.js @@ -0,0 +1,18 @@ +const { query } = require('../db'); + +// A user's balance is their starting_points plus the sum of all their ledger +// deltas. Computed in a single aggregate round-trip rather than fetching every +// ledger row and summing in JS. Defaults starting_points to 2000 if the user +// row doesn't exist yet (matches prior handler behavior). +async function getUserBalance(userId, q = query) { + const { rows } = await q( + `SELECT ( + COALESCE((SELECT starting_points FROM users WHERE id = $1), 2000) + + COALESCE((SELECT SUM(delta) FROM ledger_entries WHERE user_id = $1), 0) + )::int AS balance`, + [userId] + ); + return rows[0].balance; +} + +module.exports = { getUserBalance }; diff --git a/server/queries/leaderboard.js b/server/queries/leaderboard.js new file mode 100644 index 0000000..edfc930 --- /dev/null +++ b/server/queries/leaderboard.js @@ -0,0 +1,67 @@ +const { query } = require('../db'); + +// Group leaderboard. Returns { status, body }. +// Scores are aggregated in SQL (SUM per user) and the per-user "recent activity" +// is bounded to the latest 5 rows via a window function, instead of pulling +// every ledger row for the group and summing in JS. Only users with ledger +// activity appear (matches prior behavior). +async function getLeaderboard(groupId, userId, q = query) { + const { rows: memberRows } = await q( + 'SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1', + [groupId, userId] + ); + if (memberRows.length === 0) return { status: 403, body: { error: 'forbidden' } }; + + // One pass over the group's ledger: total delta per user (full SUM) plus the + // 5 most recent entries per user. raw_delta is the same on every row for a + // given user (window SUM over the whole partition). + const { rows: ledgerRows } = await q( + `SELECT user_id, delta, reason, created_at, raw_delta FROM ( + SELECT user_id, delta, reason, created_at, + SUM(delta) OVER (PARTITION BY user_id)::int AS raw_delta, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn + FROM ledger_entries + WHERE market_id IN (SELECT id FROM markets WHERE group_id = $1) + ) t + WHERE rn <= 5 + ORDER BY user_id, created_at DESC`, + [groupId] + ); + + if (ledgerRows.length === 0) return { status: 200, body: [] }; + + const perUser = new Map(); + for (const row of ledgerRows) { + if (!perUser.has(row.user_id)) { + perUser.set(row.user_id, { raw_delta: row.raw_delta || 0, recent: [] }); + } + perUser.get(row.user_id).recent.push({ + delta: row.delta || 0, + reason: row.reason, + created_at: row.created_at, + }); + } + + const userIds = [...perUser.keys()]; + const { rows: userRows } = await q( + 'SELECT id, email, display_name, starting_points FROM users WHERE id = ANY($1)', + [userIds] + ); + const userMap = new Map(userRows.map((u) => [u.id, u])); + + const leaderboard = userIds + .map((id) => { + const { raw_delta, recent } = perUser.get(id); + const u = userMap.get(id); + const display_name = u?.display_name ?? (u?.email ? u.email.split('@')[0] : id); + const score = (u?.starting_points ?? 2000) + raw_delta; + const trendWindow = recent.slice(0, 3).reduce((sum, r) => sum + r.delta, 0); + const trend = trendWindow > 0 ? 'up' : trendWindow < 0 ? 'down' : 'flat'; + return { user_id: id, display_name, score, raw_delta, last_deltas: recent, trend }; + }) + .sort((left, right) => right.score - left.score); + + return { status: 200, body: leaderboard }; +} + +module.exports = { getLeaderboard }; diff --git a/server/queries/marketDetail.js b/server/queries/marketDetail.js new file mode 100644 index 0000000..a5d1e26 --- /dev/null +++ b/server/queries/marketDetail.js @@ -0,0 +1,70 @@ +const { query } = require('../db'); +const { getUserBalance } = require('./balance'); + +// Market detail for a single viewer. Returns { status, body }. +// The market lookup and membership check are sequential (membership needs the +// market's group_id, and both gate access). Everything after that is +// independent, so it runs concurrently. Counts and balance are aggregated in +// SQL rather than fetched row-by-row and summed in JS. +async function getMarketDetail(marketId, userId, q = query) { + const { rows: marketRows } = await q( + `SELECT id, group_id, creator_id, title, type, state, resolve_by, resolution, created_at + FROM markets WHERE id = $1 LIMIT 1`, + [marketId] + ); + const market = marketRows[0]; + if (!market) return { status: 404, body: { error: 'market not found' } }; + + const { rows: memberRows } = await q( + 'SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1', + [market.group_id, userId] + ); + if (memberRows.length === 0) return { status: 403, body: { error: 'forbidden' } }; + + const [countsResult, settlementResult, myPredictionResult, userBalance] = await Promise.all([ + q( + `SELECT COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE choice = true)::int AS yes, + COUNT(*) FILTER (WHERE choice = false)::int AS no + FROM predictions WHERE market_id = $1`, + [marketId] + ), + q( + 'SELECT delta, reason, created_at FROM ledger_entries WHERE market_id = $1 AND user_id = $2', + [marketId, userId] + ), + q( + 'SELECT stake_points, choice, created_at FROM predictions WHERE market_id = $1 AND user_id = $2 LIMIT 1', + [marketId, userId] + ), + getUserBalance(userId, q), + ]); + + const counts = countsResult.rows[0]; + const settlementBreakdown = {}; + let settlementDelta = 0; + for (const row of settlementResult.rows) { + settlementBreakdown[row.reason] = (settlementBreakdown[row.reason] || 0) + (row.delta || 0); + settlementDelta += row.delta || 0; + } + + return { + status: 200, + body: { + market: { + ...market, + prediction_count: counts.total, + yes_count: counts.yes, + no_count: counts.no, + my_settlement: { + total_delta: settlementDelta, + breakdown: settlementBreakdown, + }, + my_prediction: myPredictionResult.rows[0] || null, + my_balance: userBalance, + }, + }, + }; +} + +module.exports = { getMarketDetail }; diff --git a/test/balance.vitest.js b/test/balance.vitest.js new file mode 100644 index 0000000..f301cf8 --- /dev/null +++ b/test/balance.vitest.js @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { makeQuerySpy, uid } from './helpers.js'; + +const { query, pool } = require('../server/db'); +const { getUserBalance } = require('../server/queries/balance'); + +const USER = uid('bal-user'); +const OTHER = uid('bal-other'); + +beforeAll(async () => { + await query( + `INSERT INTO users (id, email, starting_points) VALUES ($1, $2, 2000), ($3, $4, 2000) + ON CONFLICT (id) DO NOTHING`, + [USER, `${USER}@t.internal`, OTHER, `${OTHER}@t.internal`] + ); + // USER ledger: -100, -50, +300 => net +150 => balance 2150 + await query( + `INSERT INTO ledger_entries (user_id, delta, reason) VALUES + ($1, -100, 'wager_stake'), + ($1, -50, 'wager_stake'), + ($1, 300, 'wager_win_payout'), + ($2, 999, 'noise')`, + [USER, OTHER] + ); +}); + +afterAll(async () => { + await query(`DELETE FROM ledger_entries WHERE user_id = ANY($1)`, [[USER, OTHER]]); + await query(`DELETE FROM users WHERE id = ANY($1)`, [[USER, OTHER]]); + await pool.end(); +}); + +describe('getUserBalance', () => { + it('returns starting_points plus the sum of the user ledger deltas', async () => { + const balance = await getUserBalance(USER); + expect(balance).toBe(2150); + }); + + it('returns starting_points when the user has no ledger entries', async () => { + const balance = await getUserBalance(OTHER === USER ? OTHER : OTHER, query); + // OTHER has one entry (+999) -> 2999 + expect(balance).toBe(2999); + }); + + it('computes the balance with a single aggregate query (no full-row fetch)', async () => { + const spy = makeQuerySpy(); + await getUserBalance(USER, spy); + // Performance invariant: one round-trip, and it aggregates in SQL. + expect(spy.calls.length).toBe(1); + expect(spy.matching(/sum\s*\(/i).length).toBe(1); + // Must NOT issue a bare "SELECT delta ... " that pulls every row back. + expect(spy.matching(/select\s+delta\s+from\s+ledger_entries/i).length).toBe(0); + }); +}); diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..b0973d4 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,47 @@ +// Shared helpers for Vitest integration tests against the Neon test branch. +const { query } = require('../server/db'); + +// Wrap the real query fn so a test can assert on how many queries ran and what +// SQL was issued. This is how we encode performance invariants (e.g. "balance +// must be a single aggregate query, not a full-row fetch"). +function makeQuerySpy(realQuery = query) { + const calls = []; + const spy = (text, params) => { + calls.push({ text, params }); + return realQuery(text, params); + }; + spy.calls = calls; + spy.matching = (re) => calls.filter((c) => re.test(c.text)); + return spy; +} + +// Like makeQuerySpy, but also tracks how many queries are in flight at once. +// With Promise.all, all q() calls are invoked synchronously before any awaits +// resolve, so maxInFlight reflects true concurrency; sequential awaits keep it +// at 1. This lets us assert parallelism deterministically against the real DB. +function makeInflightSpy(realQuery = query) { + const calls = []; + let inFlight = 0; + let maxInFlight = 0; + const spy = async (text, params) => { + calls.push({ text, params }); + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + return await realQuery(text, params); + } finally { + inFlight--; + } + }; + spy.calls = calls; + spy.matching = (re) => calls.filter((c) => re.test(c.text)); + spy.maxInFlight = () => maxInFlight; + return spy; +} + +// Unique suffix so concurrent/repeated runs don't collide on ids. +function uid(prefix) { + return `${prefix}-${Math.floor(Math.random() * 1e9).toString(36)}`; +} + +module.exports = { makeQuerySpy, makeInflightSpy, uid }; diff --git a/test/indexes.vitest.js b/test/indexes.vitest.js new file mode 100644 index 0000000..d5ee1e2 --- /dev/null +++ b/test/indexes.vitest.js @@ -0,0 +1,32 @@ +import { describe, it, expect, afterAll } from 'vitest'; + +const { query, pool } = require('../server/db'); + +afterAll(async () => { + await pool.end(); +}); + +// True if some index on `table` has `column` as its leading key column — that's +// what makes a `WHERE column = $1` lookup index-backed instead of a seq scan. +async function hasLeadingIndex(table, column) { + const { rows } = await query( + `SELECT 1 + FROM pg_index ix + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ix.indkey[0] + WHERE t.relname = $1 AND a.attname = $2 + LIMIT 1`, + [table, column] + ); + return rows.length > 0; +} + +describe('performance indexes (migration 009)', () => { + it('ledger_entries has a leading index on user_id (for balance SUM)', async () => { + expect(await hasLeadingIndex('ledger_entries', 'user_id')).toBe(true); + }); + + it('markets has a leading index on group_id (for leaderboard + markets list)', async () => { + expect(await hasLeadingIndex('markets', 'group_id')).toBe(true); + }); +}); diff --git a/test/leaderboard.vitest.js b/test/leaderboard.vitest.js new file mode 100644 index 0000000..5178e69 --- /dev/null +++ b/test/leaderboard.vitest.js @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { makeQuerySpy, uid } from './helpers.js'; + +const { query, pool } = require('../server/db'); +const { getLeaderboard } = require('../server/queries/leaderboard'); + +const A = uid('lb-a'); +const B = uid('lb-b'); +const C = uid('lb-c'); +const OUTSIDER = uid('lb-out'); +let groupId; +let marketId; + +beforeAll(async () => { + await query( + `INSERT INTO users (id, email, display_name, starting_points) VALUES + ($1,$2,'Alice',2000), ($3,$4,'Bob',2000), ($5,$6,'Cara',2000), ($7,$8,'Eve',2000) + ON CONFLICT (id) DO NOTHING`, + [A, `${A}@t.internal`, B, `${B}@t.internal`, C, `${C}@t.internal`, OUTSIDER, `${OUTSIDER}@t.internal`] + ); + const { rows: g } = await query( + `INSERT INTO groups (name, owner_id, is_private) VALUES ('LB Group',$1,true) RETURNING id`, + [A] + ); + groupId = g[0].id; + await query( + `INSERT INTO group_members (group_id, user_id, role) VALUES + ($1,$2,'admin'),($1,$3,'member'),($1,$4,'member')`, + [groupId, A, B, C] + ); + const { rows: m } = await query( + `INSERT INTO markets (group_id, creator_id, title, state) VALUES ($1,$2,'LB market','resolved') RETURNING id`, + [groupId, A] + ); + marketId = m[0].id; + // A: +300 (older), +100 (newer) => raw 400, trend up. B: -50 => raw -50, trend down. C: none. + await query( + `INSERT INTO ledger_entries (user_id, market_id, delta, reason, created_at) VALUES + ($1,$3,300,'wager_win_payout', now() - interval '2 hours'), + ($1,$3,100,'bonus', now() - interval '1 hour'), + ($2,$3,-50,'wager_stake', now() - interval '90 minutes')`, + [A, B, marketId] + ); +}); + +afterAll(async () => { + await query(`DELETE FROM ledger_entries WHERE market_id = $1`, [marketId]); + await query(`DELETE FROM markets WHERE id = $1`, [marketId]); + await query(`DELETE FROM group_members WHERE group_id = $1`, [groupId]); + await query(`DELETE FROM groups WHERE id = $1`, [groupId]); + await query(`DELETE FROM users WHERE id = ANY($1)`, [[A, B, C, OUTSIDER]]); + await pool.end(); +}); + +describe('getLeaderboard', () => { + it('403s for a non-member', async () => { + const res = await getLeaderboard(groupId, OUTSIDER, query); + expect(res.status).toBe(403); + }); + + it('ranks users by balance with correct raw_delta and trend', async () => { + const res = await getLeaderboard(groupId, A, query); + expect(res.status).toBe(200); + const board = res.body; + // Only users with ledger activity appear (preserves prior behavior): A and B. + expect(board.map((r) => r.user_id).sort()).toEqual([A, B].sort()); + + const alice = board.find((r) => r.user_id === A); + const bob = board.find((r) => r.user_id === B); + expect(alice.raw_delta).toBe(400); + expect(alice.score).toBe(2400); + expect(alice.trend).toBe('up'); + expect(alice.last_deltas.length).toBe(2); + // most recent first + expect(alice.last_deltas[0].delta).toBe(100); + expect(bob.raw_delta).toBe(-50); + expect(bob.score).toBe(1950); + expect(bob.trend).toBe('down'); + + // sorted by score desc + expect(board[0].user_id).toBe(A); + expect(board[1].user_id).toBe(B); + }); + + it('aggregates ledger deltas in SQL, not by fetching every row', async () => { + const spy = makeQuerySpy(); + await getLeaderboard(groupId, A, spy); + // Aggregation happens in the database. + expect(spy.matching(/sum\s*\(/i).length).toBeGreaterThanOrEqual(1); + // The old "pull all ledger rows for the group" query is gone. + expect( + spy.matching(/select\s+user_id,\s*market_id,\s*delta,\s*reason,\s*created_at\s+from\s+ledger_entries/i).length + ).toBe(0); + }); +}); diff --git a/test/marketDetail.vitest.js b/test/marketDetail.vitest.js new file mode 100644 index 0000000..8f53bd6 --- /dev/null +++ b/test/marketDetail.vitest.js @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { makeQuerySpy, makeInflightSpy, uid } from './helpers.js'; + +const { query, pool } = require('../server/db'); +const { getMarketDetail } = require('../server/queries/marketDetail'); + +const OWNER = uid('md-owner'); +const BETTOR = uid('md-bettor'); +const OUTSIDER = uid('md-outsider'); +let groupId; +let marketId; + +beforeAll(async () => { + await query( + `INSERT INTO users (id, email, starting_points) VALUES + ($1,$2,2000), ($3,$4,2000), ($5,$6,2000) + ON CONFLICT (id) DO NOTHING`, + [OWNER, `${OWNER}@t.internal`, BETTOR, `${BETTOR}@t.internal`, OUTSIDER, `${OUTSIDER}@t.internal`] + ); + const { rows: g } = await query( + `INSERT INTO groups (name, owner_id, is_private) VALUES ('MD Group',$1,true) RETURNING id`, + [OWNER] + ); + groupId = g[0].id; + await query( + `INSERT INTO group_members (group_id, user_id, role) VALUES ($1,$2,'admin'),($1,$3,'member')`, + [groupId, OWNER, BETTOR] + ); + const { rows: m } = await query( + `INSERT INTO markets (group_id, creator_id, title, state) VALUES ($1,$2,'MD market','open') RETURNING id`, + [groupId, OWNER] + ); + marketId = m[0].id; + // predictions: OWNER yes(100), BETTOR no(50) + await query( + `INSERT INTO predictions (market_id, user_id, choice, stake_points) VALUES + ($1,$2,true,100), ($1,$3,false,50)`, + [marketId, OWNER, BETTOR] + ); + // ledger for BETTOR: -50 on this market (stake), and +200 elsewhere (other market noise) + await query( + `INSERT INTO ledger_entries (user_id, market_id, delta, reason) VALUES + ($1,$2,-50,'wager_stake'), ($1,NULL,200,'bonus')`, + [BETTOR, marketId] + ); +}); + +afterAll(async () => { + await query(`DELETE FROM ledger_entries WHERE user_id = ANY($1)`, [[OWNER, BETTOR, OUTSIDER]]); + await query(`DELETE FROM predictions WHERE market_id = $1`, [marketId]); + await query(`DELETE FROM markets WHERE id = $1`, [marketId]); + await query(`DELETE FROM group_members WHERE group_id = $1`, [groupId]); + await query(`DELETE FROM groups WHERE id = $1`, [groupId]); + await query(`DELETE FROM users WHERE id = ANY($1)`, [[OWNER, BETTOR, OUTSIDER]]); + await pool.end(); +}); + +describe('getMarketDetail', () => { + it('404s for a market that does not exist', async () => { + const res = await getMarketDetail('00000000-0000-0000-0000-000000000000', OWNER, query); + expect(res.status).toBe(404); + }); + + it('403s for a non-member', async () => { + const res = await getMarketDetail(marketId, OUTSIDER, query); + expect(res.status).toBe(403); + }); + + it('returns correct counts, settlement, prediction and balance for a member', async () => { + const res = await getMarketDetail(marketId, BETTOR, query); + expect(res.status).toBe(200); + const m = res.body.market; + expect(m.id).toBe(marketId); + expect(m.prediction_count).toBe(2); + expect(m.yes_count).toBe(1); + expect(m.no_count).toBe(1); + // BETTOR's settlement on THIS market only = -50 + expect(m.my_settlement.total_delta).toBe(-50); + expect(m.my_settlement.breakdown.wager_stake).toBe(-50); + // BETTOR's prediction + expect(m.my_prediction.choice).toBe(false); + expect(m.my_prediction.stake_points).toBe(50); + // BETTOR balance = 2000 + (-50 + 200) across ALL markets = 2150 + expect(m.my_balance).toBe(2150); + }); + + it('does not fetch the full user ledger; balance uses a SUM aggregate', async () => { + const spy = makeQuerySpy(); + await getMarketDetail(marketId, BETTOR, spy); + // The old full-row fetch is gone. + expect(spy.matching(/select\s+delta\s+from\s+ledger_entries\s+where\s+user_id/i).length).toBe(0); + // Balance is aggregated in SQL. + expect(spy.matching(/sum\s*\(/i).length).toBeGreaterThanOrEqual(1); + // Counts are aggregated in SQL, not by selecting every choice row. + expect(spy.matching(/count\s*\(/i).length).toBeGreaterThanOrEqual(1); + expect(spy.matching(/select\s+choice\s+from\s+predictions\s+where\s+market_id\s*=\s*\$1\s*$/i).length).toBe(0); + }); + + it('runs the independent reads in parallel (max in-flight > 1)', async () => { + const spy = makeInflightSpy(); + await getMarketDetail(marketId, BETTOR, spy); + expect(spy.maxInFlight()).toBeGreaterThan(1); + }); +}); diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..94d78ce --- /dev/null +++ b/test/setup.js @@ -0,0 +1,16 @@ +// Vitest setup: load the test-branch DATABASE_URL before any module that reads it. +// server/db.js resolves the connection string at require-time, so this must run first. +const dotenv = require('dotenv'); +dotenv.config({ path: '.env.test.local' }); + +if (!process.env.DATABASE_URL) { + throw new Error( + 'DATABASE_URL is not set. Create .env.test.local with a Neon test-branch URL (see vitest.config.js).' + ); +} + +// Guardrail: never let integration tests run against the production branch. +if (!/ci-perf-tests|ep-old-pond-a7d0kwqd/.test(process.env.DATABASE_URL)) { + // Best-effort check; the branch host is what we provisioned for CI. + console.warn('[test/setup] DATABASE_URL does not look like the ci-perf-tests branch.'); +} diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..507a6c2 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; + +// Integration tests run against an ephemeral Neon branch (see .env.test.local). +// They share one database, so we run serially with a single fork and rely on +// unique per-suite IDs + teardown for isolation. Neon computes can cold-start, +// so timeouts are generous. +export default defineConfig({ + test: { + environment: 'node', + globals: true, + setupFiles: ['./test/setup.js'], + fileParallelism: false, + pool: 'forks', + testTimeout: 30000, + hookTimeout: 30000, + include: ['test/**/*.vitest.js'], + }, +});