ibrahimgulbutt's picture
Score the CV, show the CV, show the interview
5104934
Raw History Blame Contribute Delete
9.04 kB
/**
* 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;
}