140 lines
5.5 KiB
JavaScript
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);
|
|
}
|
|
}
|