Files
bizmatch-app/scripts/ds-stub.mjs
2026-08-04 10:26:54 -05:00

140 lines
5.5 KiB
JavaScript

/**
* A stand-in for the two Dropbox Sign endpoints the NDA inbox uses, with the
* one property that broke the refresh in production: **the search index lags
* behind the list**.
*
* `GET /v3/signature_request/list` answers from two different sources:
*
* * without `query=` — the live list, ordered by last activity (signed_at,
* falling back to created_at), exactly like the real endpoint;
* * with `query=` — the *index*, which only holds the snapshot a fixture entry
* declares in `indexed`. A brand-new request is missing from it entirely,
* and a request signed a minute ago is still in it as pending. That is what
* an incremental refresh must not depend on.
*
* The range syntax is parsed for real, brackets and all, so `[a TO b]`
* (inclusive) and `{a TO b}` (exclusive) actually behave differently — which
* is what makes the boundary-day assertion meaningful.
*
* Run standalone for manual pokes:
* node scripts/ds-stub.mjs --port 8099 --fixture some.json
*/
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const dayOf = (unixSeconds) => new Date(unixSeconds * 1000).toISOString().slice(0, 10);
/** signed_at when there is one, created_at otherwise — the list's sort key. */
const activityOf = (request) => request.signatures?.[0]?.signed_at ?? request.created_at;
/**
* The subset of the query language the refresh sends:
* `title:"<phrase>" AND created:[<day> TO <day|*>]`, either bracket style.
*/
export function matchesQuery(request, queryString) {
const phrase = /title:"([^"]*)"/.exec(queryString)?.[1];
if (phrase && !(request.title ?? '').includes(phrase)) return false;
const range = /created:([[{])\s*(\S+)\s+TO\s+(\S+?)\s*([\]}])/.exec(queryString);
if (range) {
const [, open, from, to, close] = range;
const day = dayOf(request.created_at);
// '[' includes the boundary day, '{' excludes it — the whole point.
if (from !== '*' && (open === '[' ? day < from : day <= from)) return false;
if (to !== '*' && (close === ']' ? day > to : day >= to)) return false;
}
return true;
}
/**
* `fixture` is a list of `{request, indexed}`: the truth the list and the
* per-id endpoint serve, and the possibly stale (or missing) copy the search
* index has of it.
*/
export function createStub(fixture) {
/** Every call the app made — the test asserts on what it asked for. */
const calls = [];
const byActivity = (a, b) => activityOf(b) - activityOf(a);
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://stub');
const send = (status, body) => {
const payload = JSON.stringify(body);
res.writeHead(status, { 'content-type': 'application/json' });
res.end(payload);
};
if (url.pathname === '/v3/signature_request/list') {
const page = Number(url.searchParams.get('page') ?? '1');
const pageSize = Number(url.searchParams.get('page_size') ?? '20');
const queryString = url.searchParams.get('query');
calls.push({ endpoint: 'list', page, query: queryString });
const source = queryString
? fixture
.map((entry) => entry.indexed)
.filter(Boolean)
.filter((request) => matchesQuery(request, queryString))
: fixture.map((entry) => entry.request);
const ordered = [...source].sort(byActivity);
const numPages = Math.max(1, Math.ceil(ordered.length / pageSize));
const slice = ordered.slice((page - 1) * pageSize, page * pageSize);
return send(200, {
signature_requests: slice,
list_info: { page, num_pages: numPages, num_results: ordered.length },
});
}
const single = /^\/v3\/signature_request\/([^/]+)$/.exec(url.pathname);
if (single) {
const id = decodeURIComponent(single[1]);
calls.push({ endpoint: 'get', id });
const entry = fixture.find((e) => e.request.signature_request_id === id);
// Unknown ids 404 like the real API — the sync walks over real rows the
// stub knows nothing about and must simply report them as failures.
if (!entry) return send(404, { error: { error_msg: `no such request: ${id}` } });
return send(200, { signature_request: entry.request });
}
return send(404, { error: { error_msg: `unhandled path: ${url.pathname}` } });
});
return {
calls,
/** Resolves to the base URL once it is listening. */
listen(port = 0) {
return new Promise((resolve) => {
server.listen(port, '127.0.0.1', () =>
resolve(`http://127.0.0.1:${server.address().port}`),
);
});
},
close: () => new Promise((resolve) => server.close(resolve)),
/** What a searching walk would have found — the lag, made visible. */
search: (queryString) =>
fixture
.map((entry) => entry.indexed)
.filter(Boolean)
.filter((request) => matchesQuery(request, queryString)),
};
}
// -------------------------------------------------------- standalone mode
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/^.*\//, ''))) {
const args = process.argv.slice(2);
const arg = (name, fallback) => {
const at = args.indexOf(`--${name}`);
return at === -1 ? fallback : args[at + 1];
};
const file = arg('fixture');
if (file) {
const fixture = JSON.parse(await readFile(file, 'utf8'));
const stub = createStub(fixture);
console.log(`[ds-stub] listening on ${await stub.listen(Number(arg('port', '8099')))}`);
} else {
console.error('usage: node scripts/ds-stub.mjs --fixture <file.json> [--port 8099]');
process.exit(2);
}
}