From 48c22fb64900a95b26b750fe1fc17e89f47b03a8 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 1 Jun 2026 11:07:31 +1000 Subject: [PATCH] perf: aggregate balance/leaderboard in SQL, parallelize market detail, add indexes Hot read paths were doing O(N) work in JS and running queries sequentially. All four changes are driven by Vitest integration tests against a Neon branch that assert both correctness and the performance invariant (query count, SQL shape, parallel dispatch). - balance: single SELECT SUM(delta) instead of fetching every ledger row - market detail: 7 sequential queries -> gated reads + Promise.all + aggregate counts; balance via SUM - leaderboard: windowed GROUP BY (SUM OVER + ROW_NUMBER) instead of pulling all ledger rows and summing in JS - migration 009: indexes on ledger_entries(user_id) and markets(group_id) API handlers now delegate to tested server/queries/* modules (-153 lines). Co-Authored-By: Claude Opus 4.8 (1M context) --- db/migrations/009_perf_indexes.sql | 20 +++++ package.json | 8 +- pages/api/groups/[id]/leaderboard.js | 68 +---------------- pages/api/markets/[id]/index.js | 77 +------------------ pages/api/markets/[id]/predictions.js | 14 +--- server/queries/balance.js | 18 +++++ server/queries/leaderboard.js | 67 +++++++++++++++++ server/queries/marketDetail.js | 70 +++++++++++++++++ test/balance.vitest.js | 54 +++++++++++++ test/helpers.js | 47 ++++++++++++ test/indexes.vitest.js | 32 ++++++++ test/leaderboard.vitest.js | 95 +++++++++++++++++++++++ test/marketDetail.vitest.js | 104 ++++++++++++++++++++++++++ test/setup.js | 16 ++++ vitest.config.js | 18 +++++ 15 files changed, 555 insertions(+), 153 deletions(-) create mode 100644 db/migrations/009_perf_indexes.sql create mode 100644 server/queries/balance.js create mode 100644 server/queries/leaderboard.js create mode 100644 server/queries/marketDetail.js create mode 100644 test/balance.vitest.js create mode 100644 test/helpers.js create mode 100644 test/indexes.vitest.js create mode 100644 test/leaderboard.vitest.js create mode 100644 test/marketDetail.vitest.js create mode 100644 test/setup.js create mode 100644 vitest.config.js 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'], + }, +});