/** * Direct PostgreSQL escape hatch for setting up states the public API doesn't * expose — e.g. forcing a user into the locked-PIN state to assert the 429 * recovery path, or expiring sessions for chaos tests. * * Most tests should NOT use this: prefer `ApiClient` so the tests exercise * the same code paths real users do. Reach for direct SQL only when the API * can't get you where you need to go. */ import { Client } from 'pg'; const CONN = { host: process.env.E2E_DB_HOST ?? 'localhost', port: Number(process.env.E2E_DB_PORT ?? '55432'), user: process.env.E2E_DB_USER ?? 'eventsnap_test', password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test', database: process.env.E2E_DB_NAME ?? 'eventsnap_test', }; async function withClient(fn: (c: Client) => Promise): Promise { const client = new Client(CONN); await client.connect(); try { return await fn(client); } finally { await client.end(); } } export const db = { async lockUserPin(userId: string, minutesFromNow = 15) { await withClient((c) => c.query( `UPDATE "user" SET pin_locked_until = NOW() + ($2 || ' minutes')::interval, failed_pin_attempts = 3 WHERE id = $1`, [userId, String(minutesFromNow)] ) ); }, async expireSession(userId: string) { await withClient((c) => c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [ userId, ]) ); }, async setUploadCompressionStatus( uploadId: string, status: 'pending' | 'processing' | 'done' | 'failed' ) { await withClient((c) => c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status]) ); }, async countUploadsForUser(userId: string): Promise { return withClient(async (c) => { const r = await c.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM upload WHERE user_id = $1 AND deleted_at IS NULL`, [userId] ); return Number(r.rows[0].count); }); }, async countSessionsForUser(userId: string): Promise { return withClient(async (c) => { const r = await c.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM session WHERE user_id = $1`, [userId] ); return Number(r.rows[0].count); }); }, async countPinResetRequestsForUser(userId: string): Promise { return withClient(async (c) => { const r = await c.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM pin_reset_request WHERE user_id = $1`, [userId] ); return Number(r.rows[0].count); }); }, async setExportReleased(slug: string, released: boolean) { await withClient((c) => c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [ slug, released ? new Date() : null, ]) ); }, /** * Make an export "ready" (or not) in the epoch model. There is no `export_zip_ready` column any * more — readiness is DERIVED (`released AND job.epoch = event.export_epoch AND status='done'`), * so a job is ready exactly when its row carries the event's live epoch. To make a `done` job NOT * ready we retire it to a dead epoch (-1), which is what a reopen effectively does. * * `file_path` is deliberately left NULL, so a "ready" job with no file on disk still exercises * the download's missing-file 404 branch. */ async setExportZipReady(slug: string, ready: boolean) { await withClient((c) => c.query( `UPDATE export_job ej SET epoch = CASE WHEN $2 THEN e.export_epoch ELSE -1 END FROM event e WHERE e.id = ej.event_id AND e.slug = $1 AND ej.type = 'zip'`, [slug, ready] ) ); }, /** * Insert a pre-baked export job row to skip the (slow) real compression path. Stamped with the * event's CURRENT epoch so it counts as the live generation. */ async fakeExportJob( eventSlug: string, type: 'zip' | 'html', status: 'pending' | 'running' | 'done' ) { await withClient(async (c) => { const ev = await c.query<{ id: string; export_epoch: string }>( `SELECT id, export_epoch FROM event WHERE slug = $1`, [eventSlug] ); if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`); await c.query( `INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch) VALUES ($1, $2::export_type, $3::export_status, $4, $5, $6) ON CONFLICT (event_id, type) DO UPDATE SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct, epoch = EXCLUDED.epoch`, [ ev.rows[0].id, type, status, status === 'done' ? 100 : 0, status === 'done' ? new Date() : null, ev.rows[0].export_epoch, ] ); }); }, };