/** * Shared harness for the campaign + verification end-to-end scripts. * * These drive the REAL HTTP API against a REAL Postgres. Nothing here mocks a * request or stubs a repository: a 201 that wrote no row is a bug the response * body cannot show, so every script asserts database state as well as status * codes. `sql()` goes through the same container the app writes to. * * Env: * API_BASE default http://localhost:3001 * PG_CONTAINER default smartfolio-pg * BACKEND_LOG path to the dev-server log, for reading mock-mode OTPs */ import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; export const API_BASE = process.env.API_BASE || 'http://localhost:3001'; const PG_CONTAINER = process.env.PG_CONTAINER || 'smartfolio-pg'; const PG_DB = process.env.PG_DB || 'smartfolio'; const PG_USER = process.env.PG_USER || 'postgres'; export const BACKEND_LOG = process.env.BACKEND_LOG || ''; /* ---------------------------------------------------------------- output -- */ const ESC = String.fromCharCode(27); const C = { reset: ESC + '[0m', dim: ESC + '[2m', red: ESC + '[31m', green: ESC + '[32m', cyan: ESC + '[36m', bold: ESC + '[1m', }; let passed = 0; let failed = 0; const failures = []; export function section(title) { const rule = '-'.repeat(Math.max(0, 62 - title.length)); console.log('\n' + C.bold + C.cyan + '-- ' + title + ' ' + rule + C.reset); } export function check(label, condition, detail = '') { if (condition) { passed++; console.log(' ' + C.green + 'PASS' + C.reset + ' ' + label + (detail ? ' ' + C.dim + detail + C.reset : '')); } else { failed++; failures.push(label); console.log(' ' + C.red + 'FAIL' + C.reset + ' ' + label + (detail ? ' ' + C.red + detail + C.reset : '')); } return condition; } export function info(message) { console.log(' ' + C.dim + '. ' + message + C.reset); } export function summary(name) { const total = passed + failed; const colour = failed === 0 ? C.green : C.red; console.log('\n' + C.bold + name + ': ' + colour + passed + '/' + total + ' checks passed' + C.reset); if (failures.length) { console.log(C.red + 'failed:' + C.reset); for (const f of failures) console.log(' - ' + f); } process.exitCode = failed === 0 ? 0 : 1; return failed === 0; } /* ------------------------------------------------------------------ http -- */ /** Fetch against the API. Returns { status, body, headers, setCookie }. */ export async function api(path, options = {}) { const { token, cookies, raw, ...init } = options; const headers = new Headers(init.headers || {}); if (token) headers.set('Authorization', 'Bearer ' + token); if (cookies) headers.set('Cookie', cookies); if (init.body && typeof init.body === 'object' && !(init.body instanceof FormData)) { if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); init.body = JSON.stringify(init.body); } const res = await fetch(API_BASE + path, { ...init, headers, redirect: 'manual' }); const setCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : []; let body = null; if (!raw) { const text = await res.text(); try { body = text ? JSON.parse(text) : null; } catch { body = text; } } return { status: res.status, body, headers: res.headers, setCookie, res }; } /** Value of one cookie from a set-cookie array. */ export function cookieValue(setCookie, name) { for (const line of setCookie) { const match = line.match(new RegExp('^' + name + '=([^;]*)')); if (match) return match[1]; } return null; } /* -------------------------------------------------------------- database -- */ /** Run SQL and return rows as arrays of column strings. */ export function sql(query) { const out = execFileSync( 'docker', ['exec', PG_CONTAINER, 'psql', '-U', PG_USER, '-d', PG_DB, '-tA', '-c', query], { encoding: 'utf8' }, ); return out .split('\n') .filter((line) => line.trim() !== '') .map((line) => line.split('|')); } /** First column of the first row, or null. */ export function sqlOne(query) { const rows = sql(query); return rows.length ? rows[0][0] : null; } export function sqlEscape(value) { return String(value).replace(/'/g, "''"); } /* ------------------------------------------------------------------ auth -- */ /** * Pull the most recent OTP for an address out of the dev-server log. * * MAIL_MOCK_MODE prints it there rather than sending mail - which is what makes * the signup leg of the apply journey testable without a real inbox. */ export function otpFromLog(email) { if (!BACKEND_LOG) throw new Error('BACKEND_LOG is not set - cannot read mock OTPs'); const log = readFileSync(BACKEND_LOG, 'utf8'); const blocks = log.split('========== MOCK EMAIL =========='); for (let i = blocks.length - 1; i >= 0; i--) { if (!blocks[i].includes(email)) continue; const match = blocks[i].match(/OTP code is:\s*(\d{4,6})/); if (match) return match[1]; } return null; } export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** * The interview link sent TO a specific address. * * Scoped to the recipient, not "the last one in the file". Two suites running * in sequence both send invites, and a search over the whole log hands the * second one the first one's token — which then resolves to somebody else's * campaign and fails in four confusing ways. */ export function inviteTokenFromLog(email) { if (!BACKEND_LOG) throw new Error('BACKEND_LOG is not set - cannot read mock mail'); const log = readFileSync(BACKEND_LOG, 'utf8'); const blocks = log.split('========== MOCK EMAIL =========='); for (let i = blocks.length - 1; i >= 0; i--) { if (!blocks[i].includes(email)) continue; const match = blocks[i].match(/\/interview\/([A-Za-z0-9_-]{20,})/); if (match) return match[1]; } return null; } /** * Create a fully verified account through the real signup -> OTP -> verify flow. * Returns { email, password, userId, accessToken, refreshToken }. */ export async function createVerifiedUser({ name, email, password = 'Password@123' }) { const signup = await api('/auth/signup', { method: 'POST', body: { name, email, password } }); if (signup.status >= 400) { throw new Error('signup failed (' + signup.status + '): ' + JSON.stringify(signup.body)); } let otp = null; for (let i = 0; i < 24 && !otp; i++) { otp = otpFromLog(email); if (!otp) await sleep(250); } if (!otp) throw new Error('no OTP found in log for ' + email); const verify = await api('/auth/verify-otp', { method: 'POST', body: { email, otp } }); if (verify.status >= 400) { throw new Error('verify-otp failed (' + verify.status + '): ' + JSON.stringify(verify.body)); } const userId = sqlOne("select id from \"user\" where email = '" + sqlEscape(email) + "'"); return { name, email, password, userId, accessToken: verify.body.accessToken, refreshToken: verify.body.refreshToken, }; } export async function login(email, password) { const res = await api('/auth/login', { method: 'POST', body: { email, password } }); if (res.status >= 400) { throw new Error('login failed (' + res.status + '): ' + JSON.stringify(res.body)); } return res.body.accessToken; } /** Promote an account to admin. There is no API for this, by design. */ export function makeAdmin(email) { sql("update \"user\" set role = 'admin' where email = '" + sqlEscape(email) + "'"); return sqlOne("select role from \"user\" where email = '" + sqlEscape(email) + "'"); } /** A stable email per run so repeated runs never collide. */ export function uniqueEmail(prefix) { return prefix + '+' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + '@smartfolio.test'; } /** * Give a candidate a CV with known text. * * Applications are scored by reading the CV against the job description, so a * candidate with no CV has no score — correctly, and the admin list says "Not * scored". Gates that assert on a score therefore have to provide one. */ export function giveCv(userId, text) { return sqlOne( 'insert into resumes ("userId", "originalFileName", "filePath", "fileType", "fileSizeBytes", "isExtracted", "extractedText", "fileData") values (' + "'" + userId + "', 'cv.pdf', 'db://resume', 'pdf', 1024, true, $q$" + text + "$q$, " + "decode('255044462d312e340a25','hex')) returning id", ); } export function attachCv(candidateId, userId, text) { const resumeId = sqlOne( 'insert into resumes ("userId", "originalFileName", "filePath", "fileType", "fileSizeBytes", "isExtracted", "extractedText", "fileData") values (' + "'" + userId + "', 'cv.pdf', 'db://resume', 'pdf', 1024, true, $q$" + text + "$q$, " + "decode('255044462d312e340a25','hex')) returning id", ); sql(`update campaign_candidates set "resumeId" = '${resumeId}' where id = '${candidateId}'`); return resumeId; }