diff --git a/apps/web/lib/__tests__/participantsExport.test.ts b/apps/web/lib/__tests__/participantsExport.test.ts new file mode 100644 index 000000000..186c1fe8e --- /dev/null +++ b/apps/web/lib/__tests__/participantsExport.test.ts @@ -0,0 +1,96 @@ +// Tests for streaming participants CSV export (#1184). + +import { + csvEscape, + participantToCsvLine, + streamParticipantsCsv, + collectParticipantRows, +} from "../participantsExport"; + +describe("participants CSV export (#1184)", () => { + test("csvEscape quotes fields containing commas, quotes, newlines", () => { + expect(csvEscape("plain")).toBe("plain"); + expect(csvEscape("with,comma")).toBe('"with,comma"'); + expect(csvEscape('has "quote"')).toBe('"has ""quote"""'); + expect(csvEscape("multi\nline")).toBe('"multi\nline"'); + }); + + test("participantToCsvLine emits ordered columns", () => { + const line = participantToCsvLine({ + rank: 1, + wallet: "GABC", + alias: "alice", + score: 42, + completionTime: "2026-08-24T00:00:00.000Z", + joinedAt: "2026-08-23T00:00:00.000Z", + }); + expect(line).toBe("1,GABC,alice,42,2026-08-24T00:00:00.000Z,2026-08-23T00:00:00.000Z"); + }); + + test("streamParticipantsCsv streams header then chunks lazily", async () => { + const total = 250; + const calls: Array<[number, number]> = []; + const rowSource = async (offset: number, limit: number) => { + calls.push([offset, limit]); + const rows = []; + for (let i = offset; i < Math.min(offset + limit, total); i++) { + rows.push({ + rank: i + 1, + wallet: `W${i}`, + alias: "", + score: 100 - i, + completionTime: "2026-08-24T00:00:00.000Z", + joinedAt: "2026-08-20T00:00:00.000Z", + }); + } + return rows; + }; + + const stream = streamParticipantsCsv(total, 100, rowSource); + const reader = (stream as ReadableStream).getReader(); + const decoder = new TextDecoder(); + + let text = ""; + let chunkCount = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value); + chunkCount += 1; + } + + // header + 3 data chunks (250 rows / 100 per chunk) + expect(chunkCount).toBe(4); + const lines = text.trim().split("\n"); + expect(lines[0]).toBe("rank,wallet,alias,score,completion_time,joined_at"); + expect(lines).toHaveLength(total + 1); + expect(calls).toEqual([ + [0, 100], + [100, 100], + [200, 100], + ]); + }); + + test("collectParticipantRows excludes privacy opt-outs from ranking", async () => { + const leaderboard = [ + { address: "W1", points: 30 }, + { address: "W2", points: 20 }, + { address: "W3", points: 10 }, + ]; + const participants = [ + { address: "W1", alias: "alice", points: 30, privacy: { shareResults: true } }, + { address: "W2", alias: "bob", points: 20, privacy: { shareResults: false } }, + { address: "W3", alias: "", points: 10 }, + ]; + + const rows = await collectParticipantRows( + leaderboard, + [], + async () => participants, + ); + + // W2 opted out -> excluded entirely; W3 gets rank 2 (not 3) + expect(rows.map((r) => r.wallet)).toEqual(["W1", "W3"]); + expect(rows[1].rank).toBe(2); + }); +}); diff --git a/apps/web/lib/participantsExport.ts b/apps/web/lib/participantsExport.ts new file mode 100644 index 000000000..22efe89c5 --- /dev/null +++ b/apps/web/lib/participantsExport.ts @@ -0,0 +1,138 @@ +// Streaming CSV export of hunt participants and results (#1184). +// +// Mirrors the leaderboard export route but adds: +// - participant rows (not just leaderboard ranks), honoring privacy settings +// - streaming via ReadableStream so large exports don't buffer in memory +// +// Privacy: participants who set `shareResults: false` are excluded from the +// export entirely; their row never leaves the server. + +// Pure module: data sources are injected by the caller (route), so this +// file has no server-only imports and stays unit-testable in isolation. + +export interface ParticipantRow { + rank: number; + wallet: string; + alias: string | ""; + score: number; + completionTime: string; + joinedAt: string; +} + +export interface ExportPrivacyOptions { + /** When false, the participant is excluded from exports (their choice). */ + shareResults?: boolean; +} + +export function csvEscape(value: string | number): string { + const s = String(value); + if (/[",\n\r]/.test(s)) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +/** Yield-safe CSV header for the participants export. */ +export function participantsCsvHeader(): string { + return "rank,wallet,alias,score,completion_time,joined_at"; +} + +/** Serialize one participant row as a CSV line. */ +export function participantToCsvLine(row: ParticipantRow): string { + return [ + csvEscape(row.rank), + csvEscape(row.wallet), + csvEscape(row.alias), + csvEscape(row.score), + csvEscape(row.completionTime), + csvEscape(row.joinedAt), + ].join(","); +} + +/** + * Build a streaming web ReadableStream of CSV text from participant rows. + * Rows are pulled lazily through `rowSource` in chunks so very large hunts + * do not need to fit in memory. + */ +export function streamParticipantsCsv( + total: number, + chunkSize: number, + rowSource: (offset: number, limit: number) => Promise, +): ReadableStream { + const encoder = new TextEncoder(); + let offset = 0; + let headerSent = false; + + return new ReadableStream({ + async pull(controller) { + if (!headerSent) { + controller.enqueue(encoder.encode(participantsCsvHeader() + "\n")); + headerSent = true; + return; + } + if (offset >= total) { + controller.close(); + return; + } + const rows = await rowSource(offset, chunkSize); + if (rows.length === 0) { + controller.close(); + return; + } + const text = rows.map(participantToCsvLine).join("\n") + "\n"; + controller.enqueue(encoder.encode(text)); + offset += rows.length; + }, + }); +} + +/** + * Collect participant rows for a hunt, applying privacy filtering. + * Participants with shareResults === false are excluded before any row is built. + */ +export async function collectParticipantRows( + leaderboard: Array<{ address: string; points: number }>, + fastest: Array<{ address?: string; completionTimeSeconds?: number }>, + getParticipants: () => Promise< + Array<{ + address: string; + alias?: string; + points?: number; + completedAt?: number; + joinedAt?: number; + privacy?: ExportPrivacyOptions; + }> + >, +): Promise { + + const completionTimeByWallet = new Map(); + for (const entry of fastest) { + if (entry.address && typeof entry.completionTimeSeconds === "number") { + completionTimeByWallet.set(entry.address, entry.completionTimeSeconds * 1000); + } + } + + const participants = await getParticipants(); + const sorted = [...leaderboard].sort((a, b) => b.points - a.points); + + const rows: ParticipantRow[] = []; + let rank = 0; + for (const entry of sorted) { + const participant = participants.find((p) => p.address === entry.address); + // Privacy: exclude participants who opted out of sharing results. + if (participant?.privacy?.shareResults === false) continue; + rank += 1; + const completionMs = + completionTimeByWallet.get(entry.address) ?? + (participant?.completedAt ?? Date.now()); + rows.push({ + rank, + wallet: entry.address, + alias: participant?.alias ?? "", + score: entry.points, + completionTime: new Date(completionMs).toISOString(), + joinedAt: new Date(participant?.joinedAt ?? completionMs).toISOString(), + }); + } + return rows; +} diff --git a/apps/web/vitest.no-setup.config.ts b/apps/web/vitest.no-setup.config.ts new file mode 100644 index 000000000..0282691aa --- /dev/null +++ b/apps/web/vitest.no-setup.config.ts @@ -0,0 +1,22 @@ +import react from "@vitejs/plugin-react"; +import path from "path"; +import { defineConfig } from "vitest/config"; + +// Minimal config for pure-logic tests (#1194): no jsdom setup file, so the +// pre-existing react/react-dom version mismatch in the repo does not block +// tests that do not touch React. + +export default defineConfig({ + plugins: [react()], + oxc: { + jsx: { + runtime: "automatic", + }, + }, + test: { + environment: "node", + globals: true, + setupFiles: [], + exclude: ["e2e/**", "node_modules/**"], + }, +});