File size: 9,039 Bytes
86063f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da3259f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86063f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5104934
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
/**
 * 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;
}