/** * 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:"" AND created:[ TO ]`, 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 [--port 8099]'); process.exit(2); } }