Files
bizmatch-app/scripts/acceptance-nda-refresh.mjs
2026-08-04 10:26:54 -05:00

415 lines
15 KiB
JavaScript

#!/usr/bin/env node
/**
* Acceptance for the NDA refresh: incremental mode, the meta-key hygiene of
* the two background jobs, and the coverage marker.
*
* It runs the real server against `scripts/ds-stub.mjs` (a Dropbox Sign whose
* search index deliberately lags its list) and the real development database,
* per the README convention:
*
* * every stubbed request id starts with `req-`, and they are deleted again;
* * the `ds_*` keys in `app_meta` are snapshotted before the run and written
* back afterwards, so a test run cannot leave the mirror looking refreshed
* when it is not.
*
* Nothing else in the database is touched: the stub only ever hands out `req-`
* requests, and the ids of the real mirror are unknown to it.
*
* node scripts/acceptance-nda-refresh.mjs
*/
import { spawn } from 'node:child_process';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
import { createStub } from './ds-stub.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const APP_PORT = 8099;
const NDA_PREFIX = 'Buyer Forms - NDA';
/** Before RENAME_DATE, so the full reload runs its pre-rename leg too. */
const BOUNDARY_DAY = '2026-05-01';
const LEGACY_DAY = '2026-05-02';
const META_KEYS = [
'ds_refresh_state',
'ds_last_refresh_at',
'ds_last_refresh_result',
'ds_mirror_covers_from',
'ds_sync_state',
'ds_last_sync_at',
'ds_last_sync_result',
];
// --------------------------------------------------------------- helpers
let failures = 0;
function check(name, ok, detail = '') {
console.log(`${ok ? ' ok ' : ' FAIL '} ${name}${detail ? `${detail}` : ''}`);
if (!ok) failures += 1;
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** `npm run dev` sources .env by hand, so the script does the same. */
async function loadEnv() {
const text = await readFile(path.join(ROOT, '.env'), 'utf8').catch(() => '');
for (const line of text.split('\n')) {
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
if (!match) continue;
const value = match[2].trim().replace(/^["'](.*)["']$/, '$1');
if (process.env[match[1]] === undefined) process.env[match[1]] = value;
}
}
const seconds = (date) => Math.floor(date.getTime() / 1000);
const daysAgo = (n) => seconds(new Date(Date.now() - n * 86_400_000));
function request(id, title, createdAt, { signedAt = null, name, email } = {}) {
return {
signature_request_id: id,
title,
created_at: createdAt,
is_complete: signedAt !== null,
is_declined: false,
files_url: '',
signatures: [
{
signer_name: name,
signer_email_address: email,
status_code: signedAt ? 'signed' : 'awaiting_signature',
signed_at: signedAt,
},
],
response_data: [],
};
}
/**
* The fixture, in the order the assertions reason about:
*
* | # | request | why |
* | 0 | created now, **not in the index** | the lag: only the plain list has it |
* | 1 | created 40 days ago, signed yesterday, **stale in the index** | activity order puts it on page 1 |
* | 2.. | 200 unrelated requests | fill pages 1-2, so anything below is out of the incremental pass' reach |
* | 202 | created on the boundary day | only an inclusive `[day TO *]` returns it |
* | 203 | pre-rename title | exercises the second leg of the full reload |
*/
function buildFixture(stamp) {
const fresh = request(`req-${stamp}-fresh`, `${NDA_PREFIX} Nora Fresh - Acme Diner`, daysAgo(0), {
name: 'Nora Fresh',
email: `nora.fresh@stubtest.invalid`,
});
const oldSigned = request(
`req-${stamp}-old-signed`,
`${NDA_PREFIX} Otto Older - Old Deli`,
daysAgo(40),
{ signedAt: daysAgo(1), name: 'Otto Older', email: 'otto.older@stubtest.invalid' },
);
const boundary = request(
`req-${stamp}-boundary`,
`${NDA_PREFIX} Bea Boundary - Border Cafe`,
seconds(new Date(`${BOUNDARY_DAY}T09:00:00Z`)),
{ name: 'Bea Boundary', email: 'bea.boundary@stubtest.invalid' },
);
const legacy = request(
`req-${stamp}-legacy`,
'Buyer Forms Regina Legacy - Old Bakery',
seconds(new Date(`${LEGACY_DAY}T11:00:00Z`)),
{ name: 'Regina Legacy', email: 'regina.legacy@stubtest.invalid' },
);
// Everything the account is full of and the refresh is not interested in.
const noise = Array.from({ length: 200 }, (_, i) =>
request(`req-${stamp}-noise-${i}`, `Other Form ${i} - Someone`, daysAgo(2 + i * 0.05), {
name: `Noise ${i}`,
email: `noise${i}@stubtest.invalid`,
}),
);
/** The index copy of the freshly signed one: still pending, as it was. */
const staleIndexCopy = request(
oldSigned.signature_request_id,
oldSigned.title,
oldSigned.created_at,
{ name: 'Otto Older', email: 'otto.older@stubtest.invalid' },
);
return {
ids: {
fresh: fresh.signature_request_id,
oldSigned: oldSigned.signature_request_id,
boundary: boundary.signature_request_id,
legacy: legacy.signature_request_id,
},
fixture: [
{ request: fresh, indexed: null }, // the lag: not searchable yet at all
{ request: oldSigned, indexed: staleIndexCopy }, // the lag: stale copy
...noise.map((r) => ({ request: r, indexed: r })),
{ request: boundary, indexed: boundary },
{ request: legacy, indexed: legacy },
],
};
}
// ------------------------------------------------------------------ main
await loadEnv();
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL is missing — run this from the project root with .env in place');
process.exit(2);
}
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const stamp = String(Date.now());
const { ids, fixture } = buildFixture(stamp);
const stub = createStub(fixture);
let app = null;
let scratch = null;
let metaSnapshot = [];
const appLog = [];
const cleanup = async () => {
if (app && !app.killed) {
app.kill('SIGTERM');
await sleep(300);
}
await stub.close().catch(() => {});
await db
.query(`DELETE FROM ds_request WHERE signature_request_id LIKE 'req-%'`)
.catch((err) => console.error(`cleanup of ds_request failed: ${err.message}`));
// The meta keys go back exactly as they were, updated_at included: a test
// run must not leave "last refreshed" pointing at itself.
await db
.query('DELETE FROM app_meta WHERE key = ANY($1)', [META_KEYS])
.catch((err) => console.error(`cleanup of app_meta failed: ${err.message}`));
for (const row of metaSnapshot) {
await db
.query('INSERT INTO app_meta (key, value, updated_at) VALUES ($1, $2, $3)', [
row.key,
row.value,
row.updated_at,
])
.catch((err) => console.error(`restoring ${row.key} failed: ${err.message}`));
}
if (scratch) await rm(scratch, { recursive: true, force: true }).catch(() => {});
await db.end().catch(() => {});
};
process.on('SIGINT', async () => {
await cleanup();
process.exit(130);
});
const meta = async (key) => {
const { rows } = await db.query('SELECT value, updated_at FROM app_meta WHERE key = $1', [key]);
return rows[0] ?? null;
};
const setMeta = (key, value) =>
db.query(
`INSERT INTO app_meta (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
[key, value],
);
const mirrored = async (id) => {
const { rows } = await db.query(
'SELECT status, signed_at, created_at FROM ds_request WHERE signature_request_id = $1',
[id],
);
return rows[0] ?? null;
};
try {
metaSnapshot = (
await db.query('SELECT key, value, updated_at FROM app_meta WHERE key = ANY($1)', [META_KEYS])
).rows;
await db.query(`DELETE FROM ds_request WHERE signature_request_id LIKE 'req-%'`);
const stubUrl = await stub.listen(0);
scratch = await mkdtemp(path.join(tmpdir(), 'bizmatch-nda-'));
// ------------------------------------------------------------ the app
app = spawn('npx', ['tsx', 'src/server.ts'], {
cwd: ROOT,
env: {
...process.env,
PORT: String(APP_PORT),
DROPBOX_SIGN_API_KEY: 'stub-key',
DROPBOX_SIGN_BASE_URL: stubUrl,
NDA_ROOT: scratch,
// Keeps the sync's per-id re-check off the real mirror: this run is
// about which meta keys it writes, not about re-reading 800 rows.
SYNC_PENDING_MAX_AGE_DAYS: '0',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
for (const stream of [app.stdout, app.stderr]) {
stream.setEncoding('utf8');
stream.on('data', (chunk) => {
for (const line of chunk.split('\n').filter(Boolean)) appLog.push(line);
});
}
const base = `http://127.0.0.1:${APP_PORT}`;
const { rows: staff } = await db.query('SELECT id FROM staff ORDER BY created_at LIMIT 1');
if (!staff[0]) throw new Error('no staff row to sign in as');
const headers = { cookie: `bizmatch_staff=${staff[0].id}` };
for (let attempt = 0; ; attempt += 1) {
if (attempt > 100) throw new Error('the app did not come up');
const ok = await fetch(`${base}/api/health`)
.then((r) => r.ok)
.catch(() => false);
if (ok) break;
await sleep(300);
}
const inbox = async () =>
(await fetch(`${base}/api/nda-inbox?since=2026-01-01`, { headers })).json();
const settle = async (field) => {
for (let attempt = 0; attempt < 300; attempt += 1) {
const body = await inbox();
if (body[field] !== 'running') return body;
await sleep(500);
}
throw new Error(`${field} never settled`);
};
// ---------------------------------------- 1 · the incremental pass
console.log('\n1 · incremental refresh over a lagging search index');
await setMeta('ds_last_refresh_at', new Date(Date.now() - 3_600_000).toISOString());
await setMeta('ds_refresh_state', 'idle');
// Deliberately narrow, so the coverage assertions have something to widen.
await setMeta('ds_mirror_covers_from', '2026-12-31T00:00:00.000Z');
const searchable = stub.search(`title:"${NDA_PREFIX}" AND created:[${BOUNDARY_DAY} TO *]`);
check(
'the stub search index really lags the list',
!searchable.some((r) => r.signature_request_id === ids.fresh) &&
searchable.some((r) => r.signature_request_id === ids.boundary),
'the fresh request is in no search result, the older ones are',
);
const started = await fetch(`${base}/api/nda-inbox/refresh`, { method: 'POST', headers });
check('POST /refresh answers 202', started.status === 202, `got ${started.status}`);
const afterIncremental = await settle('refresh_state');
const incremental = afterIncremental.last_refresh_result;
check('it ran in incremental mode', incremental?.mode === 'incremental', JSON.stringify(incremental));
check(
'it read the unfiltered list, not the index',
stub.calls.filter((c) => c.endpoint === 'list' && c.query).length === 0,
`queries sent: ${stub.calls.filter((c) => c.query).length}`,
);
check(
`it walked ${2} pages of 100 unfiltered rows`,
incremental?.pages === 2 && incremental?.seen === 200,
`pages=${incremental?.pages} seen=${incremental?.seen}`,
);
const fresh = await mirrored(ids.fresh);
check(
'a request created now lands although the index does not have it',
fresh?.status === 'pending',
`mirror row: ${JSON.stringify(fresh)}`,
);
const oldSigned = await mirrored(ids.oldSigned);
check(
'an old pending signed yesterday is picked up without a sync',
oldSigned?.status === 'signed' && oldSigned?.signed_at !== null,
`mirror row: ${JSON.stringify(oldSigned)}`,
);
check(
'and it stored nothing beyond those two',
incremental?.stored === 2,
`stored=${incremental?.stored}`,
);
check(
'rows below the first two pages are out of its reach',
(await mirrored(ids.boundary)) === null && (await mirrored(ids.legacy)) === null,
'boundary and pre-rename rows are not mirrored yet',
);
// ---------------------------------------- 2 · the full reload
console.log('\n2 · full reload with inclusive bounds');
const callsBefore = stub.calls.length;
const reload = await fetch(`${base}/api/nda-inbox/refresh?since=${BOUNDARY_DAY}`, {
method: 'POST',
headers,
});
check('POST /refresh?since= answers 202', reload.status === 202, `got ${reload.status}`);
const afterFull = await settle('refresh_state');
const full = afterFull.last_refresh_result;
const queries = stub.calls.slice(callsBefore).filter((c) => c.query).map((c) => c.query);
check('it ran in full mode', full?.mode === 'full', JSON.stringify(full));
check(
'every query uses the inclusive range form',
queries.length > 0 && queries.every((q) => q.includes('created:[')) &&
!queries.some((q) => q.includes('created:{')),
queries[0] ?? 'no query sent',
);
check(
'a request created on the boundary day is included',
(await mirrored(ids.boundary)) !== null,
`${BOUNDARY_DAY}T09:00Z with since=${BOUNDARY_DAY}`,
);
check(
'the pre-rename leg ran and stored its request',
(await mirrored(ids.legacy)) !== null && full?.legacy_stored >= 1,
`legacy_stored=${full?.legacy_stored}`,
);
check(
'covers_from widened to the reloaded window',
(await meta('ds_mirror_covers_from'))?.value === `${BOUNDARY_DAY}T00:00:00.000Z`,
`covers_from=${(await meta('ds_mirror_covers_from'))?.value}`,
);
// ---------------------------------------- 3 · coverage never narrows
console.log('\n3 · covers_from only ever widens');
await setMeta('ds_mirror_covers_from', '2020-01-01T00:00:00.000Z');
await fetch(`${base}/api/nda-inbox/refresh?since=${BOUNDARY_DAY}`, { method: 'POST', headers });
await settle('refresh_state');
check(
'a narrower window does not overwrite a wider coverage',
(await meta('ds_mirror_covers_from'))?.value === '2020-01-01T00:00:00.000Z',
`covers_from=${(await meta('ds_mirror_covers_from'))?.value}`,
);
// ---------------------------------------- 4 · meta-key hygiene
console.log('\n4 · the sync never touches the refresh cursor');
const refreshBefore = await meta('ds_last_refresh_at');
const synced = await fetch(`${base}/api/nda-inbox/sync`, { method: 'POST', headers });
check('POST /sync answers 202', synced.status === 202, `got ${synced.status}`);
await settle('sync_state');
const refreshAfter = await meta('ds_last_refresh_at');
check(
'ds_last_refresh_at is untouched by a sync run',
refreshBefore?.value === refreshAfter?.value &&
String(refreshBefore?.updated_at) === String(refreshAfter?.updated_at),
`${refreshBefore?.value} @ ${refreshBefore?.updated_at} -> ${refreshAfter?.value} @ ${refreshAfter?.updated_at}`,
);
check(
'the sync stamps its own key instead',
(await meta('ds_last_sync_at'))?.value != null,
`ds_last_sync_at=${(await meta('ds_last_sync_at'))?.value}`,
);
// ---------------------------------------- the log line
console.log('\nwalk log lines the run produced:');
for (const line of appLog) {
let msg = line;
try {
msg = JSON.parse(line).msg ?? line;
} catch {
/* not JSON — a tsx or npm line */
}
if (typeof msg === 'string' && msg.includes('[nda-refresh] walk')) console.log(` ${msg}`);
}
} catch (err) {
failures += 1;
console.error(`\nrun failed: ${err.stack ?? err.message}`);
} finally {
await cleanup();
}
console.log(failures === 0 ? '\nall assertions passed' : `\n${failures} assertion(s) failed`);
process.exit(failures === 0 ? 0 : 1);