/** * 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 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, ]) ); }, /** Insert a pre-baked export job row to skip the (slow) real compression path. */ async fakeExportJob(eventSlug: string, type: 'zip' | 'html', status: 'pending' | 'running' | 'done') { await withClient(async (c) => { const ev = await c.query<{ id: string }>(`SELECT id 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) VALUES ($1, $2::export_type, $3::export_status, $4, $5) ON CONFLICT (event_id, type) DO UPDATE SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct`, [ev.rows[0].id, type, status, status === 'done' ? 100 : 0, status === 'done' ? new Date() : null] ); }); }, };