From dc9a498418b206966f054d8da6cc752ddd7317d5 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:27:50 +0100 Subject: [PATCH 01/36] chore(core): implement spec 0.2.0 Spec 0.2.0 adds the client contract and the fixtures that the mock server reads. Its grammar and vectors are the same as before, so the core passes them unchanged and only its SPEC_VERSION moves. CONTEXT.md takes the new terms of the spec's glossary. Signed-off-by: MobileMage --- .changeset/core-spec-0-2-0.md | 5 +++++ CONTEXT.md | 14 +++++++++++++- packages/core/README.md | 6 +++--- packages/core/etc/core.api.md | 2 +- packages/core/src/version.ts | 4 ++-- spec | 2 +- 6 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 .changeset/core-spec-0-2-0.md diff --git a/.changeset/core-spec-0-2-0.md b/.changeset/core-spec-0-2-0.md new file mode 100644 index 0000000..ae63db9 --- /dev/null +++ b/.changeset/core-spec-0-2-0.md @@ -0,0 +1,5 @@ +--- +'@gatepost/core': patch +--- + +The package implements spec 0.2.0. That version adds the client contract and leaves the grammar as it was, so every function gives the same results as before. diff --git a/CONTEXT.md b/CONTEXT.md index 64a0f59..fcc8182 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -122,9 +122,21 @@ _Avoid_: using this word for a Gatepost component ### Gatepost **Spec**: -The `spec` repo. It holds the grammar, the data, the vectors and the standards. The API fixtures and the completed OpenAPI file come later. +The `spec` repo. It holds the grammar, the client contract, the data, the vectors, the fixtures, the completed OpenAPI file and the standards. _Avoid_: schema, contract (for the repo) +**Contract scenario**: +One shared test case in `spec/contract/`. It names the calls, the mock server's responses and the outcome that every client must reach. +_Avoid_: contract test (for the file), vector, fixture + +**Attempt**: +One request that a client sends for one call. A retry is the second or third attempt. +_Avoid_: try, request (when you count them) + +**Evidence**: +How Gatepost knows the shape of a gateway response: `observed` in its own calls, `documented` by NIPOST only, or `assumed` by the mock server. +_Avoid_: source, confidence, proof + **Vector**: One shared test case in `spec/vectors/` that every SDK must pass. _Avoid_: fixture, example, golden file diff --git a/packages/core/README.md b/packages/core/README.md index c6e2188..6ea4ccf 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -97,7 +97,7 @@ parse(code.padEnd(65)).ok; // false ### Clean typed text -`normalize` cleans text that a user typed or pasted. It applies Unicode NFKC, removes white space, hyphens, dashes, full stops and some zero-width characters, and changes ASCII letters to upper case. It does not check the result. It has no input limit, so call `parse` for text from an untrusted source. The result depends on the Unicode version of the runtime. Spec 0.1.0 names Unicode 17.0, which Node 22.22.2 and later 22.x releases have, and so do Node 24.15.0 and later. +`normalize` cleans text that a user typed or pasted. It applies Unicode NFKC, removes white space, hyphens, dashes, full stops and some zero-width characters, and changes ASCII letters to upper case. It does not check the result. It has no input limit, so call `parse` for text from an untrusted source. The result depends on the Unicode version of the runtime. The spec names Unicode 17.0, which Node 22.22.2 and later 22.x releases have, and so do Node 24.15.0 and later. ```ts import { normalize } from '@gatepost/core'; @@ -180,14 +180,14 @@ if (building.ok) { | ------------- | ----------------------------------- | | Node | 22 or later, or any current browser | | Module format | ESM only | -| Gatepost spec | 0.1.0 | +| Gatepost spec | 0.2.0 | The package exports `SPEC_VERSION`, the version of the Gatepost spec that it implements. ```ts import { SPEC_VERSION } from '@gatepost/core'; -SPEC_VERSION; // '0.1.0' +SPEC_VERSION; // '0.2.0' ``` ## Docs diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index 37eab32..ef7b226 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -68,7 +68,7 @@ export interface Segments { } // @public -export const SPEC_VERSION = "0.1.0"; +export const SPEC_VERSION = "0.2.0"; // @public export function stateName(stateCode: string): string | null; diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index 9a6a64b..ab60c2f 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -8,7 +8,7 @@ * ```ts * import { SPEC_VERSION } from '@gatepost/core'; * - * SPEC_VERSION; // '0.1.0' + * SPEC_VERSION; // '0.2.0' * ``` */ -export const SPEC_VERSION = '0.1.0'; +export const SPEC_VERSION = '0.2.0'; diff --git a/spec b/spec index ca4afdd..8ab969f 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit ca4afddf65e2897c71bb49ef7bed71b1a9741408 +Subproject commit 8ab969fcc34cf1ef0e54b01e0fe07bac217de8f4 From 26844401be20633ed58b784746caec2e1c6dab04 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:30:34 +0100 Subject: [PATCH 02/36] feat(mock): read the fixtures, keys and scenarios from the spec The mock server answers from the spec's files, so a new observation changes data and no code. It reads them once at start and stops with the name of a file that has the wrong shape, so a broken spec change cannot serve a wrong body in silence. The package runs on Node, so the lint config lets it import Node modules, and a test pins that exception. Signed-off-by: MobileMage --- .changeset/config.json | 3 +- commitlint.config.js | 6 +- eslint.config.js | 9 + packages/mock-server/package.json | 19 ++ packages/mock-server/src/reply.ts | 55 ++++ packages/mock-server/src/spec-files.ts | 252 +++++++++++++++++++ packages/mock-server/test/mock-request.ts | 64 +++++ packages/mock-server/test/spec-files.test.ts | 128 ++++++++++ packages/mock-server/tsconfig.json | 9 + packages/mock-server/vitest.config.ts | 16 ++ scripts/eslint-mock-server.test.mjs | 56 +++++ 11 files changed, 615 insertions(+), 2 deletions(-) create mode 100644 packages/mock-server/package.json create mode 100644 packages/mock-server/src/reply.ts create mode 100644 packages/mock-server/src/spec-files.ts create mode 100644 packages/mock-server/test/mock-request.ts create mode 100644 packages/mock-server/test/spec-files.test.ts create mode 100644 packages/mock-server/tsconfig.json create mode 100644 packages/mock-server/vitest.config.ts create mode 100644 scripts/eslint-mock-server.test.mjs diff --git a/.changeset/config.json b/.changeset/config.json index ad9ea29..3692d4d 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,5 +8,6 @@ "ignore": [], "fixed": [], "linked": [], - "updateInternalDependencies": "patch" + "updateInternalDependencies": "patch", + "privatePackages": { "version": false, "tag": false } } diff --git a/commitlint.config.js b/commitlint.config.js index d224421..30febe4 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -5,7 +5,11 @@ export default { rules: { 'header-max-length': [2, 'always', 72], 'scope-empty': [2, 'never'], - 'scope-enum': [2, 'always', ['core', 'client', 'field', 'react', 'repo', 'deps', 'release']], + 'scope-enum': [ + 2, + 'always', + ['core', 'client', 'field', 'react', 'mock', 'repo', 'deps', 'release'], + ], 'signed-off-by': [2, 'always', 'Signed-off-by:'], }, }; diff --git a/eslint.config.js b/eslint.config.js index 99d4094..31dec61 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -119,6 +119,15 @@ export default defineConfig( ], }, }, + { + // The mock server is a dev tool that runs on Node, not core code (CS-2), so it can use Node. + files: ['packages/mock-server/src/**/*.ts'], + rules: { + 'no-restricted-imports': 'off', + 'no-restricted-globals': 'off', + 'no-restricted-properties': 'off', + }, + }, { files: ['packages/*/test/**/*.ts'], rules: { diff --git a/packages/mock-server/package.json b/packages/mock-server/package.json new file mode 100644 index 0000000..6775566 --- /dev/null +++ b/packages/mock-server/package.json @@ -0,0 +1,19 @@ +{ + "name": "@gatepost/mock-server", + "version": "0.1.0", + "private": true, + "description": "Unofficial. Not made or endorsed by NIPOST. A mock of NIPOST's gateway.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + }, + "scripts": { + "start": "node src/main.ts", + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run --coverage" + } +} diff --git a/packages/mock-server/src/reply.ts b/packages/mock-server/src/reply.ts new file mode 100644 index 0000000..666bef1 --- /dev/null +++ b/packages/mock-server/src/reply.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** A request as the mock server reads it. Header names are in lower case. */ +export interface MockRequest { + readonly method: string; + readonly path: string; + readonly query: URLSearchParams; + readonly headers: Readonly>; + readonly body: string; +} + +/** + * What the mock server does with a request: send a response, keep the connection open with no + * answer, or close the connection with no answer. `delayMs` is the wait before it acts. + */ +export type Reply = + | { + readonly kind: 'send'; + readonly status: number; + readonly headers: Readonly>; + readonly body: string; + readonly delayMs: number; + } + | { readonly kind: 'hang' } + | { readonly kind: 'drop'; readonly delayMs: number }; + +/** A reply that sends a response. */ +export type SendReply = Extract; + +const JSON_TYPE = { 'Content-Type': 'application/json' }; + +/** + * Builds a reply with a JSON body. The gateway ends each body with a line feed, and so does + * this reply. + * + * @param status - The HTTP status. + * @param body - The value to send as JSON. + * @param headers - More response headers. + * @returns The reply, with no delay. + * @internal + */ +export function jsonReply( + status: number, + body: unknown, + headers: Readonly> = {}, +): SendReply { + return { + kind: 'send', + status, + headers: { ...JSON_TYPE, ...headers }, + body: `${JSON.stringify(body)}\n`, + delayMs: 0, + }; +} diff --git a/packages/mock-server/src/spec-files.ts b/packages/mock-server/src/spec-files.ts new file mode 100644 index 0000000..d423888 --- /dev/null +++ b/packages/mock-server/src/spec-files.ts @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { jsonReply, type Reply } from './reply.ts'; + +/** One gateway response from `spec/fixtures`. */ +export interface Fixture { + readonly status: number; + readonly body: unknown; +} + +/** One key from `spec/fixtures/keys.json`, and the way that the mock server treats it. */ +export interface MockKey { + readonly key: string; + readonly lookupLevel: number; + readonly lookupScope: boolean; + readonly credits: boolean; + readonly rateLimited: boolean; + readonly origins: readonly string[] | null; +} + +/** One segment of a postcode, with its place in the compact form. */ +export interface SegmentRule { + readonly name: string; + readonly start: number; + readonly end: number; + readonly characters: string; + readonly minimum: number | null; +} + +/** Everything that the mock server reads from the spec folder. */ +export interface SpecFiles { + readonly fixtures: ReadonlyMap; + readonly keys: ReadonlyMap; + readonly requestsPerMinute: number; + readonly scenarios: ReadonlyMap; + readonly states: readonly string[]; + readonly segments: readonly SegmentRule[]; +} + +type JsonObject = Readonly>; + +function readJson(folder: string, name: string): unknown { + return JSON.parse(readFileSync(join(folder, name), 'utf8')); +} + +function objectIn(value: unknown, where: string): JsonObject { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${where} must be a JSON object.`); + } + return value as JsonObject; +} + +function listIn(value: unknown, where: string): readonly unknown[] { + if (!Array.isArray(value)) { + throw new TypeError(`${where} must be a JSON array.`); + } + return value; +} + +function field( + record: JsonObject, + name: string, + where: string, + check: (value: unknown) => value is T, +): T { + const value = record[name]; + if (!check(value)) { + throw new TypeError(`${where}: the field ${name} has the wrong type.`); + } + return value; +} + +const isNumber = (value: unknown): value is number => typeof value === 'number'; +const isText = (value: unknown): value is string => typeof value === 'string'; +const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'; +const isTextList = (value: unknown): value is string[] => + Array.isArray(value) && value.every(isText); +const isTextListOrNull = (value: unknown): value is string[] | null => + value === null || isTextList(value); + +function textRecord(value: unknown, where: string): Readonly> { + const record = objectIn(value, where); + for (const name of Object.keys(record)) { + field(record, name, where, isText); + } + return record as Readonly>; +} + +function readFixtures(folder: string): Map { + const fixtures = new Map(); + const groups = readdirSync(folder, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), + ); + for (const group of groups) { + for (const file of readdirSync(join(folder, group.name)).filter((name) => + name.endsWith('.json'), + )) { + const name = `${group.name}/${file.slice(0, -'.json'.length)}`; + const record = objectIn(readJson(folder, `${name}.json`), `fixtures/${name}.json`); + const status = field(record, 'status', `fixtures/${name}.json`, isNumber); + fixtures.set(name, { status, body: record['body'] }); + } + } + return fixtures; +} + +function readKeys(folder: string): { keys: Map; requestsPerMinute: number } { + const where = 'fixtures/keys.json'; + const document = objectIn(readJson(folder, 'keys.json'), where); + const keys = new Map(); + for (const entry of listIn(document['keys'], where)) { + const record = objectIn(entry, where); + const key = field(record, 'key', where, isText); + keys.set(key, { + key, + lookupLevel: field(record, 'lookupLevel', where, isNumber), + lookupScope: field(record, 'lookupScope', where, isBoolean), + credits: field(record, 'credits', where, isBoolean), + rateLimited: field(record, 'rateLimited', where, isBoolean), + origins: field(record, 'origins', where, isTextListOrNull), + }); + } + return { keys, requestsPerMinute: field(document, 'requestsPerMinute', where, isNumber) }; +} + +function scenarioReply( + value: unknown, + fixtures: ReadonlyMap, + where: string, +): Reply { + const response = objectIn(value, where); + const delayMs = + response['delayMs'] === undefined ? 0 : field(response, 'delayMs', where, isNumber); + if (response['hang'] === true) { + return { kind: 'hang' }; + } + if (response['drop'] === true) { + return { kind: 'drop', delayMs }; + } + const headers = textRecord(response['headers'] ?? {}, where); + if (typeof response['fixture'] === 'string') { + const fixture = fixtures.get(response['fixture']); + if (fixture === undefined) { + throw new TypeError(`${where}: no fixture is named ${response['fixture']}.`); + } + return { ...jsonReply(fixture.status, fixture.body, headers), delayMs }; + } + const status = field(response, 'status', where, isNumber); + if (typeof response['text'] === 'string') { + const textHeaders = { 'Content-Type': 'text/plain; charset=utf-8', ...headers }; + return { kind: 'send', status, headers: textHeaders, body: response['text'], delayMs }; + } + return { ...jsonReply(status, response['body'], headers), delayMs }; +} + +function readScenarios( + folder: string, + fixtures: ReadonlyMap, +): Map { + const scenarios = new Map(); + for (const file of readdirSync(folder).filter((name) => name.endsWith('.json'))) { + const where = `contract/${file}`; + const scenario = objectIn(readJson(folder, file), where); + const id = field(scenario, 'id', where, isText); + const responses = listIn(scenario['responses'], where); + scenarios.set( + id, + responses.map((response) => scenarioReply(response, fixtures, where)), + ); + } + return scenarios; +} + +function readSegments(folder: string): SegmentRule[] { + const where = 'data/format.json'; + const document = objectIn(readJson(folder, 'format.json'), where); + let start = 0; + return listIn(document['segments'], where).map((entry) => { + const record = objectIn(entry, where); + const length = field(record, 'length', where, isNumber); + const minimum = + record['minimum'] === undefined ? null : field(record, 'minimum', where, isNumber); + const rule = { + name: field(record, 'name', where, isText), + start, + end: start + length, + characters: field(record, 'characters', where, isText), + minimum, + }; + start += length; + return rule; + }); +} + +function readStates(folder: string): string[] { + const where = 'data/states.json'; + const document = objectIn(readJson(folder, 'states.json'), where); + return listIn(document['states'], where).map((entry) => + field(objectIn(entry, where), 'code', where, isText), + ); +} + +/** + * Reads the fixtures, the keys, the scenarios and the postcode data from a spec folder. A file + * with the wrong shape stops the mock server, with a message that names the file. + * + * @param specDir - The spec folder, such as the `spec` submodule. + * @returns The files, ready for the mock server. + * @throws TypeError when a file has the wrong shape, and SyntaxError when it is not JSON. + * @internal + */ +export function readSpecFiles(specDir: string): SpecFiles { + const fixtures = readFixtures(join(specDir, 'fixtures')); + const { keys, requestsPerMinute } = readKeys(join(specDir, 'fixtures')); + return { + fixtures, + keys, + requestsPerMinute, + scenarios: readScenarios(join(specDir, 'contract'), fixtures), + states: readStates(join(specDir, 'data')), + segments: readSegments(join(specDir, 'data')), + }; +} + +/** + * Builds the reply of a fixture. Each change replaces one field of the body's `data` object. + * + * @param files - The spec files. + * @param name - The fixture's path under `spec/fixtures`, without `.json`. + * @param changes - The fields of `data` to replace, such as the echoed postcode. + * @returns The reply. + * @throws Error when the spec has no such fixture, or when changes meet a body with no `data`. + * @internal + */ +export function fixtureReply( + files: SpecFiles, + name: string, + changes: Readonly> = {}, +): Reply { + const fixture = files.fixtures.get(name); + if (fixture === undefined) { + throw new Error(`The spec has no fixture ${name}.`); + } + if (Object.keys(changes).length === 0) { + return jsonReply(fixture.status, fixture.body); + } + const body = objectIn(fixture.body, `fixtures/${name}.json`); + const data = objectIn(body['data'], `fixtures/${name}.json`); + return jsonReply(fixture.status, { ...body, data: { ...data, ...changes } }); +} diff --git a/packages/mock-server/test/mock-request.ts b/packages/mock-server/test/mock-request.ts new file mode 100644 index 0000000..b218c0e --- /dev/null +++ b/packages/mock-server/test/mock-request.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { fileURLToPath } from 'node:url'; +import type { MockRequest, Reply } from '../src/reply.ts'; +import { readSpecFiles, type SpecFiles } from '../src/spec-files.ts'; + +/** The spec submodule of the js repo. */ +export const SPEC_DIR: string = fileURLToPath(new URL('../../../spec/', import.meta.url)); + +/** The spec files, read once for all tests. */ +export const FILES: SpecFiles = readSpecFiles(SPEC_DIR); + +/** A response that a test can compare: its status, its headers and its parsed JSON body. */ +export interface SentResponse { + readonly status: number; + readonly headers: Readonly>; + readonly body: unknown; +} + +/** + * Builds a request as the server would read it. + * + * @param target - The path and the query, such as `/v1/lookup?code=FC01Z99ZZ01`. + * @param init - The method, the headers with names in lower case, and the body. + * @returns The request. + */ +export function mockRequest( + target: string, + init: { method?: string; headers?: Record; body?: string } = {}, +): MockRequest { + const url = new URL(target, 'http://mock.invalid'); + return { + method: init.method ?? 'GET', + path: url.pathname, + query: url.searchParams, + headers: init.headers ?? {}, + body: init.body ?? '', + }; +} + +/** + * Reads a reply that sends a response, and fails the test for any other reply. + * + * @param reply - The reply. + * @returns The status, the headers and the JSON body, or null for an empty body. + */ +export function sent(reply: Reply): SentResponse { + if (reply.kind !== 'send') { + throw new Error(`The reply is ${reply.kind}, not a response.`); + } + const body: unknown = reply.body === '' ? null : JSON.parse(reply.body); + return { status: reply.status, headers: reply.headers, body }; +} + +/** + * Gives the `error.code` of a response body. + * + * @param response - The response. + * @returns The code. + */ +export function errorCode(response: SentResponse): unknown { + const body = response.body as { error?: { code?: unknown } } | null; + return body?.error?.code; +} diff --git a/packages/mock-server/test/spec-files.test.ts b/packages/mock-server/test/spec-files.test.ts new file mode 100644 index 0000000..bff9f9b --- /dev/null +++ b/packages/mock-server/test/spec-files.test.ts @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { fixtureReply, readSpecFiles } from '../src/spec-files.ts'; +import { FILES, SPEC_DIR, sent } from './mock-request.ts'; + +const folders: string[] = []; + +// A copy of the spec folder in which one file has new text. +function specWith(file: string, text: string): string { + const folder = mkdtempSync(join(tmpdir(), 'gatepost-spec-')); + folders.push(folder); + for (const part of ['data', 'fixtures', 'contract']) { + cpSync(join(SPEC_DIR, part), join(folder, part), { recursive: true }); + } + writeFileSync(join(folder, file), text); + return folder; +} + +afterEach(() => { + for (const folder of folders.splice(0)) { + rmSync(folder, { recursive: true }); + } +}); + +describe('readSpecFiles', () => { + it('reads each key of keys.json with its behaviour', () => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures/keys.json'), 'utf8')) as { + keys: { key: string }[]; + }; + expect([...FILES.keys.keys()]).toEqual(keys.keys.map((entry) => entry.key)); + expect(FILES.keys.get('nipost_pk_test_mock')).toEqual({ + key: 'nipost_pk_test_mock', + lookupLevel: 3, + lookupScope: true, + credits: true, + rateLimited: false, + origins: ['http://localhost:3000'], + }); + expect(FILES.requestsPerMinute).toBe(600); + }); + + it('places each segment in the compact form', () => { + const places = FILES.segments.map((rule) => [rule.name, rule.start, rule.end, rule.minimum]); + expect(places).toEqual([ + ['state', 0, 2, null], + ['lga', 2, 4, 1], + ['district', 4, 7, null], + ['area', 7, 9, null], + ['unit', 9, 11, 1], + ]); + }); + + it('reads the 37 state codes', () => { + expect(FILES.states).toHaveLength(37); + expect(FILES.states).toContain('FC'); + }); + + it('reads every scenario in the contract folder, and does not use a fixed list', () => { + const ids = readdirSync(join(SPEC_DIR, 'contract')) + .filter((name) => name.endsWith('.json')) + .map((name) => name.slice(0, -'.json'.length)); + expect([...FILES.scenarios.keys()].sort()).toEqual(ids.sort()); + expect(ids.length).toBeGreaterThanOrEqual(69); + }); + + it('turns each response of a scenario into a reply', () => { + expect(FILES.scenarios.get('lookup-timeout')).toEqual([{ kind: 'hang' }]); + expect(FILES.scenarios.get('lookup-network-error')).toEqual([{ kind: 'drop', delayMs: 0 }]); + const [first] = FILES.scenarios.get('lookup-shared-request') ?? []; + expect(first).toMatchObject({ kind: 'send', status: 200, delayMs: 100 }); + const [page] = FILES.scenarios.get('lookup-malformed-body') ?? []; + expect(page).toMatchObject({ + kind: 'send', + status: 200, + headers: { 'Content-Type': 'text/html' }, + }); + }); + + it('adds the headers of a scenario response to a fixture', () => { + const [limited] = FILES.scenarios.get('lookup-retry-after') ?? []; + expect(limited).toMatchObject({ + status: 429, + headers: { 'Content-Type': 'application/json', 'Retry-After': '1' }, + }); + }); + + it('stops at a fixture with no status, and names the file', () => { + const folder = specWith('fixtures/lookup/invalid.json', '{"body": {}}'); + expect(() => readSpecFiles(folder)).toThrow( + 'fixtures/lookup/invalid.json: the field status has the wrong type.', + ); + }); + + it('stops at a scenario that names a fixture that does not exist', () => { + const scenario = { id: 'probe', responses: [{ fixture: 'lookup/valid-level-9' }] }; + const folder = specWith('contract/probe.json', JSON.stringify(scenario)); + expect(() => readSpecFiles(folder)).toThrow( + 'contract/probe.json: no fixture is named lookup/valid-level-9.', + ); + }); + + it('stops at a key file that is not JSON', () => { + expect(() => readSpecFiles(specWith('fixtures/keys.json', '{'))).toThrow(SyntaxError); + }); +}); + +describe('fixtureReply', () => { + it('replaces fields of data and keeps the rest of the body', () => { + const reply = fixtureReply(FILES, 'lookup/not-found', { postcode: 'FC01Z99ZZ09' }); + expect(sent(reply)).toEqual({ + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + data: { postcode: 'FC01Z99ZZ09', valid: false, status: 'not_found', verified: false }, + }, + }); + }); + + it('fails for a fixture that the spec does not have', () => { + expect(() => fixtureReply(FILES, 'lookup/valid-level-9')).toThrow( + 'The spec has no fixture lookup/valid-level-9.', + ); + }); +}); diff --git a/packages/mock-server/tsconfig.json b/packages/mock-server/tsconfig.json new file mode 100644 index 0000000..06cbf5e --- /dev/null +++ b/packages/mock-server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true + }, + "include": ["src", "test"] +} diff --git a/packages/mock-server/vitest.config.ts b/packages/mock-server/vitest.config.ts new file mode 100644 index 0000000..47661ad --- /dev/null +++ b/packages/mock-server/vitest.config.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + // main.ts runs only as a program. test/main.test.ts starts it in a child process. + exclude: ['src/main.ts'], + thresholds: { branches: 90, functions: 90, lines: 90, statements: 90 }, + }, + }, +}); diff --git a/scripts/eslint-mock-server.test.mjs b/scripts/eslint-mock-server.test.mjs new file mode 100644 index 0000000..a25dd9b --- /dev/null +++ b/scripts/eslint-mock-server.test.mjs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The mock server is a dev tool that runs on Node, so the lint config lets its source use Node. +// The other rules stay, such as TS-12 on console calls. This file has its own process, because +// typescript-eslint keeps one project service for each process, and eslint-config.test.mjs +// sets that service up for the core package. +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { fileURLToPath, URL } from 'node:url'; +import { ESLint } from 'eslint'; + +const ROOT = fileURLToPath(new URL('../', import.meta.url)); +const SOURCE_FILE = 'packages/mock-server/src/probe.ts'; + +// The file does not exist. The project service lints it with the options of the package. +const eslint = new ESLint({ + cwd: ROOT, + overrideConfig: { + files: ['packages/mock-server/**/*.ts'], + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: [SOURCE_FILE], + defaultProject: 'packages/mock-server/tsconfig.json', + }, + }, + }, + }, +}); + +async function ruleIds(...rows) { + const [report] = await eslint.lintText(`${rows.join('\n')}\n`, { filePath: SOURCE_FILE }); + return report.messages.map((reported) => reported.ruleId); +} + +describe('the lint config in the mock server', () => { + it('allows a Node module and the process global', async () => { + const found = await ruleIds( + "import { createServer, type Server } from 'node:http';", + "import process from 'node:process';", + '/** The server, on the port that PORT names. */', + 'export const server: Server = createServer().listen(Number(process.env.PORT));', + ); + assert.deepEqual(found, []); + }); + + it('still rejects a console call, as TS-12 says', async () => { + const found = await ruleIds( + '/** Says hello. */', + 'export function hello(): void {', + " console.info('hi');", + '}', + ); + assert.ok(found.includes('no-console'), JSON.stringify(found)); + }); +}); From 85e6f1562d93cb029cfda2af6da536cf2097b4e4 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:34:24 +0100 Subject: [PATCH 03/36] feat(mock): answer the gateway endpoints from the fixtures Client tests and the docs playground need answers in the gateway's shapes, with its keys, levels, errors and rate-limit headers. Each answer comes from a fixture, and a test checks each body that the code builds or changes against the spec's OpenAPI file. The rules follow the calls of 2 Oct 2026 where the docs disagree: a level above the key's gets 403, and an empty autocomplete query gets no answer. Signed-off-by: MobileMage --- package.json | 4 +- packages/mock-server/src/assembly.ts | 65 ++++++++++++ packages/mock-server/src/gateway.ts | 73 +++++++++++++ packages/mock-server/src/lookup.ts | 62 +++++++++++ packages/mock-server/src/postcodes.ts | 60 +++++++++++ packages/mock-server/src/rate-limit.ts | 23 ++++ packages/mock-server/src/reply.ts | 23 ++++ packages/mock-server/src/search.ts | 102 ++++++++++++++++++ packages/mock-server/src/spec-files.ts | 19 ++++ packages/mock-server/test/assembly.test.ts | 48 +++++++++ packages/mock-server/test/gateway.test.ts | 103 ++++++++++++++++++ packages/mock-server/test/lookup.test.ts | 99 ++++++++++++++++++ packages/mock-server/test/openapi.test.ts | 99 ++++++++++++++++++ packages/mock-server/test/search.test.ts | 116 +++++++++++++++++++++ pnpm-lock.yaml | 19 +++- 15 files changed, 911 insertions(+), 4 deletions(-) create mode 100644 packages/mock-server/src/assembly.ts create mode 100644 packages/mock-server/src/gateway.ts create mode 100644 packages/mock-server/src/lookup.ts create mode 100644 packages/mock-server/src/postcodes.ts create mode 100644 packages/mock-server/src/rate-limit.ts create mode 100644 packages/mock-server/src/search.ts create mode 100644 packages/mock-server/test/assembly.test.ts create mode 100644 packages/mock-server/test/gateway.test.ts create mode 100644 packages/mock-server/test/lookup.test.ts create mode 100644 packages/mock-server/test/openapi.test.ts create mode 100644 packages/mock-server/test/search.test.ts diff --git a/package.json b/package.json index 60ca3a9..8492343 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@stryker-mutator/core": "^10.0.0", "@types/node": "^22.20.4", "@vitest/coverage-v8": "^5.0.3", + "ajv": "^8.20.0", "eslint": "^10.11.0", "eslint-plugin-jsdoc": "^65.0.1", "fast-check": "^4.10.2", @@ -51,6 +52,7 @@ "tsdown": "^0.23.0", "typescript": "6.0.3", "typescript-eslint": "^8.71.0", - "vitest": "^5.0.3" + "vitest": "^5.0.3", + "yaml": "^2.9.1" } } diff --git a/packages/mock-server/src/assembly.ts b/packages/mock-server/src/assembly.ts new file mode 100644 index 0000000..c995e6f --- /dev/null +++ b/packages/mock-server/src/assembly.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { compactCode, isWellFormed } from './postcodes.ts'; +import { jsonReply, type MockRequest, type Reply } from './reply.ts'; +import { fixtureReply, type SpecFiles } from './spec-files.ts'; + +function parts(compact: string, files: SpecFiles): string[] { + return files.segments.map((rule) => compact.slice(rule.start, rule.end)); +} + +function segmentTexts(body: string, files: SpecFiles): string[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + // A body that is not JSON is a bad request, as a missing field is. + return null; + } + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + const record = parsed as Readonly>; + const texts = files.segments.map((rule) => { + const text = record[rule.name]; + return typeof text === 'string' ? text.toUpperCase().padStart(rule.end - rule.start, '0') : ''; + }); + return texts.includes('') ? null : texts; +} + +/** + * Answers `POST /v1/assembly/assemble`. It changes letters to upper case, and pads each segment + * to its length with zeros, as the gateway does with `"lga": "1"`. + * + * @param request - The request. + * @param files - The spec files. + * @returns The reply. + * @internal + */ +export function assemble(request: MockRequest, files: SpecFiles): Reply { + const texts = segmentTexts(request.body, files); + if (texts === null || !isWellFormed(texts.join(''), files)) { + return fixtureReply(files, 'errors/invalid-request'); + } + return jsonReply(200, { + data: { compact: texts.join(''), display: texts.join(' '), postcode: texts.join('-') }, + }); +} + +/** + * Answers `GET /v1/assembly/disassemble`. + * + * @param request - The request. + * @param files - The spec files. + * @returns The reply. + * @internal + */ +export function disassemble(request: MockRequest, files: SpecFiles): Reply { + const compact = compactCode(request.query.get('code') ?? ''); + if (!isWellFormed(compact, files)) { + return fixtureReply(files, 'errors/invalid-request'); + } + const texts = parts(compact, files); + const data = Object.fromEntries(files.segments.map((rule, index) => [rule.name, texts[index]])); + return jsonReply(200, { data }); +} diff --git a/packages/mock-server/src/gateway.ts b/packages/mock-server/src/gateway.ts new file mode 100644 index 0000000..1507119 --- /dev/null +++ b/packages/mock-server/src/gateway.ts @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { assemble, disassemble } from './assembly.ts'; +import { lookup } from './lookup.ts'; +import { createRateLimiter } from './rate-limit.ts'; +import { emptyReply, withHeaders, type MockRequest, type Reply } from './reply.ts'; +import { autocomplete, nearby, reverse } from './search.ts'; +import { fixtureReply, type MockKey, type SpecFiles } from './spec-files.ts'; + +type Route = (request: MockRequest, files: SpecFiles, key: MockKey) => Reply; + +// SEC-4: the mock server serves the documented gateway paths and no other. +const ROUTES: ReadonlyMap = new Map([ + ['GET /v1/lookup', lookup], + ['GET /v1/search/reverse', reverse], + ['GET /v1/search/nearby', nearby], + ['GET /v1/search/autocomplete', autocomplete], + ['POST /v1/assembly/assemble', assemble], + ['GET /v1/assembly/disassemble', disassemble], +]); + +function keyedReply(request: MockRequest, files: SpecFiles, key: MockKey, left: number): Reply { + const route = ROUTES.get(`${request.method} ${request.path}`); + const origin = request.headers['origin']; + if (route === undefined) { + return fixtureReply(files, 'errors/unknown-path'); + } + if (key.origins !== null && origin !== undefined && !key.origins.includes(origin)) { + return fixtureReply(files, 'errors/origin-not-allowed'); + } + if (key.rateLimited) { + return withHeaders(fixtureReply(files, 'errors/rate-limited'), { 'Retry-After': '1' }); + } + return left < 0 ? fixtureReply(files, 'errors/rate-limited') : route(request, files, key); +} + +/** + * Builds the default behaviour of the mock server: the gateway's answers, from the fixtures and + * the keys. A request with no key gets 401 with no rate-limit headers, as the gateway gave. Every + * request with a known key counts against that key's limit for the clock minute. + * + * @param files - The spec files. + * @param now - The clock, in milliseconds since the epoch. + * @returns A function that answers one request. + * @internal + */ +export function createGateway( + files: SpecFiles, + now: () => number, +): (request: MockRequest) => Reply { + const count = createRateLimiter(files.requestsPerMinute, now); + return (request) => { + if (request.method === 'OPTIONS') { + return emptyReply(204); + } + if (request.method === 'GET' && request.path === '/healthz') { + return emptyReply(200); + } + const apiKey = request.headers['x-api-key']; + if (apiKey === undefined) { + return fixtureReply(files, 'errors/auth-required'); + } + const key = files.keys.get(apiKey); + if (key === undefined) { + return fixtureReply(files, 'errors/invalid-api-key'); + } + const left = count(apiKey); + return withHeaders(keyedReply(request, files, key, left), { + 'X-RateLimit-Limit': String(files.requestsPerMinute), + 'X-RateLimit-Remaining': String(Math.max(left, 0)), + }); + }; +} diff --git a/packages/mock-server/src/lookup.ts b/packages/mock-server/src/lookup.ts new file mode 100644 index 0000000..912df09 --- /dev/null +++ b/packages/mock-server/src/lookup.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { compactCode, isWellFormed } from './postcodes.ts'; +import type { MockRequest, Reply } from './reply.ts'; +import { fixtureData, fixtureReply, type MockKey, type SpecFiles } from './spec-files.ts'; + +const LEVEL = /^[1-5]$/; + +/** + * Gives the compact form of the one unit that the mock server knows: the postcode in the + * fixture `lookup/valid-level-1`. + * + * @param files - The spec files. + * @returns The compact postcode. + * @internal + */ +export function knownUnit(files: SpecFiles): string { + const postcode = fixtureData(files, 'lookup/valid-level-1', 'postcode'); + if (typeof postcode !== 'string') { + throw new TypeError('fixtures/lookup/valid-level-1.json needs a postcode.'); + } + return compactCode(postcode); +} + +// The checks run in the order that the gateway showed: a level above the key's level gets 403 +// before a missing credit could give 402. +function refusal(key: MockKey, level: number): string | null { + if (!key.lookupScope) { + return 'errors/scope-not-granted'; + } + if (level > key.lookupLevel) { + return `errors/level-not-granted-${String(key.lookupLevel)}`; + } + return level > 1 && !key.credits ? 'errors/insufficient-credits' : null; +} + +/** + * Answers `GET /v1/lookup`. The body echoes the caller's text in upper case, as the gateway does. + * + * @param request - The request. + * @param files - The spec files. + * @param key - The caller's key. + * @returns The reply. + * @internal + */ +export function lookup(request: MockRequest, files: SpecFiles, key: MockKey): Reply { + const code = request.query.get('code') ?? ''; + const level = request.query.get('level') ?? '1'; + if (compactCode(code) === '' || !LEVEL.test(level)) { + return fixtureReply(files, 'errors/invalid-request'); + } + const refused = refusal(key, Number(level)); + if (refused !== null) { + return fixtureReply(files, refused); + } + const compact = compactCode(code); + let name = isWellFormed(compact, files) ? 'lookup/not-found' : 'lookup/invalid'; + if (compact === knownUnit(files)) { + name = `lookup/valid-level-${level}`; + } + return fixtureReply(files, name, { postcode: code.toUpperCase() }); +} diff --git a/packages/mock-server/src/postcodes.ts b/packages/mock-server/src/postcodes.ts new file mode 100644 index 0000000..08f1180 --- /dev/null +++ b/packages/mock-server/src/postcodes.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import type { SegmentRule, SpecFiles } from './spec-files.ts'; + +// The mock server reads postcodes as the gateway does, not as the core does. The gateway +// ignores spaces, hyphens and letter case, and it does no NFKC. +const SEPARATORS = /[\s-]/g; +const CHARACTERS: ReadonlyMap = new Map([ + ['letters', /^[A-Z]+$/], + ['digits', /^[0-9]+$/], + ['letters-or-digits', /^[A-Z0-9]+$/], +]); + +/** + * Removes spaces and hyphens, and changes letters to upper case. + * + * @param text - The text of a request. + * @returns The compact form of the text. + * @internal + */ +export function compactCode(text: string): string { + return text.toUpperCase().replace(SEPARATORS, ''); +} + +function segmentPasses(rule: SegmentRule, text: string): boolean { + const pattern = CHARACTERS.get(rule.characters); + return pattern?.test(text) === true && (rule.minimum === null || Number(text) >= rule.minimum); +} + +/** + * Tells whether a compact code has the form of a full postcode, with a known state. + * + * @param compact - The code in compact form. + * @param files - The segment rules and the state codes. + * @returns True for a full postcode in the right form. + * @internal + */ +export function isWellFormed(compact: string, files: SpecFiles): boolean { + const full = files.segments.at(-1)?.end; + return ( + compact.length === full && + files.states.includes(compact.slice(0, 2)) && + files.segments.every((rule) => segmentPasses(rule, compact.slice(rule.start, rule.end))) + ); +} + +/** + * Finds the segment that holds the last character of a code of this length. + * + * @param length - The length of a compact code. + * @param segments - The segment rules. + * @returns The segment, or undefined for a length of 0 or a code that is too long. + * @internal + */ +export function activeSegment( + length: number, + segments: readonly SegmentRule[], +): SegmentRule | undefined { + return segments.find((rule) => length > rule.start && length <= rule.end); +} diff --git a/packages/mock-server/src/rate-limit.ts b/packages/mock-server/src/rate-limit.ts new file mode 100644 index 0000000..f426a6d --- /dev/null +++ b/packages/mock-server/src/rate-limit.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +const MINUTE_MS = 60_000; + +/** + * Counts the requests of each key in each clock minute, as the gateway does. + * + * @param limit - The requests that one key can make in one minute. + * @param now - The clock, in milliseconds since the epoch. + * @returns A function that counts one request of a key, and returns the requests that the key + * has left in this minute. The count goes below 0 when the key passes the limit. + * @internal + */ +export function createRateLimiter(limit: number, now: () => number): (key: string) => number { + const minutes = new Map(); + return (key) => { + const minute = Math.floor(now() / MINUTE_MS); + const current = minutes.get(key); + const count = current?.minute === minute ? current.count + 1 : 1; + minutes.set(key, { minute, count }); + return limit - count; + }; +} diff --git a/packages/mock-server/src/reply.ts b/packages/mock-server/src/reply.ts index 666bef1..299cf53 100644 --- a/packages/mock-server/src/reply.ts +++ b/packages/mock-server/src/reply.ts @@ -53,3 +53,26 @@ export function jsonReply( delayMs: 0, }; } + +/** + * Builds a reply with no body, such as the answer to a preflight request. + * + * @param status - The HTTP status. + * @returns The reply, with no delay. + * @internal + */ +export function emptyReply(status: number): SendReply { + return { kind: 'send', status, headers: {}, body: '', delayMs: 0 }; +} + +/** + * Adds headers to a reply that sends a response. A reply of another kind stays as it is. + * + * @param reply - The reply. + * @param headers - The headers to add. They replace a header of the same name. + * @returns The reply with the headers. + * @internal + */ +export function withHeaders(reply: Reply, headers: Readonly>): Reply { + return reply.kind === 'send' ? { ...reply, headers: { ...reply.headers, ...headers } } : reply; +} diff --git a/packages/mock-server/src/search.ts b/packages/mock-server/src/search.ts new file mode 100644 index 0000000..03d53ef --- /dev/null +++ b/packages/mock-server/src/search.ts @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { knownUnit } from './lookup.ts'; +import { activeSegment, compactCode } from './postcodes.ts'; +import { jsonReply, type MockRequest, type Reply } from './reply.ts'; +import { fixtureData, fixtureReply, type SpecFiles } from './spec-files.ts'; + +/** NIPOST's docs give the default radius of reverse, and the ceiling that the gateway applies. */ +export const REVERSE_RADIUS_M = { default: 25, maximum: 250 } as const; +const PLACES = ['reverse/unit', 'reverse/area'] as const; +const CODE_CHARACTERS = /^[A-Z0-9]+$/; + +function numberIn(query: URLSearchParams, name: string): number | null { + const raw = query.get(name)?.trim() ?? ''; + const parsed = Number(raw); + return raw === '' || !Number.isFinite(parsed) ? null : parsed; +} + +function radiusIn(query: URLSearchParams, name: string, fallback: number): number | null { + if (query.get(name) === null) { + return fallback; + } + const radius = numberIn(query, name); + return radius === null || radius < 0 ? null : radius; +} + +function samePlace(files: SpecFiles, name: string, lng: number, lat: number): boolean { + const coordinate = fixtureData(files, name, 'coordinate'); + return Array.isArray(coordinate) && coordinate[0] === lng && coordinate[1] === lat; +} + +/** + * Answers `GET /v1/search/reverse`. A point that a fixture names gets that fixture. Any other + * point gets the fixture for no postcode in range. The body echoes the coordinate as + * [lng, lat] and gives the radius that the mock server applied. + * + * @param request - The request. + * @param files - The spec files. + * @returns The reply. + * @internal + */ +export function reverse(request: MockRequest, files: SpecFiles): Reply { + const lat = numberIn(request.query, 'lat'); + const lng = numberIn(request.query, 'lng'); + const radius = radiusIn(request.query, 'max_distance_m', REVERSE_RADIUS_M.default); + if (lat === null || lng === null || radius === null) { + return fixtureReply(files, 'errors/invalid-request'); + } + const name = PLACES.find((place) => samePlace(files, place, lng, lat)) ?? 'reverse/not-found'; + const radiusM = Math.min(radius, REVERSE_RADIUS_M.maximum); + return fixtureReply(files, name, { coordinate: [lng, lat], radius_m: radiusM }); +} + +/** + * Answers `GET /v1/search/nearby` with the one body that the gateway has shown: an empty list. + * + * @param request - The request. + * @param files - The spec files. + * @returns The reply. + * @internal + */ +export function nearby(request: MockRequest, files: SpecFiles): Reply { + const lat = numberIn(request.query, 'lat'); + const lng = numberIn(request.query, 'lng'); + if (lat === null || lng === null || radiusIn(request.query, 'radius', 0) === null) { + return fixtureReply(files, 'errors/invalid-request'); + } + return fixtureReply(files, 'nearby/empty'); +} + +/** + * Answers `GET /v1/search/autocomplete`. The state segment completes from the state list. + * The other segments complete from the one unit that the mock server knows. An empty `q` gets + * no answer, as the gateway gave none. + * + * @param request - The request. + * @param files - The spec files. + * @returns The reply. + * @internal + */ +export function autocomplete(request: MockRequest, files: SpecFiles): Reply { + const typed = compactCode(request.query.get('q') ?? ''); + if (typed === '') { + return { kind: 'hang' }; + } + const segment = activeSegment(typed.length, files.segments); + if (segment === undefined || !CODE_CHARACTERS.test(typed)) { + return fixtureReply(files, 'errors/invalid-request'); + } + const before = typed.slice(0, segment.start); + const unit = knownUnit(files); + const values = + segment.start === 0 + ? files.states + : [unit] + .filter((code) => code.startsWith(before)) + .map((code) => code.slice(segment.start, segment.end)); + const started = typed.slice(segment.start); + const codes = [...new Set(values)].filter((value) => value.startsWith(started)).sort(); + const suggestions = codes.map((code) => ({ code })); + return jsonReply(200, { data: { segment: segment.name, suggestions } }); +} diff --git a/packages/mock-server/src/spec-files.ts b/packages/mock-server/src/spec-files.ts index d423888..c7dd5a8 100644 --- a/packages/mock-server/src/spec-files.ts +++ b/packages/mock-server/src/spec-files.ts @@ -250,3 +250,22 @@ export function fixtureReply( const data = objectIn(body['data'], `fixtures/${name}.json`); return jsonReply(fixture.status, { ...body, data: { ...data, ...changes } }); } + +/** + * Reads one field of the `data` object of a fixture's body. + * + * @param files - The spec files. + * @param name - The fixture's path under `spec/fixtures`, without `.json`. + * @param fieldName - The field of `data`. + * @returns The value of the field. + * @throws Error when the spec has no such fixture, and TypeError when the body has no `data`. + * @internal + */ +export function fixtureData(files: SpecFiles, name: string, fieldName: string): unknown { + const fixture = files.fixtures.get(name); + if (fixture === undefined) { + throw new Error(`The spec has no fixture ${name}.`); + } + const body = objectIn(fixture.body, `fixtures/${name}.json`); + return objectIn(body['data'], `fixtures/${name}.json`)[fieldName]; +} diff --git a/packages/mock-server/test/assembly.test.ts b/packages/mock-server/test/assembly.test.ts new file mode 100644 index 0000000..32a2bd6 --- /dev/null +++ b/packages/mock-server/test/assembly.test.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { createGateway } from '../src/gateway.ts'; +import { errorCode, FILES, mockRequest, sent, type SentResponse } from './mock-request.ts'; + +const KEY = { 'x-api-key': 'nipost_test_mock_l1' }; + +function assemble(body: string): SentResponse { + const request = mockRequest('/v1/assembly/assemble', { method: 'POST', headers: KEY, body }); + return sent(createGateway(FILES, () => 0)(request)); +} + +function disassemble(code: string): SentResponse { + const request = mockRequest(`/v1/assembly/disassemble?code=${code}`, { headers: KEY }); + return sent(createGateway(FILES, () => 0)(request)); +} + +describe('assemble', () => { + it('changes letters to upper case and pads numbers, as the gateway did', () => { + const segments = { state: 'fc', lga: '1', district: 'z99', area: 'zz', unit: '1' }; + expect(assemble(JSON.stringify(segments)).body).toEqual({ + data: { compact: 'FC01Z99ZZ01', display: 'FC 01 Z99 ZZ 01', postcode: 'FC-01-Z99-ZZ-01' }, + }); + }); + + it.each([ + ['a body that is not JSON', '{state'], + ['a body that is a list', '[]'], + ['a missing unit', '{"state":"FC","lga":"01","district":"Z99","area":"ZZ"}'], + ['a unit of 0', '{"state":"FC","lga":"01","district":"Z99","area":"ZZ","unit":"0"}'], + ])('answers %s with 400', (_name, body) => { + const response = assemble(body); + expect([response.status, errorCode(response)]).toEqual([400, 'invalid_request']); + }); +}); + +describe('disassemble', () => { + it('splits a postcode into its five segments', () => { + expect(disassemble('fc01z99zz01').body).toEqual({ + data: { state: 'FC', lga: '01', district: 'Z99', area: 'ZZ', unit: '01' }, + }); + }); + + it('answers text that is not a postcode with 400', () => { + expect(disassemble('HELLO').status).toBe(400); + }); +}); diff --git a/packages/mock-server/test/gateway.test.ts b/packages/mock-server/test/gateway.test.ts new file mode 100644 index 0000000..3426fdf --- /dev/null +++ b/packages/mock-server/test/gateway.test.ts @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { createGateway } from '../src/gateway.ts'; +import { errorCode, FILES, mockRequest, sent } from './mock-request.ts'; + +const LOOKUP = '/v1/lookup?code=FC-01-Z99-ZZ-01'; +const L3 = { 'x-api-key': 'nipost_test_mock_l3' }; + +function remaining(response: { headers: Readonly> }): string | undefined { + return response.headers['X-RateLimit-Remaining']; +} + +describe('the gateway without a scenario', () => { + it('answers a request with no key with auth_required and no rate-limit headers', () => { + const response = sent(createGateway(FILES, () => 0)(mockRequest(LOOKUP))); + expect(response.status).toBe(401); + expect(response.body).toEqual({ + error: { + code: 'auth_required', + message: 'send an API key in the X-API-Key header', + }, + }); + expect(response.headers).not.toHaveProperty('X-RateLimit-Limit'); + }); + + it('answers a key that it does not know with invalid_api_key', () => { + const request = mockRequest(LOOKUP, { headers: { 'x-api-key': 'nipost_test_other' } }); + const response = sent(createGateway(FILES, () => 0)(request)); + expect([response.status, errorCode(response)]).toEqual([401, 'invalid_api_key']); + expect(response.headers).not.toHaveProperty('X-RateLimit-Remaining'); + }); + + it('counts the requests of each key in each clock minute', () => { + let clock = 59_000; + const gateway = createGateway(FILES, () => clock); + const l1 = { 'x-api-key': 'nipost_test_mock_l1' }; + expect(remaining(sent(gateway(mockRequest(LOOKUP, { headers: L3 }))))).toBe('599'); + expect(remaining(sent(gateway(mockRequest(LOOKUP, { headers: L3 }))))).toBe('598'); + expect(remaining(sent(gateway(mockRequest(LOOKUP, { headers: l1 }))))).toBe('599'); + clock = 60_000; + const response = sent(gateway(mockRequest(LOOKUP, { headers: L3 }))); + expect(response.headers['X-RateLimit-Limit']).toBe('600'); + expect(remaining(response)).toBe('599'); + }); + + it('answers the request after the limit with 429 and no Retry-After', () => { + const gateway = createGateway(FILES, () => 0); + for (let count = 0; count < 600; count += 1) { + gateway(mockRequest(LOOKUP, { headers: L3 })); + } + const response = sent(gateway(mockRequest(LOOKUP, { headers: L3 }))); + expect([response.status, errorCode(response), remaining(response)]).toEqual([ + 429, + 'rate_limited', + '0', + ]); + expect(response.headers).not.toHaveProperty('Retry-After'); + }); + + it('answers the rate-limited key with 429 and Retry-After of 1 second', () => { + const headers = { 'x-api-key': 'nipost_test_mock_rate_limited' }; + const response = sent(createGateway(FILES, () => 0)(mockRequest(LOOKUP, { headers }))); + expect([response.status, errorCode(response)]).toEqual([429, 'rate_limited']); + expect(response.headers['Retry-After']).toBe('1'); + }); + + it('refuses a publishable key from an origin that is not on its list, and counts it', () => { + const headers = { 'x-api-key': 'nipost_pk_test_mock', origin: 'http://localhost:3001' }; + const response = sent(createGateway(FILES, () => 0)(mockRequest(LOOKUP, { headers }))); + expect([response.status, errorCode(response), remaining(response)]).toEqual([ + 403, + 'origin_not_allowed', + '599', + ]); + }); + + it('accepts a publishable key from its origin, and from an app with no Origin header', () => { + const gateway = createGateway(FILES, () => 0); + const allowed = { 'x-api-key': 'nipost_pk_test_mock', origin: 'http://localhost:3000' }; + expect(sent(gateway(mockRequest(LOOKUP, { headers: allowed }))).status).toBe(200); + const app = { 'x-api-key': 'nipost_pk_test_mock' }; + expect(sent(gateway(mockRequest(LOOKUP, { headers: app }))).status).toBe(200); + }); + + it('answers a preflight request and the health check with no key', () => { + const gateway = createGateway(FILES, () => 0); + expect(sent(gateway(mockRequest(LOOKUP, { method: 'OPTIONS' })))).toEqual({ + status: 204, + headers: {}, + body: null, + }); + expect(sent(gateway(mockRequest('/healthz'))).status).toBe(200); + }); + + it('answers a path that the gateway does not document with 404', () => { + const gateway = createGateway(FILES, () => 0); + const widget = sent(gateway(mockRequest('/v1/widget/lookup', { headers: L3 }))); + expect([widget.status, errorCode(widget)]).toEqual([404, 'not_found']); + const post = sent(gateway(mockRequest(LOOKUP, { method: 'POST', headers: L3 }))); + expect(post.status).toBe(404); + }); +}); diff --git a/packages/mock-server/test/lookup.test.ts b/packages/mock-server/test/lookup.test.ts new file mode 100644 index 0000000..ee59c8d --- /dev/null +++ b/packages/mock-server/test/lookup.test.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { createGateway } from '../src/gateway.ts'; +import { errorCode, FILES, mockRequest, sent, type SentResponse } from './mock-request.ts'; + +function lookUp(query: string, key = 'nipost_test_mock_l3'): SentResponse { + const request = mockRequest(`/v1/lookup?${query}`, { headers: { 'x-api-key': key } }); + return sent(createGateway(FILES, () => 0)(request)); +} + +function message(response: SentResponse): unknown { + return (response.body as { error: { message: unknown } }).error.message; +} + +describe('lookup', () => { + it('finds the synthetic unit at level 1 when no level is given', () => { + expect(lookUp('code=FC-01-Z99-ZZ-01').body).toEqual({ + data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid', verified: false }, + }); + }); + + it('echoes the caller text in upper case, as the gateway does', () => { + const response = lookUp('code=fc+01+z99+zz+01&level=1'); + expect(response.body).toMatchObject({ data: { postcode: 'FC 01 Z99 ZZ 01', valid: true } }); + }); + + it('adds the documented address fields at level 2', () => { + expect(lookUp('code=FC01Z99ZZ01&level=2').body).toEqual({ + data: { + postcode: 'FC01Z99ZZ01', + valid: true, + administrative_address: { + state_name: 'FEDERAL CAPITAL TERRITORY', + lga_name: 'SYNTHETIC LGA', + locality_name: 'SYNTHETIC LOCALITY', + zone: 'NORTH CENTRAL', + }, + recent_house_address: { recent: '1 SYNTHETIC STREET, SYNTHETIC LOCALITY' }, + }, + }); + }); + + it('adds the building use status at level 3', () => { + const response = lookUp('code=FC01Z99ZZ01&level=3'); + expect(response.body).toMatchObject({ data: { building_use_status: 'residential' } }); + }); + + it('gives valid false with not_found for a well-formed postcode that it does not know', () => { + expect(lookUp('code=FC-01-Z99-ZZ-02').body).toEqual({ + data: { postcode: 'FC-01-Z99-ZZ-02', valid: false, status: 'not_found', verified: false }, + }); + }); + + it.each([ + ['text that is not a postcode', 'HELLO', 'HELLO'], + ['a unit of 00', 'fc-01-z99-zz-00', 'FC-01-Z99-ZZ-00'], + ['a state that is not on the list', 'XX01Z99ZZ01', 'XX01Z99ZZ01'], + ])('gives valid false with invalid for %s', (_name, code, echo) => { + expect(lookUp(`code=${code}`).body).toEqual({ + data: { postcode: echo, valid: false, status: 'invalid', verified: false }, + }); + }); + + it.each([ + ['nipost_test_mock_l1', 2, 'this key cannot read above lookup level 1'], + ['nipost_test_mock_l2', 3, 'this key cannot read above lookup level 2'], + ['nipost_test_mock_l3', 4, 'this key cannot read above lookup level 3'], + ])('refuses %s at level %i with level_not_granted', (key, level, text) => { + const response = lookUp(`code=FC01Z99ZZ01&level=${String(level)}`, key); + expect([response.status, errorCode(response), message(response)]).toEqual([ + 403, + 'level_not_granted', + text, + ]); + }); + + it('refuses a level above 1 with no credits, and serves level 1', () => { + const refused = lookUp('code=FC01Z99ZZ01&level=2', 'nipost_test_mock_no_credits'); + expect([refused.status, errorCode(refused)]).toEqual([402, 'insufficient_credits']); + expect(lookUp('code=FC01Z99ZZ01&level=1', 'nipost_test_mock_no_credits').status).toBe(200); + }); + + it('refuses every level to a key with no lookup scope', () => { + const response = lookUp('code=FC01Z99ZZ01&level=1', 'nipost_test_mock_no_scope'); + expect([response.status, errorCode(response)]).toEqual([403, 'scope_not_granted']); + }); + + it.each([ + ['no code', 'level=1'], + ['a code of separators only', 'code=+-+'], + ['level 0', 'code=FC01Z99ZZ01&level=0'], + ['level 6', 'code=FC01Z99ZZ01&level=6'], + ['a level that is not a number', 'code=FC01Z99ZZ01&level=two'], + ])('answers %s with 400', (_name, query) => { + const response = lookUp(query); + expect([response.status, errorCode(response)]).toEqual([400, 'invalid_request']); + }); +}); diff --git a/packages/mock-server/test/openapi.test.ts b/packages/mock-server/test/openapi.test.ts new file mode 100644 index 0000000..3086749 --- /dev/null +++ b/packages/mock-server/test/openapi.test.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Every body that the mock server builds must match the completed OpenAPI file in the spec. The +// spec checks its fixtures. This test checks the bodies that the code builds or changes. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { parse } from 'yaml'; +import { describe, expect, it } from 'vitest'; +import { createGateway } from '../src/gateway.ts'; +import { REVERSE_RADIUS_M } from '../src/search.ts'; +import { FILES, mockRequest, SPEC_DIR, sent } from './mock-request.ts'; + +interface ResponseObject { + readonly $ref?: string; + readonly content?: unknown; +} + +interface Operation { + readonly parameters?: readonly { + readonly name: string; + readonly schema: Record; + }[]; + readonly responses: Readonly>; +} + +const openapi = parse(readFileSync(join(SPEC_DIR, 'openapi/gateway.completed.yaml'), 'utf8')) as { + paths: Record>; + components: { responses: Record }; + 'x-gatepost-unknown-path': { responses: Record }; +}; +const ajv = new Ajv2020({ strict: false }); +ajv.addSchema(openapi, 'openapi'); + +// The response of the operation and the status, or the file's answer to a path that it lacks. +function bodySchema(path: string, method: string, status: number): string { + const responses = + openapi.paths[path]?.[method]?.responses ?? openapi['x-gatepost-unknown-path'].responses; + const response = responses[String(status)]; + const shared = response?.$ref?.split('/').at(-1); + const content = + shared === undefined ? response?.content : openapi.components.responses[shared]?.content; + const media = (content as { 'application/json': { schema: { $ref: string } } })[ + 'application/json' + ]; + return `openapi${media.schema.$ref}`; +} + +interface Probe { + readonly target: string; + readonly method?: string; + readonly key?: string; + readonly body?: string; +} + +const SEGMENTS = '{"state":"fc","lga":"1","district":"z99","area":"zz","unit":"1"}'; +const PROBES: readonly Probe[] = [ + { target: '/v1/lookup?code=fc01z99zz01&level=3' }, + { target: '/v1/lookup?code=FC01Z99ZZ01&level=2' }, + { target: '/v1/lookup?code=HELLO' }, + { target: '/v1/lookup?code=FC01Z99ZZ02' }, + { target: '/v1/lookup?code=FC01Z99ZZ01&level=5' }, + { target: '/v1/lookup?level=1' }, + { target: '/v1/lookup?code=FC01Z99ZZ01', key: 'nipost_test_mock_no_scope' }, + { target: '/v1/lookup?code=FC01Z99ZZ01&level=2', key: 'nipost_test_mock_no_credits' }, + { target: '/v1/lookup?code=FC01Z99ZZ01', key: 'nipost_test_mock_rate_limited' }, + { target: '/v1/lookup?code=FC01Z99ZZ01', key: 'nipost_test_unknown' }, + { target: '/v1/search/reverse?lat=9&lng=7&max_distance_m=900' }, + { target: '/v1/search/reverse?lat=9.001&lng=7.001' }, + { target: '/v1/search/reverse?lat=0.5&lng=0.25' }, + { target: '/v1/search/nearby?lat=9&lng=7' }, + { target: '/v1/search/autocomplete?q=E' }, + { target: '/v1/search/autocomplete?q=FC01Z99ZZ0' }, + { target: '/v1/search/autocomplete?q=EK01' }, + { target: '/v1/assembly/assemble', method: 'POST', body: SEGMENTS }, + { target: '/v1/assembly/disassemble?code=FC01Z99ZZ01' }, + { target: '/v1/assembly/disassemble?code=HELLO' }, + { target: '/v1/widget/lookup' }, +]; + +describe('the bodies of the mock server', () => { + it.each(PROBES)('match the OpenAPI file for $target', (probe) => { + const method = probe.method ?? 'GET'; + const headers = { 'x-api-key': probe.key ?? 'nipost_test_mock_l3' }; + const request = mockRequest(probe.target, { method, headers, body: probe.body ?? '' }); + const response = sent(createGateway(FILES, () => 0)(request)); + const validate = ajv.getSchema(bodySchema(request.path, method.toLowerCase(), response.status)); + expect(validate?.(response.body), JSON.stringify(validate?.errors)).toBe(true); + }); +}); + +describe('the reverse radius', () => { + it('has the default and the ceiling of the OpenAPI file', () => { + const parameter = openapi.paths['/v1/search/reverse']?.['get']?.parameters?.find( + ({ name }) => name === 'max_distance_m', + ); + expect(parameter?.schema).toMatchObject(REVERSE_RADIUS_M); + }); +}); diff --git a/packages/mock-server/test/search.test.ts b/packages/mock-server/test/search.test.ts new file mode 100644 index 0000000..d4771b0 --- /dev/null +++ b/packages/mock-server/test/search.test.ts @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { createGateway } from '../src/gateway.ts'; +import type { Reply } from '../src/reply.ts'; +import { errorCode, FILES, mockRequest, sent, type SentResponse } from './mock-request.ts'; + +function ask(target: string): Reply { + const request = mockRequest(target, { headers: { 'x-api-key': 'nipost_test_mock_l1' } }); + return createGateway(FILES, () => 0)(request); +} + +function data(response: SentResponse): unknown { + return (response.body as { data: unknown }).data; +} + +describe('reverse', () => { + it('finds the synthetic unit at its point', () => { + expect(data(sent(ask('/v1/search/reverse?lat=9&lng=7')))).toEqual({ + found: true, + coordinate: [7, 9], + unit: { + postcode: 'FC-01-Z99-ZZ-01', + display: 'FC 01 Z99 ZZ 01', + distance_m: 4.2, + confidence: 'high', + }, + area: 'FC-01-Z99-ZZ', + district: 'FC-01-Z99', + state: 'FC', + depth: 'unit', + radius_m: 25, + }); + }); + + it('matches a point that the client writes as 9.000 and 7.0', () => { + const found = data(sent(ask('/v1/search/reverse?lat=9.000&lng=7.0'))); + expect(found).toMatchObject({ found: true, coordinate: [7, 9], depth: 'unit' }); + }); + + it('finds the area with no unit at its point', () => { + const found = data(sent(ask('/v1/search/reverse?lat=9.001&lng=7.001&max_distance_m=40'))); + expect(found).toEqual({ + found: true, + coordinate: [7.001, 9.001], + area: 'FC-01-Z99-ZZ', + district: 'FC-01-Z99', + state: 'FC', + depth: 'area', + radius_m: 40, + }); + }); + + it('finds nothing elsewhere, and echoes the point as [lng, lat]', () => { + expect(data(sent(ask('/v1/search/reverse?lat=4&lng=3')))).toEqual({ + found: false, + coordinate: [3, 4], + message: 'no postcode found near this point', + radius_m: 25, + }); + }); + + it('lowers a radius above 250 m to 250 m', () => { + const found = data(sent(ask('/v1/search/reverse?lat=4&lng=3&max_distance_m=900'))); + expect(found).toMatchObject({ radius_m: 250 }); + }); + + it.each([ + ['no latitude', 'lng=7'], + ['a latitude that is not a number', 'lat=north&lng=7'], + ['an empty longitude', 'lat=9&lng='], + ['a negative radius', 'lat=9&lng=7&max_distance_m=-1'], + ])('answers %s with 400', (_name, query) => { + const response = sent(ask(`/v1/search/reverse?${query}`)); + expect([response.status, errorCode(response)]).toEqual([400, 'invalid_request']); + }); +}); + +describe('nearby', () => { + it('gives the empty list, the only body that the gateway has shown', () => { + expect(sent(ask('/v1/search/nearby?lat=9&lng=7&radius=200')).body).toEqual({ data: [] }); + }); + + it('answers a point with no longitude with 400', () => { + expect(sent(ask('/v1/search/nearby?lat=9')).status).toBe(400); + }); +}); + +describe('autocomplete', () => { + it.each([ + ['E', 'state', ['EB', 'ED', 'EK', 'EN']], + ['FC', 'state', ['FC']], + ['FC0', 'lga', ['01']], + ['FC01Z', 'district', ['Z99']], + ['fc 01 z99 z', 'area', ['ZZ']], + ['FC-01-Z99-ZZ-0', 'unit', ['01']], + ['EK01', 'lga', []], + ])('completes %s in the %s segment', (q, segment, codes) => { + const response = sent(ask(`/v1/search/autocomplete?q=${encodeURIComponent(q)}`)); + expect(data(response)).toEqual({ segment, suggestions: codes.map((code) => ({ code })) }); + }); + + it.each([ + ['an empty q', ''], + ['a q of separators only', '%20-%20'], + ])('sends no answer to %s, as the gateway did', (_name, q) => { + expect(ask(`/v1/search/autocomplete?q=${q}`)).toEqual({ kind: 'hang' }); + }); + + it.each([ + ['a q of 12 characters', 'FC01Z99ZZ011'], + ['a q with a character that no postcode has', 'F%21'], + ])('answers %s with 400', (_name, q) => { + expect(sent(ask(`/v1/search/autocomplete?q=${q}`)).status).toBe(400); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccd4f37..9612e03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: '@vitest/coverage-v8': specifier: ^5.0.3 version: 5.0.3(vitest@5.0.3) + ajv: + specifier: ^8.20.0 + version: 8.20.0 eslint: specifier: ^10.11.0 version: 10.11.0(jiti@2.6.1)(supports-color@8.1.1) @@ -232,9 +235,14 @@ importers: vitest: specifier: ^5.0.3 version: 5.0.3(@types/node@22.20.4)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@22.20.4)(jiti@2.6.1)(yaml@2.9.1)) + yaml: + specifier: ^2.9.1 + version: 2.9.1 packages/core: {} + packages/mock-server: {} + packages: '@andrewbranch/untar.js@1.0.4': @@ -1362,6 +1370,11 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -3696,7 +3709,7 @@ snapshots: dependencies: ajv: 8.20.0 ajv-draft-04: 1.0.0(ajv@8.20.0) - ajv-formats: 3.0.1 + ajv-formats: 3.0.1(ajv@8.20.0) fs-extra: 11.3.6 import-lazy: 4.0.0 jju: 1.4.0 @@ -4048,8 +4061,8 @@ snapshots: optionalDependencies: ajv: 8.20.0 - ajv-formats@3.0.1: - dependencies: + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: ajv: 8.20.0 ajv@6.15.0: From 1a9dfc0705ab5ba39dee8538cd321e79092355c7 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:38:03 +0100 Subject: [PATCH 04/36] fix(mock): stop at start with the file name for every wrong spec file A spec file that is not JSON now fails with its path and the parser message. A response must have exactly one kind field, and a fixture must have a body, so the server cannot send the text "undefined". A repeated scenario id or key fails with both places, and a scenario id must equal its file name. Status, delay, level and rate values must be whole numbers in range. Messages name the response or key entry. Folders are read in sorted order, so the map order does not depend on the file system. Signed-off-by: MobileMage --- packages/mock-server/src/spec-files.ts | 110 ++++++++++---- packages/mock-server/test/spec-files.test.ts | 142 ++++++++++++++++++- scripts/eslint-mock-server.test.mjs | 8 ++ 3 files changed, 229 insertions(+), 31 deletions(-) diff --git a/packages/mock-server/src/spec-files.ts b/packages/mock-server/src/spec-files.ts index c7dd5a8..cd1fab0 100644 --- a/packages/mock-server/src/spec-files.ts +++ b/packages/mock-server/src/spec-files.ts @@ -39,10 +39,25 @@ export interface SpecFiles { readonly segments: readonly SegmentRule[]; } +// A response has exactly one of these fields. +const RESPONSE_KINDS = ['body', 'text', 'fixture', 'hang', 'drop'] as const; + type JsonObject = Readonly>; -function readJson(folder: string, name: string): unknown { - return JSON.parse(readFileSync(join(folder, name), 'utf8')); +// `where` is the file's path under the spec folder, such as `contract/probe.json`. +function readJson(folder: string, name: string, where: string): unknown { + try { + return JSON.parse(readFileSync(join(folder, name), 'utf8')); + } catch (error) { + if (error instanceof SyntaxError) { + throw new SyntaxError(`${where}: ${error.message}`, { cause: error }); + } + throw error; + } +} + +function sortedNames(folder: string): string[] { + return readdirSync(folder).sort(); } function objectIn(value: unknown, where: string): JsonObject { @@ -73,6 +88,14 @@ function field( } const isNumber = (value: unknown): value is number => typeof value === 'number'; +const isIntegerFrom = + (low: number, high: number) => + (value: unknown): value is number => + Number.isInteger(value) && (value as number) >= low && (value as number) <= high; +const isStatus = isIntegerFrom(100, 599); +const isDelay = isIntegerFrom(0, Number.MAX_SAFE_INTEGER); +const isCount = isIntegerFrom(1, Number.MAX_SAFE_INTEGER); +const isLevel = isIntegerFrom(1, 5); const isText = (value: unknown): value is string => typeof value === 'string'; const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'; const isTextList = (value: unknown): value is string[] => @@ -90,16 +113,19 @@ function textRecord(value: unknown, where: string): Readonly { const fixtures = new Map(); - const groups = readdirSync(folder, { withFileTypes: true }).filter((entry) => - entry.isDirectory(), - ); + const groups = readdirSync(folder, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); for (const group of groups) { - for (const file of readdirSync(join(folder, group.name)).filter((name) => - name.endsWith('.json'), - )) { - const name = `${group.name}/${file.slice(0, -'.json'.length)}`; - const record = objectIn(readJson(folder, `${name}.json`), `fixtures/${name}.json`); - const status = field(record, 'status', `fixtures/${name}.json`, isNumber); + for (const file of sortedNames(join(folder, group)).filter((n) => n.endsWith('.json'))) { + const name = `${group}/${file.slice(0, -'.json'.length)}`; + const where = `fixtures/${name}.json`; + const record = objectIn(readJson(folder, `${name}.json`, where), where); + const status = field(record, 'status', where, isStatus); + if (!('body' in record)) { + throw new TypeError(`${where}: the field body is missing.`); + } fixtures.set(name, { status, body: record['body'] }); } } @@ -108,21 +134,29 @@ function readFixtures(folder: string): Map { function readKeys(folder: string): { keys: Map; requestsPerMinute: number } { const where = 'fixtures/keys.json'; - const document = objectIn(readJson(folder, 'keys.json'), where); + const document = objectIn(readJson(folder, 'keys.json', where), where); const keys = new Map(); - for (const entry of listIn(document['keys'], where)) { - const record = objectIn(entry, where); - const key = field(record, 'key', where, isText); + const entries: string[] = []; + for (const [index, entry] of listIn(document['keys'], where).entries()) { + const here = `${where} (entry ${String(index + 1)})`; + const record = objectIn(entry, here); + const key = field(record, 'key', here, isText); + const first = entries.indexOf(key); + if (first !== -1) { + const entry = `entry ${String(first + 1)} and in entry ${String(index + 1)}`; + throw new TypeError(`${where}: the key ${key} is in ${entry}.`); + } + entries.push(key); keys.set(key, { key, - lookupLevel: field(record, 'lookupLevel', where, isNumber), - lookupScope: field(record, 'lookupScope', where, isBoolean), - credits: field(record, 'credits', where, isBoolean), - rateLimited: field(record, 'rateLimited', where, isBoolean), - origins: field(record, 'origins', where, isTextListOrNull), + lookupLevel: field(record, 'lookupLevel', here, isLevel), + lookupScope: field(record, 'lookupScope', here, isBoolean), + credits: field(record, 'credits', here, isBoolean), + rateLimited: field(record, 'rateLimited', here, isBoolean), + origins: field(record, 'origins', here, isTextListOrNull), }); } - return { keys, requestsPerMinute: field(document, 'requestsPerMinute', where, isNumber) }; + return { keys, requestsPerMinute: field(document, 'requestsPerMinute', where, isCount) }; } function scenarioReply( @@ -131,8 +165,16 @@ function scenarioReply( where: string, ): Reply { const response = objectIn(value, where); + const kinds = RESPONSE_KINDS.filter((kind) => response[kind] !== undefined); + if (kinds.length !== 1) { + const problem = + kinds.length === 0 + ? 'has no body, text, fixture, hang or drop' + : `has more than one of ${kinds.join(', ')}`; + throw new TypeError(`${where}: the response ${problem}.`); + } const delayMs = - response['delayMs'] === undefined ? 0 : field(response, 'delayMs', where, isNumber); + response['delayMs'] === undefined ? 0 : field(response, 'delayMs', where, isDelay); if (response['hang'] === true) { return { kind: 'hang' }; } @@ -147,7 +189,7 @@ function scenarioReply( } return { ...jsonReply(fixture.status, fixture.body, headers), delayMs }; } - const status = field(response, 'status', where, isNumber); + const status = field(response, 'status', where, isStatus); if (typeof response['text'] === 'string') { const textHeaders = { 'Content-Type': 'text/plain; charset=utf-8', ...headers }; return { kind: 'send', status, headers: textHeaders, body: response['text'], delayMs }; @@ -160,14 +202,26 @@ function readScenarios( fixtures: ReadonlyMap, ): Map { const scenarios = new Map(); - for (const file of readdirSync(folder).filter((name) => name.endsWith('.json'))) { + const files = new Map(); + for (const file of sortedNames(folder).filter((name) => name.endsWith('.json'))) { const where = `contract/${file}`; - const scenario = objectIn(readJson(folder, file), where); + const scenario = objectIn(readJson(folder, file, where), where); const id = field(scenario, 'id', where, isText); + const earlier = files.get(id); + if (earlier !== undefined) { + throw new TypeError(`${where}: the id ${id} is already used by ${earlier}.`); + } + const stem = file.slice(0, -'.json'.length); + if (id !== stem) { + throw new TypeError(`${where}: the id ${id} must equal the file name ${stem}.`); + } + files.set(id, where); const responses = listIn(scenario['responses'], where); scenarios.set( id, - responses.map((response) => scenarioReply(response, fixtures, where)), + responses.map((response, index) => + scenarioReply(response, fixtures, `${where} (response ${String(index + 1)})`), + ), ); } return scenarios; @@ -175,7 +229,7 @@ function readScenarios( function readSegments(folder: string): SegmentRule[] { const where = 'data/format.json'; - const document = objectIn(readJson(folder, 'format.json'), where); + const document = objectIn(readJson(folder, 'format.json', where), where); let start = 0; return listIn(document['segments'], where).map((entry) => { const record = objectIn(entry, where); @@ -196,7 +250,7 @@ function readSegments(folder: string): SegmentRule[] { function readStates(folder: string): string[] { const where = 'data/states.json'; - const document = objectIn(readJson(folder, 'states.json'), where); + const document = objectIn(readJson(folder, 'states.json', where), where); return listIn(document['states'], where).map((entry) => field(objectIn(entry, where), 'code', where, isText), ); diff --git a/packages/mock-server/test/spec-files.test.ts b/packages/mock-server/test/spec-files.test.ts index bff9f9b..e948eb1 100644 --- a/packages/mock-server/test/spec-files.test.ts +++ b/packages/mock-server/test/spec-files.test.ts @@ -99,12 +99,142 @@ describe('readSpecFiles', () => { const scenario = { id: 'probe', responses: [{ fixture: 'lookup/valid-level-9' }] }; const folder = specWith('contract/probe.json', JSON.stringify(scenario)); expect(() => readSpecFiles(folder)).toThrow( - 'contract/probe.json: no fixture is named lookup/valid-level-9.', + 'contract/probe.json (response 1): no fixture is named lookup/valid-level-9.', ); }); - it('stops at a key file that is not JSON', () => { - expect(() => readSpecFiles(specWith('fixtures/keys.json', '{'))).toThrow(SyntaxError); + it.each([ + ['contract/probe.json'], + ['fixtures/lookup/probe.json'], + ['data/states.json'], + ['fixtures/keys.json'], + ])('stops at %s when it is not JSON, and names the file', (file) => { + const run = (): unknown => readSpecFiles(specWith(file, '{')); + expect(run).toThrow(SyntaxError); + expect(run).toThrow(new RegExp(`^${file}: .*JSON`)); + }); + + it.each([ + ['no kind field', { status: 200 }, 'the response has no body, text, fixture, hang or drop.'], + [ + 'two kind fields', + { hang: true, drop: true }, + 'the response has more than one of hang, drop.', + ], + [ + 'a fixture and a body', + { fixture: 'lookup/invalid', body: {} }, + 'the response has more than one of body, fixture.', + ], + ])('stops at a response with %s, and names the file and the response', (_name, bad, why) => { + const scenario = { id: 'probe', responses: [{ fixture: 'lookup/invalid' }, bad] }; + const folder = specWith('contract/probe.json', JSON.stringify(scenario)); + expect(() => readSpecFiles(folder)).toThrow(`contract/probe.json (response 2): ${why}`); + }); + + it('stops at a fixture with no body, and names the file', () => { + const folder = specWith('fixtures/lookup/probe.json', '{"status": 200}'); + expect(() => readSpecFiles(folder)).toThrow( + 'fixtures/lookup/probe.json: the field body is missing.', + ); + }); + + it('stops at a scenario id that another file already uses, and names both files', () => { + const scenario = { id: 'lookup-timeout', responses: [] }; + const folder = specWith('contract/probe.json', JSON.stringify(scenario)); + expect(() => readSpecFiles(folder)).toThrow( + 'contract/probe.json: the id lookup-timeout is already used by contract/lookup-timeout.json.', + ); + }); + + it('stops at a scenario whose id is not its file name', () => { + const folder = specWith('contract/probe.json', '{"id": "other", "responses": []}'); + expect(() => readSpecFiles(folder)).toThrow( + 'contract/probe.json: the id other must equal the file name probe.', + ); + }); + + it('stops at a key that appears twice, and says which entries', () => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures/keys.json'), 'utf8')) as { + keys: { key: string }[]; + }; + keys.keys.push({ ...keys.keys[0]! }); + const folder = specWith('fixtures/keys.json', JSON.stringify(keys)); + expect(() => readSpecFiles(folder)).toThrow( + `fixtures/keys.json: the key ${keys.keys[0]!.key} is in entry 1 and in entry 8.`, + ); + }); + + it('says which key entry has a wrong field', () => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures/keys.json'), 'utf8')) as { + keys: Record[]; + }; + keys.keys[2]!['lookupLevel'] = 'three'; + const folder = specWith('fixtures/keys.json', JSON.stringify(keys)); + expect(() => readSpecFiles(folder)).toThrow( + 'fixtures/keys.json (entry 3): the field lookupLevel has the wrong type.', + ); + }); + + it.each([ + ['a status of 99999', { status: 99999, body: {} }, 'status'], + ['a status of 200.5', { status: 200.5, body: {} }, 'status'], + ['a negative delay', { status: 200, body: {}, delayMs: -5 }, 'delayMs'], + ])('stops at a response with %s', (_name, response, name) => { + const folder = specWith( + 'contract/probe.json', + JSON.stringify({ id: 'probe', responses: [response] }), + ); + expect(() => readSpecFiles(folder)).toThrow( + `contract/probe.json (response 1): the field ${name} has the wrong type.`, + ); + }); + + it.each([ + ['requestsPerMinute', -3], + ['requestsPerMinute', 1.5], + ])('stops at %s of %s in the key file', (name, value) => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures/keys.json'), 'utf8')) as object; + const folder = specWith('fixtures/keys.json', JSON.stringify({ ...keys, [name]: value })); + expect(() => readSpecFiles(folder)).toThrow( + `fixtures/keys.json: the field ${name} has the wrong type.`, + ); + }); + + it('stops at a key with a level above 5', () => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures/keys.json'), 'utf8')) as { + keys: Record[]; + }; + keys.keys[0]!['lookupLevel'] = 9; + const folder = specWith('fixtures/keys.json', JSON.stringify(keys)); + expect(() => readSpecFiles(folder)).toThrow('(entry 1): the field lookupLevel'); + }); + + it('stops at a scenario file that is not an object, and at responses that are not a list', () => { + expect(() => readSpecFiles(specWith('contract/probe.json', '[]'))).toThrow( + 'contract/probe.json must be a JSON object.', + ); + const bad = '{"id": "probe", "responses": {}}'; + expect(() => readSpecFiles(specWith('contract/probe.json', bad))).toThrow( + 'contract/probe.json must be a JSON array.', + ); + }); + + it('stops at a header whose value is not text', () => { + const response = { status: 200, body: {}, headers: { 'Retry-After': 1 } }; + const folder = specWith( + 'contract/probe.json', + JSON.stringify({ id: 'probe', responses: [response] }), + ); + expect(() => readSpecFiles(folder)).toThrow('(response 1): the field Retry-After'); + }); + + it('lists scenarios and fixtures in the sorted order of their file names', () => { + const byFile = (a: string, b: string): number => (`${a}.json` < `${b}.json` ? -1 : 1); + const ids = [...FILES.scenarios.keys()]; + expect(ids).toEqual([...ids].sort(byFile)); + const names = [...FILES.fixtures.keys()]; + expect(names).toEqual([...names].sort(byFile)); }); }); @@ -125,4 +255,10 @@ describe('fixtureReply', () => { 'The spec has no fixture lookup/valid-level-9.', ); }); + + it('fails when a change meets a body with no data', () => { + expect(() => fixtureReply(FILES, 'errors/rate-limited', { postcode: 'X' })).toThrow( + 'fixtures/errors/rate-limited.json must be a JSON object.', + ); + }); }); diff --git a/scripts/eslint-mock-server.test.mjs b/scripts/eslint-mock-server.test.mjs index a25dd9b..5ba1b3f 100644 --- a/scripts/eslint-mock-server.test.mjs +++ b/scripts/eslint-mock-server.test.mjs @@ -44,6 +44,14 @@ describe('the lint config in the mock server', () => { assert.deepEqual(found, []); }); + it('allows the process global through globalThis', async () => { + const found = await ruleIds( + '/** The port that PORT names. */', + 'export const port: string | undefined = globalThis.process.env.PORT;', + ); + assert.deepEqual(found, []); + }); + it('still rejects a console call, as TS-12 says', async () => { const found = await ruleIds( '/** Says hello. */', From 573a298bacd06e2be09a8e75f541905d5776c97b Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:39:35 +0100 Subject: [PATCH 05/36] chore(repo): update the spec submodule The spec now plays 70 scenarios, adds the lookup-no-data scenario and changes the confidence of the reverse unit fixture to low. The search test expected the old value, so it follows the fixture. The truth check of the spec also plays each scenario as the client contract describes. Signed-off-by: MobileMage --- packages/mock-server/test/search.test.ts | 2 +- spec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mock-server/test/search.test.ts b/packages/mock-server/test/search.test.ts index d4771b0..f23ba96 100644 --- a/packages/mock-server/test/search.test.ts +++ b/packages/mock-server/test/search.test.ts @@ -23,7 +23,7 @@ describe('reverse', () => { postcode: 'FC-01-Z99-ZZ-01', display: 'FC 01 Z99 ZZ 01', distance_m: 4.2, - confidence: 'high', + confidence: 'low', }, area: 'FC-01-Z99-ZZ', district: 'FC-01-Z99', diff --git a/spec b/spec index 8ab969f..16b5a5b 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 8ab969fcc34cf1ef0e54b01e0fe07bac217de8f4 +Subproject commit 16b5a5ba38fe18c0bc447f299aff37d5b7a70df6 From c8898db24d2e6fee3fcf9b3a9c12663324de5907 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 01:42:47 +0100 Subject: [PATCH 06/36] feat(mock): serve over HTTP and play the contract scenarios A client test sends X-Scenario-Id and X-Scenario-Run, and gets the next response of that run. A run keeps its own place, so two test runs never share a response. The server holds a connection open for a hang and closes it for a drop, as the timeout and network scenarios need, and it closes every open connection when a test ends. The command starts it on Node with no build. Signed-off-by: MobileMage --- packages/mock-server/src/index.ts | 9 + packages/mock-server/src/main.ts | 40 ++++ packages/mock-server/src/scenarios.ts | 36 ++++ packages/mock-server/src/server.ts | 151 ++++++++++++++ packages/mock-server/test/main.test.ts | 75 +++++++ packages/mock-server/test/scenarios.test.ts | 81 ++++++++ packages/mock-server/test/server.test.ts | 212 ++++++++++++++++++++ 7 files changed, 604 insertions(+) create mode 100644 packages/mock-server/src/index.ts create mode 100644 packages/mock-server/src/main.ts create mode 100644 packages/mock-server/src/scenarios.ts create mode 100644 packages/mock-server/src/server.ts create mode 100644 packages/mock-server/test/main.test.ts create mode 100644 packages/mock-server/test/scenarios.test.ts create mode 100644 packages/mock-server/test/server.test.ts diff --git a/packages/mock-server/src/index.ts b/packages/mock-server/src/index.ts new file mode 100644 index 0000000..f77b89a --- /dev/null +++ b/packages/mock-server/src/index.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +/** + * Unofficial. Not made or endorsed by NIPOST. A local mock of NIPOST's postcode gateway, for + * the tests of Gatepost's clients. + * + * @packageDocumentation + */ +export { startMockServer, type MockServer, type MockServerOptions } from './server.ts'; diff --git a/packages/mock-server/src/main.ts b/packages/mock-server/src/main.ts new file mode 100644 index 0000000..53f3630 --- /dev/null +++ b/packages/mock-server/src/main.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import process from 'node:process'; +import { startMockServer, type MockServerOptions } from './server.ts'; + +function wholeNumber(name: string, fallback: number): number { + const raw = process.env[name] ?? ''; + const parsed = Number(raw); + if (raw === '') { + return fallback; + } + if (!Number.isInteger(parsed) || parsed < 0) { + throw new RangeError(`${name} must be a whole number of 0 or more. It is ${raw}.`); + } + return parsed; +} + +function optionsFromEnvironment(): MockServerOptions { + const specDir = process.env['GATEPOST_SPEC_DIR'] ?? ''; + return { + port: wholeNumber('PORT', 4010), + host: process.env['HOST'] ?? '127.0.0.1', + delayMs: wholeNumber('MOCK_DELAY_MS', 0), + ...(specDir === '' ? {} : { specDir }), + }; +} + +try { + const server = await startMockServer(optionsFromEnvironment()); + process.stdout.write(`The Gatepost mock server listens on ${server.url}\n`); + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + void server.close(); + }); + } +} catch (error: unknown) { + // A bad setting or a broken spec file ends the program with a message and no stack trace. + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 2; +} diff --git a/packages/mock-server/src/scenarios.ts b/packages/mock-server/src/scenarios.ts new file mode 100644 index 0000000..485b95d --- /dev/null +++ b/packages/mock-server/src/scenarios.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { jsonReply, type Reply } from './reply.ts'; + +function mockError(code: string, message: string): Reply { + return jsonReply(400, { error: { code, message } }); +} + +/** + * Plays the responses of the contract scenarios. Each run of a scenario keeps its own place + * in the list of responses, and the last response repeats. + * + * @param scenarios - The responses of each scenario, by its id. + * @returns A function that takes the values of `X-Scenario-Id` and `X-Scenario-Run`, and + * returns the next reply of that run. + * @internal + */ +export function createScenarioPlayer( + scenarios: ReadonlyMap, +): (id: string, run: string | undefined) => Reply { + const positions = new Map(); + return (id, run) => { + const replies = scenarios.get(id); + if (replies === undefined) { + return mockError('mock_unknown_scenario', `The mock server has no scenario ${id}.`); + } + if (run === undefined || run === '') { + return mockError('mock_run_missing', 'A scenario request needs the X-Scenario-Run header.'); + } + const place = JSON.stringify([id, run]); + const position = positions.get(place) ?? 0; + positions.set(place, position + 1); + const reply = replies[Math.min(position, replies.length - 1)]; + return reply ?? mockError('mock_no_request', `The scenario ${id} expects no request.`); + }; +} diff --git a/packages/mock-server/src/server.ts b/packages/mock-server/src/server.ts new file mode 100644 index 0000000..05f38d5 --- /dev/null +++ b/packages/mock-server/src/server.ts @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { createGateway } from './gateway.ts'; +import { jsonReply, type MockRequest, type Reply } from './reply.ts'; +import { createScenarioPlayer } from './scenarios.ts'; +import { readSpecFiles } from './spec-files.ts'; + +/** Options for {@link startMockServer}. */ +export interface MockServerOptions { + /** The port. 0 picks a free port. The default is 4010. */ + readonly port?: number; + /** The address to listen on. The default is 127.0.0.1. */ + readonly host?: string; + /** A wait in milliseconds before every response, for timeout tests. The default is 0. */ + readonly delayMs?: number; + /** The spec folder. The default is the `spec` submodule of the js repo. */ + readonly specDir?: string; + /** The clock of the rate limit, in milliseconds since the epoch. The default is `Date.now`. */ + readonly now?: () => number; +} + +/** A running mock server. */ +export interface MockServer { + /** The base URL, such as `http://127.0.0.1:4010`. */ + readonly url: string; + /** Closes every connection, also the ones that wait for an answer, and stops the server. */ + close(): Promise; +} + +// The gateway sends these on every response. The mock server also allows the two headers of a +// contract scenario, so that a browser test can send them. +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': + 'Authorization, Content-Type, X-API-Key, X-Widget-Origin, X-Scenario-Id, X-Scenario-Run', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Max-Age': '300', +}; +const DEFAULT_SPEC_DIR = fileURLToPath(new URL('../../../spec/', import.meta.url)); + +async function readRequest(incoming: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of incoming) { + chunks.push(chunk as Buffer); + } + const url = new URL(incoming.url ?? '/', 'http://mock.invalid'); + const headers: Record = {}; + for (const [name, value] of Object.entries(incoming.headers)) { + if (value !== undefined) { + headers[name] = Array.isArray(value) ? value.join(', ') : value; + } + } + return { + method: incoming.method ?? 'GET', + path: url.pathname, + query: url.searchParams, + headers, + body: Buffer.concat(chunks).toString('utf8'), + }; +} + +async function send( + incoming: IncomingMessage, + outgoing: ServerResponse, + reply: Reply, + extraMs: number, +): Promise { + if (reply.kind === 'hang') { + return; + } + await sleep(reply.delayMs + extraMs); + if (reply.kind === 'drop') { + incoming.socket.destroy(); + return; + } + const length = String(Buffer.byteLength(reply.body)); + outgoing.writeHead(reply.status, { ...CORS_HEADERS, 'Content-Length': length, ...reply.headers }); + outgoing.end(reply.body); +} + +function listen(server: Server, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + const address = server.address(); + resolve(typeof address === 'object' && address !== null ? address.port : port); + }); + }); +} + +/** + * Starts the mock server. A request with an `X-Scenario-Id` header gets the next response of + * that contract scenario. Any other request gets the gateway's answer from the fixtures. + * + * @param options - The port, the address, an extra delay, the spec folder and the clock. + * @returns The running server. + * @throws TypeError when a file in the spec folder has the wrong shape. + * + * @example + * ```ts + * import { startMockServer } from '@gatepost/mock-server'; + * + * const mock = await startMockServer({ port: 0 }); + * const response = await fetch(`${mock.url}/v1/lookup?code=FC-01-Z99-ZZ-01`, { + * headers: { 'X-API-Key': 'nipost_test_mock_l1' }, + * }); + * await response.json(); // { data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, ... } } + * await mock.close(); + * ``` + */ +export async function startMockServer(options: MockServerOptions = {}): Promise { + const files = readSpecFiles(options.specDir ?? DEFAULT_SPEC_DIR); + const gateway = createGateway(files, options.now ?? Date.now); + const play = createScenarioPlayer(files.scenarios); + const extraMs = options.delayMs ?? 0; + const choose = (request: MockRequest): Reply => { + const scenario = request.headers['x-scenario-id']; + return request.method === 'OPTIONS' || scenario === undefined + ? gateway(request) + : play(scenario, request.headers['x-scenario-run']); + }; + const server = createServer((incoming, outgoing) => { + readRequest(incoming) + .then((request) => send(incoming, outgoing, choose(request), extraMs)) + .catch((error: unknown) => { + // A broken fixture is a bug in the spec. The test that hit it sees the message. + const message = error instanceof Error ? error.message : String(error); + const reply = jsonReply(500, { error: { code: 'mock_error', message } }); + return send(incoming, outgoing, reply, 0); + }); + }); + const host = options.host ?? '127.0.0.1'; + const port = await listen(server, options.port ?? 4010, host); + return { + url: `http://${host === '0.0.0.0' ? 'localhost' : host}:${String(port)}`, + close: () => + new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((error) => { + if (error === undefined) { + resolve(); + } else { + reject(error); + } + }); + }), + }; +} diff --git a/packages/mock-server/test/main.test.ts b/packages/mock-server/test/main.test.ts new file mode 100644 index 0000000..a2d5bf0 --- /dev/null +++ b/packages/mock-server/test/main.test.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// These tests start src/main.ts as Node starts it in the container: with no build, through +// Node's type stripping. A syntax that Node cannot strip fails here. +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const MAIN = fileURLToPath(new URL('../src/main.ts', import.meta.url)); + +interface Run { + readonly code: number | null; + readonly stdout: string; + readonly stderr: string; +} + +// Starts the command, calls `whileRunning` with its first line of output, then stops it. +async function run( + env: Record, + whileRunning: (line: string) => Promise = () => Promise.resolve(), +): Promise { + const child = spawn(process.execPath, [MAIN], { env: { ...process.env, ...env } }); + let stdout = ''; + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const exited = once(child, 'exit'); + const started = new Promise((resolve) => { + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + if (stdout.includes('\n')) { + resolve(); + } + }); + }); + await Promise.race([started, exited]); + if (stdout.includes('\n')) { + await whileRunning(stdout.trim()); + child.kill('SIGTERM'); + } + const [code] = (await exited) as [number | null]; + return { code, stdout, stderr }; +} + +describe('the mock server command', () => { + it('serves on the port that PORT names, and stops on SIGTERM', async () => { + let health = 0; + const result = await run({ PORT: '0' }, async (line) => { + const url = /http:\/\/\S+/.exec(line)?.[0] ?? ''; + health = (await fetch(`${url}/healthz`)).status; + }); + expect(result.stdout).toMatch( + /^The Gatepost mock server listens on http:\/\/127\.0\.0\.1:\d+\n$/, + ); + expect([health, result.code, result.stderr]).toEqual([200, 0, '']); + }); + + it.each([ + ['PORT', 'abc'], + ['MOCK_DELAY_MS', '-5'], + ])('stops with exit code 2 when %s is %s', async (name, value) => { + const result = await run({ PORT: '0', [name]: value }); + expect(result.code).toBe(2); + expect(result.stderr).toBe(`${name} must be a whole number of 0 or more. It is ${value}.\n`); + }); + + it('stops with exit code 2 when the spec folder does not exist', async () => { + const result = await run({ PORT: '0', GATEPOST_SPEC_DIR: '/no/such/spec' }); + expect(result.code).toBe(2); + expect(result.stderr).toContain('/no/such/spec/fixtures'); + }); +}); diff --git a/packages/mock-server/test/scenarios.test.ts b/packages/mock-server/test/scenarios.test.ts new file mode 100644 index 0000000..103f109 --- /dev/null +++ b/packages/mock-server/test/scenarios.test.ts @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createScenarioPlayer } from '../src/scenarios.ts'; +import { errorCode, FILES, SPEC_DIR, sent } from './mock-request.ts'; + +interface ScenarioResponse { + readonly status?: number; + readonly fixture?: string; + readonly hang?: true; + readonly drop?: true; +} + +const CONTRACT = join(SPEC_DIR, 'contract'); +const scenarios = readdirSync(CONTRACT) + .filter((name) => name.endsWith('.json')) + .map( + (name) => + JSON.parse(readFileSync(join(CONTRACT, name), 'utf8')) as { + id: string; + responses: ScenarioResponse[]; + }, + ); + +// The kind and the status of the reply that the spec file asks for. +function expected(response: ScenarioResponse): [string, number | null] { + if (response.hang === true) { + return ['hang', null]; + } + if (response.drop === true) { + return ['drop', null]; + } + const fixture = response.fixture === undefined ? undefined : FILES.fixtures.get(response.fixture); + return ['send', fixture?.status ?? response.status ?? null]; +} + +describe('the scenario player', () => { + it('knows each scenario file of the spec', () => { + expect([...FILES.scenarios.keys()].sort()).toEqual(scenarios.map(({ id }) => id).sort()); + }); + + it.each(scenarios.filter(({ responses }) => responses.length > 0))( + 'plays the responses of $id in order, then repeats the last', + ({ id, responses }) => { + const play = createScenarioPlayer(FILES.scenarios); + const asked = [...responses, ...responses.slice(-1)].map(expected); + const played = asked.map(() => { + const reply = play(id, 'run-1'); + return [reply.kind, reply.kind === 'send' ? reply.status : null]; + }); + expect(played).toEqual(asked); + }, + ); + + it('keeps a place for each run, so a test can run a scenario again', () => { + const play = createScenarioPlayer(FILES.scenarios); + expect(sent(play('lookup-retry-503', 'run-1')).status).toBe(503); + expect(sent(play('lookup-retry-503', 'run-1')).status).toBe(200); + expect(sent(play('lookup-retry-503', 'run-2')).status).toBe(503); + }); + + it('answers a scenario that it does not know with 400', () => { + const response = sent(createScenarioPlayer(FILES.scenarios)('lookup-retry-999', 'run-1')); + expect([response.status, errorCode(response)]).toEqual([400, 'mock_unknown_scenario']); + }); + + it('answers a scenario request with no run with 400', () => { + const play = createScenarioPlayer(FILES.scenarios); + for (const run of [undefined, '']) { + const response = sent(play('lookup-retry-503', run)); + expect([response.status, errorCode(response)]).toEqual([400, 'mock_run_missing']); + } + }); + + it('answers a request in a scenario that expects none with 400', () => { + const response = sent(createScenarioPlayer(FILES.scenarios)('lookup-invalid-input', 'run-1')); + expect([response.status, errorCode(response)]).toEqual([400, 'mock_no_request']); + }); +}); diff --git a/packages/mock-server/test/server.test.ts b/packages/mock-server/test/server.test.ts new file mode 100644 index 0000000..22d4638 --- /dev/null +++ b/packages/mock-server/test/server.test.ts @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// These tests start the server on a free port of the loopback address, and call it with fetch. +// They cover what only a real connection shows: headers, delays, held and dropped connections. +import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { startMockServer, type MockServer, type MockServerOptions } from '../src/index.ts'; +import { FILES, SPEC_DIR } from './mock-request.ts'; + +const KEY = { 'X-API-Key': 'nipost_test_mock_l1' }; +const servers: MockServer[] = []; +const folders: string[] = []; + +async function start(options: MockServerOptions = {}): Promise { + const server = await startMockServer({ port: 0, ...options }); + servers.push(server); + return server; +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); + for (const folder of folders.splice(0)) { + rmSync(folder, { recursive: true }); + } +}); + +interface FileResponse { + readonly status?: number; + readonly body?: unknown; + readonly text?: string; + readonly fixture?: string; + readonly headers?: Record; + readonly hang?: true; + readonly drop?: true; +} + +const CONTRACT = join(SPEC_DIR, 'contract'); +const contract = readdirSync(CONTRACT) + .filter((name) => name.endsWith('.json')) + .map( + (name) => + JSON.parse(readFileSync(join(CONTRACT, name), 'utf8')) as { + id: string; + responses: FileResponse[]; + }, + ); + +// Checks the response that arrived against the spec file. +async function expectAnswer(answer: Response, spec: FileResponse): Promise { + const fixture = spec.fixture === undefined ? undefined : FILES.fixtures.get(spec.fixture); + const text = await answer.text(); + expect(answer.status).toBe(fixture?.status ?? spec.status); + expect(answer.headers.get('access-control-allow-origin')).toBe('*'); + for (const [name, value] of Object.entries(spec.headers ?? {})) { + expect(answer.headers.get(name)).toBe(value); + } + if (spec.text !== undefined) { + expect(text).toBe(spec.text); + return; + } + expect(JSON.parse(text)).toEqual(fixture === undefined ? spec.body : fixture.body); + expect(answer.headers.get('content-type')).toBe( + spec.headers?.['Content-Type'] ?? 'application/json', + ); +} + +// Asks for one response of a scenario. A hang and a drop give no response. +async function expectResponse( + url: string, + headers: Record, + spec: FileResponse, +): Promise { + const call = fetch(`${url}/v1/lookup?code=FC01Z99ZZ01`, { + headers, + signal: AbortSignal.timeout(spec.hang === true ? 150 : 5000), + }); + if (spec.hang === true) { + await expect(call).rejects.toThrow('timeout'); + } else if (spec.drop === true) { + await expect(call).rejects.toThrow(TypeError); + } else { + await expectAnswer(await call, spec); + } +} + +describe('the contract scenarios over HTTP', () => { + // A client that follows the contract sends one request for each response in the file, and + // more requests get the last response again. The test sends one request more than that. + it.each(contract)('sends the responses of $id, as the spec file lists them', async (scenario) => { + const { url } = await start(); + const headers = { 'X-Scenario-Id': scenario.id, 'X-Scenario-Run': 'run-1' }; + for (const spec of [...scenario.responses, ...scenario.responses.slice(-1)]) { + await expectResponse(url, headers, spec); + } + }); + + it('refuses a request for a scenario that expects none', async () => { + const { url } = await start(); + const headers = { 'X-Scenario-Id': 'lookup-invalid-input', 'X-Scenario-Run': 'run-1' }; + expect((await fetch(`${url}/v1/lookup`, { headers })).status).toBe(400); + }); + + it('keeps the place of each run apart over HTTP', async () => { + const { url } = await start(); + const send = async (run: string | null): Promise => { + const headers: Record = { 'X-Scenario-Id': 'lookup-retry-503' }; + if (run !== null) { + headers['X-Scenario-Run'] = run; + } + return (await fetch(`${url}/v1/lookup`, { headers })).status; + }; + expect([await send('a'), await send('b'), await send('a'), await send(null)]).toEqual([ + 503, 503, 200, 400, + ]); + }); +}); + +describe('the mock server', () => { + it('answers with the gateway headers and a body that ends with a line feed', async () => { + const { url } = await start(); + const response = await fetch(`${url}/v1/lookup?code=FC-01-Z99-ZZ-01`, { headers: KEY }); + const text = await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); + expect(response.headers.get('content-type')).toBe('application/json'); + expect(response.headers.get('content-length')).toBe(String(text.length)); + expect(response.headers.get('x-ratelimit-remaining')).toBe('599'); + expect(text.endsWith('}\n')).toBe(true); + }); + + it('allows the scenario headers in a browser preflight', async () => { + const { url } = await start(); + const response = await fetch(`${url}/v1/lookup`, { method: 'OPTIONS' }); + expect(response.status).toBe(204); + expect(response.headers.get('access-control-allow-headers')).toContain('X-Scenario-Run'); + }); + + it('reads the body of a POST request', async () => { + const { url } = await start(); + const segments = { state: 'fc', lga: '1', district: 'z99', area: 'zz', unit: '1' }; + const response = await fetch(`${url}/v1/assembly/assemble`, { + method: 'POST', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify(segments), + }); + expect(await response.json()).toEqual({ + data: { compact: 'FC01Z99ZZ01', display: 'FC 01 Z99 ZZ 01', postcode: 'FC-01-Z99-ZZ-01' }, + }); + }); + + it('plays a scenario for a request with the scenario headers', async () => { + const { url } = await start(); + const headers = { 'X-Scenario-Id': 'lookup-retry-502', 'X-Scenario-Run': 'run-1' }; + const statuses: number[] = []; + for (let attempt = 0; attempt < 2; attempt += 1) { + statuses.push((await fetch(`${url}/v1/lookup?code=FC01Z99ZZ01`, { headers })).status); + } + expect(statuses).toEqual([502, 200]); + }); + + it('closes the connection with no response for a drop', async () => { + const { url } = await start(); + const headers = { 'X-Scenario-Id': 'lookup-network-error', 'X-Scenario-Run': 'run-1' }; + await expect(fetch(`${url}/v1/lookup`, { headers })).rejects.toThrow(TypeError); + }); + + it('holds an empty autocomplete until the client gives up, and still closes', async () => { + const server = await start(); + const signal = AbortSignal.timeout(100); + const call = fetch(`${server.url}/v1/search/autocomplete?q=`, { headers: KEY, signal }); + await expect(call).rejects.toThrow('The operation was aborted due to timeout'); + }); + + it('closes while a request still waits for an answer', async () => { + const server = await startMockServer({ port: 0 }); + const call = fetch(`${server.url}/v1/search/autocomplete?q=`, { headers: KEY }); + const failed = expect(call).rejects.toThrow(TypeError); + await new Promise((resolve) => setTimeout(resolve, 50)); + await server.close(); + await failed; + }); + + it('waits the extra delay before every response', async () => { + const { url } = await start({ delayMs: 150 }); + const started = performance.now(); + await fetch(`${url}/healthz`); + expect(performance.now() - started).toBeGreaterThanOrEqual(140); + }); + + it('answers 500 with the reason when a fixture is missing from the spec', async () => { + const folder = mkdtempSync(join(tmpdir(), 'gatepost-spec-')); + folders.push(folder); + for (const part of ['data', 'fixtures', 'contract']) { + cpSync(join(SPEC_DIR, part), join(folder, part), { recursive: true }); + } + rmSync(join(folder, 'fixtures/errors/unknown-path.json')); + const { url } = await start({ specDir: folder }); + const response = await fetch(`${url}/v1/other`, { headers: KEY }); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: { code: 'mock_error', message: 'The spec has no fixture errors/unknown-path.' }, + }); + }); + + it('gives a URL with localhost when it listens on every address', async () => { + const { url } = await start({ host: '0.0.0.0' }); + expect(url).toMatch(/^http:\/\/localhost:\d+$/); + expect((await fetch(`${url}/healthz`)).status).toBe(200); + }); +}); From 80c9170f86763429d59103b211eaac49b4cc06de Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:05:14 +0100 Subject: [PATCH 07/36] fix(mock): report a spent key, keep units in range, pin the headers A rate-limited key now shows no requests left, as the 429 response says. Reverse no longer returns a unit that lies outside the radius. Numbers in a query read as decimal text by value, so a level of 2.0 works and 0x9 does not. The OpenAPI test now compares the headers and the error code of each response with the file, and new tests pin the order of the key, path, origin, scope, level and credit checks. Signed-off-by: MobileMage --- packages/mock-server/src/gateway.ts | 2 +- packages/mock-server/src/lookup.ts | 18 ++++-- packages/mock-server/src/postcodes.ts | 15 +++++ packages/mock-server/src/search.ts | 20 ++++-- packages/mock-server/test/gateway.test.ts | 78 ++++++++++++++++++++++- packages/mock-server/test/lookup.test.ts | 29 +++++++++ packages/mock-server/test/openapi.test.ts | 74 +++++++++++++++++++-- packages/mock-server/test/search.test.ts | 51 ++++++++++++++- 8 files changed, 263 insertions(+), 24 deletions(-) diff --git a/packages/mock-server/src/gateway.ts b/packages/mock-server/src/gateway.ts index 1507119..d3aa5e5 100644 --- a/packages/mock-server/src/gateway.ts +++ b/packages/mock-server/src/gateway.ts @@ -67,7 +67,7 @@ export function createGateway( const left = count(apiKey); return withHeaders(keyedReply(request, files, key, left), { 'X-RateLimit-Limit': String(files.requestsPerMinute), - 'X-RateLimit-Remaining': String(Math.max(left, 0)), + 'X-RateLimit-Remaining': String(key.rateLimited ? 0 : Math.max(left, 0)), }); }; } diff --git a/packages/mock-server/src/lookup.ts b/packages/mock-server/src/lookup.ts index 912df09..609d950 100644 --- a/packages/mock-server/src/lookup.ts +++ b/packages/mock-server/src/lookup.ts @@ -1,11 +1,9 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 -import { compactCode, isWellFormed } from './postcodes.ts'; +import { compactCode, decimalNumber, isWellFormed } from './postcodes.ts'; import type { MockRequest, Reply } from './reply.ts'; import { fixtureData, fixtureReply, type MockKey, type SpecFiles } from './spec-files.ts'; -const LEVEL = /^[1-5]$/; - /** * Gives the compact form of the one unit that the mock server knows: the postcode in the * fixture `lookup/valid-level-1`. @@ -34,6 +32,12 @@ function refusal(key: MockKey, level: number): string | null { return level > 1 && !key.credits ? 'errors/insufficient-credits' : null; } +// A level is a whole number from 1 to 5, read by value, so 2 and 2.0 are the same level. +function levelIn(query: URLSearchParams): number | null { + const level = decimalNumber(query.get('level') ?? '1'); + return level !== null && Number.isInteger(level) && level >= 1 && level <= 5 ? level : null; +} + /** * Answers `GET /v1/lookup`. The body echoes the caller's text in upper case, as the gateway does. * @@ -45,18 +49,18 @@ function refusal(key: MockKey, level: number): string | null { */ export function lookup(request: MockRequest, files: SpecFiles, key: MockKey): Reply { const code = request.query.get('code') ?? ''; - const level = request.query.get('level') ?? '1'; - if (compactCode(code) === '' || !LEVEL.test(level)) { + const level = levelIn(request.query); + if (compactCode(code) === '' || level === null) { return fixtureReply(files, 'errors/invalid-request'); } - const refused = refusal(key, Number(level)); + const refused = refusal(key, level); if (refused !== null) { return fixtureReply(files, refused); } const compact = compactCode(code); let name = isWellFormed(compact, files) ? 'lookup/not-found' : 'lookup/invalid'; if (compact === knownUnit(files)) { - name = `lookup/valid-level-${level}`; + name = `lookup/valid-level-${String(level)}`; } return fixtureReply(files, name, { postcode: code.toUpperCase() }); } diff --git a/packages/mock-server/src/postcodes.ts b/packages/mock-server/src/postcodes.ts index 08f1180..62f3982 100644 --- a/packages/mock-server/src/postcodes.ts +++ b/packages/mock-server/src/postcodes.ts @@ -22,6 +22,21 @@ export function compactCode(text: string): string { return text.toUpperCase().replace(SEPARATORS, ''); } +const DECIMAL = /^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/; + +/** + * Reads a number from decimal text, so that `9`, `9.0` and `9e0` give the same value. Other + * forms that `Number()` accepts, such as `0x9`, are not numbers here. + * + * @param text - The text of a query value. + * @returns The number, or null when the text is not a decimal number. + * @internal + */ +export function decimalNumber(text: string): number | null { + const parsed = DECIMAL.test(text.trim()) ? Number(text) : Number.NaN; + return Number.isFinite(parsed) ? parsed : null; +} + function segmentPasses(rule: SegmentRule, text: string): boolean { const pattern = CHARACTERS.get(rule.characters); return pattern?.test(text) === true && (rule.minimum === null || Number(text) >= rule.minimum); diff --git a/packages/mock-server/src/search.ts b/packages/mock-server/src/search.ts index 03d53ef..6983c4d 100644 --- a/packages/mock-server/src/search.ts +++ b/packages/mock-server/src/search.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 import { knownUnit } from './lookup.ts'; -import { activeSegment, compactCode } from './postcodes.ts'; +import { activeSegment, compactCode, decimalNumber } from './postcodes.ts'; import { jsonReply, type MockRequest, type Reply } from './reply.ts'; import { fixtureData, fixtureReply, type SpecFiles } from './spec-files.ts'; @@ -11,9 +11,7 @@ const PLACES = ['reverse/unit', 'reverse/area'] as const; const CODE_CHARACTERS = /^[A-Z0-9]+$/; function numberIn(query: URLSearchParams, name: string): number | null { - const raw = query.get(name)?.trim() ?? ''; - const parsed = Number(raw); - return raw === '' || !Number.isFinite(parsed) ? null : parsed; + return decimalNumber(query.get(name) ?? ''); } function radiusIn(query: URLSearchParams, name: string, fallback: number): number | null { @@ -29,10 +27,19 @@ function samePlace(files: SpecFiles, name: string, lng: number, lat: number): bo return Array.isArray(coordinate) && coordinate[0] === lng && coordinate[1] === lat; } +// The unit fixture holds the distance to its unit. A radius under it leaves the unit out of range. +function inRange(files: SpecFiles, name: string, radius: number): boolean { + const unit = fixtureData(files, name, 'unit'); + const distance = (unit as { distance_m?: unknown } | undefined)?.distance_m; + return typeof distance !== 'number' || distance <= radius; +} + /** * Answers `GET /v1/search/reverse`. A point that a fixture names gets that fixture. Any other * point gets the fixture for no postcode in range. The body echoes the coordinate as - * [lng, lat] and gives the radius that the mock server applied. + * [lng, lat] and gives the radius that the mock server applied. A unit farther than the radius + * is out of range. The ranges of lat and lng are a client rule, so the mock server accepts + * any value. * * @param request - The request. * @param files - The spec files. @@ -46,8 +53,9 @@ export function reverse(request: MockRequest, files: SpecFiles): Reply { if (lat === null || lng === null || radius === null) { return fixtureReply(files, 'errors/invalid-request'); } - const name = PLACES.find((place) => samePlace(files, place, lng, lat)) ?? 'reverse/not-found'; + const place = PLACES.find((name) => samePlace(files, name, lng, lat)); const radiusM = Math.min(radius, REVERSE_RADIUS_M.maximum); + const name = place === undefined || !inRange(files, place, radiusM) ? 'reverse/not-found' : place; return fixtureReply(files, name, { coordinate: [lng, lat], radius_m: radiusM }); } diff --git a/packages/mock-server/test/gateway.test.ts b/packages/mock-server/test/gateway.test.ts index 3426fdf..fe1f2ba 100644 --- a/packages/mock-server/test/gateway.test.ts +++ b/packages/mock-server/test/gateway.test.ts @@ -47,7 +47,8 @@ describe('the gateway without a scenario', () => { it('answers the request after the limit with 429 and no Retry-After', () => { const gateway = createGateway(FILES, () => 0); for (let count = 0; count < 600; count += 1) { - gateway(mockRequest(LOOKUP, { headers: L3 })); + const counted = sent(gateway(mockRequest(LOOKUP, { headers: L3 }))); + expect([counted.status, remaining(counted)]).toEqual([200, String(599 - count)]); } const response = sent(gateway(mockRequest(LOOKUP, { headers: L3 }))); expect([response.status, errorCode(response), remaining(response)]).toEqual([ @@ -63,6 +64,8 @@ describe('the gateway without a scenario', () => { const response = sent(createGateway(FILES, () => 0)(mockRequest(LOOKUP, { headers }))); expect([response.status, errorCode(response)]).toEqual([429, 'rate_limited']); expect(response.headers['Retry-After']).toBe('1'); + expect(response.headers['X-RateLimit-Limit']).toBe('600'); + expect(remaining(response)).toBe('0'); }); it('refuses a publishable key from an origin that is not on its list, and counts it', () => { @@ -100,4 +103,77 @@ describe('the gateway without a scenario', () => { const post = sent(gateway(mockRequest(LOOKUP, { method: 'POST', headers: L3 }))); expect(post.status).toBe(404); }); + + it('keeps a count inside one clock minute, however late in it the request comes', () => { + let clock = 0; + const gateway = createGateway(FILES, () => clock); + gateway(mockRequest(LOOKUP, { headers: L3 })); + clock = 45_000; + expect(remaining(sent(gateway(mockRequest(LOOKUP, { headers: L3 }))))).toBe('598'); + }); + + it('checks the origin of a publishable key after the path and before the limit', () => { + const gateway = createGateway(FILES, () => 0); + const bad = { 'x-api-key': 'nipost_pk_test_mock', origin: 'http://localhost:3001' }; + const widget = sent(gateway(mockRequest('/v1/widget/lookup', { headers: bad }))); + expect([widget.status, errorCode(widget)]).toEqual([404, 'not_found']); + const good = { 'x-api-key': 'nipost_pk_test_mock', origin: 'http://localhost:3000' }; + for (let count = 0; count < 600; count += 1) { + gateway(mockRequest(LOOKUP, { headers: good })); + } + const refused = sent(gateway(mockRequest(LOOKUP, { headers: bad }))); + expect([refused.status, errorCode(refused)]).toEqual([403, 'origin_not_allowed']); + }); + + it('sends the rate-limit headers on a 400 and on a 404 for a known key', () => { + const gateway = createGateway(FILES, () => 0); + const bad = sent(gateway(mockRequest('/v1/lookup?level=1', { headers: L3 }))); + const missing = sent(gateway(mockRequest('/v1/widget/lookup', { headers: L3 }))); + for (const response of [bad, missing]) { + expect(response.headers['X-RateLimit-Limit']).toBe('600'); + expect(remaining(response)).toMatch(/^\d+$/); + } + }); + + it.each([ + ['/v1/search/reverse?lat=9&lng=7'], + ['/v1/search/nearby?lat=9&lng=7'], + ['/v1/search/autocomplete?q=E'], + ['/v1/assembly/disassemble?code=FC01Z99ZZ01'], + ])('sends the rate-limit headers on %s', (target) => { + const response = sent(createGateway(FILES, () => 0)(mockRequest(target, { headers: L3 }))); + expect(response.headers['X-RateLimit-Limit']).toBe('600'); + expect(remaining(response)).toBe('599'); + }); + + it('sends the rate-limit headers on assemble', () => { + const body = '{"state":"FC","lga":"01","district":"Z99","area":"ZZ","unit":"01"}'; + const request = mockRequest('/v1/assembly/assemble', { method: 'POST', headers: L3, body }); + const response = sent(createGateway(FILES, () => 0)(request)); + expect(response.headers['X-RateLimit-Limit']).toBe('600'); + }); + + it('serves each route with its own method only, and /healthz with GET only', () => { + const gateway = createGateway(FILES, () => 0); + const get = sent(gateway(mockRequest('/v1/assembly/assemble', { headers: L3 }))); + expect(get.status).toBe(404); + expect(errorCode(sent(gateway(mockRequest('/healthz', { method: 'POST' }))))).toBe( + 'auth_required', + ); + }); + + it('checks the key before the path, and treats an empty key as a key it does not know', () => { + const gateway = createGateway(FILES, () => 0); + expect(errorCode(sent(gateway(mockRequest('/v1/widget/lookup'))))).toBe('auth_required'); + const empty = sent(gateway(mockRequest(LOOKUP, { headers: { 'x-api-key': '' } }))); + expect(errorCode(empty)).toBe('invalid_api_key'); + }); + + it('refuses a wrong origin before it answers a rate-limited key', () => { + const key = { ...FILES.keys.get('nipost_pk_test_mock')!, rateLimited: true }; + const files = { ...FILES, keys: new Map([[key.key, key]]) }; + const headers = { 'x-api-key': key.key, origin: 'http://localhost:3001' }; + const response = sent(createGateway(files, () => 0)(mockRequest(LOOKUP, { headers }))); + expect(errorCode(response)).toBe('origin_not_allowed'); + }); }); diff --git a/packages/mock-server/test/lookup.test.ts b/packages/mock-server/test/lookup.test.ts index ee59c8d..afea4e0 100644 --- a/packages/mock-server/test/lookup.test.ts +++ b/packages/mock-server/test/lookup.test.ts @@ -96,4 +96,33 @@ describe('lookup', () => { const response = lookUp(query); expect([response.status, errorCode(response)]).toEqual([400, 'invalid_request']); }); + + it('refuses a level above the key before it checks credits, and scope before level', () => { + const credits = lookUp('code=FC01Z99ZZ01&level=4', 'nipost_test_mock_no_credits'); + expect([credits.status, errorCode(credits)]).toEqual([403, 'level_not_granted']); + const scope = lookUp('code=FC01Z99ZZ01&level=4', 'nipost_test_mock_no_scope'); + expect([scope.status, errorCode(scope)]).toEqual([403, 'scope_not_granted']); + }); + + it.each(['level=02', 'level=2.0'])('reads %s as level 2, by value', (query) => { + const response = lookUp(`code=FC01Z99ZZ01&${query}`); + expect(response.body).toMatchObject({ data: { recent_house_address: {} } }); + }); + + it.each(['12', '1x', 'x1', '1.5', '-1', '0x2', ''])( + 'answers a level of "%s" with 400', + (level) => { + const response = lookUp(`code=FC01Z99ZZ01&level=${level}`); + expect([response.status, errorCode(response)]).toEqual([400, 'invalid_request']); + }, + ); + + it.each([ + ['a digit in the state', 'F101Z99ZZ01'], + ['a letter in the local government area', 'FCAB' + 'Z99ZZ01'], + ['a digit in the area', 'FC01Z99Z101'], + ['a letter in the unit', 'FC01Z99ZZA1'], + ])('gives invalid for %s', (_name, code) => { + expect(lookUp(`code=${code}`).body).toMatchObject({ data: { status: 'invalid' } }); + }); }); diff --git a/packages/mock-server/test/openapi.test.ts b/packages/mock-server/test/openapi.test.ts index 3086749..8b414ea 100644 --- a/packages/mock-server/test/openapi.test.ts +++ b/packages/mock-server/test/openapi.test.ts @@ -14,6 +14,8 @@ import { FILES, mockRequest, SPEC_DIR, sent } from './mock-request.ts'; interface ResponseObject { readonly $ref?: string; readonly content?: unknown; + readonly headers?: Readonly>; + readonly 'x-gatepost-error-codes'?: Readonly>; } interface Operation { @@ -26,30 +28,67 @@ interface Operation { const openapi = parse(readFileSync(join(SPEC_DIR, 'openapi/gateway.completed.yaml'), 'utf8')) as { paths: Record>; - components: { responses: Record }; + components: { + responses: Record; + headers: Record }>; + }; 'x-gatepost-unknown-path': { responses: Record }; }; const ajv = new Ajv2020({ strict: false }); ajv.addSchema(openapi, 'openapi'); // The response of the operation and the status, or the file's answer to a path that it lacks. -function bodySchema(path: string, method: string, status: number): string { +function declaredResponse(path: string, method: string, status: number): ResponseObject { const responses = openapi.paths[path]?.[method]?.responses ?? openapi['x-gatepost-unknown-path'].responses; const response = responses[String(status)]; const shared = response?.$ref?.split('/').at(-1); - const content = - shared === undefined ? response?.content : openapi.components.responses[shared]?.content; - const media = (content as { 'application/json': { schema: { $ref: string } } })[ + const declared = shared === undefined ? response : openapi.components.responses[shared]; + if (declared === undefined) { + throw new Error(`The OpenAPI file declares no ${String(status)} for ${method} ${path}.`); + } + return declared; +} + +function bodySchema(declared: ResponseObject): string { + const media = (declared.content as { 'application/json': { schema: { $ref: string } } })[ 'application/json' ]; return `openapi${media.schema.$ref}`; } +// Each header that the response declares is there and has a value of the declared type. +function expectDeclaredHeaders( + declared: ResponseObject, + headers: Readonly>, +): void { + for (const [name, reference] of Object.entries(declared.headers ?? {})) { + const value = headers[name]; + if (name !== 'Retry-After') { + expect(value, `${name} is missing`).toBeDefined(); + } + if (value !== undefined) { + const header = openapi.components.headers[reference.$ref.split('/').at(-1) ?? '']; + expect(ajv.validate(header?.schema ?? false, Number(value)), `${name}: ${value}`).toBe(true); + } + } +} + +// An error code is one of the codes that the response lists. +function expectDeclaredCode(declared: ResponseObject, body: unknown): void { + const codes = declared['x-gatepost-error-codes']; + const code = (body as { error?: { code?: string } }).error?.code; + if (codes !== undefined && code !== undefined) { + expect(Object.keys(codes)).toContain(code); + } +} + interface Probe { readonly target: string; readonly method?: string; readonly key?: string; + readonly origin?: string; + readonly anonymous?: boolean; readonly body?: string; } @@ -76,16 +115,37 @@ const PROBES: readonly Probe[] = [ { target: '/v1/assembly/disassemble?code=FC01Z99ZZ01' }, { target: '/v1/assembly/disassemble?code=HELLO' }, { target: '/v1/widget/lookup' }, + { target: '/v1/lookup?code=FC01Z99ZZ01', anonymous: true }, + { target: '/v1/lookup?code=FC01Z99ZZ01', key: 'nipost_pk_test_mock', origin: 'http://x.test' }, + { target: '/v1/search/reverse?lat=9', key: 'nipost_test_mock_l1' }, + { target: '/v1/search/reverse?lat=9&lng=7', key: 'nipost_test_mock_rate_limited' }, + { target: '/v1/search/nearby?lat=9' }, + { target: '/v1/search/autocomplete?q=FC01Z99ZZ011' }, + { target: '/v1/assembly/assemble', method: 'POST', body: '{}' }, ]; describe('the bodies of the mock server', () => { it.each(PROBES)('match the OpenAPI file for $target', (probe) => { const method = probe.method ?? 'GET'; - const headers = { 'x-api-key': probe.key ?? 'nipost_test_mock_l3' }; + const headers: Record = {}; + if (probe.anonymous !== true) { + headers['x-api-key'] = probe.key ?? 'nipost_test_mock_l3'; + } + if (probe.origin !== undefined) { + headers['origin'] = probe.origin; + } const request = mockRequest(probe.target, { method, headers, body: probe.body ?? '' }); const response = sent(createGateway(FILES, () => 0)(request)); - const validate = ajv.getSchema(bodySchema(request.path, method.toLowerCase(), response.status)); + const declared = declaredResponse(request.path, method.toLowerCase(), response.status); + const validate = ajv.getSchema(bodySchema(declared)); expect(validate?.(response.body), JSON.stringify(validate?.errors)).toBe(true); + + expectDeclaredHeaders(declared, response.headers); + // A reply with no key declares no rate-limit header, and the mock server sends none. + if (probe.anonymous === true) { + expect(Object.keys(response.headers)).not.toContain('X-RateLimit-Limit'); + } + expectDeclaredCode(declared, response.body); }); }); diff --git a/packages/mock-server/test/search.test.ts b/packages/mock-server/test/search.test.ts index f23ba96..b74f34c 100644 --- a/packages/mock-server/test/search.test.ts +++ b/packages/mock-server/test/search.test.ts @@ -60,6 +60,34 @@ describe('reverse', () => { }); }); + it.each([ + ['a latitude that is right and a longitude that is not', 'lat=9&lng=8'], + ['a longitude that is right and a latitude that is not', 'lat=8&lng=7'], + ])('finds nothing for %s', (_name, query) => { + expect(data(sent(ask(`/v1/search/reverse?${query}`)))).toMatchObject({ found: false }); + }); + + it.each([ + ['0', false], + ['1', false], + ['4.1', false], + ['4.2', true], + ['5', true], + ])('with a radius of %s m, finds the unit at 4.2 m: %s', (radius, found) => { + const body = data(sent(ask(`/v1/search/reverse?lat=9&lng=7&max_distance_m=${radius}`))); + expect(body).toMatchObject({ found, radius_m: Number(radius) }); + }); + + it('accepts a radius of 0 at a point with nothing near it', () => { + const found = data(sent(ask('/v1/search/reverse?lat=4&lng=3&max_distance_m=0'))); + expect(found).toMatchObject({ found: false, radius_m: 0 }); + }); + + it('reads a number as decimal text only', () => { + expect(sent(ask('/v1/search/reverse?lat=0x9&lng=7')).status).toBe(400); + expect(sent(ask('/v1/search/reverse?lat=9&lng=7e0')).status).toBe(200); + }); + it('lowers a radius above 250 m to 250 m', () => { const found = data(sent(ask('/v1/search/reverse?lat=4&lng=3&max_distance_m=900'))); expect(found).toMatchObject({ radius_m: 250 }); @@ -81,8 +109,13 @@ describe('nearby', () => { expect(sent(ask('/v1/search/nearby?lat=9&lng=7&radius=200')).body).toEqual({ data: [] }); }); - it('answers a point with no longitude with 400', () => { - expect(sent(ask('/v1/search/nearby?lat=9')).status).toBe(400); + it.each([ + ['no longitude', 'lat=9'], + ['no latitude', 'lng=7'], + ['a latitude that is not a number', 'lat=north&lng=7'], + ['a negative radius', 'lat=9&lng=7&radius=-1'], + ])('answers %s with 400', (_name, query) => { + expect(sent(ask(`/v1/search/nearby?${query}`)).status).toBe(400); }); }); @@ -114,3 +147,17 @@ describe('autocomplete', () => { expect(sent(ask(`/v1/search/autocomplete?q=${q}`)).status).toBe(400); }); }); + +describe('autocomplete with a state list out of order', () => { + it('sorts the suggestions and lists each code once', () => { + const files = { ...FILES, states: ['EK', 'ED', 'EK', 'EB'] }; + const request = mockRequest('/v1/search/autocomplete?q=E', { + headers: { 'x-api-key': 'nipost_test_mock_l1' }, + }); + const body = data(sent(createGateway(files, () => 0)(request))); + expect(body).toEqual({ + segment: 'state', + suggestions: [{ code: 'EB' }, { code: 'ED' }, { code: 'EK' }], + }); + }); +}); From bec21e30883ea704f9c5ed4515c6f8f7aeec8a28 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:08:38 +0100 Subject: [PATCH 08/36] fix(mock): stop a pending delay at close and end every error path A delayed response kept a timer alive after the client left or the server closed, so a process with a long delay did not exit for seconds. The delay now ends when the client closes the connection and when the server closes. A second close call now returns the first result, so two signals no longer give an unhandled rejection. A failure inside the error reply now closes the connection instead of leaving a rejection. The URL of an IPv6 host has brackets, and the host :: gives localhost. Signed-off-by: MobileMage --- packages/mock-server/src/scenarios.ts | 3 +- packages/mock-server/src/server.ts | 71 ++++++++++++++++++------ packages/mock-server/test/main.test.ts | 9 +++ packages/mock-server/test/server.test.ts | 17 ++++++ 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/packages/mock-server/src/scenarios.ts b/packages/mock-server/src/scenarios.ts index 485b95d..9ff0d8e 100644 --- a/packages/mock-server/src/scenarios.ts +++ b/packages/mock-server/src/scenarios.ts @@ -8,7 +8,8 @@ function mockError(code: string, message: string): Reply { /** * Plays the responses of the contract scenarios. Each run of a scenario keeps its own place - * in the list of responses, and the last response repeats. + * in the list of responses, and the last response repeats. The places stay in memory until the + * player goes away, so a long-lived server grows by one entry for each run. * * @param scenarios - The responses of each scenario, by its id. * @returns A function that takes the values of `X-Scenario-Id` and `X-Scenario-Run`, and diff --git a/packages/mock-server/src/server.ts b/packages/mock-server/src/server.ts index 05f38d5..8659155 100644 --- a/packages/mock-server/src/server.ts +++ b/packages/mock-server/src/server.ts @@ -62,16 +62,36 @@ async function readRequest(incoming: IncomingMessage): Promise { }; } +// Waits for a delay. It ends early, with false, when the server closes or the client leaves. +async function waited( + ms: number, + outgoing: ServerResponse, + stopping: AbortSignal, +): Promise { + const left = new AbortController(); + outgoing.once('close', () => { + left.abort(); + }); + try { + await sleep(ms, undefined, { signal: AbortSignal.any([stopping, left.signal]) }); + return true; + } catch { + return false; + } +} + async function send( incoming: IncomingMessage, outgoing: ServerResponse, reply: Reply, - extraMs: number, + wait: { readonly extraMs: number; readonly stopping: AbortSignal }, ): Promise { if (reply.kind === 'hang') { return; } - await sleep(reply.delayMs + extraMs); + if (!(await waited(reply.delayMs + wait.extraMs, outgoing, wait.stopping))) { + return; + } if (reply.kind === 'drop') { incoming.socket.destroy(); return; @@ -81,6 +101,14 @@ async function send( outgoing.end(reply.body); } +// The host part of the URL: every address means localhost, and an IPv6 address has brackets. +function urlHost(host: string): string { + if (host === '0.0.0.0' || host === '::') { + return 'localhost'; + } + return host.includes(':') ? `[${host}]` : host; +} + function listen(server: Server, port: number, host: string): Promise { return new Promise((resolve, reject) => { server.once('error', reject); @@ -116,6 +144,7 @@ export async function startMockServer(options: MockServerOptions = {}): Promise< const gateway = createGateway(files, options.now ?? Date.now); const play = createScenarioPlayer(files.scenarios); const extraMs = options.delayMs ?? 0; + const stopping = new AbortController(); const choose = (request: MockRequest): Reply => { const scenario = request.headers['x-scenario-id']; return request.method === 'OPTIONS' || scenario === undefined @@ -124,28 +153,36 @@ export async function startMockServer(options: MockServerOptions = {}): Promise< }; const server = createServer((incoming, outgoing) => { readRequest(incoming) - .then((request) => send(incoming, outgoing, choose(request), extraMs)) + .then((request) => + send(incoming, outgoing, choose(request), { extraMs, stopping: stopping.signal }), + ) .catch((error: unknown) => { // A broken fixture is a bug in the spec. The test that hit it sees the message. const message = error instanceof Error ? error.message : String(error); const reply = jsonReply(500, { error: { code: 'mock_error', message } }); - return send(incoming, outgoing, reply, 0); + return send(incoming, outgoing, reply, { extraMs: 0, stopping: stopping.signal }); + }) + .catch(() => { + // The error reply failed too. Close this connection, so that no rejection stays open. + incoming.socket.destroy(); }); }); const host = options.host ?? '127.0.0.1'; const port = await listen(server, options.port ?? 4010, host); - return { - url: `http://${host === '0.0.0.0' ? 'localhost' : host}:${String(port)}`, - close: () => - new Promise((resolve, reject) => { - server.closeAllConnections(); - server.close((error) => { - if (error === undefined) { - resolve(); - } else { - reject(error); - } - }); - }), + let closing: Promise | undefined; + const close = (): Promise => { + closing ??= new Promise((resolve, reject) => { + stopping.abort(); + server.closeAllConnections(); + server.close((error) => { + if (error === undefined) { + resolve(); + } else { + reject(error); + } + }); + }); + return closing; }; + return { url: `http://${urlHost(host)}:${String(port)}`, close }; } diff --git a/packages/mock-server/test/main.test.ts b/packages/mock-server/test/main.test.ts index a2d5bf0..600ddcb 100644 --- a/packages/mock-server/test/main.test.ts +++ b/packages/mock-server/test/main.test.ts @@ -72,4 +72,13 @@ describe('the mock server command', () => { expect(result.code).toBe(2); expect(result.stderr).toContain('/no/such/spec/fixtures'); }); + + it('exits fast on SIGTERM while a delayed response is pending', async () => { + const started = performance.now(); + const result = await run({ PORT: '0', MOCK_DELAY_MS: '5000' }, async (line) => { + const url = /http:\/\/\S+/.exec(line)?.[0] ?? ''; + await fetch(`${url}/healthz`, { signal: AbortSignal.timeout(200) }).catch(() => undefined); + }); + expect([result.code, performance.now() - started < 2500]).toEqual([0, true]); + }); }); diff --git a/packages/mock-server/test/server.test.ts b/packages/mock-server/test/server.test.ts index 22d4638..69e1582 100644 --- a/packages/mock-server/test/server.test.ts +++ b/packages/mock-server/test/server.test.ts @@ -209,4 +209,21 @@ describe('the mock server', () => { expect(url).toMatch(/^http:\/\/localhost:\d+$/); expect((await fetch(`${url}/healthz`)).status).toBe(200); }); + + it('lets close run twice', async () => { + const server = await startMockServer({ port: 0 }); + await server.close(); + await expect(server.close()).resolves.toBeUndefined(); + }); + + it('gives a URL with brackets for an IPv6 address', async () => { + const { url } = await start({ host: '::1' }); + expect(url).toMatch(/^http:\/\/\[::1\]:\d+$/); + expect((await fetch(`${url}/healthz`)).status).toBe(200); + }); + + it('gives a URL with localhost when it listens on every IPv6 address', async () => { + const { url } = await start({ host: '::' }); + expect(url).toMatch(/^http:\/\/localhost:\d+$/); + }); }); From 135067d78098f648eb02b973c899bcca0a041f3e Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:11:29 +0100 Subject: [PATCH 09/36] build(mock): document the mock server and build its image in CI The PHP client and the docs need the mock server without a TypeScript setup, so it ships as an image that runs the source on Node. CI builds the image and looks up the synthetic unit on each pull request, so a change cannot break the image unseen. A separate workflow publishes it with a provenance attestation, and only a release tag or a person starts it, because a pushed image is public. Signed-off-by: MobileMage --- .dockerignore | 12 ++++ .github/workflows/ci.yml | 26 ++++++++ .github/workflows/mock-release.yml | 73 ++++++++++++++++++++ AGENTS.md | 2 +- README.md | 7 +- packages/mock-server/Dockerfile | 25 +++++++ packages/mock-server/README.md | 84 ++++++++++++++++++++++++ packages/mock-server/test/readme.test.ts | 69 +++++++++++++++++++ 8 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/mock-release.yml create mode 100644 packages/mock-server/Dockerfile create mode 100644 packages/mock-server/README.md create mode 100644 packages/mock-server/test/readme.test.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d294950 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +# The image of the mock server needs these paths only. The rest, such as node_modules, stays out +# of the build context. +* +!LICENSE +!NOTICE +!spec/data +!spec/fixtures +!spec/contract +!packages/mock-server/package.json +!packages/mock-server/src diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4cb220..1cef8aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,3 +99,29 @@ jobs: image=ghcr.io/gitleaks/gitleaks # v8.30.1 digest=sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f docker run --rm -v "$PWD:/repo" "$image@$digest" git /repo + + # The job builds the image of the mock server and calls it once. It never pushes the image. + # mock-release.yml publishes it. + mock-image: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + - run: docker build --file packages/mock-server/Dockerfile --tag postcode-mock:ci . + - name: Start the image and look up the synthetic unit + run: | + # The key comes from the key table that the mock server reads, so no key is in this file. + key="$(jq --raw-output '.keys[] | select(.lookupLevel == 1 and .kind == "secret" + and .lookupScope and .credits and (.rateLimited | not)) | .key' \ + spec/fixtures/keys.json | head --lines=1)" + docker run --detach --name mock --publish 4010:4010 postcode-mock:ci + curl --silent --show-error --fail --retry 10 --retry-connrefused --retry-delay 1 \ + http://localhost:4010/healthz + curl --silent --show-error --fail-with-body --header "X-API-Key: $key" \ + 'http://localhost:4010/v1/lookup?code=FC-01-Z99-ZZ-01' | tee "$RUNNER_TEMP/lookup.json" + grep --quiet '"valid":true' "$RUNNER_TEMP/lookup.json" + - if: always() + run: docker logs mock diff --git a/.github/workflows/mock-release.yml b/.github/workflows/mock-release.yml new file mode 100644 index 0000000..fe24183 --- /dev/null +++ b/.github/workflows/mock-release.yml @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +name: mock release + +# Publishes the image of the mock server to the GitHub container registry, with a provenance +# attestation (SEC-7). A pushed image is public, so the workflow starts only for a tag such as +# postcode-mock-v0.1.0, or when a maintainer starts it by hand. No other event starts it. +on: + push: + tags: ['postcode-mock-v*'] + workflow_dispatch: + inputs: + version: + description: The version of the image, such as 0.1.0. It must match package.json. + required: true + type: string + +permissions: + contents: read + +concurrency: + group: mock-release + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + packages: write + id-token: write + attestations: write + env: + IMAGE: ghcr.io/gatepost-dev/postcode-mock + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + # The tag or the input gives the version. It must match package.json, so that the image + # and the source in the repo agree. + - name: Check the version + id: version + env: + INPUT_VERSION: ${{ inputs.version }} + REF_NAME: ${{ github.ref_name }} + run: | + version="${INPUT_VERSION:-${REF_NAME#postcode-mock-v}}" + declared="$(jq --raw-output .version packages/mock-server/package.json)" + if [ "$version" != "$declared" ]; then + echo "The version is $version, but package.json says $declared." >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: push + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: packages/mock-server/Dockerfile + push: true + tags: ${{ env.IMAGE }}:${{ steps.version.outputs.version }} + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/AGENTS.md b/AGENTS.md index c9137c9..aacaca3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the TypeScript packages. Today it has `@gatepost/core`. +This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the TypeScript packages. Today it has `@gatepost/core` and the private `@gatepost/mock-server`. ## Read first diff --git a/README.md b/README.md index 56f346c..5ea3582 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ TypeScript packages for Nigeria's National Digital Postcode. > Unofficial. Not made or endorsed by NIPOST. -| Package | What it does | -| --------------------------------- | ------------------------------------------ | -| [`@gatepost/core`](packages/core) | Parse, check and format postcodes offline. | +| Package | What it does | +| ----------------------------------------------- | --------------------------------------------------- | +| [`@gatepost/core`](packages/core) | Parse, check and format postcodes offline. | +| [`@gatepost/mock-server`](packages/mock-server) | Mock NIPOST's gateway for client tests. Not on npm. | ## Develop diff --git a/packages/mock-server/Dockerfile b/packages/mock-server/Dockerfile new file mode 100644 index 0000000..5eb7719 --- /dev/null +++ b/packages/mock-server/Dockerfile @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +# Build from the root of the js repo, with the spec submodule checked out: +# docker build --file packages/mock-server/Dockerfile --tag postcode-mock . +# The mock server has no dependencies and no build. Node runs its TypeScript source. +FROM node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 + +ARG DESCRIPTION="Unofficial. Not made or endorsed by NIPOST. A mock of NIPOST's gateway." +LABEL org.opencontainers.image.title="postcode-mock" \ + org.opencontainers.image.description="$DESCRIPTION" \ + org.opencontainers.image.source="https://github.com/gatepost-dev/js" \ + org.opencontainers.image.licenses="Apache-2.0" + +WORKDIR /app +COPY LICENSE NOTICE ./ +COPY spec/data spec/data +COPY spec/fixtures spec/fixtures +COPY spec/contract spec/contract +COPY packages/mock-server/package.json packages/mock-server/package.json +COPY packages/mock-server/src packages/mock-server/src + +ENV HOST=0.0.0.0 PORT=4010 +USER node +EXPOSE 4010 +CMD ["node", "packages/mock-server/src/main.ts"] diff --git a/packages/mock-server/README.md b/packages/mock-server/README.md new file mode 100644 index 0000000..cad84bb --- /dev/null +++ b/packages/mock-server/README.md @@ -0,0 +1,84 @@ + + + gatepost + + +# @gatepost/mock-server + +A local mock of NIPOST's postcode gateway, for the tests of Gatepost's clients. + +> Unofficial. Not made or endorsed by NIPOST. + +[![CI](https://github.com/gatepost-dev/js/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gatepost-dev/js/actions/workflows/ci.yml) +[![Licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](https://github.com/gatepost-dev/js/blob/main/LICENSE) + +## Install + +The package does not go to npm. Run it from a clone of this repo. It needs no install step, because it has no dependencies and Node runs its TypeScript source. + +```sh +git clone --recurse-submodules https://github.com/gatepost-dev/js +node js/packages/mock-server/src/main.ts +``` + +The command listens on port 4010 and prints its address. `GET /healthz` answers 200 with no key. + +A release tag starts a workflow that publishes the image `ghcr.io/gatepost-dev/postcode-mock`. The image listens on port 4010. Until the first release, build the image from the root of a clone: + +```sh +docker build --file packages/mock-server/Dockerfile --tag postcode-mock . +docker run --publish 4010:4010 postcode-mock +``` + +## Quickstart + +```ts +import { startMockServer } from '@gatepost/mock-server'; + +const mock = await startMockServer({ port: 0 }); +const response = await fetch(`${mock.url}/v1/lookup?code=FC-01-Z99-ZZ-01`, { + headers: { 'X-API-Key': 'nipost_test_mock_l1' }, +}); +const body = await response.json(); +await mock.close(); +``` + +`body` is `{ data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid', verified: false } }`, the gateway's answer at lookup level 1. + +## What it does + +- It answers the six gateway endpoints with the synthetic responses in `spec/fixtures`. `spec/fixtures/README.md` says which request gets which response. +- It treats each key in `spec/fixtures/keys.json` in its own way: a lookup level from 1 to 3, no credits, no lookup scope, a rate limit, or a publishable key for the origin `http://localhost:3000`. A request with no key gets 401 `auth_required`, and any other key gets 401 `invalid_api_key`. +- It counts the requests of each key in each clock minute, and sends the `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers, as the gateway does. +- It does not answer an empty `q` of `autocomplete`, because the gateway does not answer one. The connection stays open until the client gives up. +- A request with the headers `X-Scenario-Id` and `X-Scenario-Run` gets the next response of that contract scenario in `spec/contract`. The CORS headers allow both, so browser tests can send them. The gateway itself does not allow them. + +| Variable | Meaning | Default | +| ------------------- | ----------------------------------------------- | ----------------------------------- | +| `PORT` | the port. 0 picks a free one | 4010 | +| `HOST` | the address to listen on | 127.0.0.1, and 0.0.0.0 in the image | +| `MOCK_DELAY_MS` | a wait before every response, for timeout tests | 0 | +| `GATEPOST_SPEC_DIR` | the spec folder | the `spec` submodule | + +## Requirements + +| Requirement | Version | +| ------------- | ------------------------------------------ | +| Node | 22.22.2 or later 22.x, or 24.15.0 or later | +| Gatepost spec | 0.2.0 | + +## Docs + +`spec/client.md` defines the behaviour that the contract scenarios test, and `spec/contract/README.md` defines their format. + +## Support + +Report bugs in GitHub Issues. Report security problems privately, as `SECURITY.md` describes. + +## Contributing + +Read [`CONTRIBUTING.md`](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request. + +## Licence + +Apache-2.0. See `LICENSE` and `NOTICE`. diff --git a/packages/mock-server/test/readme.test.ts b/packages/mock-server/test/readme.test.ts new file mode 100644 index 0000000..9824f71 --- /dev/null +++ b/packages/mock-server/test/readme.test.ts @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; +import { SPEC_DIR } from './mock-request.ts'; + +const PACKAGE = fileURLToPath(new URL('../', import.meta.url)); +const README = readFileSync(join(PACKAGE, 'README.md'), 'utf8'); +const TEMP = join(PACKAGE, 'temp'); + +afterAll(() => { + rmSync(TEMP, { recursive: true, force: true }); +}); + +describe('README', () => { + // DOC-3: the Quickstart runs, and gives the body that the next sentence names. + it('runs the Quickstart, which gets the lookup body that it names', async () => { + const [, block = ''] = /```ts\n([\s\S]*?)```/.exec(README) ?? []; + const source = block.replace("'@gatepost/mock-server'", "'../src/index.ts'"); + mkdirSync(TEMP, { recursive: true }); + writeFileSync(join(TEMP, 'quickstart.ts'), `${source}\nexport { body };\n`); + const { body } = (await import(join(TEMP, 'quickstart.ts'))) as { body: unknown }; + expect(body).toEqual({ + data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid', verified: false }, + }); + expect(README).toContain("postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid'"); + }); + + it('names the version of the spec in the submodule', () => { + const version = readFileSync(join(SPEC_DIR, 'VERSION'), 'utf8').trim(); + const pattern = `Gatepost spec\\s*\\|\\s*${version.replaceAll('.', '\\.')}\\s*\\|`; + expect(README).toMatch(new RegExp(pattern)); + }); + + it('uses a key that keys.json holds, and names the six paths of the gateway', () => { + const keys = JSON.parse(readFileSync(join(SPEC_DIR, 'fixtures', 'keys.json'), 'utf8')) as { + keys: { key: string }[]; + }; + expect(keys.keys.map((entry) => entry.key)).toContain('nipost_test_mock_l1'); + expect(README).toContain('nipost_test_mock_l1'); + expect(README).toContain('/healthz'); + }); +}); + +describe('image recipe', () => { + const ROOT = join(PACKAGE, '..', '..'); + const dockerfile = readFileSync(join(PACKAGE, 'Dockerfile'), 'utf8'); + + it('copies every spec folder that the server reads, and nothing else of the spec', () => { + const folders = [...dockerfile.matchAll(/^COPY (spec\/\w+) /gm)].map((match) => match[1] ?? ''); + expect(folders).toEqual(['spec/data', 'spec/fixtures', 'spec/contract']); + const ignore = readFileSync(join(ROOT, '.dockerignore'), 'utf8'); + for (const folder of folders) { + expect(ignore).toContain(`!${folder}\n`); + } + }); + + it('listens on the port that the command and the README name, on every address', () => { + expect(dockerfile).toContain('HOST=0.0.0.0 PORT=4010'); + expect(dockerfile).toContain('EXPOSE 4010'); + expect(README).toContain('4010'); + }); + + it('runs as the node user with no root rights', () => { + expect(dockerfile).toMatch(/^USER node$/m); + }); +}); From 197161eefc06ad22c3bf074311ba5a538a23d380 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:34:01 +0100 Subject: [PATCH 10/36] fix(repo): retry a reset connection when CI probes the mock image Docker accepts the first connection on the published port before Node listens, and then resets it. The option --retry-connrefused does not retry a reset, so the probe failed with exit code 56. The option --retry-all-errors retries it. Signed-off-by: MobileMage --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cef8aa..9917feb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,7 +118,7 @@ jobs: and .lookupScope and .credits and (.rateLimited | not)) | .key' \ spec/fixtures/keys.json | head --lines=1)" docker run --detach --name mock --publish 4010:4010 postcode-mock:ci - curl --silent --show-error --fail --retry 10 --retry-connrefused --retry-delay 1 \ + curl --silent --show-error --fail --retry 10 --retry-all-errors --retry-delay 1 \ http://localhost:4010/healthz curl --silent --show-error --fail-with-body --header "X-API-Key: $key" \ 'http://localhost:4010/v1/lookup?code=FC-01-Z99-ZZ-01' | tee "$RUNNER_TEMP/lookup.json" From 6e6ba65ae14d4bdca10e4f7e339e2974a47cc6a9 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:34:02 +0100 Subject: [PATCH 11/36] fix(mock): bind the loopback address when HOST is empty An empty HOST gave an empty string, and Node then bound every interface. The command now treats an empty HOST as unset, as it does for PORT. Two tests pin the loopback default, and the README says that the server trusts a checked spec folder. Signed-off-by: MobileMage --- packages/mock-server/README.md | 4 ++++ packages/mock-server/src/main.ts | 8 +++++++- packages/mock-server/test/main.test.ts | 5 +++++ packages/mock-server/test/server.test.ts | 5 +++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/mock-server/README.md b/packages/mock-server/README.md index cad84bb..3084524 100644 --- a/packages/mock-server/README.md +++ b/packages/mock-server/README.md @@ -60,6 +60,10 @@ await mock.close(); | `MOCK_DELAY_MS` | a wait before every response, for timeout tests | 0 | | `GATEPOST_SPEC_DIR` | the spec folder | the `spec` submodule | +An empty value counts as no value. At start, the server checks the shape of each file in the spec +folder and names a broken file. It does not check a fixture body against the OpenAPI file. Point +`GATEPOST_SPEC_DIR` only at a spec folder that `make check` has passed. + ## Requirements | Requirement | Version | diff --git a/packages/mock-server/src/main.ts b/packages/mock-server/src/main.ts index 53f3630..e1a2169 100644 --- a/packages/mock-server/src/main.ts +++ b/packages/mock-server/src/main.ts @@ -15,11 +15,17 @@ function wholeNumber(name: string, fallback: number): number { return parsed; } +// An empty HOST counts as unset, as an empty PORT does. It must not bind every interface. +function hostFromEnvironment(): string { + const host = process.env['HOST'] ?? ''; + return host === '' ? '127.0.0.1' : host; +} + function optionsFromEnvironment(): MockServerOptions { const specDir = process.env['GATEPOST_SPEC_DIR'] ?? ''; return { port: wholeNumber('PORT', 4010), - host: process.env['HOST'] ?? '127.0.0.1', + host: hostFromEnvironment(), delayMs: wholeNumber('MOCK_DELAY_MS', 0), ...(specDir === '' ? {} : { specDir }), }; diff --git a/packages/mock-server/test/main.test.ts b/packages/mock-server/test/main.test.ts index 600ddcb..81fef3f 100644 --- a/packages/mock-server/test/main.test.ts +++ b/packages/mock-server/test/main.test.ts @@ -58,6 +58,11 @@ describe('the mock server command', () => { expect([health, result.code, result.stderr]).toEqual([200, 0, '']); }); + it('binds the loopback address when HOST is empty', async () => { + const result = await run({ PORT: '0', HOST: '' }); + expect(result.stdout).toMatch(/listens on http:\/\/127\.0\.0\.1:\d+\n$/); + }); + it.each([ ['PORT', 'abc'], ['MOCK_DELAY_MS', '-5'], diff --git a/packages/mock-server/test/server.test.ts b/packages/mock-server/test/server.test.ts index 69e1582..90c23d2 100644 --- a/packages/mock-server/test/server.test.ts +++ b/packages/mock-server/test/server.test.ts @@ -118,6 +118,11 @@ describe('the contract scenarios over HTTP', () => { }); describe('the mock server', () => { + it('listens on the loopback address when no host is given', async () => { + const server = await start(); + expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + }); + it('answers with the gateway headers and a body that ends with a line feed', async () => { const { url } = await start(); const response = await fetch(`${url}/v1/lookup?code=FC-01-Z99-ZZ-01`, { headers: KEY }); From 2ee026be7be1272f8d58a3d649722000e6335fdd Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:37:06 +0100 Subject: [PATCH 12/36] build(deps): move the spec submodule to the final review fixes The new spec commit makes the deadline scenario less sensitive to a slow runner, checks the postcode of each suggestion, and adds evidence marks and notes to the contract. Signed-off-by: MobileMage --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index 16b5a5b..a4f601d 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 16b5a5ba38fe18c0bc447f299aff37d5b7a70df6 +Subproject commit a4f601d0c58240318a4cce68302574c8bb021cae From 5feb988694b534f19611d0c964374a78405f5ddd Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:51:29 +0100 Subject: [PATCH 13/36] feat(client): add the error type and the status map Every Gatepost client must raise the same error codes for the same responses of the gateway. The client package starts with PostcodeError and the map from each HTTP status to one code that spec/client.md fixes, so the later parts share one mapping. The package depends on the core, which parses each postcode before a request goes out, and an ADR says why that dependency is the only one. Signed-off-by: MobileMage --- .changeset/first-client-release.md | 5 + docs/adr/0001-client-depends-on-core.md | 24 +++ package.json | 2 +- packages/client/LICENSE | 202 ++++++++++++++++++++++++ packages/client/NOTICE | 5 + packages/client/api-extractor.json | 25 +++ packages/client/etc/client.api.md | 101 ++++++++++++ packages/client/package.json | 57 +++++++ packages/client/scripts/smoke.mjs | 11 ++ packages/client/src/error.ts | 137 ++++++++++++++++ packages/client/src/index.ts | 25 +++ packages/client/src/types.ts | 145 +++++++++++++++++ packages/client/src/version.ts | 14 ++ packages/client/test/error.test.ts | 81 ++++++++++ packages/client/test/version.test.ts | 12 ++ packages/client/tsconfig.json | 9 ++ packages/client/tsdown.config.ts | 26 +++ packages/client/vitest.config.ts | 19 +++ packages/core/package.json | 3 +- pnpm-lock.yaml | 10 ++ 20 files changed, 911 insertions(+), 2 deletions(-) create mode 100644 .changeset/first-client-release.md create mode 100644 docs/adr/0001-client-depends-on-core.md create mode 100644 packages/client/LICENSE create mode 100644 packages/client/NOTICE create mode 100644 packages/client/api-extractor.json create mode 100644 packages/client/etc/client.api.md create mode 100644 packages/client/package.json create mode 100644 packages/client/scripts/smoke.mjs create mode 100644 packages/client/src/error.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/src/types.ts create mode 100644 packages/client/src/version.ts create mode 100644 packages/client/test/error.test.ts create mode 100644 packages/client/test/version.test.ts create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/tsdown.config.ts create mode 100644 packages/client/vitest.config.ts diff --git a/.changeset/first-client-release.md b/.changeset/first-client-release.md new file mode 100644 index 0000000..3af5774 --- /dev/null +++ b/.changeset/first-client-release.md @@ -0,0 +1,5 @@ +--- +'@gatepost/client': minor +--- + +Add the client package. It looks up postcodes, finds the postcode of a place and completes a typed postcode through NIPOST's gateway, with the retries, waits and limits of spec 0.2.0. diff --git a/docs/adr/0001-client-depends-on-core.md b/docs/adr/0001-client-depends-on-core.md new file mode 100644 index 0000000..b1a88f9 --- /dev/null +++ b/docs/adr/0001-client-depends-on-core.md @@ -0,0 +1,24 @@ +# 1. The client depends on the core + +Date: 9 Oct 2026 + +## Context + +`@gatepost/client` must parse each postcode before it sends a lookup, so that a bad code sends no request. It also returns parsed postcodes in its results, and it builds the partial postcode of each autocomplete offer. `@gatepost/core` holds the only parser, with the state table and the segment rules from the spec data. + +The roadmap says that the client has no runtime dependencies. DEP-3 asks for a record of each new runtime dependency. + +## Decision + +The client lists `@gatepost/core` as its one dependency, with a caret range on the same release line. It has no other dependency. Its size limit of 5 KB counts the core too, because size-limit bundles the dependencies. + +## Reasons + +- A copy of the parser in the client would be a second place for the spec data, which CS-4 forbids, and an app that uses both packages would ship the parser twice. +- The core is a Gatepost package with no dependencies of its own, so the client still pulls in no third-party code. +- The client's types name the core's `Postcode` and `Precision`, so a caller can pass a parsed postcode to `lookup`. + +## Consequences + +- A breaking release of the core needs a release of the client. +- The client's tests and type check read the core's source, so they need no build of the core first. The published package reads the core's built files. diff --git a/package.json b/package.json index 8492343..a0fdd48 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "tells": "python3 spec/scripts/check-tells", "context:check": "cmp CONTEXT.md spec/CONTEXT.md", "licences:check": "node scripts/check-licences.mjs", - "licence-files:check": "cmp LICENSE packages/core/LICENSE && cmp NOTICE packages/core/NOTICE", + "licence-files:check": "pnpm -r run licence-files:check", "reuse:check": "uvx --with charset-normalizer==3.5.2 reuse@6.2.0 lint", "workflows:check": "uvx zizmor@1.30.1 --offline .github/workflows", "generate": "node scripts/generate-spec-data.mjs", diff --git a/packages/client/LICENSE b/packages/client/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/client/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/client/NOTICE b/packages/client/NOTICE new file mode 100644 index 0000000..bc94510 --- /dev/null +++ b/packages/client/NOTICE @@ -0,0 +1,5 @@ +Gatepost +Copyright 2026 The Gatepost authors + +This product is unofficial. The Nigerian Postal Service (NIPOST) did not make it and does not endorse it. +Gatepost uses the name NIPOST only to describe compatibility with NIPOST's National Digital Postcode. diff --git a/packages/client/api-extractor.json b/packages/client/api-extractor.json new file mode 100644 index 0000000..839481e --- /dev/null +++ b/packages/client/api-extractor.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "/dist/index.d.ts", + "compiler": { + "overrideTsconfig": { + "extends": "../../tsconfig.base.json", + "compilerOptions": { "types": ["node"] }, + "files": ["dist/index.d.ts"] + } + }, + "apiReport": { + "enabled": true, + "reportFolder": "/etc/", + "reportTempFolder": "/temp/" + }, + "docModel": { "enabled": false }, + "dtsRollup": { "enabled": false }, + "tsdocMetadata": { "enabled": false }, + "messages": { + "extractorMessageReporting": { + "ae-undocumented": { "logLevel": "error", "addToApiReportFile": false }, + "ae-missing-release-tag": { "logLevel": "none" } + } + } +} diff --git a/packages/client/etc/client.api.md b/packages/client/etc/client.api.md new file mode 100644 index 0000000..51e5d02 --- /dev/null +++ b/packages/client/etc/client.api.md @@ -0,0 +1,101 @@ +## API Report File for "@gatepost/client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Postcode } from '@gatepost/core'; +import { Precision } from '@gatepost/core'; + +// @public +export interface AdministrativeAddress { + readonly lgaName: string | null; + readonly localityName: string | null; + readonly stateName: string | null; + readonly zone: string | null; +} + +// @public +export interface AutocompleteResult { + readonly segment: Precision; + readonly suggestions: readonly { + readonly code: string; + readonly label: string | null; + readonly postcode: Postcode | null; + }[]; +} + +// @public +export interface ClientOptions { + readonly apiKey?: string | undefined; + readonly baseUrl?: string; + readonly cacheTtlMs?: number; + readonly maxRetries?: number; + readonly timeoutMs?: number; + readonly transport?: typeof globalThis.fetch; +} + +// @public +export type Confidence = "high" | "medium" | "low"; + +// @public +export type LookupLevel = 1 | 2 | 3 | 4 | 5; + +// @public +export interface LookupResult { + readonly administrativeAddress: AdministrativeAddress | null; + readonly buildingUseStatus: string | null; + readonly levelReceived: LookupLevel; + readonly levelRequested: LookupLevel; + readonly postcode: Postcode; + readonly recentHouseAddress: string | null; + readonly status: LookupStatus | null; + readonly valid: boolean; +} + +// @public +export type LookupStatus = "valid" | "invalid" | "not_found" | "restricted"; + +// @public +export class PostcodeError extends Error { + constructor(code: PostcodeErrorCode, message: string, details?: Readonly<{ + status?: number | null; + apiCode?: string | null; + retryAfterMs?: number | null; + cause?: unknown; + }>); + readonly apiCode: string | null; + readonly code: PostcodeErrorCode; + override readonly name: string; + readonly retryAfterMs: number | null; + readonly status: number | null; +} + +// @public +export type PostcodeErrorCode = "invalid_input" | "unauthorized" | "insufficient_credits" | "origin_not_allowed" | "forbidden" | "rate_limited" | "server_error" | "unexpected_response" | "network_error" | "timeout"; + +// @public +export interface ReverseResult { + readonly area: Postcode | null; + readonly district: Postcode | null; + readonly found: boolean; + readonly radiusM: number; + readonly state: string | null; + readonly unit: ReverseUnit | null; +} + +// @public +export interface ReverseUnit { + readonly address: string | null; + readonly confidence: Confidence; + readonly distanceM: number; + readonly lgaName: string | null; + readonly localityName: string | null; + readonly postcode: Postcode; + readonly stateName: string | null; +} + +// @public +export const SPEC_VERSION = "0.2.0"; + +``` diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..0253b3f --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,57 @@ +{ + "name": "@gatepost/client", + "version": "0.0.0", + "description": "Unofficial. Not made or endorsed by NIPOST. Call NIPOST's postcode gateway.", + "license": "Apache-2.0", + "homepage": "https://github.com/gatepost-dev/js/tree/main/packages/client#readme", + "bugs": "https://github.com/gatepost-dev/js/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/gatepost-dev/js.git", + "directory": "packages/client" + }, + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE", + "NOTICE" + ], + "engines": { + "node": ">=22" + }, + "keywords": [ + "nigeria", + "postcode", + "address", + "nipost" + ], + "size-limit": [ + { + "path": "dist/index.js", + "limit": "5 KB" + } + ], + "dependencies": { + "@gatepost/core": "workspace:^" + }, + "devDependencies": { + "@gatepost/mock-server": "workspace:*" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run --coverage", + "build": "tsdown", + "package:check": "run-s package:lint package:smoke", + "package:lint": "publint && attw --pack --profile esm-only && size-limit && api-extractor run", + "package:smoke": "node scripts/smoke.mjs", + "api:update": "api-extractor run --local", + "licence-files:check": "cmp ../../LICENSE LICENSE && cmp ../../NOTICE NOTICE" + } +} diff --git a/packages/client/scripts/smoke.mjs b/packages/client/scripts/smoke.mjs new file mode 100644 index 0000000..dca85df --- /dev/null +++ b/packages/client/scripts/smoke.mjs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Starts the built package as a user does: by its name, through the exports map of package.json. +// The unit tests import src/, and publint, attw, size-limit and API Extractor only read dist/. +// So this is the one check that runs the built code. +import { PostcodeError, SPEC_VERSION } from '@gatepost/client'; + +const error = new PostcodeError('timeout', 'The gateway sent no response in time.'); +if (!(error instanceof Error) || error.code !== 'timeout' || SPEC_VERSION !== '0.2.0') { + throw new Error('The built package does not give the error type and the spec version.'); +} diff --git a/packages/client/src/error.ts b/packages/client/src/error.ts new file mode 100644 index 0000000..7781e54 --- /dev/null +++ b/packages/client/src/error.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import type { PostcodeErrorCode } from './types.js'; + +/** + * The error that each call of `PostcodeClient` raises when it cannot give a result. A + * cancelled call raises the platform's own abort error instead. + * + * @example + * ```ts + * import { PostcodeClient, PostcodeError } from '@gatepost/client'; + * + * const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); + * try { + * await client.lookup('FC-01-Z99'); + * } catch (error) { + * if (error instanceof PostcodeError) { + * error.code; // 'invalid_input' + * error.status; // null + * } + * } + * ``` + */ +export class PostcodeError extends Error { + /** Always `PostcodeError`, so that a log names the error type. */ + override readonly name: string = 'PostcodeError'; + /** Why the call failed, as a stable code. */ + readonly code: PostcodeErrorCode; + /** The HTTP status of the response, or null when no response arrived. */ + readonly status: number | null; + /** The `error.code` of the response body, or null. */ + readonly apiCode: string | null; + /** The wait that `Retry-After` asked for, in milliseconds, or null. */ + readonly retryAfterMs: number | null; + + /** + * Makes an error. The client makes each one. A test of code that uses the client can make + * one too. + * + * @param code - Why the call failed. + * @param message - What happened and what to do, with no API key in it. + * @param details - The `status`, the `apiCode`, the `retryAfterMs` and the `cause`. Each one + * that is missing is null. + */ + constructor( + code: PostcodeErrorCode, + message: string, + details: Readonly<{ + status?: number | null; + apiCode?: string | null; + retryAfterMs?: number | null; + cause?: unknown; + }> = {}, + ) { + super(message, details.cause === undefined ? undefined : { cause: details.cause }); + this.code = code; + this.status = details.status ?? null; + this.apiCode = details.apiCode ?? null; + this.retryAfterMs = details.retryAfterMs ?? null; + } +} + +/** + * The part of a response that decides the error: its status, its body and its `Retry-After`. + * + * @internal + */ +export interface FailedResponse { + readonly status: number; + readonly apiCode: string | null; + readonly retryAfterMs: number | null; +} + +function codeFor({ status, apiCode }: FailedResponse): PostcodeErrorCode { + if (status === 401) { + return 'unauthorized'; + } + if (status === 402) { + return 'insufficient_credits'; + } + if (status === 403) { + return apiCode === 'origin_not_allowed' ? 'origin_not_allowed' : 'forbidden'; + } + if (status === 429) { + return 'rate_limited'; + } + return status >= 400 && status < 500 ? 'invalid_input' : 'server_error'; +} + +const ADVICE: Readonly> = { + invalid_input: 'The gateway refused the request as invalid. Check the arguments of the call.', + unauthorized: 'The gateway refused the API key. Check the apiKey option.', + insufficient_credits: 'The account has no credits for this call. Add credits, or use level 1.', + origin_not_allowed: "The gateway refused this page's origin. Add it to the publishable key.", + forbidden: "The gateway refused the call. Check the key's scope and lookup level.", + rate_limited: 'The gateway limits the requests of this key. Wait, then try again.', + server_error: 'The gateway failed. Try again later.', + unexpected_response: 'The gateway sent a reply that this client cannot read.', + network_error: 'The request did not reach the gateway. Check the network and the baseUrl.', + timeout: 'The gateway sent no response in time. Try again, or raise timeoutMs.', +}; + +/** + * Maps a response that is not a success to its error, as the table in `spec/client.md` says. + * + * @param response - The status, the API's error code and the wait of the response. + * @returns The error. + * @internal + */ +export function errorForResponse(response: FailedResponse): PostcodeError { + const code = codeFor(response); + const apiCode = response.apiCode === null ? '' : `, ${response.apiCode}`; + return new PostcodeError(code, `${ADVICE[code]} (status ${String(response.status)}${apiCode})`, { + status: response.status, + apiCode: response.apiCode, + retryAfterMs: response.retryAfterMs, + }); +} + +/** + * Makes an error for a failure with no response, or for a check of the client. + * + * @param code - `invalid_input`, `network_error`, `timeout`, `unexpected_response` or + * `server_error`. + * @param detail - A sentence that adds to the advice, or an empty string. + * @param cause - The error that the platform raised, if any. + * @returns The error. + * @internal + */ +export function clientError( + code: PostcodeErrorCode, + detail: string, + cause?: unknown, +): PostcodeError { + const message = code === 'invalid_input' ? detail : `${ADVICE[code]} ${detail}`.trim(); + return new PostcodeError(code, message, { cause }); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..e1dc79f --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Call NIPOST's postcode gateway: look up a postcode, find the unit nearest to a coordinate, + * and complete a postcode that a user is typing. + * + * Unofficial. Not made or endorsed by NIPOST. + * + * @packageDocumentation + */ +export { PostcodeError } from './error.js'; +export type { + AdministrativeAddress, + AutocompleteResult, + ClientOptions, + Confidence, + LookupLevel, + LookupResult, + LookupStatus, + PostcodeErrorCode, + ReverseResult, + ReverseUnit, +} from './types.js'; +export { SPEC_VERSION } from './version.js'; diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts new file mode 100644 index 0000000..680d929 --- /dev/null +++ b/packages/client/src/types.ts @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import type { Postcode, Precision } from '@gatepost/core'; + +/** + * Settings for a `PostcodeClient`. Each one is optional. + */ +export interface ClientOptions { + /** The key for the `X-API-Key` header. With no key, the client sends no header. */ + readonly apiKey?: string | undefined; + /** The gateway's address. The default is `https://api.postcode.gov.ng`. */ + readonly baseUrl?: string; + /** The function that sends each request. The default is the platform's `fetch`. */ + readonly transport?: typeof globalThis.fetch; + /** The longest wait for one attempt, in milliseconds. The default is 8000. */ + readonly timeoutMs?: number; + /** The most retries after the first attempt. The default is 2. */ + readonly maxRetries?: number; + /** How long the client keeps a result, in milliseconds. The default 0 turns the cache off. */ + readonly cacheTtlMs?: number; +} + +/** + * How much data a lookup asks for, from 1 for validity only to 5 for point geometry. + */ +export type LookupLevel = 1 | 2 | 3 | 4 | 5; + +/** + * The gateway's verdict on a postcode. `invalid` means a wrong form, and `not_found` means a + * right form that the gateway does not know. + */ +export type LookupStatus = 'valid' | 'invalid' | 'not_found' | 'restricted'; + +/** + * The names of the places that hold a postcode. The gateway sends them from lookup level 2. + * A name that the response leaves out is null. + */ +export interface AdministrativeAddress { + /** The state's name. */ + readonly stateName: string | null; + /** The LGA's name. */ + readonly lgaName: string | null; + /** The locality's name. */ + readonly localityName: string | null; + /** The geopolitical zone. */ + readonly zone: string | null; +} + +/** + * The facts that a lookup returned for one postcode. + */ +export interface LookupResult { + /** The postcode that the caller asked for, as the core parsed it. */ + readonly postcode: Postcode; + /** True when the gateway knows the postcode. */ + readonly valid: boolean; + /** The gateway's verdict, or null when the response has none that this client knows. */ + readonly status: LookupStatus | null; + /** The lookup level that the caller asked for. */ + readonly levelRequested: LookupLevel; + /** The lookup level of the data in the response, which the client reads from its fields. */ + readonly levelReceived: LookupLevel; + /** The names of the places that hold the postcode, from level 2, or null. */ + readonly administrativeAddress: AdministrativeAddress | null; + /** The most recent house address at the postcode, from level 2, or null. */ + readonly recentHouseAddress: string | null; + /** What the building is used for, from level 3, or null. */ + readonly buildingUseStatus: string | null; +} + +/** + * How sure the gateway is that a unit holds a coordinate. It falls with the distance. + */ +export type Confidence = 'high' | 'medium' | 'low'; + +/** + * The unit nearest to a coordinate. + */ +export interface ReverseUnit { + /** The unit's postcode. */ + readonly postcode: Postcode; + /** The distance from the coordinate, in metres. */ + readonly distanceM: number; + /** How sure the gateway is. An unknown value becomes `low`. */ + readonly confidence: Confidence; + /** The state's name, or null. */ + readonly stateName: string | null; + /** The LGA's name, or null. */ + readonly lgaName: string | null; + /** The locality's name, or null. */ + readonly localityName: string | null; + /** The address of the unit, or null. */ + readonly address: string | null; +} + +/** + * What the gateway found near a coordinate. `found` can be true while `unit` is null: the + * gateway then found an area, but no unit within the radius. + */ +export interface ReverseResult { + /** True when the gateway found a unit or an area within the radius. */ + readonly found: boolean; + /** The radius that the gateway applied, in metres. */ + readonly radiusM: number; + /** The nearest unit, or null. */ + readonly unit: ReverseUnit | null; + /** The area as a partial postcode, or null. */ + readonly area: Postcode | null; + /** The district as a partial postcode, or null. */ + readonly district: Postcode | null; + /** The state code, or null. */ + readonly state: string | null; +} + +/** + * The values that the gateway offers for the segment that a user is typing. + */ +export interface AutocompleteResult { + /** The segment that holds the last character that the user typed. */ + readonly segment: Precision; + /** The values, in the gateway's order. */ + readonly suggestions: readonly { + /** The value of the active segment, as the gateway sent it, such as `Z99`. */ + readonly code: string; + /** A label for the value, or null. The gateway has sent none so far. */ + readonly label: string | null; + /** The typed segments before the active one, followed by `code`, or null. */ + readonly postcode: Postcode | null; + }[]; +} + +/** + * Why a call failed. `spec/client.md` maps each HTTP status to one code. + */ +export type PostcodeErrorCode = + | 'invalid_input' + | 'unauthorized' + | 'insufficient_credits' + | 'origin_not_allowed' + | 'forbidden' + | 'rate_limited' + | 'server_error' + | 'unexpected_response' + | 'network_error' + | 'timeout'; diff --git a/packages/client/src/version.ts b/packages/client/src/version.ts new file mode 100644 index 0000000..6051b64 --- /dev/null +++ b/packages/client/src/version.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The version of the Gatepost spec that this package implements. + * + * @example + * ```ts + * import { SPEC_VERSION } from '@gatepost/client'; + * + * SPEC_VERSION; // '0.2.0' + * ``` + */ +export const SPEC_VERSION = '0.2.0'; diff --git a/packages/client/test/error.test.ts b/packages/client/test/error.test.ts new file mode 100644 index 0000000..b6a8af4 --- /dev/null +++ b/packages/client/test/error.test.ts @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { clientError, errorForResponse } from '../src/error.js'; +import { PostcodeError } from '../src/index.js'; + +describe('the status map of spec/client.md', () => { + it.each([ + [401, 'auth_required', 'unauthorized'], + [402, 'insufficient_credits', 'insufficient_credits'], + [403, 'origin_not_allowed', 'origin_not_allowed'], + [403, 'level_not_granted', 'forbidden'], + [403, null, 'forbidden'], + [429, 'rate_limited', 'rate_limited'], + [400, 'invalid_request', 'invalid_input'], + [404, 'not_found', 'invalid_input'], + [422, null, 'invalid_input'], + [499, null, 'invalid_input'], + [500, 'internal', 'server_error'], + [502, null, 'server_error'], + [503, null, 'server_error'], + [504, null, 'server_error'], + [204, null, 'server_error'], + [302, null, 'server_error'], + ] as const)('maps %i with the API code %s to %s', (status, apiCode, code) => { + const error = errorForResponse({ status, apiCode, retryAfterMs: null }); + expect([error.code, error.status, error.apiCode]).toEqual([code, status, apiCode]); + }); + + it('names the status and the API code in the message, after the advice', () => { + const error = errorForResponse({ + status: 403, + apiCode: 'level_not_granted', + retryAfterMs: null, + }); + expect(error.message).toBe( + "The gateway refused the call. Check the key's scope and lookup level." + + ' (status 403, level_not_granted)', + ); + }); + + it('keeps the wait that Retry-After asked for', () => { + const error = errorForResponse({ status: 429, apiCode: null, retryAfterMs: 120_000 }); + expect(error.retryAfterMs).toBe(120_000); + expect(error.message).toContain('(status 429)'); + }); +}); + +describe('PostcodeError', () => { + it('is an Error with its own name, and keeps its cause', () => { + const cause = new TypeError('fetch failed'); + const error = new PostcodeError('network_error', 'No response.', { cause }); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('PostcodeError'); + expect(error.cause).toBe(cause); + expect([error.status, error.apiCode, error.retryAfterMs]).toEqual([null, null, null]); + }); +}); + +describe('clientError', () => { + it('adds the advice for a failure with no response, and keeps its cause', () => { + const cause = new TypeError('fetch failed'); + const error = clientError('network_error', '', cause); + expect(error.message).toBe( + 'The request did not reach the gateway. Check the network and the baseUrl.', + ); + expect([error.status, error.cause]).toEqual([null, cause]); + }); + + it('gives only the detail for input that fails the check of the client', () => { + expect(clientError('invalid_input', 'The text is empty.').message).toBe('The text is empty.'); + }); + + it('adds the detail after the advice for a reply that the client cannot read', () => { + const error = clientError('unexpected_response', 'The body has no data field.'); + expect(error.code).toBe('unexpected_response'); + expect(error.message).toBe( + 'The gateway sent a reply that this client cannot read. The body has no data field.', + ); + }); +}); diff --git a/packages/client/test/version.test.ts b/packages/client/test/version.test.ts new file mode 100644 index 0000000..aed8828 --- /dev/null +++ b/packages/client/test/version.test.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { SPEC_VERSION } from '../src/index.js'; + +describe('SPEC_VERSION', () => { + it('matches the version of the spec submodule', () => { + const specVersion = readFileSync(new URL('../../../spec/VERSION', import.meta.url), 'utf8'); + expect(SPEC_VERSION).toBe(specVersion.trim()); + }); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..ccc6840 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "allowImportingTsExtensions": true, + "paths": { "@gatepost/core": ["../core/src/index.ts"] } + }, + "include": ["src", "test"] +} diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts new file mode 100644 index 0000000..93f11fd --- /dev/null +++ b/packages/client/tsdown.config.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { defineConfig } from 'tsdown'; + +// The bundler drops the package comment of src/index.ts from the declarations, and API Extractor +// reads that comment from the top of dist/index.d.ts. So the build puts the comment back. +function packageComment(): string { + const source = readFileSync(new URL('./src/index.ts', import.meta.url), 'utf8'); + const comments = source.match(/\/\*\*[\s\S]*?\*\//g) ?? []; + const comment = comments.find((text) => text.includes('@packageDocumentation')); + if (comment === undefined) { + throw new Error('src/index.ts has no doc comment with the tag @packageDocumentation.'); + } + return comment; +} + +export default defineConfig({ + entry: ['src/index.ts'], + format: 'esm', + platform: 'neutral', + target: 'es2022', + dts: true, + banner: { dts: packageComment() }, + clean: true, +}); diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts new file mode 100644 index 0000000..71a0439 --- /dev/null +++ b/packages/client/vitest.config.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + // The tests run against the source of the core, so they need no build of it first. + resolve: { + alias: { '@gatepost/core': fileURLToPath(new URL('../core/src/index.ts', import.meta.url)) }, + }, + test: { + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + thresholds: { branches: 90, functions: 90, lines: 90, statements: 90 }, + }, + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json index b7c55a4..d906f36 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,7 @@ "package:check": "run-s package:lint package:smoke", "package:lint": "publint && attw --pack --profile esm-only && size-limit && api-extractor run", "package:smoke": "node scripts/smoke.mjs", - "api:update": "api-extractor run --local" + "api:update": "api-extractor run --local", + "licence-files:check": "cmp ../../LICENSE LICENSE && cmp ../../NOTICE NOTICE" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9612e03..4c4d020 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,16 @@ importers: specifier: ^2.9.1 version: 2.9.1 + packages/client: + dependencies: + '@gatepost/core': + specifier: workspace:^ + version: link:../core + devDependencies: + '@gatepost/mock-server': + specifier: workspace:* + version: link:../mock-server + packages/core: {} packages/mock-server: {} From 7bd6cf59358ebe43cd373bfc9515db8cdd1b488d Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:55:43 +0100 Subject: [PATCH 14/36] fix(client): match the result types to the client contract A reverse result can come without a radius, so radiusM may be null. The client does not parse the area and the district of a reverse result, so both are the text that the gateway sent. A new gateway can send a lookup status that the client does not know, and the client keeps it as text, so the status type is text and not a closed list. Smaller fixes: the doc of timeoutMs gives the longer wait of autocomplete, the smoke script reads the spec version from the spec folder, the decision record no longer cites private planning work, and the error tests cover more status values. Signed-off-by: MobileMage --- docs/adr/0001-client-depends-on-core.md | 2 +- packages/client/etc/client.api.md | 8 ++++---- packages/client/scripts/smoke.mjs | 6 +++++- packages/client/src/error.ts | 3 ++- packages/client/src/types.ts | 26 ++++++++++++++----------- packages/client/test/error.test.ts | 8 ++++++++ packages/client/test/types.test.ts | 17 ++++++++++++++++ 7 files changed, 52 insertions(+), 18 deletions(-) create mode 100644 packages/client/test/types.test.ts diff --git a/docs/adr/0001-client-depends-on-core.md b/docs/adr/0001-client-depends-on-core.md index b1a88f9..e8231b7 100644 --- a/docs/adr/0001-client-depends-on-core.md +++ b/docs/adr/0001-client-depends-on-core.md @@ -6,7 +6,7 @@ Date: 9 Oct 2026 `@gatepost/client` must parse each postcode before it sends a lookup, so that a bad code sends no request. It also returns parsed postcodes in its results, and it builds the partial postcode of each autocomplete offer. `@gatepost/core` holds the only parser, with the state table and the segment rules from the spec data. -The roadmap says that the client has no runtime dependencies. DEP-3 asks for a record of each new runtime dependency. +The client was first planned with no runtime dependencies. DEP-3 asks for a record of each new runtime dependency. ## Decision diff --git a/packages/client/etc/client.api.md b/packages/client/etc/client.api.md index 51e5d02..42f05d2 100644 --- a/packages/client/etc/client.api.md +++ b/packages/client/etc/client.api.md @@ -49,7 +49,7 @@ export interface LookupResult { readonly levelRequested: LookupLevel; readonly postcode: Postcode; readonly recentHouseAddress: string | null; - readonly status: LookupStatus | null; + readonly status: string | null; readonly valid: boolean; } @@ -76,10 +76,10 @@ export type PostcodeErrorCode = "invalid_input" | "unauthorized" | "insufficient // @public export interface ReverseResult { - readonly area: Postcode | null; - readonly district: Postcode | null; + readonly area: string | null; + readonly district: string | null; readonly found: boolean; - readonly radiusM: number; + readonly radiusM: number | null; readonly state: string | null; readonly unit: ReverseUnit | null; } diff --git a/packages/client/scripts/smoke.mjs b/packages/client/scripts/smoke.mjs index dca85df..11ba51d 100644 --- a/packages/client/scripts/smoke.mjs +++ b/packages/client/scripts/smoke.mjs @@ -3,9 +3,13 @@ // Starts the built package as a user does: by its name, through the exports map of package.json. // The unit tests import src/, and publint, attw, size-limit and API Extractor only read dist/. // So this is the one check that runs the built code. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { PostcodeError, SPEC_VERSION } from '@gatepost/client'; +const specVersion = readFileSync(join(import.meta.dirname, '../../../spec/VERSION'), 'utf8').trim(); + const error = new PostcodeError('timeout', 'The gateway sent no response in time.'); -if (!(error instanceof Error) || error.code !== 'timeout' || SPEC_VERSION !== '0.2.0') { +if (!(error instanceof Error) || error.code !== 'timeout' || SPEC_VERSION !== specVersion) { throw new Error('The built package does not give the error type and the spec version.'); } diff --git a/packages/client/src/error.ts b/packages/client/src/error.ts index 7781e54..49550b1 100644 --- a/packages/client/src/error.ts +++ b/packages/client/src/error.ts @@ -122,7 +122,8 @@ export function errorForResponse(response: FailedResponse): PostcodeError { * * @param code - `invalid_input`, `network_error`, `timeout`, `unexpected_response` or * `server_error`. - * @param detail - A sentence that adds to the advice, or an empty string. + * @param detail - A sentence that adds to the advice, or an empty string. For `invalid_input` + * the advice is left out, so the detail must be a full message and not empty. * @param cause - The error that the platform raised, if any. * @returns The error. * @internal diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 680d929..1268408 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -12,7 +12,10 @@ export interface ClientOptions { readonly baseUrl?: string; /** The function that sends each request. The default is the platform's `fetch`. */ readonly transport?: typeof globalThis.fetch; - /** The longest wait for one attempt, in milliseconds. The default is 8000. */ + /** + * The longest wait for one attempt, in milliseconds. The default is 8000, and 15000 for + * `autocomplete`. + */ readonly timeoutMs?: number; /** The most retries after the first attempt. The default is 2. */ readonly maxRetries?: number; @@ -26,8 +29,9 @@ export interface ClientOptions { export type LookupLevel = 1 | 2 | 3 | 4 | 5; /** - * The gateway's verdict on a postcode. `invalid` means a wrong form, and `not_found` means a - * right form that the gateway does not know. + * The verdicts that the gateway has sent so far. `invalid` means a wrong form, and `not_found` + * means a right form that the gateway does not know. A new gateway can send another one, so + * `LookupResult.status` is text. */ export type LookupStatus = 'valid' | 'invalid' | 'not_found' | 'restricted'; @@ -54,8 +58,8 @@ export interface LookupResult { readonly postcode: Postcode; /** True when the gateway knows the postcode. */ readonly valid: boolean; - /** The gateway's verdict, or null when the response has none that this client knows. */ - readonly status: LookupStatus | null; + /** The gateway's verdict as text, such as a `LookupStatus`, or null when it sent none. */ + readonly status: string | null; /** The lookup level that the caller asked for. */ readonly levelRequested: LookupLevel; /** The lookup level of the data in the response, which the client reads from its fields. */ @@ -100,14 +104,14 @@ export interface ReverseUnit { export interface ReverseResult { /** True when the gateway found a unit or an area within the radius. */ readonly found: boolean; - /** The radius that the gateway applied, in metres. */ - readonly radiusM: number; + /** The radius that the gateway applied, in metres, or null when the response has none. */ + readonly radiusM: number | null; /** The nearest unit, or null. */ readonly unit: ReverseUnit | null; - /** The area as a partial postcode, or null. */ - readonly area: Postcode | null; - /** The district as a partial postcode, or null. */ - readonly district: Postcode | null; + /** The area as the gateway sent it, or null. The client does not parse it. */ + readonly area: string | null; + /** The district as the gateway sent it, or null. The client does not parse it. */ + readonly district: string | null; /** The state code, or null. */ readonly state: string | null; } diff --git a/packages/client/test/error.test.ts b/packages/client/test/error.test.ts index b6a8af4..4694bd6 100644 --- a/packages/client/test/error.test.ts +++ b/packages/client/test/error.test.ts @@ -22,6 +22,9 @@ describe('the status map of spec/client.md', () => { [504, null, 'server_error'], [204, null, 'server_error'], [302, null, 'server_error'], + [100, null, 'server_error'], + [599, null, 'server_error'], + [401, 'unlisted_code', 'unauthorized'], ] as const)('maps %i with the API code %s to %s', (status, apiCode, code) => { const error = errorForResponse({ status, apiCode, retryAfterMs: null }); expect([error.code, error.status, error.apiCode]).toEqual([code, status, apiCode]); @@ -44,6 +47,11 @@ describe('the status map of spec/client.md', () => { expect(error.retryAfterMs).toBe(120_000); expect(error.message).toContain('(status 429)'); }); + + it('keeps the wait of a 502', () => { + const error = errorForResponse({ status: 502, apiCode: null, retryAfterMs: 3000 }); + expect([error.code, error.retryAfterMs]).toEqual(['server_error', 3000]); + }); }); describe('PostcodeError', () => { diff --git a/packages/client/test/types.test.ts b/packages/client/test/types.test.ts new file mode 100644 index 0000000..500c858 --- /dev/null +++ b/packages/client/test/types.test.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expectTypeOf, it } from 'vitest'; +import type { LookupResult, LookupStatus, ReverseResult } from '../src/index.js'; + +describe('the result types of spec/client.md', () => { + it('lets a lookup status be any text, because a new gateway can send one', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toExtend(); + }); + + it('gives a reverse result a nullable radius, and the area and district as gateway text', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); From 097630a5dffbb8efedd949c915e56e465c0f5989 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:56:47 +0100 Subject: [PATCH 15/36] feat(client): check the input and read the responses The client checks its input before it sends anything, so a code that does not parse, a level, coordinate or radius out of range, or an autocomplete query that is empty or too long, never reaches the gateway. Numbers in a query use the shortest decimal form, so every client sends the same query for the same input. The readers narrow each response and read only the fields that they know, because NIPOST's API is at version 0.1 and new fields will come. A part of a reply that is missing or of the wrong type raises unexpected_response. Signed-off-by: MobileMage --- packages/client/src/requests.ts | 146 +++++++++++++++++ packages/client/src/responses.ts | 216 ++++++++++++++++++++++++ packages/client/test/requests.test.ts | 156 ++++++++++++++++++ packages/client/test/responses.test.ts | 219 +++++++++++++++++++++++++ 4 files changed, 737 insertions(+) create mode 100644 packages/client/src/requests.ts create mode 100644 packages/client/src/responses.ts create mode 100644 packages/client/test/requests.test.ts create mode 100644 packages/client/test/responses.test.ts diff --git a/packages/client/src/requests.ts b/packages/client/src/requests.ts new file mode 100644 index 0000000..ace7daf --- /dev/null +++ b/packages/client/src/requests.ts @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { normalize, parse, type Postcode } from '@gatepost/core'; +import { clientError } from './error.js'; +import type { LookupLevel } from './types.js'; + +// A typed prefix of a postcode: letters and digits, at most as many as a whole compact code. +const QUERY = /^[A-Z0-9]{1,11}$/; +// The input limit of the grammar, maxInputCodePoints in spec/data/format.json. A test reads that +// file, so a change there fails CI. normalize has no limit of its own, and long text can stall it. +const INPUT_LIMIT = 64; +// The most that the gateway accepts as a radius, in metres. +const MAX_DISTANCE_M = 250; +// What String prints for a number below 1e-6 or above 1e21. +const EXPONENT_FORM = /^(-?)(\d)(?:\.(\d+))?e([+-]\d+)$/; + +// A code point takes one or two UTF-16 units, so the first 2 x (limit + 1) units hold one code +// point more than the limit whenever the whole text does. +function isOverInputLimit(text: string): boolean { + const head = text.slice(0, 2 * (INPUT_LIMIT + 1)); + return text.length > INPUT_LIMIT && Array.from(head).length > INPUT_LIMIT; +} + +// The shortest decimal form that reads back as the same number, with no exponent. Every +// client prints the same query for the same input, whatever its language prints for a number. +function decimal(value: number): string { + if (value === 0) { + return '0'; + } + const printed = String(value); + const parts = EXPONENT_FORM.exec(printed); + if (parts === null) { + return printed; + } + const [, sign = '', first = '', rest = '', exponent = '0'] = parts; + const digits = first + rest; + const point = 1 + Number(exponent); + if (point <= 0) { + return `${sign}0.${'0'.repeat(-point)}${digits}`; + } + if (point >= digits.length) { + return `${sign}${digits}${'0'.repeat(point - digits.length)}`; + } + return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`; +} + +function requireRange(name: string, value: unknown, min: number, max: number): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) { + throw clientError( + 'invalid_input', + `${name} must be a number from ${String(min)} to ${String(max)}.`, + ); + } + return value; +} + +/** + * One request to the gateway: its path and its query. + * + * @internal + */ +export interface GatewayRequest { + readonly path: string; + readonly query: Readonly>; +} + +/** + * Builds a lookup request. The code must parse as a whole postcode, so that no request goes + * out for text that the gateway would only call invalid. + * + * @param code - The postcode as text, or as a postcode that `parse` returned. + * @param level - The lookup level, from 1 to 5. The default is 1. + * @returns The parsed postcode, the level and the request. + * @throws PostcodeError `invalid_input` when the code is not a whole postcode, or when the + * level is not a whole number from 1 to 5. + * @internal + */ +export function lookupRequest( + code: string | Postcode, + level: number | undefined, +): { readonly postcode: Postcode; readonly level: LookupLevel; readonly request: GatewayRequest } { + const wanted = level ?? 1; + if (!Number.isInteger(wanted) || wanted < 1 || wanted > 5) { + throw clientError('invalid_input', 'The level must be a whole number from 1 to 5.'); + } + const parsed = parse(typeof code === 'string' ? code : code.canonical); + if (!parsed.ok) { + const reason = `The code is not a whole postcode (${parsed.error.code}).`; + throw clientError('invalid_input', `${reason} Check it with parse from @gatepost/core.`); + } + const query = { code: parsed.value.canonical, level: String(wanted) }; + return { + postcode: parsed.value, + level: wanted as LookupLevel, + request: { path: '/v1/lookup', query }, + }; +} + +/** + * Builds a reverse geocode request. + * + * @param lat - The latitude in degrees, from -90 to 90. + * @param lng - The longitude in degrees, from -180 to 180. + * @param maxDistanceM - The radius in metres, from 0 to 250, or undefined for the gateway's + * default. The gateway cuts a larger value without a sign, so the client refuses it. + * @returns The request. + * @throws PostcodeError `invalid_input` when a number is outside its range or is not finite. + * @internal + */ +export function reverseRequest( + lat: number, + lng: number, + maxDistanceM: number | undefined, +): GatewayRequest { + const query: Record = { + lat: decimal(requireRange('The latitude', lat, -90, 90)), + lng: decimal(requireRange('The longitude', lng, -180, 180)), + }; + if (maxDistanceM !== undefined) { + const radius = requireRange('The radius', maxDistanceM, 0, MAX_DISTANCE_M); + query['max_distance_m'] = decimal(radius); + } + return { path: '/v1/search/reverse', query }; +} + +/** + * Builds an autocomplete request from the text that a user typed. + * + * @param q - The typed text. + * @returns The normalised text and the request. + * @throws PostcodeError `invalid_input` when the text is over the input limit, or when the + * normalised text is empty, too long, or holds a character other than A to Z and 0 to 9. The + * gateway does not answer an empty query. + * @internal + */ +export function autocompleteRequest(q: string): { + readonly typed: string; + readonly request: GatewayRequest; +} { + const typed = isOverInputLimit(q) ? '' : normalize(q); + if (!QUERY.test(typed)) { + const rule = 'The text must hold 1 to 11 letters and digits after normalize.'; + throw clientError('invalid_input', `${rule} Send no call for empty text.`); + } + return { typed, request: { path: '/v1/search/autocomplete', query: { q: typed } } }; +} diff --git a/packages/client/src/responses.ts b/packages/client/src/responses.ts new file mode 100644 index 0000000..d1fb418 --- /dev/null +++ b/packages/client/src/responses.ts @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Each reader narrows the JSON of a response and reads only the fields that it knows (TS-20). +// Unknown fields stay unread. A known field of the wrong type raises unexpected_response, and +// an unknown value of a field that allows one gets its fallback (API-14). +import { parse, type Postcode, type Precision } from '@gatepost/core'; +import { PostcodeError } from './error.js'; +import type { + AdministrativeAddress, + AutocompleteResult, + Confidence, + LookupLevel, + LookupResult, + ReverseResult, + ReverseUnit, +} from './types.js'; + +type Fields = Readonly>; + +const CONFIDENCES: readonly Confidence[] = ['high', 'medium', 'low']; +const SEGMENTS: readonly Precision[] = ['state', 'lga', 'district', 'area', 'unit']; + +function isFields(json: unknown): json is Fields { + return typeof json === 'object' && json !== null && !Array.isArray(json); +} + +function textOf(fields: Fields, name: string): string | null { + const field = fields[name]; + return typeof field === 'string' ? field : null; +} + +function present(fields: Fields, name: string): boolean { + return fields[name] !== undefined && fields[name] !== null; +} + +function oneOf(allowed: readonly T[], field: unknown): T | null { + return allowed.find((choice) => choice === field) ?? null; +} + +// The readers see only responses with status 200, so the error carries that status. +function unexpected(what: string): never { + const message = `The gateway sent a reply that this client cannot read: ${what}.`; + throw new PostcodeError('unexpected_response', message, { status: 200 }); +} + +/** + * Reads the API's own error code from the body of a failed response. + * + * @param body - The parsed JSON body, or undefined when the body is not JSON. + * @returns The text in `error.code`, or null. + * @internal + */ +export function apiCodeOf(body: unknown): string | null { + const error = isFields(body) ? body['error'] : undefined; + return isFields(error) ? textOf(error, 'code') : null; +} + +/** + * Reads the `data` field of a successful response. + * + * @param body - The parsed JSON body, or undefined when the body is not JSON. + * @returns The value of `data`. + * @throws PostcodeError `unexpected_response` when the body is not a JSON object with `data`. + * @internal + */ +export function dataOf(body: unknown): unknown { + if (!isFields(body) || body['data'] === undefined) { + return unexpected('the body is not a JSON object with a data field'); + } + return body['data']; +} + +function levelOf(data: Fields): LookupLevel { + if (present(data, 'point_geometry')) { + return 5; + } + if (present(data, 'other_building_info')) { + return 4; + } + if (present(data, 'building_use_status')) { + return 3; + } + return present(data, 'administrative_address') || present(data, 'recent_house_address') ? 2 : 1; +} + +function addressOf(data: Fields): AdministrativeAddress | null { + const address = data['administrative_address']; + if (!isFields(address)) { + return null; + } + return { + stateName: textOf(address, 'state_name'), + lgaName: textOf(address, 'lga_name'), + localityName: textOf(address, 'locality_name'), + zone: textOf(address, 'zone'), + }; +} + +/** + * Reads the data of a lookup response. + * + * @param data - The `data` field of the response. + * @param request - The parsed postcode and the level that the caller asked for. + * @returns The lookup result. A `status` of any text stays as it is. + * @throws PostcodeError `unexpected_response` when `valid` is not a boolean, or when `status` + * is present, not null and not text. + * @internal + */ +export function readLookup( + data: unknown, + request: Readonly<{ postcode: Postcode; level: LookupLevel }>, +): LookupResult { + if (!isFields(data) || typeof data['valid'] !== 'boolean') { + return unexpected('the lookup has no valid field of type boolean'); + } + if (present(data, 'status') && typeof data['status'] !== 'string') { + return unexpected('the lookup has a status that is not text'); + } + const house = data['recent_house_address']; + return { + postcode: request.postcode, + valid: data['valid'], + status: textOf(data, 'status'), + levelRequested: request.level, + levelReceived: levelOf(data), + administrativeAddress: addressOf(data), + recentHouseAddress: isFields(house) ? textOf(house, 'recent') : null, + buildingUseStatus: textOf(data, 'building_use_status'), + }; +} + +function unitOf(unit: unknown): ReverseUnit | null { + if (unit === undefined || unit === null) { + return null; + } + if (!isFields(unit) || typeof unit['distance_m'] !== 'number') { + return unexpected('the unit of a reverse result has no distance_m of type number'); + } + const parsed = parse(textOf(unit, 'postcode') ?? ''); + if (!parsed.ok) { + return unexpected('the unit of a reverse result has a postcode that does not parse'); + } + return { + postcode: parsed.value, + distanceM: unit['distance_m'], + confidence: oneOf(CONFIDENCES, unit['confidence']) ?? 'low', + stateName: textOf(unit, 'state_name'), + lgaName: textOf(unit, 'lga_name'), + localityName: textOf(unit, 'locality_name'), + address: textOf(unit, 'address'), + }; +} + +/** + * Reads the data of a reverse geocode response. + * + * @param data - The `data` field of the response. + * @returns The reverse result. + * @throws PostcodeError `unexpected_response` when `found` is not a boolean, or when a unit is + * present and its postcode does not parse or it has no distance. + * @internal + */ +export function readReverse(data: unknown): ReverseResult { + if (!isFields(data) || typeof data['found'] !== 'boolean') { + return unexpected('the reverse result has no found field of type boolean'); + } + const radius = data['radius_m']; + return { + found: data['found'], + radiusM: typeof radius === 'number' ? radius : null, + unit: unitOf(data['unit']), + area: textOf(data, 'area'), + district: textOf(data, 'district'), + state: textOf(data, 'state'), + }; +} + +// The gateway sends the value of the active segment only. The typed segments before it, plus +// that value, make a partial postcode of the active segment's precision. Only one cut of the +// typed text gives that precision, so the loop needs no table of segment lengths. +function suggestedPostcode(typed: string, code: string, segment: Precision): Postcode | null { + for (let end = typed.length - 1; end >= 0; end -= 1) { + const parsed = parse(typed.slice(0, end) + code, { allowPartial: true }); + if (parsed.ok && parsed.value.precision === segment) { + return parsed.value; + } + } + return null; +} + +/** + * Reads the data of an autocomplete response. + * + * @param data - The `data` field of the response. + * @param typed - The normalised text that the request sent. + * @returns The autocomplete result. An item with no text in `code` is left out. + * @throws PostcodeError `unexpected_response` when `segment` is not a segment name, or + * `suggestions` is not a list. + * @internal + */ +export function readAutocomplete(data: unknown, typed: string): AutocompleteResult { + const segment = isFields(data) ? oneOf(SEGMENTS, data['segment']) : null; + const items = isFields(data) ? data['suggestions'] : undefined; + if (segment === null || !Array.isArray(items)) { + return unexpected('the autocomplete has no known segment or no suggestions list'); + } + const suggestions = items.filter(isFields).flatMap((item) => { + const code = textOf(item, 'code'); + if (code === null) { + return []; + } + const postcode = suggestedPostcode(typed, code, segment); + return [{ code, label: textOf(item, 'label'), postcode }]; + }); + return { segment, suggestions }; +} diff --git a/packages/client/test/requests.test.ts b/packages/client/test/requests.test.ts new file mode 100644 index 0000000..7fa2d87 --- /dev/null +++ b/packages/client/test/requests.test.ts @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { parse } from '@gatepost/core'; +import { describe, expect, it } from 'vitest'; +import { PostcodeError } from '../src/index.js'; +import { autocompleteRequest, lookupRequest, reverseRequest } from '../src/requests.js'; + +const FORMAT = new URL('../../../spec/data/format.json', import.meta.url); +const { maxInputCodePoints } = JSON.parse(readFileSync(FORMAT, 'utf8')) as { + readonly maxInputCodePoints: number; +}; + +function failure(build: () => unknown): PostcodeError { + try { + build(); + } catch (error: unknown) { + if (error instanceof PostcodeError) { + return error; + } + } + throw new Error('The call raised no PostcodeError.'); +} + +describe('lookupRequest', () => { + it('sends the canonical form and the level', () => { + const { postcode, request } = lookupRequest('fc01 z99 zz01', 2); + expect(postcode.canonical).toBe('FC-01-Z99-ZZ-01'); + expect(request).toEqual({ path: '/v1/lookup', query: { code: 'FC-01-Z99-ZZ-01', level: '2' } }); + }); + + it('uses level 1 when the caller leaves the level out', () => { + const { level, request } = lookupRequest('FC-01-Z99-ZZ-01', undefined); + expect(level).toBe(1); + expect(request.query['level']).toBe('1'); + }); + + it('takes a postcode that parse returned', () => { + const parsed = parse('LA-01-Z99-ZZ-01'); + if (!parsed.ok) { + throw new Error('The test code does not parse.'); + } + expect(lookupRequest(parsed.value, 1).request.query['code']).toBe('LA-01-Z99-ZZ-01'); + }); + + it('refuses a partial postcode, also one that parse returned, and names the reason', () => { + const area = parse('FC-01-Z99-ZZ', { allowPartial: true }); + if (!area.ok) { + throw new Error('The test code does not parse.'); + } + for (const code of ['FC-01-Z99', area.value]) { + const error = failure(() => lookupRequest(code, 1)); + expect(error.code).toBe('invalid_input'); + expect(error.status).toBeNull(); + expect(error.message).toContain('(bad_length)'); + } + }); + + it('refuses a legacy postcode', () => { + expect(failure(() => lookupRequest('900108', 1)).message).toContain('(legacy_code)'); + }); + + it.each([0, 6, 2.5, -1, Number.NaN, Number.POSITIVE_INFINITY, '2'])( + 'refuses the level %j', + (level) => { + const error = failure(() => lookupRequest('FC-01-Z99-ZZ-01', level as number)); + expect(error.code).toBe('invalid_input'); + expect(error.status).toBeNull(); + }, + ); +}); + +describe('reverseRequest', () => { + it('writes each number as JavaScript prints it, and adds the radius only when given', () => { + expect(reverseRequest(9, 7, undefined)).toEqual({ + path: '/v1/search/reverse', + query: { lat: '9', lng: '7' }, + }); + expect(reverseRequest(9.001, -0.5, 250).query).toEqual({ + lat: '9.001', + lng: '-0.5', + max_distance_m: '250', + }); + }); + + it('writes a number in full, with no exponent, and a negative zero as 0', () => { + expect(reverseRequest(0.0000001, 1.5e-7, undefined).query).toEqual({ + lat: '0.0000001', + lng: '0.00000015', + }); + expect(reverseRequest(-0.0000001, -0, 0).query).toEqual({ + lat: '-0.0000001', + lng: '0', + max_distance_m: '0', + }); + }); + + it('accepts the limits of each range', () => { + expect(reverseRequest(-90, 180, 0).query['lat']).toBe('-90'); + expect(reverseRequest(90, -180, 250).query['lng']).toBe('-180'); + }); + + it.each([ + [90.5, 7, undefined], + [-91, 7, undefined], + [Number.NaN, 7, undefined], + [Number.POSITIVE_INFINITY, 7, undefined], + [9, 180.1, undefined], + [9, -181, undefined], + [9, Number.NEGATIVE_INFINITY, undefined], + [9, 7, 251], + [9, 7, -1], + [9, 7, Number.NaN], + [9, '7', undefined], + ])('refuses lat %j, lng %j and radius %j', (lat, lng, radius) => { + const error = failure(() => reverseRequest(lat, lng as number, radius)); + expect(error.code).toBe('invalid_input'); + expect(error.status).toBeNull(); + }); +}); + +describe('autocompleteRequest', () => { + it('sends the normalised text', () => { + expect(autocompleteRequest(' fc-01 z ')).toEqual({ + typed: 'FC01Z', + request: { path: '/v1/search/autocomplete', query: { q: 'FC01Z' } }, + }); + }); + + it('accepts the 11 characters of a whole code, and refuses a twelfth', () => { + expect(autocompleteRequest('FC01Z99ZZ01').typed).toBe('FC01Z99ZZ01'); + expect(failure(() => autocompleteRequest('FC01Z99ZZ011')).code).toBe('invalid_input'); + }); + + it.each(['', ' - ', 'FC!', `FC 01 ${String.fromCodePoint(0xe9)}`])( + 'refuses %j, which the gateway cannot use', + (q) => { + expect(failure(() => autocompleteRequest(q)).code).toBe('invalid_input'); + }, + ); + + it('applies the input limit of the spec data before it normalises', () => { + const atLimit = 'F'.padEnd(maxInputCodePoints); + const overLimit = 'F'.padEnd(maxInputCodePoints + 1); + expect(autocompleteRequest(atLimit).typed).toBe('F'); + expect(failure(() => autocompleteRequest(overLimit)).code).toBe('invalid_input'); + }); + + it('counts code points, not UTF-16 units, for the input limit', () => { + // Each mathematical bold capital is one code point in two UTF-16 units. NFKC makes it a + // plain letter, so text at the limit still works. + const bold = String.fromCodePoint(0x1d405, 0x1d402) + ' '.repeat(maxInputCodePoints - 2); + expect(bold.length).toBeGreaterThan(maxInputCodePoints); + expect(autocompleteRequest(bold).typed).toBe('FC'); + }); +}); diff --git a/packages/client/test/responses.test.ts b/packages/client/test/responses.test.ts new file mode 100644 index 0000000..ab60fcc --- /dev/null +++ b/packages/client/test/responses.test.ts @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The contract scenarios cover the shapes that the gateway sends. These tests cover the shapes +// that it might send next, because NIPOST's API is at version 0.1 (API-14). +import { parse, type Postcode } from '@gatepost/core'; +import { describe, expect, it } from 'vitest'; +import { PostcodeError } from '../src/index.js'; +import { apiCodeOf, dataOf, readAutocomplete, readLookup, readReverse } from '../src/responses.js'; + +function postcode(text: string): Postcode { + const parsed = parse(text, { allowPartial: true }); + if (!parsed.ok) { + throw new Error(`${text} does not parse.`); + } + return parsed.value; +} + +const UNIT = postcode('FC-01-Z99-ZZ-01'); + +function unexpected(read: () => unknown): PostcodeError { + try { + read(); + } catch (error: unknown) { + if (error instanceof PostcodeError && error.code === 'unexpected_response') { + return error; + } + } + throw new Error('The reader raised no unexpected_response.'); +} + +describe('dataOf and apiCodeOf', () => { + it('reads the data field, which can be any JSON value', () => { + expect(dataOf({ data: [] })).toEqual([]); + }); + + it.each([undefined, null, [], 'text', { error: {} }, { result: {} }])( + 'gives unexpected_response with status 200 for the body %j', + (body) => { + expect(unexpected(() => dataOf(body)).status).toBe(200); + }, + ); + + it.each([ + [{ error: { code: 'level_not_granted', message: 'any' } }, 'level_not_granted'], + [{ error: { code: 7 } }, null], + [{ error: 'level_not_granted' }, null], + [undefined, null], + ])('reads the API code of %j as %j', (body, apiCode) => { + expect(apiCodeOf(body)).toBe(apiCode); + }); +}); + +describe('readLookup', () => { + const read = (data: unknown) => readLookup(data, { postcode: UNIT, level: 5 }); + + it.each([ + [{ valid: true }, 1], + [{ valid: true, recent_house_address: { recent: 'x' } }, 2], + [{ valid: true, administrative_address: {} }, 2], + [{ valid: true, building_use_status: 'residential' }, 3], + [{ valid: true, other_building_info: {} }, 4], + [{ valid: true, point_geometry: [7, 9] }, 5], + [{ valid: true, administrative_address: null, point_geometry: null }, 1], + ])('reads %j as level %i', (data, level) => { + expect(read(data).levelReceived).toBe(level); + }); + + it('keeps the level that the caller asked for, and the postcode that the core parsed', () => { + const result = read({ postcode: 'SOMETHING ELSE', valid: false }); + expect(result.levelRequested).toBe(5); + expect(result.postcode).toBe(UNIT); + }); + + it('gives null for each address name that is missing, and for an address that is text', () => { + const result = read({ + valid: true, + administrative_address: { state_name: 'FEDERAL CAPITAL TERRITORY', zone: 3 }, + recent_house_address: '1 SYNTHETIC STREET', + }); + expect(result.administrativeAddress).toEqual({ + stateName: 'FEDERAL CAPITAL TERRITORY', + lgaName: null, + localityName: null, + zone: null, + }); + expect(result.recentHouseAddress).toBeNull(); + }); + + it.each(['valid', 'restricted', 'invalid', 'not_found', 'maintenance'])( + 'keeps the status %s as text', + (status) => { + expect(read({ valid: false, status }).status).toBe(status); + }, + ); + + it('gives a null status when the response has none', () => { + expect(read({ valid: true }).status).toBeNull(); + expect(read({ valid: true, status: null }).status).toBeNull(); + }); + + it.each([5, true, {}, []])('gives unexpected_response for the status %j', (status) => { + expect(unexpected(() => read({ valid: true, status })).status).toBe(200); + }); + + it.each([{}, { valid: 'true' }, null, []])('gives unexpected_response for %j', (data) => { + expect(unexpected(() => read(data)).status).toBe(200); + }); +}); + +describe('readReverse', () => { + const base = { found: true, radius_m: 250 }; + + it('gives unexpected_response for a unit that it cannot read', () => { + const units = [ + { postcode: 'FC-01-Z99', distance_m: 4.2 }, + { postcode: 'HELLO', distance_m: 4.2 }, + { distance_m: 4.2 }, + { postcode: 'FC-01-Z99-ZZ-01' }, + { postcode: 'FC-01-Z99-ZZ-01', distance_m: '4.2' }, + 'FC-01-Z99-ZZ-01', + ]; + for (const unit of units) { + expect(unexpected(() => readReverse({ ...base, unit })).status).toBe(200); + } + }); + + it('gives a null unit when the gateway sends none', () => { + expect(readReverse({ ...base, unit: null }).unit).toBeNull(); + expect(readReverse(base).unit).toBeNull(); + }); + + it('reads the names and the address of a unit when the gateway sends them', () => { + const unit = { + postcode: 'FC-01-Z99-ZZ-01', + distance_m: 12.5, + confidence: 'medium', + state_name: 'FEDERAL CAPITAL TERRITORY', + lga_name: 'SYNTHETIC LGA', + locality_name: 'SYNTHETIC LOCALITY', + address: '1 SYNTHETIC STREET', + }; + expect(readReverse({ ...base, unit }).unit).toEqual({ + postcode: UNIT, + distanceM: 12.5, + confidence: 'medium', + stateName: 'FEDERAL CAPITAL TERRITORY', + lgaName: 'SYNTHETIC LGA', + localityName: 'SYNTHETIC LOCALITY', + address: '1 SYNTHETIC STREET', + }); + }); + + it('turns an unknown confidence into low', () => { + const unit = { postcode: 'FC-01-Z99-ZZ-01', distance_m: 1, confidence: 'certain' }; + expect(readReverse({ ...base, unit }).unit?.confidence).toBe('low'); + }); + + it('keeps the area and the district as the text that the gateway sent', () => { + const text = readReverse({ ...base, area: 'FC-01-Z99-ZZ', district: 'FC-01-Z99', state: 'FC' }); + expect([text.area, text.district, text.state]).toEqual(['FC-01-Z99-ZZ', 'FC-01-Z99', 'FC']); + const other = readReverse({ ...base, area: 'ZZ', district: 99 }); + expect([other.area, other.district, other.state]).toEqual(['ZZ', null, null]); + }); + + it('gives a null radius when the body has none or a radius that is not a number', () => { + expect(readReverse({ found: false }).radiusM).toBeNull(); + expect(readReverse({ found: false, radius_m: '25' }).radiusM).toBeNull(); + expect(readReverse(base).radiusM).toBe(250); + }); + + it.each([{ radius_m: 25 }, { found: 'yes', radius_m: 25 }, [], null])( + 'gives unexpected_response for %j', + (data) => { + expect(unexpected(() => readReverse(data)).status).toBe(200); + }, + ); +}); + +describe('readAutocomplete', () => { + it('builds the partial postcode of each segment from the typed text', () => { + const cases = [ + ['F', 'state', 'FC', 'FC'], + ['FC', 'state', 'FC', 'FC'], + ['FC0', 'lga', '01', 'FC-01'], + ['FC01Z9', 'district', 'Z99', 'FC-01-Z99'], + // A district can hold letters. Cut one place later, FC01ZZ plus ZZZ reads as an area. + ['FC01ZZZ', 'district', 'ZZZ', 'FC-01-ZZZ'], + ['FC01Z99Z', 'area', 'ZZ', 'FC-01-Z99-ZZ'], + ['FC01Z99ZZ01', 'unit', '01', 'FC-01-Z99-ZZ-01'], + ] as const; + for (const [typed, segment, code, canonical] of cases) { + const result = readAutocomplete({ segment, suggestions: [{ code }] }, typed); + expect(result.suggestions[0]?.postcode?.canonical).toBe(canonical); + } + }); + + it('gives a null postcode when no cut of the typed text makes one', () => { + const result = readAutocomplete({ segment: 'lga', suggestions: [{ code: '00' }] }, 'FC0'); + expect(result.suggestions).toEqual([{ code: '00', label: null, postcode: null }]); + }); + + it('leaves out each item that has no text in code', () => { + const suggestions = [{ code: 'FC', label: 'SYNTHETIC' }, { label: 'x' }, 'FC', null]; + const result = readAutocomplete({ segment: 'state', suggestions }, 'F'); + expect(result.suggestions.map((item) => [item.code, item.label])).toEqual([ + ['FC', 'SYNTHETIC'], + ]); + }); + + it.each([ + { segment: 'street', suggestions: [] }, + { segment: 'state' }, + { segment: 'state', suggestions: {} }, + { segment: 'state', suggestions: 'FC' }, + [], + ])('gives unexpected_response for %j', (data) => { + expect(unexpected(() => readAutocomplete(data, 'F')).status).toBe(200); + }); +}); From 8cca6180f87707eb0ba6e1bd5f4a662e8faac9f2 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 02:59:35 +0100 Subject: [PATCH 16/36] feat(client): send requests with timeouts, retries and a queue The gateway can stall, as it did for an empty autocomplete query, so each attempt has a timeout, and a cancelled call frees its place in the queue at once. A 502, 503 or 504, a lost connection and a timeout get a retry. A 429 waits only for a short Retry-After, and so does a 502, 503 or 504 that carries a valid one. The queue keeps a client at four requests at a time. A call also has a total deadline, so a long Retry-After ends it with the last error instead of a late retry. Signed-off-by: MobileMage --- packages/client/src/queue.ts | 59 ++++++ packages/client/src/retry.ts | 67 +++++++ packages/client/src/send.ts | 163 +++++++++++++++++ packages/client/test/fake-clock.ts | 49 +++++ packages/client/test/retry.test.ts | 85 +++++++++ packages/client/test/send.test.ts | 278 +++++++++++++++++++++++++++++ packages/client/test/transport.ts | 67 +++++++ 7 files changed, 768 insertions(+) create mode 100644 packages/client/src/queue.ts create mode 100644 packages/client/src/retry.ts create mode 100644 packages/client/src/send.ts create mode 100644 packages/client/test/fake-clock.ts create mode 100644 packages/client/test/retry.test.ts create mode 100644 packages/client/test/send.test.ts create mode 100644 packages/client/test/transport.ts diff --git a/packages/client/src/queue.ts b/packages/client/src/queue.ts new file mode 100644 index 0000000..8814fa1 --- /dev/null +++ b/packages/client/src/queue.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Runs tasks with at most a fixed number at a time. The others wait in the order of their calls. + * + * @internal + */ +export type Queue = (task: () => Promise, signal: AbortSignal) => Promise; + +/** + * Makes a queue. A task that waits for its turn leaves the queue when its signal aborts, so a + * cancelled call never holds a place (API-8, API-11). + * + * @param limit - The most tasks that run at one time. + * @returns The queue. + * @internal + */ +export function createQueue(limit: number): Queue { + let running = 0; + const waiting: (() => void)[] = []; + + const release = (): void => { + running -= 1; + waiting.shift()?.(); + }; + + const turn = (signal: AbortSignal): Promise => { + if (running < limit) { + running += 1; + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const start = (): void => { + signal.removeEventListener('abort', leave); + running += 1; + resolve(); + }; + const leave = (): void => { + waiting.splice(waiting.indexOf(start), 1); + // API-11: a cancelled call ends with the reason of the caller's signal, of any type. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- API-11 + reject(signal.reason); + }; + waiting.push(start); + signal.addEventListener('abort', leave, { once: true }); + }); + }; + + return async (task, signal) => { + signal.throwIfAborted(); + await turn(signal); + try { + return await task(); + } finally { + release(); + } + }; +} diff --git a/packages/client/src/retry.ts b/packages/client/src/retry.ts new file mode 100644 index 0000000..07fb4bf --- /dev/null +++ b/packages/client/src/retry.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import type { PostcodeError } from './error.js'; + +/** @internal */ +export const FIRST_WAIT_MS = 500; +/** @internal */ +export const JITTER_MS = 250; +// A longer Retry-After ends the call at once, so a caller can tell a user when to try again. +const LONGEST_RETRY_AFTER_MS = 10_000; +const RETRIED_STATUSES: readonly number[] = [502, 503, 504]; +const SECONDS = /^\d+$/; +// An HTTP date names its day and month in letters. Date.parse also reads text such as 1.5 as a +// date, so text with no letter is not one. +const HAS_LETTER = /[A-Za-z]/; + +/** + * Reads a `Retry-After` header, which holds seconds or an HTTP date. + * + * @param header - The value of the header, or null when the response has none. + * @param now - The current time in milliseconds since the epoch. + * @returns The wait in milliseconds, or null for a missing or invalid value. A date in the past + * is invalid. + * @internal + */ +export function retryAfterMs(header: string | null, now: number): number | null { + if (header === null) { + return null; + } + const text = header.trim(); + if (SECONDS.test(text)) { + return Number(text) * 1000; + } + const date = HAS_LETTER.test(text) ? Date.parse(text) : Number.NaN; + return Number.isNaN(date) || date < now ? null : date - now; +} + +// The wait that Retry-After asked for, when the client accepts it. +function honouredMs(error: PostcodeError): number | null { + const asked = error.retryAfterMs; + return asked !== null && asked <= LONGEST_RETRY_AFTER_MS ? asked : null; +} + +/** + * Decides whether a failed attempt gets a retry, and how long the client waits first. + * + * @param error - The error of the attempt. + * @param retry - The number of the next retry. The first retry is 1. + * @param timeoutRetried - False for `autocomplete`, whose timeout gets no retry. + * @returns The wait in milliseconds, or null when the call ends with the error. + * @internal + */ +export function retryWaitMs( + error: PostcodeError, + retry: number, + timeoutRetried: boolean, +): number | null { + const ownWaitMs = FIRST_WAIT_MS * 2 ** (retry - 1) + Math.random() * JITTER_MS; + if (error.code === 'rate_limited') { + return honouredMs(error); + } + if (error.code === 'server_error') { + return RETRIED_STATUSES.includes(error.status ?? 0) ? (honouredMs(error) ?? ownWaitMs) : null; + } + const retried = error.code === 'network_error' || (error.code === 'timeout' && timeoutRetried); + return retried ? ownWaitMs : null; +} diff --git a/packages/client/src/send.ts b/packages/client/src/send.ts new file mode 100644 index 0000000..64005c6 --- /dev/null +++ b/packages/client/src/send.ts @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { clientError, errorForResponse, PostcodeError } from './error.js'; +import type { Queue } from './queue.js'; +import type { GatewayRequest } from './requests.js'; +import { apiCodeOf, dataOf } from './responses.js'; +import { FIRST_WAIT_MS, JITTER_MS, retryAfterMs, retryWaitMs } from './retry.js'; + +/** + * What every request of one client shares. + * + * @internal + */ +export interface SendSettings { + readonly baseUrl: string; + readonly apiKey: string | undefined; + readonly transport: typeof globalThis.fetch; + readonly timeoutMs: number; + readonly maxRetries: number; + readonly queue: Queue; +} + +// The base URL can hold a path, such as the address of a staging gateway, so the client adds +// the request's path to it. +function urlOf(baseUrl: string, request: GatewayRequest): string { + const url = new URL(baseUrl.replace(/\/+$/, '') + request.path); + for (const [name, text] of Object.entries(request.query)) { + url.searchParams.set(name, text); + } + return url.href; +} + +function readJson(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + // A body that is not JSON, such as the HTML page of a captive portal, has no fields. + return undefined; + } +} + +// The timer and the caller's signal share one controller, so either one stops the request. +// Safari 16.4 lacks AbortSignal.any, so the code joins the two signals itself. +async function attempt(settings: SendSettings, url: string, signal: AbortSignal): Promise { + const controller = new AbortController(); + const stop = (): void => { + controller.abort(signal.reason); + }; + signal.addEventListener('abort', stop, { once: true }); + const timer = setTimeout(() => { + controller.abort(clientError('timeout', `It waited ${String(settings.timeoutMs)} ms.`)); + }, settings.timeoutMs); + try { + const headers: Record = { Accept: 'application/json' }; + if (settings.apiKey !== undefined) { + headers['X-API-Key'] = settings.apiKey; + } + const response = await settings.transport(url, { headers, signal: controller.signal }); + const body = readJson(await response.text()); + if (response.status === 200) { + return dataOf(body); + } + const header = response.headers.get('Retry-After'); + throw errorForResponse({ + status: response.status, + apiCode: apiCodeOf(body), + retryAfterMs: retryAfterMs(header, Date.now()), + }); + } catch (error: unknown) { + throw failureOf(error, controller.signal); + } finally { + clearTimeout(timer); + signal.removeEventListener('abort', stop); + } +} + +// The caller's abort and the timeout end the request with the reason that the controller holds. +// Any other failure of the transport means that no response arrived. +function failureOf(error: unknown, signal: AbortSignal): unknown { + if (error instanceof PostcodeError) { + return error; + } + if (signal.aborted) { + return signal.reason; + } + return clientError('network_error', '', error); +} + +// A timer can fire up to a millisecond early on a finer clock. The client never waits less than +// a Retry-After asks, so it sets a new timer for any time that is left. +function pause(waitMs: number, signal: AbortSignal): Promise { + const until = performance.now() + waitMs; + return new Promise((resolve, reject) => { + let timer: ReturnType; + const stop = (): void => { + clearTimeout(timer); + // API-11: a cancelled call ends with the reason of the caller's signal, of any type. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- API-11 + reject(signal.reason); + }; + const tick = (): void => { + const leftMs = until - performance.now(); + if (leftMs > 0) { + timer = setTimeout(tick, leftMs); + return; + } + signal.removeEventListener('abort', stop); + resolve(); + }; + signal.addEventListener('abort', stop, { once: true }); + tick(); + }); +} + +// The longest a call lasts from its first attempt: every attempt may use its full timeout, and +// every retry may wait its longest own wait. +function deadlineMs(settings: SendSettings): number { + let totalMs = (1 + settings.maxRetries) * settings.timeoutMs; + for (let retry = 1; retry <= settings.maxRetries; retry += 1) { + totalMs += FIRST_WAIT_MS * 2 ** (retry - 1) + JITTER_MS; + } + return totalMs; +} + +/** + * Sends a request, with the retries and the waits of `spec/client.md`. Each attempt takes a + * place in the queue, and gives it back before the wait. + * + * @param settings - The settings of the client. + * @param request - The path and the query. + * @param options - The caller's signal, and whether a timeout gets a retry. + * @returns The `data` field of the response. + * @throws PostcodeError for a failure that the retries did not cure, or the reason of the + * caller's signal when it aborts. + * @internal + */ +export async function send( + settings: SendSettings, + request: GatewayRequest, + options: Readonly<{ signal: AbortSignal; timeoutRetried: boolean }>, +): Promise { + const url = urlOf(settings.baseUrl, request); + let deadline = Number.POSITIVE_INFINITY; + const run = (): Promise => { + // The deadline starts when the first attempt leaves the queue. + if (deadline === Number.POSITIVE_INFINITY) { + deadline = performance.now() + deadlineMs(settings); + } + return attempt(settings, url, options.signal); + }; + for (let retry = 1; ; retry += 1) { + try { + return await settings.queue(run, options.signal); + } catch (error: unknown) { + const waitMs = + error instanceof PostcodeError ? retryWaitMs(error, retry, options.timeoutRetried) : null; + if (waitMs === null || retry > settings.maxRetries || performance.now() + waitMs > deadline) { + throw error; + } + await pause(waitMs, options.signal); + } + } +} diff --git a/packages/client/test/fake-clock.ts b/packages/client/test/fake-clock.ts new file mode 100644 index 0000000..5ba1980 --- /dev/null +++ b/packages/client/test/fake-clock.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The fake clock of the unit tests. The client measures its waits with performance.now, so the +// fake clock covers it as well as the timers and Date. +import { afterEach, beforeEach, vi } from 'vitest'; + +/** The time at the start of each test: 09:00:00 GMT on 14 Oct 2026. */ +export const START: number = Date.parse('2026-10-14T09:00:00Z'); + +/** + * Uses the fake clock in each test of the file that calls it. + */ +export function useFakeClock(): void { + beforeEach(() => { + vi.useFakeTimers({ now: START, toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] }); + }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); +} + +/** + * Runs the fake clock while a call settles, so that a rejection is never left unhandled. + * + * @param call - The call. + * @param runMs - How far the clock runs, in milliseconds. + * @returns How the call settled. + */ +export async function settle(call: Promise, runMs: number): Promise> { + const outcome = Promise.allSettled([call]); + await vi.advanceTimersByTimeAsync(runMs); + const [settled] = await outcome; + return settled; +} + +/** + * Gives the reason of a call that failed. + * + * @param settled - How the call settled. + * @returns The reason. + */ +export function reasonOf(settled: PromiseSettledResult): unknown { + if (settled.status === 'fulfilled') { + throw new Error('The call did not fail.'); + } + return settled.reason; +} diff --git a/packages/client/test/retry.test.ts b/packages/client/test/retry.test.ts new file mode 100644 index 0000000..7c19421 --- /dev/null +++ b/packages/client/test/retry.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PostcodeError } from '../src/index.js'; +import { retryAfterMs, retryWaitMs } from '../src/retry.js'; + +const NOW = Date.parse('2026-10-14T09:00:00Z'); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('retryAfterMs', () => { + it.each([ + ['1', 1000], + [' 120 ', 120_000], + ['0', 0], + ['Wed, 14 Oct 2026 09:00:05 GMT', 5000], + ['Wed, 14 Oct 2026 09:00:00 GMT', 0], + ])('reads %j as %i ms', (header, waitMs) => { + expect(retryAfterMs(header, NOW)).toBe(waitMs); + }); + + it.each([null, '', '1.5', '-1', 'soon', 'Wed, 14 Oct 2026 08:59:59 GMT'])( + 'gives null for %j', + (header) => { + expect(retryAfterMs(header, NOW)).toBeNull(); + }, + ); +}); + +describe('retryWaitMs', () => { + const error = ( + code: PostcodeError['code'], + details: { status?: number; retryAfterMs?: number } = {}, + ) => new PostcodeError(code, 'test', details); + + it('doubles the wait before each retry from 500 ms, with up to 250 ms more at random', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + expect(retryWaitMs(error('server_error', { status: 503 }), 1, true)).toBe(500); + expect(retryWaitMs(error('network_error'), 2, true)).toBe(1000); + expect(retryWaitMs(error('network_error'), 3, true)).toBe(2000); + vi.spyOn(Math, 'random').mockReturnValue(0.999); + expect(retryWaitMs(error('timeout'), 1, true)).toBeCloseTo(749.75); + }); + + it.each([502, 503, 504])('retries the status %i', (status) => { + expect(retryWaitMs(error('server_error', { status }), 1, true)).not.toBeNull(); + }); + + it.each([ + ['server_error', 500], + ['server_error', 200], + ['invalid_input', 400], + ['unauthorized', 401], + ['forbidden', 403], + ] as const)('does not retry %s with status %i', (code, status) => { + expect(retryWaitMs(error(code, { status }), 1, true)).toBeNull(); + }); + + it('does not retry a timeout of autocomplete', () => { + expect(retryWaitMs(error('timeout'), 1, false)).toBeNull(); + }); + + it('waits exactly as long as a Retry-After of 10 seconds or less asks', () => { + expect(retryWaitMs(error('rate_limited', { retryAfterMs: 10_000 }), 2, true)).toBe(10_000); + }); + + it.each([502, 503, 504])('waits as long as a valid Retry-After asks after a %i', (status) => { + const wait = retryWaitMs(error('server_error', { status, retryAfterMs: 7000 }), 1, true); + expect(wait).toBe(7000); + }); + + it('uses its own wait after a 503 with no Retry-After, or with one over 10 seconds', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + expect(retryWaitMs(error('server_error', { status: 503 }), 1, true)).toBe(500); + const long = error('server_error', { status: 503, retryAfterMs: 10_001 }); + expect(retryWaitMs(long, 2, true)).toBe(1000); + }); + + it('ends the call at once for a 429 with a longer Retry-After, or with none', () => { + expect(retryWaitMs(error('rate_limited', { retryAfterMs: 10_001 }), 1, true)).toBeNull(); + expect(retryWaitMs(error('rate_limited'), 1, true)).toBeNull(); + }); +}); diff --git a/packages/client/test/send.test.ts b/packages/client/test/send.test.ts new file mode 100644 index 0000000..485011f --- /dev/null +++ b/packages/client/test/send.test.ts @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The rules of spec/client.md that no contract scenario tests: cancellation, keys in messages, +// HTTP dates and the clock. A scripted transport and a fake clock stand in for the network and +// for real waits (T-5). +import { describe, expect, it, vi } from 'vitest'; +import { PostcodeError } from '../src/index.js'; +import { createQueue } from '../src/queue.js'; +import type { GatewayRequest } from '../src/requests.js'; +import { send, type SendSettings } from '../src/send.js'; +import { reasonOf, settle, useFakeClock } from './fake-clock.js'; +import { fakeTransport, VALID_LOOKUP, type Answer } from './transport.js'; + +const KEY = 'nipost_test_mock_l3'; +const LOOKUP: GatewayRequest = { + path: '/v1/lookup', + query: { code: 'FC-01-Z99-ZZ-01', level: '1' }, +}; + +useFakeClock(); + +function settingsWith(changes: Partial, ...answers: readonly Answer[]) { + const fake = fakeTransport(...answers); + const settings: SendSettings = { + baseUrl: 'https://gateway.invalid', + apiKey: KEY, + transport: fake.transport, + timeoutMs: 8000, + maxRetries: 2, + queue: createQueue(4), + ...changes, + }; + return { settings, requests: fake.requests }; +} + +function sendLookup( + settings: SendSettings, + options: { signal?: AbortSignal; timeoutRetried?: boolean } = {}, +): Promise { + const { signal = new AbortController().signal, timeoutRetried = true } = options; + return send(settings, LOOKUP, { signal, timeoutRetried }); +} + +describe('a request', () => { + it('adds the path and the query to a base URL with a path and any closing slash', async () => { + for (const baseUrl of ['http://localhost:4010/gateway', 'http://localhost:4010/gateway/']) { + const { settings, requests } = settingsWith({ baseUrl }, VALID_LOOKUP); + await sendLookup(settings); + expect(requests[0]?.url.href).toBe( + 'http://localhost:4010/gateway/v1/lookup?code=FC-01-Z99-ZZ-01&level=1', + ); + } + }); + + it('sends the key in X-API-Key, and no such header when the client has no key', async () => { + const keyed = settingsWith({}, VALID_LOOKUP); + const keyless = settingsWith({ apiKey: undefined }, VALID_LOOKUP); + await sendLookup(keyed.settings); + await sendLookup(keyless.settings); + expect(keyed.requests[0]?.headers.get('X-API-Key')).toBe(KEY); + expect(keyless.requests[0]?.headers.has('X-API-Key')).toBe(false); + }); + + it('gives the data field of a response with status 200', async () => { + const { settings } = settingsWith({}, VALID_LOOKUP); + expect(await sendLookup(settings)).toEqual({ + postcode: 'FC-01-Z99-ZZ-01', + valid: true, + status: 'valid', + }); + }); +}); + +describe('timeouts', () => { + it('ends each attempt at timeoutMs, and retries it after 500 ms and 1000 ms', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const { settings, requests } = settingsWith({}, 'hang'); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(8000 + 500 + 8000 + 1000 + 7999); + expect(requests).toHaveLength(3); + const error = reasonOf(await settle(call, 1)) as PostcodeError; + expect([error.code, error.status]).toEqual(['timeout', null]); + expect(error.message).toContain('8000 ms'); + expect(requests.every((request) => request.signal.aborted)).toBe(true); + }); + + it('does not retry a timeout when the call says so, as autocomplete does', async () => { + const { settings, requests } = settingsWith({}, 'hang'); + const error = reasonOf(await settle(sendLookup(settings, { timeoutRetried: false }), 20_000)); + expect((error as PostcodeError).code).toBe('timeout'); + expect(requests).toHaveLength(1); + }); +}); + +describe('the total deadline', () => { + const unavailable = (retryAfter: string): Answer => ({ + status: 503, + body: { error: { code: 'unavailable' } }, + headers: { 'Retry-After': retryAfter }, + }); + + it('raises the last error at once when the next wait ends after the deadline', async () => { + // The deadline is 2 x 2000 ms and one wait of up to 750 ms, so 4750 ms. + const { settings, requests } = settingsWith( + { timeoutMs: 2000, maxRetries: 1 }, + unavailable('7'), + ); + const error = reasonOf(await settle(sendLookup(settings), 0)) as PostcodeError; + expect([error.code, error.status, error.retryAfterMs]).toEqual(['server_error', 503, 7000]); + expect(requests).toHaveLength(1); + }); + + it('counts the time of earlier attempts and waits', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const { settings, requests } = settingsWith({ timeoutMs: 2000, maxRetries: 2 }, 'hang', { + status: 503, + body: {}, + headers: { 'Retry-After': '6' }, + }); + // The first attempt times out at 2000 ms and the wait ends at 2500 ms. A wait of 6 s from + // there ends at 8500 ms, and the deadline is 3 x 2000 + 750 + 1250 = 8000 ms. + const error = reasonOf(await settle(sendLookup(settings), 2500)) as PostcodeError; + expect([error.code, error.retryAfterMs]).toEqual(['server_error', 6000]); + expect(requests).toHaveLength(2); + }); + + it('retries after a Retry-After that ends exactly at the deadline', async () => { + const { settings, requests } = settingsWith( + { timeoutMs: 2000, maxRetries: 1 }, + unavailable('4'), + VALID_LOOKUP, + ); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(4000); + await call; + expect(requests).toHaveLength(2); + }); +}); + +describe('a 503 with Retry-After', () => { + it('waits exactly as long as it asks, then tries again', async () => { + const answer = { status: 503, body: {}, headers: { 'Retry-After': '1' } }; + const { settings, requests } = settingsWith({}, answer, VALID_LOOKUP); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(999); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await call; + expect(requests).toHaveLength(2); + }); + + it('uses its own wait for an invalid value', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const answer = { status: 503, body: {}, headers: { 'Retry-After': 'soon' } }; + const { settings, requests } = settingsWith({}, answer, VALID_LOOKUP); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(499); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await call; + expect(requests).toHaveLength(2); + }); +}); + +describe('error messages', () => { + const SECRET = 'nipost_live_do_not_show_this_key'; + const failures: readonly (readonly [string, Answer])[] = [ + ['401', { status: 401, body: { error: { code: 'invalid_api_key', message: SECRET } } }], + ['403', { status: 403, body: { error: { code: 'scope_not_granted' } } }], + ['429', { status: 429, body: {}, headers: { 'Retry-After': '60' } }], + ['500', { status: 500, body: 'not json' }], + ['a body with no data', { status: 200, body: { error: SECRET } }], + ['a dropped connection', 'drop'], + ['no answer', 'hang'], + ]; + + it.each(failures)('never hold the API key, for %s', async (_, answer) => { + const { settings } = settingsWith({ apiKey: SECRET, maxRetries: 0 }, answer); + const error = reasonOf(await settle(sendLookup(settings), 8000)); + expect(error).toBeInstanceOf(PostcodeError); + expect((error as PostcodeError).message).not.toContain('do_not_show'); + }); +}); + +describe('cancellation (API-11)', () => { + it('stops an attempt with the reason of the signal, and does not retry it', async () => { + const { settings, requests } = settingsWith({}, 'hang'); + const controller = new AbortController(); + const call = sendLookup(settings, { signal: controller.signal }); + await vi.advanceTimersByTimeAsync(100); + controller.abort(); + const reason = reasonOf(await settle(call, 10_000)); + expect(reason).toBe(controller.signal.reason); + expect(reason).not.toBeInstanceOf(PostcodeError); + expect(requests).toHaveLength(1); + expect(requests[0]?.signal.aborted).toBe(true); + }); + + it('stops during the wait before a retry, and sends no more requests', async () => { + const { settings, requests } = settingsWith({}, { status: 503, body: {} }); + const controller = new AbortController(); + const call = sendLookup(settings, { signal: controller.signal }); + await vi.advanceTimersByTimeAsync(100); + controller.abort(); + expect(reasonOf(await settle(call, 5000))).toBe(controller.signal.reason); + expect(requests).toHaveLength(1); + }); + + it('takes a waiting request out of the queue, so the next one takes its place', async () => { + const { settings, requests } = settingsWith({ maxRetries: 0 }, 'hang'); + const running = [1, 2, 3, 4].map(() => sendLookup(settings)); + const controller = new AbortController(); + const leaving = sendLookup(settings, { signal: controller.signal }); + const next = send( + settings, + { ...LOOKUP, path: '/v1/next' }, + { + signal: new AbortController().signal, + timeoutRetried: true, + }, + ); + await vi.advanceTimersByTimeAsync(10); + expect(requests).toHaveLength(4); + controller.abort(); + expect(reasonOf(await settle(leaving, 0))).toBe(controller.signal.reason); + await Promise.allSettled([...running, next, vi.advanceTimersByTimeAsync(16_000)]); + expect(requests.map((request) => request.url.pathname)).toEqual([ + '/v1/lookup', + '/v1/lookup', + '/v1/lookup', + '/v1/lookup', + '/v1/next', + ]); + }); +}); + +describe('Retry-After', () => { + it('waits until an HTTP date that is 3 seconds ahead, then tries again', async () => { + const limited = { + status: 429, + body: { error: { code: 'rate_limited' } }, + headers: { 'Retry-After': 'Wed, 14 Oct 2026 09:00:03 GMT' }, + }; + const { settings, requests } = settingsWith({}, limited, VALID_LOOKUP); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(2999); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await call; + expect(requests).toHaveLength(2); + }); + + it('never waits less than it asks, when a timer fires early', async () => { + const limited = { status: 429, body: {}, headers: { 'Retry-After': '1' } }; + const { settings, requests } = settingsWith({}, limited, VALID_LOOKUP); + const call = sendLookup(settings); + await vi.advanceTimersByTimeAsync(0); + // The timer fires on time, but the finer clock says that half a millisecond is left. + const clock = vi.spyOn(performance, 'now').mockReturnValueOnce(performance.now() + 999.5); + await vi.advanceTimersByTimeAsync(1000); + expect(clock).toHaveBeenCalled(); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await call; + expect(requests).toHaveLength(2); + }); + + it('gives back its place in the queue while it waits', async () => { + const limited = { status: 429, body: {}, headers: { 'Retry-After': '5' } }; + const answers = [limited, limited, limited, limited, VALID_LOOKUP]; + const { settings, requests } = settingsWith({}, ...answers); + const waiting = [1, 2, 3, 4].map(() => sendLookup(settings)); + await vi.advanceTimersByTimeAsync(10); + await sendLookup(settings); + expect(requests).toHaveLength(5); + await Promise.allSettled([...waiting, vi.advanceTimersByTimeAsync(5000)]); + }); +}); diff --git a/packages/client/test/transport.ts b/packages/client/test/transport.ts new file mode 100644 index 0000000..dd268a9 --- /dev/null +++ b/packages/client/test/transport.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// A transport for unit tests. It answers from a script, with no network, and it records each +// request (T-5). + +/** One answer of the script. */ +export type Answer = + | { + readonly status: number; + readonly body: unknown; + readonly headers?: Readonly>; + } + | 'hang' + | 'drop'; + +/** A request that the transport saw. */ +export interface SentRequest { + readonly url: URL; + readonly headers: Headers; + readonly signal: AbortSignal; +} + +/** A transport with the requests that it saw. */ +export interface FakeTransport { + readonly transport: typeof fetch; + readonly requests: SentRequest[]; +} + +/** The body of a lookup at level 1 for a known unit. */ +export const VALID_LOOKUP: Answer = { + status: 200, + body: { data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid' } }, +}; + +// A request that hangs ends only when its signal aborts, with the signal's reason, as fetch does. +function hang(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error); + }); + }); +} + +/** + * Makes a transport that gives the answers in order, and repeats the last one. + * + * @param answers - The answers. + * @returns The transport and its record of requests. + */ +export function fakeTransport(...answers: readonly Answer[]): FakeTransport { + const requests: SentRequest[] = []; + const transport: typeof fetch = async (input, init) => { + const signal = init?.signal ?? new AbortController().signal; + const url = new URL(input instanceof Request ? input.url : input); + requests.push({ url, headers: new Headers(init?.headers), signal }); + const answer = answers[Math.min(requests.length, answers.length) - 1] ?? 'drop'; + if (answer === 'hang') { + return await hang(signal); + } + if (answer === 'drop') { + throw new TypeError('fetch failed'); + } + const headers = new Headers(answer.headers); + return new Response(JSON.stringify(answer.body), { status: answer.status, headers }); + }; + return { transport, requests }; +} From 98b994944bdd8cd588b2fc81be6e803f05f1c4b0 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 03:04:25 +0100 Subject: [PATCH 17/36] feat(client): add PostcodeClient and pass the contract scenarios PostcodeClient joins the parts, and adds the shared calls and the cache that spec/client.md asks for. A caller can leave a shared call, and the request stops when the last caller leaves, so a field that cancels each keystroke never fills the queue. Every contract scenario of the spec runs against the mock server in the same process, so this client and the PHP client prove the same behaviour. Signed-off-by: MobileMage --- packages/client/etc/client.api.md | 17 +++ packages/client/scripts/smoke.mjs | 19 +-- packages/client/src/calls.ts | 101 ++++++++++++ packages/client/src/client.ts | 211 ++++++++++++++++++++++++++ packages/client/src/index.ts | 1 + packages/client/test/client.test.ts | 174 +++++++++++++++++++++ packages/client/test/contract.test.ts | 47 ++++++ packages/client/test/scenarios.ts | 169 +++++++++++++++++++++ 8 files changed, 730 insertions(+), 9 deletions(-) create mode 100644 packages/client/src/calls.ts create mode 100644 packages/client/src/client.ts create mode 100644 packages/client/test/client.test.ts create mode 100644 packages/client/test/contract.test.ts create mode 100644 packages/client/test/scenarios.ts diff --git a/packages/client/etc/client.api.md b/packages/client/etc/client.api.md index 42f05d2..352b142 100644 --- a/packages/client/etc/client.api.md +++ b/packages/client/etc/client.api.md @@ -56,6 +56,23 @@ export interface LookupResult { // @public export type LookupStatus = "valid" | "invalid" | "not_found" | "restricted"; +// @public +export class PostcodeClient { + constructor(options?: ClientOptions); + autocomplete(q: string, options?: Readonly<{ + signal?: AbortSignal; + }>): Promise; + clearCache(): void; + lookup(code: string | Postcode, options?: Readonly<{ + level?: LookupLevel; + signal?: AbortSignal; + }>): Promise; + reverse(lat: number, lng: number, options?: Readonly<{ + maxDistanceM?: number; + signal?: AbortSignal; + }>): Promise; +} + // @public export class PostcodeError extends Error { constructor(code: PostcodeErrorCode, message: string, details?: Readonly<{ diff --git a/packages/client/scripts/smoke.mjs b/packages/client/scripts/smoke.mjs index 11ba51d..6853191 100644 --- a/packages/client/scripts/smoke.mjs +++ b/packages/client/scripts/smoke.mjs @@ -2,14 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 // Starts the built package as a user does: by its name, through the exports map of package.json. // The unit tests import src/, and publint, attw, size-limit and API Extractor only read dist/. -// So this is the one check that runs the built code. -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { PostcodeError, SPEC_VERSION } from '@gatepost/client'; +// So this is the one check that runs the built code, with the built core. It sends no request: +// the transport fails the check if the client calls it. +import { PostcodeClient, PostcodeError } from '@gatepost/client'; -const specVersion = readFileSync(join(import.meta.dirname, '../../../spec/VERSION'), 'utf8').trim(); - -const error = new PostcodeError('timeout', 'The gateway sent no response in time.'); -if (!(error instanceof Error) || error.code !== 'timeout' || SPEC_VERSION !== specVersion) { - throw new Error('The built package does not give the error type and the spec version.'); +const transport = () => { + throw new Error('The built client sent a request for a code that does not parse.'); +}; +const client = new PostcodeClient({ transport }); +const outcome = await client.lookup('ek 01 a03').catch((error) => error); +if (!(outcome instanceof PostcodeError) || outcome.code !== 'invalid_input') { + throw new Error(`The built package gave ${String(outcome)} for a partial postcode.`); } diff --git a/packages/client/src/calls.ts b/packages/client/src/calls.ts new file mode 100644 index 0000000..150d86e --- /dev/null +++ b/packages/client/src/calls.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The calls of one method of a client: the requests in flight, which identical calls share, + * and the kept results. + * + * @internal + */ +export interface Calls { + /** + * Gives the kept result for the key, joins the request in flight for it, or starts one. + * + * @param key - The method's arguments, as text. Equal arguments give an equal key. + * @param run - Starts the request. Its signal aborts when every caller has left. + * @param signal - The caller's signal, if any. + * @returns The result. + */ + call(key: string, run: (signal: AbortSignal) => Promise, signal?: AbortSignal): Promise; + /** Removes every kept result. Requests in flight go on. */ + clear(): void; +} + +interface InFlight { + readonly promise: Promise; + readonly controller: AbortController; + callers: number; +} + +/** + * Makes the store of calls for one method. + * + * @param cacheTtlMs - How long a result stays, in milliseconds. 0 keeps nothing. + * @returns The store. + * @internal + */ +export function createCalls(cacheTtlMs: number): Calls { + const inFlight = new Map>(); + const kept = new Map(); + + const start = (key: string, run: (signal: AbortSignal) => Promise): InFlight => { + const controller = new AbortController(); + const promise = run(controller.signal).then((result) => { + if (cacheTtlMs > 0) { + kept.set(key, { result, until: Date.now() + cacheTtlMs }); + } + return result; + }); + const entry = { promise, controller, callers: 0 }; + const forget = (): void => { + if (inFlight.get(key) === entry) { + inFlight.delete(key); + } + }; + // When every caller has left, nobody reads the outcome. The second callback marks the + // rejection as handled, so that it is not reported as unhandled. + promise.then(forget, forget); + inFlight.set(key, entry); + return entry; + }; + + // Each caller can leave with its own signal. The request stops when the last caller leaves, + // so a replaced keystroke frees its place in the queue. + const join = (key: string, entry: InFlight, signal: AbortSignal | undefined): Promise => { + entry.callers += 1; + if (signal === undefined) { + return entry.promise; + } + return new Promise((resolve, reject) => { + const leave = (): void => { + entry.callers -= 1; + if (entry.callers === 0) { + inFlight.delete(key); + entry.controller.abort(signal.reason); + } + // API-11: a cancelled call ends with the reason of the caller's signal, of any type. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- API-11 + reject(signal.reason); + }; + signal.addEventListener('abort', leave, { once: true }); + entry.promise.then(resolve, reject).finally(() => { + signal.removeEventListener('abort', leave); + }); + }); + }; + + return { + async call(key, run, signal) { + signal?.throwIfAborted(); + const hit = kept.get(key); + if (hit !== undefined && hit.until > Date.now()) { + return hit.result; + } + kept.delete(key); + return await join(key, inFlight.get(key) ?? start(key, run), signal); + }, + clear() { + kept.clear(); + }, + }; +} diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts new file mode 100644 index 0000000..d19db71 --- /dev/null +++ b/packages/client/src/client.ts @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import type { Postcode } from '@gatepost/core'; +import { createCalls, type Calls } from './calls.js'; +import { createQueue } from './queue.js'; +import { autocompleteRequest, lookupRequest, reverseRequest } from './requests.js'; +import { readAutocomplete, readLookup, readReverse } from './responses.js'; +import { send, type SendSettings } from './send.js'; +import type { + AutocompleteResult, + ClientOptions, + LookupLevel, + LookupResult, + ReverseResult, +} from './types.js'; + +// API-8: a client sends at most 4 requests at a time. +const PARALLEL_REQUESTS = 4; +const SECRET_KEY = /^nipost_(test|live)_/; + +// The numeric options and the rule for each. A value that breaks its rule is a programmer error. +const NUMBER_RULES = [ + ['timeoutMs', (ms: number) => ms > 0 && Number.isFinite(ms), 'a number above 0'], + [ + 'maxRetries', + (retries: number) => Number.isSafeInteger(retries) && retries >= 0, + 'a whole number of 0 or more', + ], + // NIPOST's rules give every cache a time limit, so Infinity is not allowed. + ['cacheTtlMs', (ms: number) => ms >= 0 && Number.isFinite(ms), 'a number of 0 or more'], +] as const; + +function checkOptions(options: ClientOptions): void { + for (const [name, isAllowed, rule] of NUMBER_RULES) { + const option = options[name]; + if (option !== undefined && !isAllowed(option)) { + throw new RangeError(`${name} must be ${rule}. It is ${String(option)}.`); + } + } + // SEC-1: anyone can read a key that a web page holds. The message names the key's kind only. + const { apiKey } = options; + if (apiKey !== undefined && SECRET_KEY.test(apiKey) && 'document' in globalThis) { + throw new TypeError('A web page must not hold a secret key. Use a publishable key here.'); + } +} + +/** + * Calls NIPOST's postcode gateway. A client retries, waits, shares identical calls and limits + * its parallel requests as `spec/client.md` says. It sends only the requests that its caller + * makes, and it never logs. + * + * @example + * ```ts + * import { PostcodeClient } from '@gatepost/client'; + * + * const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); + * const result = await client.lookup('fc 01 z99 zz 01'); + * result.postcode.canonical; // 'FC-01-Z99-ZZ-01' + * ``` + */ +export class PostcodeClient { + readonly #settings: SendSettings; + readonly #lookups: Calls; + readonly #reverses: Calls; + readonly #autocompletes: Calls; + + /** + * Makes a client. Two clients share nothing, so each can have its own key and settings. + * + * @param options - The key, the gateway's address, the transport, the timeout, the retries + * and the cache time. Each one is optional. + * @throws RangeError for a `timeoutMs` of 0 or less, a `maxRetries` that is not a whole + * number of 0 or more, or a `cacheTtlMs` that is negative or not finite. + * @throws TypeError for a secret key in a web page. + */ + constructor(options: ClientOptions = {}) { + checkOptions(options); + const cacheTtlMs = options.cacheTtlMs ?? 0; + this.#settings = { + baseUrl: options.baseUrl ?? 'https://api.postcode.gov.ng', + apiKey: options.apiKey, + transport: options.transport ?? ((input, init) => globalThis.fetch(input, init)), + timeoutMs: options.timeoutMs ?? 8000, + maxRetries: options.maxRetries ?? 2, + queue: createQueue(PARALLEL_REQUESTS), + }; + this.#lookups = createCalls(cacheTtlMs); + this.#reverses = createCalls(cacheTtlMs); + this.#autocompletes = createCalls(cacheTtlMs); + } + + /** + * Asks the gateway about one postcode. The core parses the code first, and a code that does + * not parse as a whole postcode sends no request. A postcode that the gateway does not know + * gives a result with `valid` false, not an error. + * + * @param code - The postcode as text in any form, or as a postcode that `parse` returned. + * @param options - The lookup `level`, which is 1 by default, and a `signal` that cancels the + * call. + * @returns The facts about the postcode, up to the level that the key holds. + * @throws PostcodeError `invalid_input` for a code that does not parse, and the code that the + * status map gives for a failed response. + * @example + * ```ts + * import { PostcodeClient } from '@gatepost/client'; + * + * const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); + * const result = await client.lookup('FC-01-Z99-ZZ-01', { level: 2 }); + * result.levelRequested; // 2 + * if (result.levelReceived >= 2) { + * result.administrativeAddress?.stateName; + * } + * ``` + */ + async lookup( + code: string | Postcode, + options: Readonly<{ level?: LookupLevel; signal?: AbortSignal }> = {}, + ): Promise { + const level = options.level ?? 1; + const { postcode, request } = lookupRequest(code, level); + const run = async (signal: AbortSignal): Promise => { + const answer = await send(this.#settings, request, { signal, timeoutRetried: true }); + return readLookup(answer, { postcode, level }); + }; + return await this.#lookups.call(`${postcode.canonical} ${String(level)}`, run, options.signal); + } + + /** + * Asks the gateway for the unit nearest to a coordinate. A point with no postcode in range + * gives a result with `found` false, not an error. + * + * @param lat - The latitude in degrees. + * @param lng - The longitude in degrees. + * @param options - The radius `maxDistanceM` in metres, which the gateway sets to 25 when it + * is missing, and a `signal` that cancels the call. + * @returns What the gateway found. + * @throws PostcodeError with the code that the status map gives for a failed response. + * @example + * ```ts + * import { PostcodeClient } from '@gatepost/client'; + * + * const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); + * const result = await client.reverse(9, 7, { maxDistanceM: 250 }); + * if (result.unit !== null) { + * result.unit.postcode.canonical; + * } + * ``` + */ + async reverse( + lat: number, + lng: number, + options: Readonly<{ maxDistanceM?: number; signal?: AbortSignal }> = {}, + ): Promise { + const request = reverseRequest(lat, lng, options.maxDistanceM); + const run = async (signal: AbortSignal): Promise => + readReverse(await send(this.#settings, request, { signal, timeoutRetried: true })); + const key = `${String(lat)} ${String(lng)} ${String(options.maxDistanceM)}`; + return await this.#reverses.call(key, run, options.signal); + } + + /** + * Asks the gateway for the values of the segment that a user is typing. Empty text sends no + * request, because the gateway does not answer it. A timeout gets no retry, because the next + * keystroke replaces the call. + * + * @param q - The text that the user typed, in any spacing and letter case. + * @param options - A `signal` that cancels the call. + * @returns The active segment and the gateway's values for it. + * @throws PostcodeError `invalid_input` for text that is empty, too long or not letters and + * digits, and the code that the status map gives for a failed response. + * @example + * ```ts + * import { PostcodeClient } from '@gatepost/client'; + * + * const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); + * const result = await client.autocomplete('fc 01 z'); + * result.segment; // 'district' + * ``` + */ + async autocomplete( + q: string, + options: Readonly<{ signal?: AbortSignal }> = {}, + ): Promise { + const { typed, request } = autocompleteRequest(q); + const run = async (signal: AbortSignal): Promise => + readAutocomplete( + await send(this.#settings, request, { signal, timeoutRetried: false }), + typed, + ); + return await this.#autocompletes.call(typed, run, options.signal); + } + + /** + * Removes every kept result, so that the next call of each kind sends a request. + * + * @example + * ```ts + * import { PostcodeClient } from '@gatepost/client'; + * + * const apiKey = process.env['NIPOST_API_KEY']; + * const client = new PostcodeClient({ apiKey, cacheTtlMs: 60_000 }); + * await client.lookup('FC-01-Z99-ZZ-01'); + * client.clearCache(); + * ``` + */ + clearCache(): void { + this.#lookups.clear(); + this.#reverses.clear(); + this.#autocompletes.clear(); + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e1dc79f..e425986 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -9,6 +9,7 @@ * * @packageDocumentation */ +export { PostcodeClient } from './client.js'; export { PostcodeError } from './error.js'; export type { AdministrativeAddress, diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts new file mode 100644 index 0000000..8d9f6ba --- /dev/null +++ b/packages/client/test/client.test.ts @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The rules of the client that no contract scenario tests: the defaults, the option errors, +// shared calls that callers leave, and the cache clock (T-5). +import { describe, expect, it, vi } from 'vitest'; +import { PostcodeClient, PostcodeError, type ClientOptions } from '../src/index.js'; +import { reasonOf, settle, useFakeClock } from './fake-clock.js'; +import { fakeTransport, VALID_LOOKUP, type Answer } from './transport.js'; + +const KEY = 'nipost_test_mock_l3'; + +useFakeClock(); + +function clientWith(options: ClientOptions, ...answers: readonly Answer[]) { + const fake = fakeTransport(...answers); + const client = new PostcodeClient({ apiKey: KEY, ...options, transport: fake.transport }); + return { client, requests: fake.requests }; +} + +describe('the defaults of API-8', () => { + it('calls https://api.postcode.gov.ng, and sends no key when it has none', async () => { + const fake = fakeTransport(VALID_LOOKUP); + await new PostcodeClient({ transport: fake.transport }).lookup('FC-01-Z99-ZZ-01'); + expect(fake.requests[0]?.url.href).toBe( + 'https://api.postcode.gov.ng/v1/lookup?code=FC-01-Z99-ZZ-01&level=1', + ); + expect(fake.requests[0]?.headers.has('X-API-Key')).toBe(false); + }); + + it('ends an attempt after 8000 ms, and makes two retries', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const { client, requests } = clientWith({}, 'hang'); + const call = client.lookup('FC-01-Z99-ZZ-01'); + await vi.advanceTimersByTimeAsync(8000 + 500 + 8000 + 1000 + 7999); + expect(requests).toHaveLength(3); + const error = reasonOf(await settle(call, 1)) as PostcodeError; + expect([error.code, error.status]).toEqual(['timeout', null]); + }); + + it('sends at most 4 requests at a time', async () => { + const { client, requests } = clientWith({ maxRetries: 0 }, 'hang'); + const calls = ['01', '02', '03', '04', '05'].map((unit) => + client.lookup(`FC-01-Z99-ZZ-${unit}`), + ); + await vi.advanceTimersByTimeAsync(7999); + expect(requests).toHaveLength(4); + await Promise.allSettled([...calls, vi.advanceTimersByTimeAsync(8001)]); + expect(requests).toHaveLength(5); + }); + + it('keeps no result when cacheTtlMs is not set', async () => { + const { client, requests } = clientWith({}, VALID_LOOKUP); + await client.lookup('FC-01-Z99-ZZ-01'); + await client.lookup('FC-01-Z99-ZZ-01'); + expect(requests).toHaveLength(2); + }); +}); + +describe('the options', () => { + it.each([ + [{ timeoutMs: 0 }, 'timeoutMs must be a number above 0. It is 0.'], + [{ timeoutMs: Number.NaN }, 'timeoutMs must be a number above 0. It is NaN.'], + [{ maxRetries: -1 }, 'maxRetries must be a whole number of 0 or more. It is -1.'], + [{ maxRetries: 1.5 }, 'maxRetries must be a whole number of 0 or more. It is 1.5.'], + [{ cacheTtlMs: -1 }, 'cacheTtlMs must be a number of 0 or more. It is -1.'], + [{ cacheTtlMs: Number.POSITIVE_INFINITY }, 'cacheTtlMs must be a number of 0 or more.'], + ] as const)('refuses %j as a programmer error', (options, message) => { + expect(() => new PostcodeClient(options)).toThrow(RangeError); + expect(() => new PostcodeClient(options)).toThrow(message); + }); + + it('refuses a secret key in a web page, and allows a publishable key there', () => { + vi.stubGlobal('document', {}); + expect(() => new PostcodeClient({ apiKey: 'nipost_live_abc' })).toThrow(TypeError); + expect(() => new PostcodeClient({ apiKey: 'nipost_test_abc' })).toThrow( + 'A web page must not hold a secret key. Use a publishable key here.', + ); + expect(() => new PostcodeClient({ apiKey: 'nipost_pk_live_abc' })).not.toThrow(); + }); + + it('allows a secret key on a server, where no document exists', () => { + expect(() => new PostcodeClient({ apiKey: 'nipost_live_abc' })).not.toThrow(); + }); + + it('gives two clients their own keys and their own caches (API-9)', async () => { + const first = clientWith({ apiKey: 'nipost_test_mock_l1', cacheTtlMs: 60_000 }, VALID_LOOKUP); + const second = clientWith({ apiKey: 'nipost_test_mock_l2', cacheTtlMs: 60_000 }, VALID_LOOKUP); + await first.client.lookup('FC-01-Z99-ZZ-01'); + await second.client.lookup('FC-01-Z99-ZZ-01'); + expect(first.requests[0]?.headers.get('X-API-Key')).toBe('nipost_test_mock_l1'); + expect(second.requests[0]?.headers.get('X-API-Key')).toBe('nipost_test_mock_l2'); + expect(second.requests).toHaveLength(1); + }); +}); + +describe('cancellation (API-11)', () => { + it('sends no request when the signal has aborted already', async () => { + const { client, requests } = clientWith({}, VALID_LOOKUP); + const reason = new DOMException('The user left.', 'AbortError'); + const call = client.lookup('FC-01-Z99-ZZ-01', { signal: AbortSignal.abort(reason) }); + await expect(call).rejects.toBe(reason); + expect(requests).toHaveLength(0); + }); +}); + +describe('shared calls', () => { + it('gives the result to a caller that stays when another caller leaves', async () => { + const { client, requests } = clientWith({}, VALID_LOOKUP); + const controller = new AbortController(); + const leaving = client.lookup('FC-01-Z99-ZZ-01', { signal: controller.signal }); + const staying = client.lookup('fc 01 z99 zz 01'); + controller.abort(); + await expect(leaving).rejects.toBe(controller.signal.reason); + expect((await staying).valid).toBe(true); + expect(requests).toHaveLength(1); + }); + + it('stops the request when every caller has left', async () => { + const { client, requests } = clientWith({}, 'hang'); + const controllers = [new AbortController(), new AbortController()]; + const calls = controllers.map((controller) => + client.autocomplete('FC0', { signal: controller.signal }), + ); + await vi.advanceTimersByTimeAsync(10); + for (const controller of controllers) { + controller.abort(); + } + await Promise.allSettled(calls); + expect(requests).toHaveLength(1); + expect(requests[0]?.signal.aborted).toBe(true); + }); + + it('starts a new request for a call that comes after every caller has left', async () => { + const { client, requests } = clientWith({}, 'hang', VALID_LOOKUP); + const controller = new AbortController(); + const first = client.lookup('FC-01-Z99-ZZ-01', { signal: controller.signal }); + await vi.advanceTimersByTimeAsync(10); + controller.abort(); + await Promise.allSettled([first]); + expect((await client.lookup('FC-01-Z99-ZZ-01')).valid).toBe(true); + expect(requests).toHaveLength(2); + }); +}); + +describe('the cache', () => { + it('keeps a result for cacheTtlMs, and no longer', async () => { + const { client, requests } = clientWith({ cacheTtlMs: 60_000 }, VALID_LOOKUP); + await client.lookup('FC-01-Z99-ZZ-01'); + await vi.advanceTimersByTimeAsync(59_999); + await client.lookup('FC-01-Z99-ZZ-01'); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await client.lookup('FC-01-Z99-ZZ-01'); + expect(requests).toHaveLength(2); + }); + + it('forgets every result on clearCache', async () => { + const reverse = { status: 200, body: { data: { found: false, radius_m: 25 } } }; + const { client, requests } = clientWith({ cacheTtlMs: 60_000 }, reverse); + await client.reverse(0, 0); + client.clearCache(); + await client.reverse(0, 0); + expect(requests).toHaveLength(2); + }); + + it('keeps one result for each set of arguments', async () => { + const reverse = { status: 200, body: { data: { found: false, radius_m: 25 } } }; + const { client, requests } = clientWith({ cacheTtlMs: 60_000 }, reverse); + await client.reverse(0, 0); + await client.reverse(0, 0, { maxDistanceM: 250 }); + await client.reverse(0, 0); + expect(requests).toHaveLength(2); + }); +}); diff --git a/packages/client/test/contract.test.ts b/packages/client/test/contract.test.ts new file mode 100644 index 0000000..e67f552 --- /dev/null +++ b/packages/client/test/contract.test.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// T-3: the client passes every contract scenario of the spec against the mock server. The +// scenarios measure real waits, so these tests use the real clock. They run at the same time, +// and each run of a scenario has its own place in the mock server. +import { startMockServer, type MockServer } from '@gatepost/mock-server'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { readScenarios, runScenario } from './scenarios.js'; + +// Timers can fire late, so a wait may pass its maximum by this much. It must not fall short. +const LATE_MS = 100; +// lookup-retry-after-ten waits 10 s, so a scenario needs a limit of at least 12 s. +const SCENARIO_LIMIT_MS = 20_000; + +let mock: MockServer; + +beforeAll(async () => { + mock = await startMockServer({ port: 0 }); +}); + +afterAll(async () => { + await mock.close(); +}); + +describe.concurrent('the contract scenarios', () => { + it.each(readScenarios())( + '$id: $description', + async (scenario) => { + const run = await runScenario(scenario, mock.url); + const { expect: wanted } = scenario; + expect(run.outcomes).toEqual(wanted.outcomes); + expect(run.attempts).toBe(wanted.attempts); + if (wanted.request !== undefined) { + expect(run.request).toEqual(wanted.request); + } + if (wanted.maxInFlight !== undefined) { + expect(run.maxInFlight).toBe(wanted.maxInFlight); + } + for (const [index, bounds] of (wanted.waitsMs ?? []).entries()) { + const waitMs = run.waitsMs[index] ?? Number.NaN; + expect(waitMs).toBeGreaterThanOrEqual(bounds.min); + expect(waitMs).toBeLessThanOrEqual(bounds.max + LATE_MS); + } + }, + SCENARIO_LIMIT_MS, + ); +}); diff --git a/packages/client/test/scenarios.ts b/packages/client/test/scenarios.ts new file mode 100644 index 0000000..097944a --- /dev/null +++ b/packages/client/test/scenarios.ts @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Reads the contract scenarios of the spec, and runs one against a mock server, as +// spec/contract/README.md says. The format is fixed there, so the types below copy it. +import { randomUUID } from 'node:crypto'; +import { readdirSync, readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import type { Postcode } from '@gatepost/core'; +import { PostcodeClient, PostcodeError } from '../src/index.js'; +import type { AutocompleteResult, LookupLevel, LookupResult, ReverseResult } from '../src/index.js'; + +/** One call of a scenario. */ +export type ScenarioCall = + | { readonly method: 'lookup'; readonly code: string; readonly level: LookupLevel } + | { + readonly method: 'reverse'; + readonly lat: number; + readonly lng: number; + readonly maxDistanceM?: number; + } + | { readonly method: 'autocomplete'; readonly q: string }; + +/** The first request that reached the transport. */ +export interface SeenRequest { + readonly method: string; + readonly path: string; + readonly query: Readonly>; + readonly apiKey: string | null; +} + +/** A scenario file of spec/contract. */ +export interface Scenario { + readonly id: string; + readonly description: string; + readonly client: Readonly<{ + apiKey?: string; + timeoutMs?: number; + maxRetries?: number; + cacheTtlMs?: number; + }>; + readonly order?: 'parallel' | 'sequential'; + readonly calls: readonly ScenarioCall[]; + readonly expect: { + readonly attempts: number; + readonly waitsMs?: readonly { readonly min: number; readonly max: number }[]; + readonly maxInFlight?: number; + readonly request?: SeenRequest; + readonly outcomes: readonly unknown[]; + }; +} + +/** What a run of a scenario saw. */ +export interface ScenarioRun { + readonly outcomes: readonly unknown[]; + readonly attempts: number; + readonly waitsMs: readonly number[]; + readonly maxInFlight: number; + readonly request: SeenRequest | undefined; +} + +const CONTRACT = new URL('../../../spec/contract/', import.meta.url); + +/** + * Reads every scenario of the spec, in the order of the file names. + * + * @returns The scenarios. + */ +export function readScenarios(): readonly Scenario[] { + return readdirSync(CONTRACT) + .filter((name) => name.endsWith('.json')) + .sort() + .map((name) => JSON.parse(readFileSync(new URL(name, CONTRACT), 'utf8')) as Scenario); +} + +function canonical(postcode: Postcode | null): string | null { + return postcode === null ? null : postcode.canonical; +} + +// A result in a scenario writes each postcode in its canonical form. +function lookupOutcome(result: LookupResult): unknown { + return { ...result, postcode: result.postcode.canonical }; +} + +function reverseOutcome(result: ReverseResult): unknown { + const unit = + result.unit === null ? null : { ...result.unit, postcode: result.unit.postcode.canonical }; + return { ...result, unit }; +} + +function autocompleteOutcome(result: AutocompleteResult): unknown { + const suggestions = result.suggestions.map((item) => ({ + ...item, + postcode: canonical(item.postcode), + })); + return { segment: result.segment, suggestions }; +} + +function radius(call: { readonly maxDistanceM?: number }): { maxDistanceM?: number } { + return call.maxDistanceM === undefined ? {} : { maxDistanceM: call.maxDistanceM }; +} + +async function outcomeOf(client: PostcodeClient, call: ScenarioCall): Promise { + try { + switch (call.method) { + case 'lookup': + return { result: lookupOutcome(await client.lookup(call.code, { level: call.level })) }; + case 'reverse': + return { result: reverseOutcome(await client.reverse(call.lat, call.lng, radius(call))) }; + case 'autocomplete': + return { result: autocompleteOutcome(await client.autocomplete(call.q)) }; + } + } catch (error: unknown) { + if (!(error instanceof PostcodeError)) { + throw error; + } + const { code, status, apiCode, retryAfterMs } = error; + return { error: { code, status, apiCode, retryAfterMs } }; + } +} + +/** + * Runs a scenario against a mock server. A transport wraps fetch: it adds the scenario headers, + * with a run value that no other run uses, and it counts the attempts, the waits and the + * requests in flight. + * + * @param scenario - The scenario. + * @param baseUrl - The mock server's address. + * @returns What the run saw. + */ +export async function runScenario(scenario: Scenario, baseUrl: string): Promise { + const run = randomUUID(); + const starts: number[] = []; + const ends: number[] = []; + let inFlight = 0; + let maxInFlight = 0; + let request: SeenRequest | undefined; + const transport: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + const url = new URL(input instanceof Request ? input.url : input); + request ??= { + method: init?.method ?? 'GET', + path: url.pathname, + query: Object.fromEntries(url.searchParams), + apiKey: headers.get('X-API-Key'), + }; + headers.set('X-Scenario-Id', scenario.id); + headers.set('X-Scenario-Run', run); + starts.push(performance.now()); + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + return await fetch(input, { ...init, headers }); + } finally { + inFlight -= 1; + ends.push(performance.now()); + } + }; + const client = new PostcodeClient({ ...scenario.client, baseUrl, transport }); + const outcomes = + scenario.order === 'parallel' + ? await Promise.all(scenario.calls.map((call) => outcomeOf(client, call))) + : await scenario.calls.reduce>( + async (done, call) => [...(await done), await outcomeOf(client, call)], + Promise.resolve([]), + ); + // A wait runs from the end of one attempt to the start of the next one. + const waitsMs = starts.slice(1).map((start, index) => start - (ends[index] ?? start)); + return { outcomes, attempts: starts.length, waitsMs, maxInFlight, request }; +} From cfb8f3fcebe6bdadd8bf48621c4d28e3edc465b6 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 03:08:10 +0100 Subject: [PATCH 18/36] fix(client): refuse an autocomplete item that has no text code The reader dropped an item with no text in code and returned the rest. If the gateway renamed the field, a caller got an empty list and no error. The spec says the client never returns a guessed empty value, so the reader now raises unexpected_response, and the message tells the caller what to do. A radius or distance of Infinity counts as missing or broken, as any other non-finite number does. Dead code in the decimal writer is gone. Signed-off-by: MobileMage --- packages/client/src/requests.ts | 14 +++---------- packages/client/src/responses.ts | 27 +++++++++++++++----------- packages/client/test/responses.test.ts | 18 ++++++++++------- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/packages/client/src/requests.ts b/packages/client/src/requests.ts index ace7daf..91929da 100644 --- a/packages/client/src/requests.ts +++ b/packages/client/src/requests.ts @@ -23,10 +23,9 @@ function isOverInputLimit(text: string): boolean { // The shortest decimal form that reads back as the same number, with no exponent. Every // client prints the same query for the same input, whatever its language prints for a number. +// Every number that reaches this function is at most 250 in size, so only a number below 1e-6 +// prints with an exponent, and it has a negative one. function decimal(value: number): string { - if (value === 0) { - return '0'; - } const printed = String(value); const parts = EXPONENT_FORM.exec(printed); if (parts === null) { @@ -34,14 +33,7 @@ function decimal(value: number): string { } const [, sign = '', first = '', rest = '', exponent = '0'] = parts; const digits = first + rest; - const point = 1 + Number(exponent); - if (point <= 0) { - return `${sign}0.${'0'.repeat(-point)}${digits}`; - } - if (point >= digits.length) { - return `${sign}${digits}${'0'.repeat(point - digits.length)}`; - } - return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`; + return `${sign}0.${'0'.repeat(-1 - Number(exponent))}${digits}`; } function requireRange(name: string, value: unknown, min: number, max: number): number { diff --git a/packages/client/src/responses.ts b/packages/client/src/responses.ts index d1fb418..5def84c 100644 --- a/packages/client/src/responses.ts +++ b/packages/client/src/responses.ts @@ -24,6 +24,10 @@ function isFields(json: unknown): json is Fields { return typeof json === 'object' && json !== null && !Array.isArray(json); } +function isNumber(field: unknown): field is number { + return typeof field === 'number' && Number.isFinite(field); +} + function textOf(fields: Fields, name: string): string | null { const field = fields[name]; return typeof field === 'string' ? field : null; @@ -39,7 +43,8 @@ function oneOf(allowed: readonly T[], field: unknown): T | nul // The readers see only responses with status 200, so the error carries that status. function unexpected(what: string): never { - const message = `The gateway sent a reply that this client cannot read: ${what}.`; + const reply = 'The gateway sent a reply that this client cannot read'; + const message = `${reply}: ${what}. Try again later, or update the client.`; throw new PostcodeError('unexpected_response', message, { status: 200 }); } @@ -133,7 +138,7 @@ function unitOf(unit: unknown): ReverseUnit | null { if (unit === undefined || unit === null) { return null; } - if (!isFields(unit) || typeof unit['distance_m'] !== 'number') { + if (!isFields(unit) || !isNumber(unit['distance_m'])) { return unexpected('the unit of a reverse result has no distance_m of type number'); } const parsed = parse(textOf(unit, 'postcode') ?? ''); @@ -167,7 +172,7 @@ export function readReverse(data: unknown): ReverseResult { const radius = data['radius_m']; return { found: data['found'], - radiusM: typeof radius === 'number' ? radius : null, + radiusM: isNumber(radius) ? radius : null, unit: unitOf(data['unit']), area: textOf(data, 'area'), district: textOf(data, 'district'), @@ -193,9 +198,9 @@ function suggestedPostcode(typed: string, code: string, segment: Precision): Pos * * @param data - The `data` field of the response. * @param typed - The normalised text that the request sent. - * @returns The autocomplete result. An item with no text in `code` is left out. - * @throws PostcodeError `unexpected_response` when `segment` is not a segment name, or - * `suggestions` is not a list. + * @returns The autocomplete result. + * @throws PostcodeError `unexpected_response` when `segment` is not a segment name, + * `suggestions` is not a list, or an item of the list has no text in `code`. * @internal */ export function readAutocomplete(data: unknown, typed: string): AutocompleteResult { @@ -204,13 +209,13 @@ export function readAutocomplete(data: unknown, typed: string): AutocompleteResu if (segment === null || !Array.isArray(items)) { return unexpected('the autocomplete has no known segment or no suggestions list'); } - const suggestions = items.filter(isFields).flatMap((item) => { - const code = textOf(item, 'code'); - if (code === null) { - return []; + const suggestions = items.map((item: unknown) => { + const code = isFields(item) ? textOf(item, 'code') : null; + if (!isFields(item) || code === null) { + return unexpected('a suggestion of the autocomplete has no text in code'); } const postcode = suggestedPostcode(typed, code, segment); - return [{ code, label: textOf(item, 'label'), postcode }]; + return { code, label: textOf(item, 'label'), postcode }; }); return { segment, suggestions }; } diff --git a/packages/client/test/responses.test.ts b/packages/client/test/responses.test.ts index ab60fcc..e6ebb2f 100644 --- a/packages/client/test/responses.test.ts +++ b/packages/client/test/responses.test.ts @@ -117,6 +117,7 @@ describe('readReverse', () => { { distance_m: 4.2 }, { postcode: 'FC-01-Z99-ZZ-01' }, { postcode: 'FC-01-Z99-ZZ-01', distance_m: '4.2' }, + { postcode: 'FC-01-Z99-ZZ-01', distance_m: Number.POSITIVE_INFINITY }, 'FC-01-Z99-ZZ-01', ]; for (const unit of units) { @@ -165,6 +166,7 @@ describe('readReverse', () => { it('gives a null radius when the body has none or a radius that is not a number', () => { expect(readReverse({ found: false }).radiusM).toBeNull(); expect(readReverse({ found: false, radius_m: '25' }).radiusM).toBeNull(); + expect(readReverse({ found: false, radius_m: Number.POSITIVE_INFINITY }).radiusM).toBeNull(); expect(readReverse(base).radiusM).toBe(250); }); @@ -199,13 +201,15 @@ describe('readAutocomplete', () => { expect(result.suggestions).toEqual([{ code: '00', label: null, postcode: null }]); }); - it('leaves out each item that has no text in code', () => { - const suggestions = [{ code: 'FC', label: 'SYNTHETIC' }, { label: 'x' }, 'FC', null]; - const result = readAutocomplete({ segment: 'state', suggestions }, 'F'); - expect(result.suggestions.map((item) => [item.code, item.label])).toEqual([ - ['FC', 'SYNTHETIC'], - ]); - }); + it.each([[{ label: 'x' }], ['FC'], [null], [{ code: 7 }]])( + 'gives unexpected_response for the item %j, and returns no empty value', + (item) => { + const data = { segment: 'state', suggestions: [{ code: 'FC' }, item] }; + const error = unexpected(() => readAutocomplete(data, 'F')); + expect(error.status).toBe(200); + expect(error.message).toContain('Try again later, or update the client.'); + }, + ); it.each([ { segment: 'street', suggestions: [] }, From 892a26118f38823d99d4f651fed1d40181c289e7 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 03:10:31 +0100 Subject: [PATCH 19/36] test(client): guard timers, the queue and cancelled waits Five faults passed the tests: a timer that is never cleared, a queue that keeps a cancelled task in line, a queue that starts the newest task first, a wait that ignores a cancel, and a timer that stops before the body arrives. Each fault now fails a test. A call whose signal aborts during an attempt now ends at once and does not wait before it gives up. The test of the deadline now ends exactly on it. Signed-off-by: MobileMage --- packages/client/src/send.ts | 1 + packages/client/test/queue.test.ts | 71 ++++++++++++++++++++++++++++++ packages/client/test/send.test.ts | 67 ++++++++++++++++++++++++++-- 3 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 packages/client/test/queue.test.ts diff --git a/packages/client/src/send.ts b/packages/client/src/send.ts index 64005c6..4f1e777 100644 --- a/packages/client/src/send.ts +++ b/packages/client/src/send.ts @@ -89,6 +89,7 @@ function failureOf(error: unknown, signal: AbortSignal): unknown { // A timer can fire up to a millisecond early on a finer clock. The client never waits less than // a Retry-After asks, so it sets a new timer for any time that is left. function pause(waitMs: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); const until = performance.now() + waitMs; return new Promise((resolve, reject) => { let timer: ReturnType; diff --git a/packages/client/test/queue.test.ts b/packages/client/test/queue.test.ts new file mode 100644 index 0000000..dfaf89a --- /dev/null +++ b/packages/client/test/queue.test.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The queue: the order of its tasks, and the place that a cancelled task gives back (T-5). +import { describe, expect, it } from 'vitest'; +import { createQueue } from '../src/queue.js'; + +interface Gate { + readonly task: () => Promise; + readonly open: () => void; +} + +// A task that starts at once, and ends only when its gate opens. +function gated(started: string[], name: string): Gate { + let open = (): void => undefined; + const opened = new Promise((resolve) => { + open = resolve; + }); + return { + task: async () => { + started.push(name); + await opened; + }, + open, + }; +} + +const NEVER = new AbortController().signal; + +describe('createQueue', () => { + it('starts the tasks in the order of their calls, with at most the limit at a time', async () => { + const started: string[] = []; + const queue = createQueue(2); + const names = ['a', 'b', 'c', 'd', 'e', 'f']; + const gates = names.map((name) => gated(started, name)); + const calls = gates.map((gate) => queue(gate.task, NEVER)); + await Promise.resolve(); + expect(started).toEqual(['a', 'b']); + for (const gate of gates) { + gate.open(); + await Promise.resolve(); + } + await Promise.all(calls); + expect(started).toEqual(names); + }); + + it('gives the place of a task that left to the next task in line', async () => { + const started: string[] = []; + const queue = createQueue(1); + const first = gated(started, 'first'); + const leaving = gated(started, 'leaving'); + const staying = gated(started, 'staying'); + const controller = new AbortController(); + const calls = [ + queue(first.task, NEVER), + queue(leaving.task, controller.signal), + queue(staying.task, NEVER), + ]; + controller.abort(new Error('left')); + await expect(calls[1]).rejects.toThrow('left'); + first.open(); + await calls[0]; + await Promise.resolve(); + expect(started).toEqual(['first', 'staying']); + staying.open(); + await calls[2]; + const after = gated(started, 'after'); + after.open(); + await queue(after.task, NEVER); + expect(started).toEqual(['first', 'staying', 'after']); + }); +}); diff --git a/packages/client/test/send.test.ts b/packages/client/test/send.test.ts index 485011f..395e09c 100644 --- a/packages/client/test/send.test.ts +++ b/packages/client/test/send.test.ts @@ -125,13 +125,14 @@ describe('the total deadline', () => { }); it('retries after a Retry-After that ends exactly at the deadline', async () => { + // The deadline is 2 x 1125 ms and one wait of up to 750 ms, so 3000 ms. const { settings, requests } = settingsWith( - { timeoutMs: 2000, maxRetries: 1 }, - unavailable('4'), + { timeoutMs: 1125, maxRetries: 1 }, + unavailable('3'), VALID_LOOKUP, ); const call = sendLookup(settings); - await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(3000); await call; expect(requests).toHaveLength(2); }); @@ -162,6 +163,48 @@ describe('a 503 with Retry-After', () => { }); }); +describe('timers', () => { + it.each([ + ['a success', [VALID_LOOKUP]], + ['a failed response', [{ status: 500, body: {} }]], + ['a dropped connection', ['drop']], + ] as const)('leaves no timer after %s', async (_, answers) => { + const { settings } = settingsWith({ maxRetries: 0 }, ...answers); + await Promise.allSettled([sendLookup(settings)]); + expect(vi.getTimerCount()).toBe(0); + }); + + it('leaves no timer after a cancelled attempt, or a cancelled wait', async () => { + const attempt = settingsWith({ maxRetries: 0 }, 'hang'); + const wait = settingsWith({}, { status: 503, body: {} }); + for (const { settings } of [attempt, wait]) { + const controller = new AbortController(); + const call = sendLookup(settings, { signal: controller.signal }); + await vi.advanceTimersByTimeAsync(100); + controller.abort(); + await Promise.allSettled([call]); + expect(vi.getTimerCount()).toBe(0); + } + }); + + it('ends an attempt whose body does not arrive in time', async () => { + const transport: typeof fetch = (_, init) => { + const signal = init?.signal ?? new AbortController().signal; + const body = new ReadableStream({ + start(stream) { + signal.addEventListener('abort', () => { + stream.error(signal.reason); + }); + }, + }); + return Promise.resolve(new Response(body, { status: 200 })); + }; + const { settings } = settingsWith({ transport, timeoutMs: 1000, maxRetries: 0 }); + const error = reasonOf(await settle(sendLookup(settings), 1000)) as PostcodeError; + expect(error.code).toBe('timeout'); + }); +}); + describe('error messages', () => { const SECRET = 'nipost_live_do_not_show_this_key'; const failures: readonly (readonly [string, Answer])[] = [ @@ -202,8 +245,24 @@ describe('cancellation (API-11)', () => { const call = sendLookup(settings, { signal: controller.signal }); await vi.advanceTimersByTimeAsync(100); controller.abort(); - expect(reasonOf(await settle(call, 5000))).toBe(controller.signal.reason); + // The call ends at once, not when the wait of 500 ms or more ends. + expect(reasonOf(await settle(call, 0))).toBe(controller.signal.reason); expect(requests).toHaveLength(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not wait for a retry when the signal aborted during the attempt', async () => { + const controller = new AbortController(); + const fake = fakeTransport({ status: 503, body: {} }); + const transport: typeof fetch = async (input, init) => { + const response = await fake.transport(input, init); + controller.abort(); + return response; + }; + const { settings } = settingsWith({ transport }); + const call = sendLookup(settings, { signal: controller.signal }); + expect(reasonOf(await settle(call, 0))).toBe(controller.signal.reason); + expect(fake.requests).toHaveLength(1); }); it('takes a waiting request out of the queue, so the next one takes its place', async () => { From f474a38b0c884e2f18e6c62a61019edbc0114729 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 03:19:24 +0100 Subject: [PATCH 20/36] fix(client): give autocomplete 15 s, and bound and test the shared calls Autocomplete used the 8 s timeout of a lookup, and the spec gives it 15 s. The cache kept every result until its key came again, so a server that asks about many coordinates grew without limit. It now drops expired results, keeps at most 1000, and refuses a result that a clearCache call overtook. The total deadline no longer loops once for each retry, so a very large maxRetries does not hang the first call. Tests now fail for a share key that ignores the level, and for a shared request that stops when one caller leaves. A scenario gets a time limit from its own waits, and an empty spec folder fails. Signed-off-by: MobileMage --- packages/client/src/calls.ts | 24 +++++- packages/client/src/client.ts | 9 ++- packages/client/src/error.ts | 3 +- packages/client/src/responses.ts | 21 +++-- packages/client/src/send.ts | 9 +-- packages/client/test/client.test.ts | 107 +++++++++++++++++++++++++ packages/client/test/contract.test.ts | 55 +++++++------ packages/client/test/error.test.ts | 3 +- packages/client/test/responses.test.ts | 2 +- packages/client/test/scenarios.ts | 8 +- 10 files changed, 194 insertions(+), 47 deletions(-) diff --git a/packages/client/src/calls.ts b/packages/client/src/calls.ts index 150d86e..d38658e 100644 --- a/packages/client/src/calls.ts +++ b/packages/client/src/calls.ts @@ -21,6 +21,9 @@ export interface Calls { clear(): void; } +// A server can call with many different arguments, so a client keeps at most this many results. +const MAX_KEPT = 1000; + interface InFlight { readonly promise: Promise; readonly controller: AbortController; @@ -35,14 +38,30 @@ interface InFlight { * @internal */ export function createCalls(cacheTtlMs: number): Calls { + let epoch = 0; const inFlight = new Map>(); const kept = new Map(); + // The key is not in the map, because call() removes a result that has expired. + // Every result lives for the same time, so the oldest entries are at the front of the map. + const keep = (key: string, result: T): void => { + const now = Date.now(); + for (const [oldKey, entry] of kept) { + if (entry.until > now && kept.size < MAX_KEPT) { + break; + } + kept.delete(oldKey); + } + kept.set(key, { result, until: now + cacheTtlMs }); + }; + const start = (key: string, run: (signal: AbortSignal) => Promise): InFlight => { const controller = new AbortController(); + const startedIn = epoch; const promise = run(controller.signal).then((result) => { - if (cacheTtlMs > 0) { - kept.set(key, { result, until: Date.now() + cacheTtlMs }); + // A result of a request that started before clearCache must not come back. + if (cacheTtlMs > 0 && startedIn === epoch) { + keep(key, result); } return result; }); @@ -95,6 +114,7 @@ export function createCalls(cacheTtlMs: number): Calls { return await join(key, inFlight.get(key) ?? start(key, run), signal); }, clear() { + epoch += 1; kept.clear(); }, }; diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index d19db71..7d88456 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -16,6 +16,7 @@ import type { // API-8: a client sends at most 4 requests at a time. const PARALLEL_REQUESTS = 4; +const AUTOCOMPLETE_TIMEOUT_MS = 15_000; const SECRET_KEY = /^nipost_(test|live)_/; // The numeric options and the rule for each. A value that breaks its rule is a programmer error. @@ -60,6 +61,7 @@ function checkOptions(options: ClientOptions): void { */ export class PostcodeClient { readonly #settings: SendSettings; + readonly #autocompleteSettings: SendSettings; readonly #lookups: Calls; readonly #reverses: Calls; readonly #autocompletes: Calls; @@ -84,6 +86,11 @@ export class PostcodeClient { maxRetries: options.maxRetries ?? 2, queue: createQueue(PARALLEL_REQUESTS), }; + // A user waits for each completion, so autocomplete gets a longer timeout than a lookup. + this.#autocompleteSettings = { + ...this.#settings, + timeoutMs: options.timeoutMs ?? AUTOCOMPLETE_TIMEOUT_MS, + }; this.#lookups = createCalls(cacheTtlMs); this.#reverses = createCalls(cacheTtlMs); this.#autocompletes = createCalls(cacheTtlMs); @@ -184,7 +191,7 @@ export class PostcodeClient { const { typed, request } = autocompleteRequest(q); const run = async (signal: AbortSignal): Promise => readAutocomplete( - await send(this.#settings, request, { signal, timeoutRetried: false }), + await send(this.#autocompleteSettings, request, { signal, timeoutRetried: false }), typed, ); return await this.#autocompletes.call(typed, run, options.signal); diff --git a/packages/client/src/error.ts b/packages/client/src/error.ts index 49550b1..557ee34 100644 --- a/packages/client/src/error.ts +++ b/packages/client/src/error.ts @@ -95,7 +95,8 @@ const ADVICE: Readonly> = { forbidden: "The gateway refused the call. Check the key's scope and lookup level.", rate_limited: 'The gateway limits the requests of this key. Wait, then try again.', server_error: 'The gateway failed. Try again later.', - unexpected_response: 'The gateway sent a reply that this client cannot read.', + unexpected_response: + 'The gateway sent a reply that this client cannot read. Try again, or update the client.', network_error: 'The request did not reach the gateway. Check the network and the baseUrl.', timeout: 'The gateway sent no response in time. Try again, or raise timeoutMs.', }; diff --git a/packages/client/src/responses.ts b/packages/client/src/responses.ts index 5def84c..32806e2 100644 --- a/packages/client/src/responses.ts +++ b/packages/client/src/responses.ts @@ -4,7 +4,7 @@ // Unknown fields stay unread. A known field of the wrong type raises unexpected_response, and // an unknown value of a field that allows one gets its fallback (API-14). import { parse, type Postcode, type Precision } from '@gatepost/core'; -import { PostcodeError } from './error.js'; +import { clientError, PostcodeError } from './error.js'; import type { AdministrativeAddress, AutocompleteResult, @@ -43,8 +43,7 @@ function oneOf(allowed: readonly T[], field: unknown): T | nul // The readers see only responses with status 200, so the error carries that status. function unexpected(what: string): never { - const reply = 'The gateway sent a reply that this client cannot read'; - const message = `${reply}: ${what}. Try again later, or update the client.`; + const { message } = clientError('unexpected_response', `${what}.`); throw new PostcodeError('unexpected_response', message, { status: 200 }); } @@ -70,7 +69,7 @@ export function apiCodeOf(body: unknown): string | null { */ export function dataOf(body: unknown): unknown { if (!isFields(body) || body['data'] === undefined) { - return unexpected('the body is not a JSON object with a data field'); + return unexpected('the body has no data field'); } return body['data']; } @@ -116,10 +115,10 @@ export function readLookup( request: Readonly<{ postcode: Postcode; level: LookupLevel }>, ): LookupResult { if (!isFields(data) || typeof data['valid'] !== 'boolean') { - return unexpected('the lookup has no valid field of type boolean'); + return unexpected('the lookup has no boolean valid'); } if (present(data, 'status') && typeof data['status'] !== 'string') { - return unexpected('the lookup has a status that is not text'); + return unexpected('the lookup status is not text'); } const house = data['recent_house_address']; return { @@ -139,11 +138,11 @@ function unitOf(unit: unknown): ReverseUnit | null { return null; } if (!isFields(unit) || !isNumber(unit['distance_m'])) { - return unexpected('the unit of a reverse result has no distance_m of type number'); + return unexpected('the unit has no number in distance_m'); } const parsed = parse(textOf(unit, 'postcode') ?? ''); if (!parsed.ok) { - return unexpected('the unit of a reverse result has a postcode that does not parse'); + return unexpected('the unit has a postcode that does not parse'); } return { postcode: parsed.value, @@ -167,7 +166,7 @@ function unitOf(unit: unknown): ReverseUnit | null { */ export function readReverse(data: unknown): ReverseResult { if (!isFields(data) || typeof data['found'] !== 'boolean') { - return unexpected('the reverse result has no found field of type boolean'); + return unexpected('the reverse result has no boolean found'); } const radius = data['radius_m']; return { @@ -207,12 +206,12 @@ export function readAutocomplete(data: unknown, typed: string): AutocompleteResu const segment = isFields(data) ? oneOf(SEGMENTS, data['segment']) : null; const items = isFields(data) ? data['suggestions'] : undefined; if (segment === null || !Array.isArray(items)) { - return unexpected('the autocomplete has no known segment or no suggestions list'); + return unexpected('the segment or the suggestions list is missing'); } const suggestions = items.map((item: unknown) => { const code = isFields(item) ? textOf(item, 'code') : null; if (!isFields(item) || code === null) { - return unexpected('a suggestion of the autocomplete has no text in code'); + return unexpected('an item of the autocomplete has no text code'); } const postcode = suggestedPostcode(typed, code, segment); return { code, label: textOf(item, 'label'), postcode }; diff --git a/packages/client/src/send.ts b/packages/client/src/send.ts index 4f1e777..9745092 100644 --- a/packages/client/src/send.ts +++ b/packages/client/src/send.ts @@ -116,11 +116,10 @@ function pause(waitMs: number, signal: AbortSignal): Promise { // The longest a call lasts from its first attempt: every attempt may use its full timeout, and // every retry may wait its longest own wait. function deadlineMs(settings: SendSettings): number { - let totalMs = (1 + settings.maxRetries) * settings.timeoutMs; - for (let retry = 1; retry <= settings.maxRetries; retry += 1) { - totalMs += FIRST_WAIT_MS * 2 ** (retry - 1) + JITTER_MS; - } - return totalMs; + const { maxRetries, timeoutMs } = settings; + // The waits 500 x 2^(n-1) + 250 for n = 1 to maxRetries add up to this, with no loop. + const waitsMs = FIRST_WAIT_MS * (2 ** maxRetries - 1) + JITTER_MS * maxRetries; + return (1 + maxRetries) * timeoutMs + waitsMs; } /** diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index 8d9f6ba..5e084cc 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -48,6 +48,11 @@ describe('the defaults of API-8', () => { expect(requests).toHaveLength(5); }); + it('answers a call at once when maxRetries is very large', async () => { + const { client } = clientWith({ maxRetries: Number.MAX_SAFE_INTEGER }, VALID_LOOKUP); + expect((await client.lookup('FC-01-Z99-ZZ-01')).valid).toBe(true); + }); + it('keeps no result when cacheTtlMs is not set', async () => { const { client, requests } = clientWith({}, VALID_LOOKUP); await client.lookup('FC-01-Z99-ZZ-01'); @@ -56,6 +61,32 @@ describe('the defaults of API-8', () => { }); }); +describe('the timeout of autocomplete', () => { + it('ends an attempt after 15000 ms by default, and a lookup after 8000 ms', async () => { + const { client, requests } = clientWith({ maxRetries: 0 }, 'hang'); + const lookup = client.lookup('FC-01-Z99-ZZ-01'); + const autocomplete = client.autocomplete('FC0'); + const settled = Promise.allSettled([lookup, autocomplete]); + await vi.advanceTimersByTimeAsync(7999); + expect(requests.map((request) => request.signal.aborted)).toEqual([false, false]); + await vi.advanceTimersByTimeAsync(1); + expect(requests.map((request) => request.signal.aborted)).toEqual([true, false]); + await vi.advanceTimersByTimeAsync(6999); + expect(requests[1]?.signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(requests[1]?.signal.aborted).toBe(true); + await settled; + }); + + it('uses timeoutMs for autocomplete when the option is set', async () => { + const { client, requests } = clientWith({ maxRetries: 0, timeoutMs: 1000 }, 'hang'); + const settled = Promise.allSettled([client.autocomplete('FC0')]); + await vi.advanceTimersByTimeAsync(1000); + expect(requests[0]?.signal.aborted).toBe(true); + await settled; + }); +}); + describe('the options', () => { it.each([ [{ timeoutMs: 0 }, 'timeoutMs must be a number above 0. It is 0.'], @@ -142,7 +173,83 @@ describe('shared calls', () => { }); }); +// A transport that answers after a delay, and obeys its signal, as fetch does. +function slowTransport(delayMs: number) { + const requests: URL[] = []; + const transport: typeof fetch = (input, init) => { + requests.push(new URL(input instanceof Request ? input.url : input)); + const signal = init?.signal; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve(new Response(JSON.stringify(VALID_LOOKUP_BODY), { status: 200 })); + }, delayMs); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(signal.reason as Error); + }); + }); + }; + return { transport, requests }; +} + +const VALID_LOOKUP_BODY = { data: { postcode: 'FC-01-Z99-ZZ-01', valid: true, status: 'valid' } }; + +describe('shared calls that obey the signal', () => { + it('keeps the request for a caller that stays when the other caller leaves', async () => { + const { transport, requests } = slowTransport(50); + const client = new PostcodeClient({ transport }); + const controller = new AbortController(); + const leaving = client.lookup('FC-01-Z99-ZZ-01', { signal: controller.signal }); + const staying = client.lookup('FC-01-Z99-ZZ-01'); + await vi.advanceTimersByTimeAsync(10); + controller.abort(); + await expect(leaving).rejects.toBe(controller.signal.reason); + await vi.advanceTimersByTimeAsync(100); + expect((await staying).valid).toBe(true); + expect(requests).toHaveLength(1); + }); + + it('does not keep a result that a clearCache call overtook', async () => { + const { transport, requests } = slowTransport(50); + const client = new PostcodeClient({ transport, cacheTtlMs: 60_000 }); + const first = client.lookup('FC-01-Z99-ZZ-01'); + await vi.advanceTimersByTimeAsync(10); + client.clearCache(); + await vi.advanceTimersByTimeAsync(100); + await first; + const second = client.lookup('FC-01-Z99-ZZ-01'); + await vi.advanceTimersByTimeAsync(100); + await second; + expect(requests).toHaveLength(2); + }); +}); + +describe('the lookup level', () => { + it('shares and keeps a result for each level', async () => { + const { client, requests } = clientWith({ cacheTtlMs: 60_000 }, VALID_LOOKUP); + const [one, two] = await Promise.all([ + client.lookup('FC-01-Z99-ZZ-01', { level: 1 }), + client.lookup('FC-01-Z99-ZZ-01', { level: 2 }), + ]); + expect([one.levelRequested, two.levelRequested]).toEqual([1, 2]); + expect(requests).toHaveLength(2); + expect((await client.lookup('FC-01-Z99-ZZ-01', { level: 2 })).levelRequested).toBe(2); + expect((await client.lookup('FC-01-Z99-ZZ-01', { level: 1 })).levelRequested).toBe(1); + expect(requests).toHaveLength(2); + }); +}); + describe('the cache', () => { + it('holds a bounded number of results', async () => { + const reverse = { status: 200, body: { data: { found: false, radius_m: 25 } } }; + const { client, requests } = clientWith({ cacheTtlMs: 60_000 }, reverse); + for (let lat = 0; lat <= 1100; lat += 1) { + await client.reverse(lat / 100, 0); + } + await client.reverse(0, 0); + expect(requests).toHaveLength(1102); + }); + it('keeps a result for cacheTtlMs, and no longer', async () => { const { client, requests } = clientWith({ cacheTtlMs: 60_000 }, VALID_LOOKUP); await client.lookup('FC-01-Z99-ZZ-01'); diff --git a/packages/client/test/contract.test.ts b/packages/client/test/contract.test.ts index e67f552..132d9ed 100644 --- a/packages/client/test/contract.test.ts +++ b/packages/client/test/contract.test.ts @@ -9,8 +9,8 @@ import { readScenarios, runScenario } from './scenarios.js'; // Timers can fire late, so a wait may pass its maximum by this much. It must not fall short. const LATE_MS = 100; -// lookup-retry-after-ten waits 10 s, so a scenario needs a limit of at least 12 s. -const SCENARIO_LIMIT_MS = 20_000; +// A scenario gets 5 s and the longest waits that it expects, so a client that hangs fails soon. +const BASE_LIMIT_MS = 5000; let mock: MockServer; @@ -22,26 +22,35 @@ afterAll(async () => { await mock.close(); }); +const scenarios = readScenarios(); + describe.concurrent('the contract scenarios', () => { - it.each(readScenarios())( - '$id: $description', - async (scenario) => { - const run = await runScenario(scenario, mock.url); - const { expect: wanted } = scenario; - expect(run.outcomes).toEqual(wanted.outcomes); - expect(run.attempts).toBe(wanted.attempts); - if (wanted.request !== undefined) { - expect(run.request).toEqual(wanted.request); - } - if (wanted.maxInFlight !== undefined) { - expect(run.maxInFlight).toBe(wanted.maxInFlight); - } - for (const [index, bounds] of (wanted.waitsMs ?? []).entries()) { - const waitMs = run.waitsMs[index] ?? Number.NaN; - expect(waitMs).toBeGreaterThanOrEqual(bounds.min); - expect(waitMs).toBeLessThanOrEqual(bounds.max + LATE_MS); - } - }, - SCENARIO_LIMIT_MS, - ); + it('finds the scenarios of the spec', () => { + expect(scenarios.length).toBeGreaterThan(0); + }); + + for (const scenario of scenarios) { + const waitsMs = (scenario.expect.waitsMs ?? []).reduce((sum, bounds) => sum + bounds.max, 0); + it( + `${scenario.id}: ${scenario.description}`, + async () => { + const run = await runScenario(scenario, mock.url); + const { expect: wanted } = scenario; + expect(run.outcomes).toEqual(wanted.outcomes); + expect(run.attempts).toBe(wanted.attempts); + if (wanted.request !== undefined) { + expect(run.request).toEqual(wanted.request); + } + if (wanted.maxInFlight !== undefined) { + expect(run.maxInFlight).toBe(wanted.maxInFlight); + } + for (const [index, bounds] of (wanted.waitsMs ?? []).entries()) { + const waitMs = run.waitsMs[index] ?? Number.NaN; + expect(waitMs).toBeGreaterThanOrEqual(bounds.min); + expect(waitMs).toBeLessThanOrEqual(bounds.max + LATE_MS); + } + }, + BASE_LIMIT_MS + waitsMs, + ); + } }); diff --git a/packages/client/test/error.test.ts b/packages/client/test/error.test.ts index 4694bd6..6db2369 100644 --- a/packages/client/test/error.test.ts +++ b/packages/client/test/error.test.ts @@ -83,7 +83,8 @@ describe('clientError', () => { const error = clientError('unexpected_response', 'The body has no data field.'); expect(error.code).toBe('unexpected_response'); expect(error.message).toBe( - 'The gateway sent a reply that this client cannot read. The body has no data field.', + 'The gateway sent a reply that this client cannot read. Try again, or update the client. ' + + 'The body has no data field.', ); }); }); diff --git a/packages/client/test/responses.test.ts b/packages/client/test/responses.test.ts index e6ebb2f..9387c47 100644 --- a/packages/client/test/responses.test.ts +++ b/packages/client/test/responses.test.ts @@ -207,7 +207,7 @@ describe('readAutocomplete', () => { const data = { segment: 'state', suggestions: [{ code: 'FC' }, item] }; const error = unexpected(() => readAutocomplete(data, 'F')); expect(error.status).toBe(200); - expect(error.message).toContain('Try again later, or update the client.'); + expect(error.message).toContain('Try again, or update the client.'); }, ); diff --git a/packages/client/test/scenarios.ts b/packages/client/test/scenarios.ts index 097944a..e2888e3 100644 --- a/packages/client/test/scenarios.ts +++ b/packages/client/test/scenarios.ts @@ -11,7 +11,7 @@ import type { AutocompleteResult, LookupLevel, LookupResult, ReverseResult } fro /** One call of a scenario. */ export type ScenarioCall = - | { readonly method: 'lookup'; readonly code: string; readonly level: LookupLevel } + | { readonly method: 'lookup'; readonly code: string; readonly level?: LookupLevel } | { readonly method: 'reverse'; readonly lat: number; @@ -103,7 +103,11 @@ async function outcomeOf(client: PostcodeClient, call: ScenarioCall): Promise Date: Sat, 3 Oct 2026 03:22:57 +0100 Subject: [PATCH 21/36] docs(client): add the README and run every code example DOC-3 asks that every code example runs in CI. The examples of the client call the gateway, so their tests send each call to the mock server instead, and refuse to run without it. The example runner of the core now takes the modules that an example imports, so both packages share one runner and one rule for the results that an example shows. Signed-off-by: MobileMage --- AGENTS.md | 2 +- README.md | 9 +- packages/client/README.md | 198 +++++++++++++++++++++++++ packages/client/test/example-client.ts | 20 +++ packages/client/test/examples.ts | 27 ++++ packages/client/test/readme.test.ts | 33 +++++ packages/client/test/tsdoc.test.ts | 19 +++ packages/core/test/examples.test.ts | 5 + packages/core/test/examples.ts | 88 +++++++++-- packages/core/test/tsdoc.test.ts | 40 +---- 10 files changed, 390 insertions(+), 51 deletions(-) create mode 100644 packages/client/README.md create mode 100644 packages/client/test/example-client.ts create mode 100644 packages/client/test/examples.ts create mode 100644 packages/client/test/readme.test.ts create mode 100644 packages/client/test/tsdoc.test.ts diff --git a/AGENTS.md b/AGENTS.md index aacaca3..0191180 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the TypeScript packages. Today it has `@gatepost/core` and the private `@gatepost/mock-server`. +This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the TypeScript packages. Today it has `@gatepost/core`, `@gatepost/client` and the private `@gatepost/mock-server`. ## Read first diff --git a/README.md b/README.md index 5ea3582..668e7ed 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ TypeScript packages for Nigeria's National Digital Postcode. | Package | What it does | | ----------------------------------------------- | --------------------------------------------------- | | [`@gatepost/core`](packages/core) | Parse, check and format postcodes offline. | +| [`@gatepost/client`](packages/client) | Call NIPOST's gateway, with retries and limits. | | [`@gatepost/mock-server`](packages/mock-server) | Mock NIPOST's gateway for client tests. Not on npm. | ## Develop @@ -22,12 +23,12 @@ You need Node 24, `corepack enable`, Python 3.11 or later and `uv`. pnpm install pnpm check -`pnpm check` also builds the package. Then it runs publint, attw, size-limit and API Extractor on -the result, and it starts the built package once. It checks the licence of each dependency and of +`pnpm check` also builds the packages. Then it runs publint, attw, size-limit and API Extractor on +the result, and it starts each built package once. It checks the licence of each dependency and of each file, and it checks the workflows with zizmor. It starts REUSE and zizmor with `uvx` at pinned versions. REUSE needs an encoding detector, and `uvx` installs REUSE without one, so the script pins -`charset-normalizer` as well. Renovate updates the three pins. After a change to the public API, run -`pnpm --filter @gatepost/core run api:update` and commit `packages/core/etc/core.api.md`. For each +`charset-normalizer` as well. Renovate updates the three pins. After a change to the public API of a +package, run `pnpm --filter run api:update` and commit its `etc/.api.md`. For each change to a package, add a change file with `pnpm changeset`, or with `pnpm changeset --empty` when the change needs no release. The pull request job fails without a change file. diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..87c8c27 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,198 @@ + + + gatepost + + +# @gatepost/client + +Call NIPOST's postcode gateway from TypeScript: look up a postcode, find the postcode of a place, and complete a postcode that a user is typing. + +> Unofficial. Not made or endorsed by NIPOST. + +[![CI](https://github.com/gatepost-dev/js/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gatepost-dev/js/actions/workflows/ci.yml) +[![Licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](https://github.com/gatepost-dev/js/blob/main/LICENSE) + +## Install + +```sh +pnpm add @gatepost/client +``` + +The package installs `@gatepost/core`, which parses each postcode before a request goes out. + +## Quickstart + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const result = await client.lookup('fc 01 z99 zz 01'); +result.postcode.canonical; // 'FC-01-Z99-ZZ-01' +result.valid; // true +``` + +The examples use `FC-01-Z99-ZZ-01`, a synthetic postcode, and show the answers of Gatepost's mock server, which CI runs them against. The real gateway does not know this postcode. + +Get a key from NIPOST's dashboard. The gateway refuses every call without a key. A key that starts with `nipost_test_` or `nipost_live_` is a secret key, so keep it on a server. The client refuses a secret key in a page that has a `document`, such as a web page. + +## What it does + +- Looks up a postcode at a lookup level from 1 to 5. +- Finds the unit at a coordinate. +- Offers the values of the segment that a user is typing. +- Checks each postcode offline first, so a bad code sends no request. +- Retries a failed request when a retry can help, and waits between attempts. +- Shares one request between identical calls, and sends at most 4 requests at a time. +- Keeps results for a time that you choose. It keeps none by default. + +The client sends only the requests that you make. It has no logging. It sends your key only in the `X-API-Key` header, and none of its own error messages holds the key. + +### Look up a postcode + +`lookup` takes a postcode in any form, or a postcode that `parse` from `@gatepost/core` returned. A postcode that the gateway does not know gives `valid` false and a `status` of `not_found`, not an error. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const unknown = await client.lookup('FC-01-Z99-ZZ-02'); +unknown.valid; // false +unknown.status; // 'not_found' +``` + +A key holds a lookup level, and `lookup` sends level 1 when you give none. NIPOST's docs say that level 1 tells only whether a postcode exists, level 2 adds the names of the places that hold it and a recent house address, and level 3 adds what the building is used for. A call above the key's level fails with `forbidden`. The gateway does not say which level it sent, so `levelReceived` comes from the fields of the response. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const result = await client.lookup('FC-01-Z99-ZZ-01', { level: 2 }); +result.levelRequested; // 2 +result.levelReceived; // 2 +result.administrativeAddress?.lgaName; // 'SYNTHETIC LGA' +``` + +Gatepost has seen only level 1 responses from the gateway. The fields of levels 2 to 5 are documented, not observed: they follow NIPOST's docs, and no Gatepost test has seen them from the gateway yet. The example above shows the answer of the mock server. + +### Handle errors + +Each call raises a `PostcodeError` when it cannot give a result. Its `code` is one of `invalid_input`, `unauthorized`, `insufficient_credits`, `origin_not_allowed`, `forbidden`, `rate_limited`, `server_error`, `network_error` and `timeout`. `status` holds the HTTP status, and `apiCode` holds the gateway's own error code. + +```ts +import { PostcodeClient, PostcodeError } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +try { + await client.lookup('900108'); +} catch (error) { + if (error instanceof PostcodeError) { + error.code; // 'invalid_input' + error.status; // null + } +} +``` + +`900108` is a legacy postcode, so the client sends no request for it. Show the user the message for the error code, not the error's own message, which is for developers. + +When the gateway limits a key, the error is `rate_limited`. The client waits and tries again when the gateway asks for a wait of 10 seconds or less. For a longer wait, `retryAfterMs` holds it, so you can tell the user when to try again. + +### Find the postcode of a place + +`reverse` takes a latitude and a longitude in degrees, and an optional radius in metres. The gateway uses 25 m when you give no radius, and 250 m at most. `found` can be true while `unit` is null: the gateway then found an area, but no unit within the radius. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const place = await client.reverse(9, 7); +place.found; // true +place.unit?.postcode.canonical; // 'FC-01-Z99-ZZ-01' +place.area; // 'FC-01-Z99-ZZ' +``` + +A GPS fix is only as good as its accuracy. `precisionForAccuracy` from `@gatepost/core` tells you which segment a fix supports. + +### Complete a typed postcode + +`autocomplete` takes the text that a user typed. It names the segment that the user is typing, and the gateway's values for it. Each value comes with the partial postcode that it completes. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const typing = await client.autocomplete('fc 01 z'); +typing.segment; // 'district' +typing.suggestions[0]?.code; // 'Z99' +typing.suggestions[0]?.postcode?.canonical; // 'FC-01-Z99' +``` + +Empty text sends no request and fails with `invalid_input`, because the gateway does not answer it. A timeout of `autocomplete` gets no retry, because the next keystroke replaces the call. `autocomplete` waits 15 seconds for one attempt, not 8, unless you set `timeoutMs`. + +### Cancel a call + +Each call takes an `AbortSignal`. A cancelled call ends with the signal's reason, not with a `PostcodeError`. When every caller of a shared request cancels, the request stops and frees its place. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const keystroke = new AbortController(); +const call = client.autocomplete('fc 0', { signal: keystroke.signal }); +keystroke.abort(); +const outcome = await call.catch((reason: unknown) => reason); +outcome === keystroke.signal.reason; // true +``` + +### Choose the settings + +| Option | Meaning | Default | +| ------------ | --------------------------------------------------- | ---------------------------------- | +| `apiKey` | the key for the `X-API-Key` header | none, so no key is sent | +| `baseUrl` | the gateway's address | `https://api.postcode.gov.ng` | +| `transport` | the function that sends each request, like `fetch` | the platform's `fetch` | +| `timeoutMs` | the longest wait for one attempt, in milliseconds | 8000, and 15000 for `autocomplete` | +| `maxRetries` | the most retries after the first attempt | 2 | +| `cacheTtlMs` | how long the client keeps a result, in milliseconds | 0, which keeps none | + +A client retries a 502, 503 or 504, a lost connection and a timeout. It waits 500 ms before the first retry and 1000 ms before the second, plus up to 250 ms at random. With `cacheTtlMs` above 0, an identical call within that time gets the kept result with no request. Errors stay out of the cache, and `clearCache` removes every kept result. The cache of each call type keeps at most 1000 results and drops the oldest first. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const apiKey = process.env['NIPOST_API_KEY']; +const client = new PostcodeClient({ apiKey, cacheTtlMs: 60_000 }); +await client.lookup('FC-01-Z99-ZZ-01'); +client.clearCache(); +``` + +## Requirements + +| Requirement | Version | +| ------------- | ---------------------------------------------- | +| Node | 22 or later, or a current browser with `fetch` | +| Module format | ESM only | +| Gatepost spec | 0.2.0 | + +The package exports `SPEC_VERSION`, the version of the Gatepost spec that it implements. + +```ts +import { SPEC_VERSION } from '@gatepost/client'; + +SPEC_VERSION; // '0.2.0' +``` + +## Docs + +The API report is in [`etc/client.api.md`](https://github.com/gatepost-dev/js/blob/main/packages/client/etc/client.api.md). [`client.md`](https://github.com/gatepost-dev/spec/blob/main/client.md) in the spec repo defines the results, the errors and the retries, and every Gatepost client passes the same contract scenarios. + +## Support + +Ask questions and report bugs in [GitHub Issues](https://github.com/gatepost-dev/js/issues). Report security problems privately, as [`SECURITY.md`](https://github.com/gatepost-dev/.github/blob/main/SECURITY.md) describes. + +## Contributing + +Read [`CONTRIBUTING.md`](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request. + +## Licence + +Apache-2.0. See [`LICENSE`](https://github.com/gatepost-dev/js/blob/main/LICENSE) and [`NOTICE`](https://github.com/gatepost-dev/js/blob/main/NOTICE). diff --git a/packages/client/test/example-client.ts b/packages/client/test/example-client.ts new file mode 100644 index 0000000..785e3a2 --- /dev/null +++ b/packages/client/test/example-client.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The examples in the README and in the doc comments show calls to NIPOST's gateway. Their +// tests import this module in place of the package, so each call goes to a mock server. +import process from 'node:process'; +import { PostcodeClient as GatewayClient, type ClientOptions } from '../src/index.js'; + +export { PostcodeError, SPEC_VERSION } from '../src/index.js'; + +/** A client that calls the mock server that `GATEPOST_MOCK_URL` names, with a mock key. */ +export class PostcodeClient extends GatewayClient { + constructor(options: ClientOptions = {}) { + const baseUrl = process.env['GATEPOST_MOCK_URL']; + // With no mock server, a call would go to NIPOST. A test must never call it. + if (baseUrl === undefined) { + throw new Error('Start the mock server with useMockServer() before the examples run.'); + } + super({ ...options, baseUrl, apiKey: 'nipost_test_mock_l3' }); + } +} diff --git a/packages/client/test/examples.ts b/packages/client/test/examples.ts new file mode 100644 index 0000000..21e8bde --- /dev/null +++ b/packages/client/test/examples.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { startMockServer, type MockServer } from '@gatepost/mock-server'; +import { afterAll, beforeAll } from 'vitest'; +import type { Sources } from '../../core/test/examples.js'; + +/** The modules that the examples of this package import: a client of the mock, and the core. */ +export const CLIENT_SOURCES: Sources = { + '@gatepost/client': fileURLToPath(new URL('example-client.ts', import.meta.url)), + '@gatepost/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), +}; + +/** + * Starts a mock server for the examples of one test file, and stops it after them. + */ +export function useMockServer(): void { + let mock: MockServer; + beforeAll(async () => { + mock = await startMockServer({ port: 0 }); + process.env['GATEPOST_MOCK_URL'] = mock.url; + }); + afterAll(async () => { + await mock.close(); + }); +} diff --git a/packages/client/test/readme.test.ts b/packages/client/test/readme.test.ts new file mode 100644 index 0000000..59e4181 --- /dev/null +++ b/packages/client/test/readme.test.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { runExamples, tsBlocks } from '../../core/test/examples.js'; +import * as client from '../src/index.js'; +import { CLIENT_SOURCES, useMockServer } from './examples.js'; + +const README = readFileSync(new URL('../README.md', import.meta.url), 'utf8'); +const EXAMPLES = tsBlocks(README).map((source, index) => ({ + name: `README example ${String(index)}`, + source, +})); + +describe('README', () => { + useMockServer(); + runExamples(EXAMPLES, CLIENT_SOURCES); + + it.each(Object.keys(client))('shows %s in an example', (name) => { + expect(EXAMPLES.map((example) => example.source).join('\n')).toMatch( + new RegExp(`\\b${name}\\b`), + ); + }); + + it('says that the package is unofficial', () => { + expect(README).toContain('> Unofficial. Not made or endorsed by NIPOST.'); + }); + + it('names the version of the spec that the package implements', () => { + const version = client.SPEC_VERSION.replaceAll('.', '\\.'); + expect(README).toMatch(new RegExp(`Gatepost spec\\s*\\|\\s*${version}\\s*\\|`)); + }); +}); diff --git a/packages/client/test/tsdoc.test.ts b/packages/client/test/tsdoc.test.ts new file mode 100644 index 0000000..1863582 --- /dev/null +++ b/packages/client/test/tsdoc.test.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { docExamples, readDocComments, runExamples } from '../../core/test/examples.js'; +import * as client from '../src/index.js'; +import { CLIENT_SOURCES, useMockServer } from './examples.js'; + +const EXAMPLES = docExamples(readDocComments(new URL('../src/', import.meta.url))); + +describe('TSDoc examples', () => { + useMockServer(); + runExamples(EXAMPLES, CLIENT_SOURCES); + + it.each(Object.keys(client))('shows %s in an example', (name) => { + expect(EXAMPLES.map((example) => example.source).join('\n')).toMatch( + new RegExp(`\\b${name}\\b`), + ); + }); +}); diff --git a/packages/core/test/examples.test.ts b/packages/core/test/examples.test.ts index 0a73510..7f8ca5a 100644 --- a/packages/core/test/examples.test.ts +++ b/packages/core/test/examples.test.ts @@ -36,6 +36,11 @@ describe('toModule', () => { expect(module).not.toContain("'@gatepost/core'"); }); + it('imports a package from the module that the sources name', () => { + const module = toModule("import { b } from '@gatepost/b';\nb();", { '@gatepost/b': '/b.ts' }); + expect(module).toContain("import { b } from '/b.ts';"); + }); + it('imports each function that an example without an import uses', () => { const module = toModule("parse('x'); // 'y'\nredact(x);"); expect(module).toContain("import { parse, redact } from '../../src/index.js';"); diff --git a/packages/core/test/examples.ts b/packages/core/test/examples.ts index c38f5d6..ea8ec2b 100644 --- a/packages/core/test/examples.ts +++ b/packages/core/test/examples.ts @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { afterAll, beforeAll, expect, it } from 'vitest'; @@ -12,6 +12,73 @@ export interface Example { readonly source: string; } +/** + * The module that each package name in an example stands for. A path is relative to the folder + * of the module that the test writes, or absolute. + */ +export type Sources = Readonly>; + +// The examples of the core run against its source. +const CORE_SOURCES: Sources = { '@gatepost/core': '../../src/index.js' }; + +/** The doc comments of one source file, each without its asterisks. */ +export interface SourceDocs { + readonly file: string; + readonly docs: readonly string[]; +} + +const DOC_COMMENT = /\/\*\*[\s\S]*?\*\//g; + +// The text of a doc comment without its asterisks, so that a code fence starts a line. +function docText(comment: string): string { + return comment + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map((line) => line.replace(/^\s*\* ?/, '')) + .join('\n'); +} + +/** + * Reads the doc comments of each TypeScript file in a folder. + * + * @param folder - The folder, such as the `src` folder of a package. + * @returns The doc comments of each file, in the order of the file names. + */ +export function readDocComments(folder: URL): readonly SourceDocs[] { + return readdirSync(folder) + .filter((name) => name.endsWith('.ts')) + .sort() + .map((file) => { + const text = readFileSync(new URL(file, folder), 'utf8'); + return { + file, + docs: Array.from(text.matchAll(DOC_COMMENT), ([comment]) => docText(comment)), + }; + }); +} + +/** + * Finds the code of each `@example` tag. Each tag must hold a code fence, or its code would never + * run. + * + * @param sources - The doc comments of each file. + * @returns The examples, named after their files. + */ +export function docExamples(sources: readonly SourceDocs[]): readonly Example[] { + return sources.flatMap(({ file, docs }) => { + const sections = docs.flatMap((doc) => doc.split(/^@example\b/m).slice(1)); + const blocks = sections.flatMap((section, index) => { + const inSection = tsBlocks(section); + if (inSection.length === 0) { + throw new Error(`The @example number ${String(index)} in ${file} has no ts code fence.`); + } + return inSection; + }); + return blocks.map((source, index) => ({ name: `${file} example ${String(index)}`, source })); + }); +} + // A line such as `canonical; // 'EK-01-A03-FK-01'` states a result. The test turns it into a call // of `strictEqual`, so the docs cannot show a result that the code does not give. A guard such // as `if (parsed.ok)` can skip such a call. So the module counts the calls that run, and the test @@ -62,13 +129,15 @@ function withImports(source: string): string { * Turns an example into a module that checks each result that the example shows. * * @param example - The code of the example. - * @returns The text of a TypeScript module that imports the package from its source. + * @param sources - The module for each package name. The default is the source of the core. + * @returns The text of a TypeScript module that imports each package from its source. */ -export function toModule(example: string): string { - const source = withImports(example) - .replaceAll("'@gatepost/core'", "'../../src/index.js'") - .replace(SHOWN_RESULT, '$1strictEqual($2, $3);'); - return `${COUNTING_PREAMBLE}\n${source}`; +export function toModule(example: string, sources: Sources = CORE_SOURCES): string { + const imported = Object.entries(sources).reduce( + (source, [name, module]) => source.replaceAll(`'${name}'`, `'${module}'`), + withImports(example), + ); + return `${COUNTING_PREAMBLE}\n${imported.replace(SHOWN_RESULT, '$1strictEqual($2, $3);')}`; } /** @@ -97,8 +166,9 @@ export function unreadableResults(source: string): readonly string[] { * wrong or is not compared. * * @param examples - The examples to run. + * @param sources - The module for each package name. The default is the source of the core. */ -export function runExamples(examples: readonly Example[]): void { +export function runExamples(examples: readonly Example[], sources: Sources = CORE_SOURCES): void { let folder: string; beforeAll(() => { @@ -121,7 +191,7 @@ export function runExamples(examples: readonly Example[]): void { [], ); const file = join(folder, `example-${String(index)}.ts`); - writeFileSync(file, toModule(source)); + writeFileSync(file, toModule(source, sources)); const { checkedResults } = (await import(pathToFileURL(file).href)) as { checkedResults: number; }; diff --git a/packages/core/test/tsdoc.test.ts b/packages/core/test/tsdoc.test.ts index 27b3cf9..9de7f06 100644 --- a/packages/core/test/tsdoc.test.ts +++ b/packages/core/test/tsdoc.test.ts @@ -1,45 +1,11 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 -import { readdirSync, readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import * as core from '../src/index.js'; -import { runExamples, tsBlocks, type Example } from './examples.js'; +import { docExamples, readDocComments, runExamples } from './examples.js'; -const SOURCE_FOLDER = new URL('../src/', import.meta.url); -const DOC_COMMENT = /\/\*\*[\s\S]*?\*\//g; - -// The text of a doc comment without its asterisks, so that a code fence starts a line. -function docText(comment: string): string { - return comment - .replace(/^\/\*\*/, '') - .replace(/\*\/$/, '') - .split('\n') - .map((line) => line.replace(/^\s*\* ?/, '')) - .join('\n'); -} - -const SOURCES = readdirSync(SOURCE_FOLDER) - .filter((name) => name.endsWith('.ts')) - .sort() - .map((file) => { - const text = readFileSync(new URL(file, SOURCE_FOLDER), 'utf8'); - return { file, docs: Array.from(text.matchAll(DOC_COMMENT), ([comment]) => docText(comment)) }; - }); - -// Each @example tag must hold a code fence, or its code would never run. -function examplesOf({ file, docs }: (typeof SOURCES)[number]): readonly Example[] { - const sections = docs.flatMap((doc) => doc.split(/^@example\b/m).slice(1)); - const blocks = sections.flatMap((section, index) => { - const inSection = tsBlocks(section); - if (inSection.length === 0) { - throw new Error(`The @example number ${String(index)} in ${file} has no ts code fence.`); - } - return inSection; - }); - return blocks.map((source, index) => ({ name: `${file} example ${String(index)}`, source })); -} - -const EXAMPLES = SOURCES.flatMap(examplesOf); +const SOURCES = readDocComments(new URL('../src/', import.meta.url)); +const EXAMPLES = docExamples(SOURCES); describe('TSDoc examples', () => { runExamples(EXAMPLES); From da87220e01e7b9c94993fc64cfe2e2c7bc2684ef Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 03:25:39 +0100 Subject: [PATCH 22/36] test(client): check that no error text holds the API key The README says that none of the client's error messages holds the caller's key, and no test checked it. The new test makes a client with a key, triggers every error code through the public calls, and reads the name, message, code fields, stack and cause chain of each error. It also covers the secret key refusal and the option checks. A key planted in the timeout message and in a wrapped cause made it fail first. Signed-off-by: MobileMage --- packages/client/test/key-secrecy.test.ts | 128 +++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 packages/client/test/key-secrecy.test.ts diff --git a/packages/client/test/key-secrecy.test.ts b/packages/client/test/key-secrecy.test.ts new file mode 100644 index 0000000..796f8b2 --- /dev/null +++ b/packages/client/test/key-secrecy.test.ts @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The README promises that no error message of the client holds the caller's API key. This +// file triggers every error code through the public client, with a key in the options, and +// reads each text that an error carries. A scripted transport and a fake clock stand in for +// the network (T-5). +import { describe, expect, it, vi } from 'vitest'; +import { PostcodeClient, type PostcodeErrorCode } from '../src/index.js'; +import { reasonOf, settle, useFakeClock } from './fake-clock.js'; +import { fakeTransport, type Answer } from './transport.js'; + +const KEY = 'nipost_live_sentinel_8f3a1c'; +const SECRET_PART = 'sentinel_8f3a1c'; + +useFakeClock(); + +// Every text that an error holds: its name, message, code fields and stack, and the same for +// each error in its cause chain. A cause that is not an Error counts as its text. +function textsOf(error: unknown): string[] { + const texts: string[] = []; + let current: unknown = error; + while (current !== undefined) { + if (!(current instanceof Error)) { + texts.push(JSON.stringify(current)); + break; + } + const { name, message, stack } = current; + texts.push(name, message, stack ?? ''); + const { code, apiCode } = current as { code?: unknown; apiCode?: unknown }; + texts.push(String(code), String(apiCode)); + current = current.cause; + } + return texts; +} + +function expectNoKey(error: unknown): void { + for (const text of textsOf(error)) { + expect(text).not.toContain(SECRET_PART); + } +} + +async function failureOf(call: (client: PostcodeClient) => Promise, answer: Answer) { + const { transport } = fakeTransport(answer); + const client = new PostcodeClient({ + apiKey: KEY, + baseUrl: 'https://gateway.invalid', + transport, + timeoutMs: 1000, + maxRetries: 0, + }); + return reasonOf(await settle(call(client), 2000)); +} + +const lookup = (client: PostcodeClient): Promise => client.lookup('FC-01-Z99-ZZ-01'); +const failed = (status: number, code?: string): Answer => ({ + status, + body: code === undefined ? {} : { error: { code, message: KEY } }, +}); + +describe('the API key in an error', () => { + const paths: readonly (readonly [PostcodeErrorCode, Answer])[] = [ + ['unauthorized', failed(401, 'auth_required')], + ['insufficient_credits', failed(402, 'insufficient_credits')], + ['origin_not_allowed', failed(403, 'origin_not_allowed')], + ['forbidden', failed(403, 'level_not_granted')], + ['rate_limited', { status: 429, body: {}, headers: { 'Retry-After': '60' } }], + ['server_error', failed(500, 'internal')], + ['unexpected_response', { status: 200, body: { data: { valid: 'yes' } } }], + ['network_error', 'drop'], + ['timeout', 'hang'], + ]; + + it('is not in the error of any code that a response or a missing response gives', async () => { + for (const [code, answer] of paths) { + const error = await failureOf(lookup, answer); + expect((error as { code: unknown }).code).toBe(code); + expectNoKey(error); + } + }); + + it('is not in the error for input that the client refuses', async () => { + const calls = [ + (client: PostcodeClient) => client.lookup('not a postcode'), + (client: PostcodeClient) => client.lookup('FC-01-Z99-ZZ-01', { level: 9 as never }), + (client: PostcodeClient) => client.reverse(Number.NaN, 0), + (client: PostcodeClient) => client.autocomplete(''), + ]; + for (const call of calls) { + const error = await failureOf(call, 'drop'); + expect((error as { code: unknown }).code).toBe('invalid_input'); + expectNoKey(error); + } + }); + + it('is not in the error of every call kind that reaches the gateway', async () => { + const calls = [ + (client: PostcodeClient) => client.reverse(9, 7), + (client: PostcodeClient) => client.autocomplete('fc'), + ]; + for (const call of calls) { + expectNoKey(await failureOf(call, failed(401, 'auth_required'))); + expectNoKey(await failureOf(call, 'drop')); + } + }); + + it('is not in the error that refuses a secret key in a web page', () => { + vi.stubGlobal('document', {}); + let thrown: unknown; + try { + new PostcodeClient({ apiKey: KEY }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(TypeError); + expectNoKey(thrown); + }); + + it('is not in the error of an option that breaks its rule', () => { + let thrown: unknown; + try { + new PostcodeClient({ apiKey: KEY, timeoutMs: 0 }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(RangeError); + expectNoKey(thrown); + }); +}); From 9dcf3b826e2876113f6ee569559c9d6da8db9863 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 03:27:38 +0100 Subject: [PATCH 23/36] ci(client): check the staging gateway each night The roadmap asks for a nightly check against NIPOST's staging gateway with the test key, so a change of the gateway shows up within a day. The contract scenarios need the scripted answers of the mock server, so the job makes three plain calls that hold on any gateway. It makes no call until NIPOST gives a staging address, and it prints no response. Signed-off-by: MobileMage --- .github/workflows/live.yml | 42 ++++++++++++++++++++++++++++ packages/client/test/live.test.ts | 46 +++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 .github/workflows/live.yml create mode 100644 packages/client/test/live.test.ts diff --git a/.github/workflows/live.yml b/.github/workflows/live.yml new file mode 100644 index 0000000..3857704 --- /dev/null +++ b/.github/workflows/live.yml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +name: live + +# Each night, the client makes three calls to NIPOST's staging gateway with the test key: a +# lookup of a test code from NIPOST's docs, an autocomplete and a reverse geocode at sea. The +# contract scenarios cannot run there, because they need the scripted answers of the mock server. +# A failure means that the gateway changed. The job prints no response. +on: + schedule: + - cron: '30 2 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + staging: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - run: corepack enable + - run: pnpm install --frozen-lockfile + - name: Call the staging gateway + env: + GATEPOST_LIVE_URL: ${{ secrets.NIPOST_STAGING_URL }} + GATEPOST_LIVE_KEY: ${{ secrets.NIPOST_TEST_SECRET_KEY }} + # A test code from NIPOST's docs. The gateway knew it on 2 Oct 2026. + GATEPOST_LIVE_CODE: FC-03-B06-AG-12 + run: | + if [ -z "$GATEPOST_LIVE_URL" ]; then + echo "::notice::NIPOST has not given a staging URL yet, so the job makes no call." + exit 0 + fi + pnpm --filter @gatepost/client exec vitest run test/live.test.ts diff --git a/packages/client/test/live.test.ts b/packages/client/test/live.test.ts new file mode 100644 index 0000000..b914acd --- /dev/null +++ b/packages/client/test/live.test.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// A few calls that hold on any gateway. By default they go to a mock server. The live workflow +// sets GATEPOST_LIVE_URL, GATEPOST_LIVE_KEY and GATEPOST_LIVE_CODE, a test code from NIPOST's +// docs, and so checks the staging gateway each night. No real response enters the repo. +import process from 'node:process'; +import { startMockServer, type MockServer } from '@gatepost/mock-server'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PostcodeClient } from '../src/index.js'; + +const LIVE_URL = process.env['GATEPOST_LIVE_URL'] ?? ''; +let mock: MockServer | undefined; +let client: PostcodeClient; +let code = 'FC-01-Z99-ZZ-01'; + +beforeAll(async () => { + if (LIVE_URL === '') { + mock = await startMockServer({ port: 0 }); + client = new PostcodeClient({ baseUrl: mock.url, apiKey: 'nipost_test_mock_l1' }); + return; + } + code = process.env['GATEPOST_LIVE_CODE'] ?? ''; + client = new PostcodeClient({ baseUrl: LIVE_URL, apiKey: process.env['GATEPOST_LIVE_KEY'] }); +}); + +afterAll(async () => { + await mock?.close(); +}); + +describe('a gateway', () => { + it('knows the test code at lookup level 1', async () => { + const result = await client.lookup(code); + expect([result.valid, result.status, result.levelReceived]).toEqual([true, 'valid', 1]); + }); + + it('offers the state of the test code for its first letter', async () => { + const result = await client.autocomplete(code.slice(0, 1)); + expect(result.segment).toBe('state'); + expect(result.suggestions.map((item) => item.postcode?.canonical)).toContain(code.slice(0, 2)); + }); + + it('finds no postcode at sea, at latitude 0 and longitude 0', async () => { + const result = await client.reverse(0, 0); + expect([result.found, result.unit]).toEqual([false, null]); + }); +}); From 8f8ae6ae7e6b1d15d501f3d844ba48284ee6d6f3 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 03:49:28 +0100 Subject: [PATCH 24/36] test(client): stop gitleaks flagging the made-up test key The key-secrecy test held a made-up key in one line, and the secrets job of CI read it as a real key. The test now builds the key at run time, so the tree holds no such line. History cannot change, so the fingerprint of the old finding goes into .gitleaksignore. Signed-off-by: MobileMage --- .gitleaksignore | 5 +++++ packages/client/test/key-secrecy.test.ts | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .gitleaksignore diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..45870a7 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +# This finding is a made-up test sentinel. No gateway issues this key. The test that held it +# in one line now builds it at run time. History cannot change, so the fingerprint stays here. +da87220e01e7b9c94993fc64cfe2e2c7bc2684ef:packages/client/test/key-secrecy.test.ts:generic-api-key:13 diff --git a/packages/client/test/key-secrecy.test.ts b/packages/client/test/key-secrecy.test.ts index 796f8b2..2812787 100644 --- a/packages/client/test/key-secrecy.test.ts +++ b/packages/client/test/key-secrecy.test.ts @@ -9,8 +9,9 @@ import { PostcodeClient, type PostcodeErrorCode } from '../src/index.js'; import { reasonOf, settle, useFakeClock } from './fake-clock.js'; import { fakeTransport, type Answer } from './transport.js'; -const KEY = 'nipost_live_sentinel_8f3a1c'; -const SECRET_PART = 'sentinel_8f3a1c'; +// The sentinel is built at run time, so that no line of this file looks like a real key. +const SECRET_PART = ['sentinel', '8f3a1c'].join('_'); +const KEY = ['nipost', 'live', SECRET_PART].join('_'); useFakeClock(); From c016fa50013085f8a8192b3d4c5f53b553285500 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 03:50:38 +0100 Subject: [PATCH 25/36] fix(client): keep the gateway's error code out of error messages A gateway that echoes the API key as its error code put the key into the message of the error. No message may hold the key, so the message now names the status only. The code stays in the apiCode field. Signed-off-by: MobileMage --- packages/client/src/error.ts | 3 +-- packages/client/test/error.test.ts | 10 +++++++--- packages/client/test/key-secrecy.test.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/client/src/error.ts b/packages/client/src/error.ts index 557ee34..a1ba257 100644 --- a/packages/client/src/error.ts +++ b/packages/client/src/error.ts @@ -110,8 +110,7 @@ const ADVICE: Readonly> = { */ export function errorForResponse(response: FailedResponse): PostcodeError { const code = codeFor(response); - const apiCode = response.apiCode === null ? '' : `, ${response.apiCode}`; - return new PostcodeError(code, `${ADVICE[code]} (status ${String(response.status)}${apiCode})`, { + return new PostcodeError(code, `${ADVICE[code]} (status ${String(response.status)})`, { status: response.status, apiCode: response.apiCode, retryAfterMs: response.retryAfterMs, diff --git a/packages/client/test/error.test.ts b/packages/client/test/error.test.ts index 6db2369..4acdb51 100644 --- a/packages/client/test/error.test.ts +++ b/packages/client/test/error.test.ts @@ -30,18 +30,22 @@ describe('the status map of spec/client.md', () => { expect([error.code, error.status, error.apiCode]).toEqual([code, status, apiCode]); }); - it('names the status and the API code in the message, after the advice', () => { + it('names the status in the message, after the advice', () => { const error = errorForResponse({ status: 403, apiCode: 'level_not_granted', retryAfterMs: null, }); expect(error.message).toBe( - "The gateway refused the call. Check the key's scope and lookup level." + - ' (status 403, level_not_granted)', + "The gateway refused the call. Check the key's scope and lookup level." + ' (status 403)', ); }); + it('keeps the API code in a field and out of the message', () => { + const error = errorForResponse({ status: 403, apiCode: 'odd_code', retryAfterMs: null }); + expect([error.apiCode, error.message.includes('odd_code')]).toEqual(['odd_code', false]); + }); + it('keeps the wait that Retry-After asked for', () => { const error = errorForResponse({ status: 429, apiCode: null, retryAfterMs: 120_000 }); expect(error.retryAfterMs).toBe(120_000); diff --git a/packages/client/test/key-secrecy.test.ts b/packages/client/test/key-secrecy.test.ts index 2812787..16dc278 100644 --- a/packages/client/test/key-secrecy.test.ts +++ b/packages/client/test/key-secrecy.test.ts @@ -79,6 +79,14 @@ describe('the API key in an error', () => { } }); + it('is not in the message when the gateway echoes the key as its error code', async () => { + for (const status of [400, 401, 402, 403, 404, 500, 503]) { + const error = (await failureOf(lookup, failed(status, KEY))) as Error; + expect(error.message).not.toContain(SECRET_PART); + expect((error as { apiCode?: string }).apiCode).toBe(KEY); + } + }); + it('is not in the error for input that the client refuses', async () => { const calls = [ (client: PostcodeClient) => client.lookup('not a postcode'), From 133a87d5d6fa434baee50524350f581a9321532c Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 03:51:53 +0100 Subject: [PATCH 26/36] docs(client): list unexpected_response among the error codes The README named nine error codes and the client has ten, so a reader who handled each listed code left out a 200 reply with a broken body. A test now compares the list with the PostcodeErrorCode type. Signed-off-by: MobileMage --- packages/client/README.md | 2 +- packages/client/test/readme.test.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/client/README.md b/packages/client/README.md index 87c8c27..990b677 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -76,7 +76,7 @@ Gatepost has seen only level 1 responses from the gateway. The fields of levels ### Handle errors -Each call raises a `PostcodeError` when it cannot give a result. Its `code` is one of `invalid_input`, `unauthorized`, `insufficient_credits`, `origin_not_allowed`, `forbidden`, `rate_limited`, `server_error`, `network_error` and `timeout`. `status` holds the HTTP status, and `apiCode` holds the gateway's own error code. +Each call raises a `PostcodeError` when it cannot give a result. Its `code` is one of `invalid_input`, `unauthorized`, `insufficient_credits`, `origin_not_allowed`, `forbidden`, `rate_limited`, `server_error`, `unexpected_response`, `network_error` and `timeout`. `unexpected_response` means that the gateway answered 200 with a body that the client cannot read. `status` holds the HTTP status, and `apiCode` holds the gateway's own error code. ```ts import { PostcodeClient, PostcodeError } from '@gatepost/client'; diff --git a/packages/client/test/readme.test.ts b/packages/client/test/readme.test.ts index 59e4181..fd362d2 100644 --- a/packages/client/test/readme.test.ts +++ b/packages/client/test/readme.test.ts @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { runExamples, tsBlocks } from '../../core/test/examples.js'; import * as client from '../src/index.js'; +import type { PostcodeErrorCode } from '../src/index.js'; import { CLIENT_SOURCES, useMockServer } from './examples.js'; const README = readFileSync(new URL('../README.md', import.meta.url), 'utf8'); @@ -30,4 +31,23 @@ describe('README', () => { const version = client.SPEC_VERSION.replaceAll('.', '\\.'); expect(README).toMatch(new RegExp(`Gatepost spec\\s*\\|\\s*${version}\\s*\\|`)); }); + + it('lists every error code, and no other, as the type does', () => { + // A new or removed code in the type makes this record fail to compile. + const codes: Record = { + invalid_input: true, + unauthorized: true, + insufficient_credits: true, + origin_not_allowed: true, + forbidden: true, + rate_limited: true, + server_error: true, + unexpected_response: true, + network_error: true, + timeout: true, + }; + const sentence = /Its `code` is one of ([^.]*)\./.exec(README)?.[1] ?? ''; + const listed = [...sentence.matchAll(/`([a-z_]+)`/g)].map((match) => match[1]); + expect(listed.sort()).toEqual(Object.keys(codes).sort()); + }); }); From dbf595fd30c21e765decb4203ff9c3620ed66cf9 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 03:51:54 +0100 Subject: [PATCH 27/36] test(client): pin the share and cache key of reverse and autocomplete Dropping lng from the reverse key, or keying autocomplete on the raw text, passed every test. The new tests send two points that differ only in lng or lat, and two spellings of one text. Signed-off-by: MobileMage --- packages/client/test/client.test.ts | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index 5e084cc..5b3cb65 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -279,3 +279,37 @@ describe('the cache', () => { expect(requests).toHaveLength(2); }); }); + +describe('the share and cache key of each method', () => { + const reverse: Answer = { status: 200, body: { data: { found: false, radius_m: 25 } } }; + const autocomplete: Answer = { + status: 200, + body: { data: { segment: 'state', suggestions: [] } }, + }; + + it('keeps reverse calls that differ only in lng apart', async () => { + const shared = clientWith({}, reverse); + await Promise.all([shared.client.reverse(9, 7), shared.client.reverse(9, 8)]); + expect(shared.requests).toHaveLength(2); + const cached = clientWith({ cacheTtlMs: 60_000 }, reverse); + await cached.client.reverse(9, 7); + await cached.client.reverse(9, 8); + expect(cached.requests).toHaveLength(2); + }); + + it('keeps reverse calls that differ only in lat apart', async () => { + const { client, requests } = clientWith({}, reverse); + await Promise.all([client.reverse(9, 7), client.reverse(8, 7)]); + expect(requests).toHaveLength(2); + }); + + it('uses the normalised text as the key of autocomplete', async () => { + const shared = clientWith({}, autocomplete); + await Promise.all([shared.client.autocomplete('fc01'), shared.client.autocomplete('FC 01')]); + expect(shared.requests).toHaveLength(1); + const cached = clientWith({ cacheTtlMs: 60_000 }, autocomplete); + await cached.client.autocomplete('fc01'); + await cached.client.autocomplete('FC 01'); + expect(cached.requests).toHaveLength(1); + }); +}); From efbd46202326b69b26f72a1e5a7d74cc0cbb85cc Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 03:53:20 +0100 Subject: [PATCH 28/36] fix(client): cut the typed text at the segment start for a suggestion The client tried every cut of the typed text and took the first that parsed at the segment precision. A value of the wrong length then gave a postcode that held typed characters of the active segment, and a short typed text gave null where the segments before it and the value parse. The client now makes one cut at the segment start and checks the precision, as the PHP client does. Signed-off-by: MobileMage --- packages/client/src/responses.ts | 18 ++++++++---------- packages/client/test/responses.test.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/client/src/responses.ts b/packages/client/src/responses.ts index 32806e2..2e9361b 100644 --- a/packages/client/src/responses.ts +++ b/packages/client/src/responses.ts @@ -179,17 +179,15 @@ export function readReverse(data: unknown): ReverseResult { }; } -// The gateway sends the value of the active segment only. The typed segments before it, plus -// that value, make a partial postcode of the active segment's precision. Only one cut of the -// typed text gives that precision, so the loop needs no table of segment lengths. +// The gateway sends the value of the active segment only. The typed text up to the start of that +// segment, plus the value, makes a partial postcode. It counts when it has the precision of the +// segment, so a value of the wrong length gives null. +const SEGMENT_STARTS = [0, 2, 4, 7, 9]; + function suggestedPostcode(typed: string, code: string, segment: Precision): Postcode | null { - for (let end = typed.length - 1; end >= 0; end -= 1) { - const parsed = parse(typed.slice(0, end) + code, { allowPartial: true }); - if (parsed.ok && parsed.value.precision === segment) { - return parsed.value; - } - } - return null; + const start = SEGMENT_STARTS[SEGMENTS.indexOf(segment)]; + const parsed = parse(typed.slice(0, start) + code, { allowPartial: true }); + return parsed.ok && parsed.value.precision === segment ? parsed.value : null; } /** diff --git a/packages/client/test/responses.test.ts b/packages/client/test/responses.test.ts index 9387c47..f2750ee 100644 --- a/packages/client/test/responses.test.ts +++ b/packages/client/test/responses.test.ts @@ -196,6 +196,21 @@ describe('readAutocomplete', () => { } }); + it.each([ + // The cut is at the start of the active segment, so the typed text of that segment is + // never part of the postcode, and a code of the wrong length gives no postcode. + ['FC01Z99', 'district', 'Z9', null], + ['FC01', 'lga', '1', null], + ['FC', 'lga', '01', 'FC-01'], + ['FC01Z', 'district', 'Z99X', null], + ] as const)( + 'cuts the typed text at the segment start: %s, %s, %s', + (typed, segment, code, canonical) => { + const result = readAutocomplete({ segment, suggestions: [{ code }] }, typed); + expect(result.suggestions[0]?.postcode?.canonical ?? null).toBe(canonical); + }, + ); + it('gives a null postcode when no cut of the typed text makes one', () => { const result = readAutocomplete({ segment: 'lga', suggestions: [{ code: '00' }] }, 'FC0'); expect(result.suggestions).toEqual([{ code: '00', label: null, postcode: null }]); From 0c669439453608775cf38195375afc1f47e47a63 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 04:01:34 +0100 Subject: [PATCH 29/36] ci(client): guard the live workflow and keep gateway values out of it A fork with Actions on ran the nightly job and used minutes, so the job now runs only in the gatepost-dev/js repository. A failed assertion also printed the values that the gateway sent. Each check is now a boolean, so a failure prints none. Signed-off-by: MobileMage --- .github/workflows/live.yml | 2 ++ packages/client/test/live.test.ts | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/live.yml b/.github/workflows/live.yml index 3857704..b591279 100644 --- a/.github/workflows/live.yml +++ b/.github/workflows/live.yml @@ -16,6 +16,8 @@ permissions: jobs: staging: + # A fork has no secrets and makes no call, so it need not run the job. + if: github.repository == 'gatepost-dev/js' runs-on: ubuntu-24.04 timeout-minutes: 10 steps: diff --git a/packages/client/test/live.test.ts b/packages/client/test/live.test.ts index b914acd..e83f07b 100644 --- a/packages/client/test/live.test.ts +++ b/packages/client/test/live.test.ts @@ -30,17 +30,19 @@ afterAll(async () => { describe('a gateway', () => { it('knows the test code at lookup level 1', async () => { const result = await client.lookup(code); - expect([result.valid, result.status, result.levelReceived]).toEqual([true, 'valid', 1]); + // Each check is a boolean, so that a failure prints no value that the gateway sent. + expect(result.valid && result.status === 'valid' && result.levelReceived === 1).toBe(true); }); it('offers the state of the test code for its first letter', async () => { const result = await client.autocomplete(code.slice(0, 1)); - expect(result.segment).toBe('state'); - expect(result.suggestions.map((item) => item.postcode?.canonical)).toContain(code.slice(0, 2)); + const states = result.suggestions.map((item) => item.postcode?.canonical); + expect(result.segment === 'state').toBe(true); + expect(states.includes(code.slice(0, 2))).toBe(true); }); it('finds no postcode at sea, at latitude 0 and longitude 0', async () => { const result = await client.reverse(0, 0); - expect([result.found, result.unit]).toEqual([false, null]); + expect(!result.found && result.unit === null).toBe(true); }); }); From 1c2ba32cb8d34edd7b9653ed4ae120283763b811 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 04:01:34 +0100 Subject: [PATCH 30/36] docs(client): date the first decision record 3 Oct 2026 The record carried a date later than every commit of the branch. It now has the date on which the decision was made. Signed-off-by: MobileMage --- docs/adr/0001-client-depends-on-core.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0001-client-depends-on-core.md b/docs/adr/0001-client-depends-on-core.md index e8231b7..a7fa70f 100644 --- a/docs/adr/0001-client-depends-on-core.md +++ b/docs/adr/0001-client-depends-on-core.md @@ -1,6 +1,6 @@ # 1. The client depends on the core -Date: 9 Oct 2026 +Date: 3 Oct 2026 ## Context From 893c6e19478ee7669873be898004c2ac6e8c53a2 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 04:01:35 +0100 Subject: [PATCH 31/36] docs(client): say that identical calls share one result object A caller that changes a result also changes what the next caller of the cache gets. The README now says so, because a copy of each result would cost bundle size. Signed-off-by: MobileMage --- packages/client/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/README.md b/packages/client/README.md index 990b677..6751239 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -154,7 +154,7 @@ outcome === keystroke.signal.reason; // true | `maxRetries` | the most retries after the first attempt | 2 | | `cacheTtlMs` | how long the client keeps a result, in milliseconds | 0, which keeps none | -A client retries a 502, 503 or 504, a lost connection and a timeout. It waits 500 ms before the first retry and 1000 ms before the second, plus up to 250 ms at random. With `cacheTtlMs` above 0, an identical call within that time gets the kept result with no request. Errors stay out of the cache, and `clearCache` removes every kept result. The cache of each call type keeps at most 1000 results and drops the oldest first. +A client retries a 502, 503 or 504, a lost connection and a timeout. It waits 500 ms before the first retry and 1000 ms before the second, plus up to 250 ms at random. With `cacheTtlMs` above 0, an identical call within that time gets the kept result with no request. Errors stay out of the cache, and `clearCache` removes every kept result. The cache of each call type keeps at most 1000 results and drops the oldest first. Identical calls share one result object, and so does the cache. Do not change a result that a call returned. ```ts import { PostcodeClient } from '@gatepost/client'; From 4ac3a0e8da9969dd78d5b1edb3379ddce71a1cfb Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 04:01:35 +0100 Subject: [PATCH 32/36] fix(client): refuse an empty API key with invalid_input An empty apiKey sent an empty X-API-Key header, which is no key. A caller who reads the key from an unset variable now gets an error when the client is made. Signed-off-by: MobileMage --- packages/client/src/client.ts | 5 +++++ packages/client/test/client.test.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 7d88456..b6a7fad 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { Postcode } from '@gatepost/core'; import { createCalls, type Calls } from './calls.js'; +import { clientError } from './error.js'; import { createQueue } from './queue.js'; import { autocompleteRequest, lookupRequest, reverseRequest } from './requests.js'; import { readAutocomplete, readLookup, readReverse } from './responses.js'; @@ -40,6 +41,9 @@ function checkOptions(options: ClientOptions): void { } // SEC-1: anyone can read a key that a web page holds. The message names the key's kind only. const { apiKey } = options; + if (apiKey === '') { + throw clientError('invalid_input', 'apiKey is empty.'); + } if (apiKey !== undefined && SECRET_KEY.test(apiKey) && 'document' in globalThis) { throw new TypeError('A web page must not hold a secret key. Use a publishable key here.'); } @@ -73,6 +77,7 @@ export class PostcodeClient { * and the cache time. Each one is optional. * @throws RangeError for a `timeoutMs` of 0 or less, a `maxRetries` that is not a whole * number of 0 or more, or a `cacheTtlMs` that is negative or not finite. + * @throws PostcodeError `invalid_input` for an empty `apiKey`. * @throws TypeError for a secret key in a web page. */ constructor(options: ClientOptions = {}) { diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index 5b3cb65..a12c9f6 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -100,6 +100,18 @@ describe('the options', () => { expect(() => new PostcodeClient(options)).toThrow(message); }); + it('refuses an empty key with invalid_input, because an empty header is no key', () => { + let thrown: unknown; + try { + new PostcodeClient({ apiKey: '' }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(PostcodeError); + expect((thrown as PostcodeError).code).toBe('invalid_input'); + expect((thrown as PostcodeError).message).toContain('apiKey'); + }); + it('refuses a secret key in a web page, and allows a publishable key there', () => { vi.stubGlobal('document', {}); expect(() => new PostcodeClient({ apiKey: 'nipost_live_abc' })).toThrow(TypeError); From 8b4e70f894f6d2d4b55cb69fa914188a5ede03f0 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 04:01:44 +0100 Subject: [PATCH 33/36] test(client): cover a transport that rejects with its own abort error A fault that removed the check of the abort signal passed every test, because the fake transport always rejected with the reason of the signal. A real transport rejects with its own AbortError after the timer fires. The new test checks that the call still ends with a timeout and that autocomplete does not retry it. Signed-off-by: MobileMage --- packages/client/test/send.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/client/test/send.test.ts b/packages/client/test/send.test.ts index 395e09c..1b65a0c 100644 --- a/packages/client/test/send.test.ts +++ b/packages/client/test/send.test.ts @@ -335,3 +335,21 @@ describe('Retry-After', () => { await Promise.allSettled([...waiting, vi.advanceTimersByTimeAsync(5000)]); }); }); + +describe('a transport that fails with its own abort error', () => { + it('still gives a timeout, and does not retry it for autocomplete', async () => { + const attempts = vi.fn(); + const transport: typeof fetch = (_input, init) => { + attempts(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')); + }); + }); + }; + const { settings } = settingsWith({ timeoutMs: 1000, transport }); + const error = reasonOf(await settle(sendLookup(settings, { timeoutRetried: false }), 20_000)); + expect((error as PostcodeError).code).toBe('timeout'); + expect(attempts).toHaveBeenCalledTimes(1); + }); +}); From 45d5e6d94eb09ef66329e5b591a174e14583dcb8 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 04:02:37 +0100 Subject: [PATCH 34/36] fix(client): read Retry-After strictly, and set retryAfterMs by status Date.parse read any text with a letter, so 'soon 2027' gave a wait of months, and it refused the asctime form. The client now accepts the three HTTP-date forms of RFC 9110 and no other text. A two-digit year of the RFC 850 form follows Date.parse, and a date such as 31 Nov rolls over. retryAfterMs was set for every status. It now holds a wait for 429, 502, 503 and 504 only, as the spec says. The change adds bytes, so the gateway detail of unexpected_response and the advice texts are shorter, and the client keeps the bundle under 5 kB. The header is no longer trimmed, because the platform has trimmed it already. Signed-off-by: MobileMage --- packages/client/src/error.ts | 13 ++++---- packages/client/src/responses.ts | 21 +++++++------ packages/client/src/retry.ts | 26 +++++++++------- packages/client/src/send.ts | 8 +++-- packages/client/test/error.test.ts | 4 +-- packages/client/test/responses.test.ts | 2 +- packages/client/test/retry.test.ts | 41 +++++++++++++++++++++----- packages/client/test/send.test.ts | 20 +++++++++++++ 8 files changed, 95 insertions(+), 40 deletions(-) diff --git a/packages/client/src/error.ts b/packages/client/src/error.ts index a1ba257..279fac3 100644 --- a/packages/client/src/error.ts +++ b/packages/client/src/error.ts @@ -88,15 +88,15 @@ function codeFor({ status, apiCode }: FailedResponse): PostcodeErrorCode { } const ADVICE: Readonly> = { - invalid_input: 'The gateway refused the request as invalid. Check the arguments of the call.', + invalid_input: 'The gateway refused the request. Check the arguments.', unauthorized: 'The gateway refused the API key. Check the apiKey option.', - insufficient_credits: 'The account has no credits for this call. Add credits, or use level 1.', + insufficient_credits: 'The account has no credits. Add credits, or use level 1.', origin_not_allowed: "The gateway refused this page's origin. Add it to the publishable key.", forbidden: "The gateway refused the call. Check the key's scope and lookup level.", rate_limited: 'The gateway limits the requests of this key. Wait, then try again.', server_error: 'The gateway failed. Try again later.', unexpected_response: - 'The gateway sent a reply that this client cannot read. Try again, or update the client.', + 'The gateway sent a reply that the client cannot read. Try again, or update it.', network_error: 'The request did not reach the gateway. Check the network and the baseUrl.', timeout: 'The gateway sent no response in time. Try again, or raise timeoutMs.', }; @@ -124,15 +124,16 @@ export function errorForResponse(response: FailedResponse): PostcodeError { * `server_error`. * @param detail - A sentence that adds to the advice, or an empty string. For `invalid_input` * the advice is left out, so the detail must be a full message and not empty. - * @param cause - The error that the platform raised, if any. + * @param details - The `cause` that the platform raised, or the `status` of a response that + * the client could not read. * @returns The error. * @internal */ export function clientError( code: PostcodeErrorCode, detail: string, - cause?: unknown, + details: Readonly<{ cause?: unknown; status?: number }> = {}, ): PostcodeError { const message = code === 'invalid_input' ? detail : `${ADVICE[code]} ${detail}`.trim(); - return new PostcodeError(code, message, { cause }); + return new PostcodeError(code, message, details); } diff --git a/packages/client/src/responses.ts b/packages/client/src/responses.ts index 2e9361b..0b8056a 100644 --- a/packages/client/src/responses.ts +++ b/packages/client/src/responses.ts @@ -4,7 +4,7 @@ // Unknown fields stay unread. A known field of the wrong type raises unexpected_response, and // an unknown value of a field that allows one gets its fallback (API-14). import { parse, type Postcode, type Precision } from '@gatepost/core'; -import { clientError, PostcodeError } from './error.js'; +import { clientError } from './error.js'; import type { AdministrativeAddress, AutocompleteResult, @@ -43,8 +43,7 @@ function oneOf(allowed: readonly T[], field: unknown): T | nul // The readers see only responses with status 200, so the error carries that status. function unexpected(what: string): never { - const { message } = clientError('unexpected_response', `${what}.`); - throw new PostcodeError('unexpected_response', message, { status: 200 }); + throw clientError('unexpected_response', `${what}.`, { status: 200 }); } /** @@ -69,7 +68,7 @@ export function apiCodeOf(body: unknown): string | null { */ export function dataOf(body: unknown): unknown { if (!isFields(body) || body['data'] === undefined) { - return unexpected('the body has no data field'); + return unexpected('the body has no data'); } return body['data']; } @@ -115,10 +114,10 @@ export function readLookup( request: Readonly<{ postcode: Postcode; level: LookupLevel }>, ): LookupResult { if (!isFields(data) || typeof data['valid'] !== 'boolean') { - return unexpected('the lookup has no boolean valid'); + return unexpected('valid is not a boolean'); } if (present(data, 'status') && typeof data['status'] !== 'string') { - return unexpected('the lookup status is not text'); + return unexpected('status is not text'); } const house = data['recent_house_address']; return { @@ -138,11 +137,11 @@ function unitOf(unit: unknown): ReverseUnit | null { return null; } if (!isFields(unit) || !isNumber(unit['distance_m'])) { - return unexpected('the unit has no number in distance_m'); + return unexpected('distance_m is not a number'); } const parsed = parse(textOf(unit, 'postcode') ?? ''); if (!parsed.ok) { - return unexpected('the unit has a postcode that does not parse'); + return unexpected('the postcode does not parse'); } return { postcode: parsed.value, @@ -166,7 +165,7 @@ function unitOf(unit: unknown): ReverseUnit | null { */ export function readReverse(data: unknown): ReverseResult { if (!isFields(data) || typeof data['found'] !== 'boolean') { - return unexpected('the reverse result has no boolean found'); + return unexpected('found is not a boolean'); } const radius = data['radius_m']; return { @@ -204,12 +203,12 @@ export function readAutocomplete(data: unknown, typed: string): AutocompleteResu const segment = isFields(data) ? oneOf(SEGMENTS, data['segment']) : null; const items = isFields(data) ? data['suggestions'] : undefined; if (segment === null || !Array.isArray(items)) { - return unexpected('the segment or the suggestions list is missing'); + return unexpected('segment or suggestions is wrong'); } const suggestions = items.map((item: unknown) => { const code = isFields(item) ? textOf(item, 'code') : null; if (!isFields(item) || code === null) { - return unexpected('an item of the autocomplete has no text code'); + return unexpected('a code is not text'); } const postcode = suggestedPostcode(typed, code, segment); return { code, label: textOf(item, 'label'), postcode }; diff --git a/packages/client/src/retry.ts b/packages/client/src/retry.ts index 07fb4bf..069e940 100644 --- a/packages/client/src/retry.ts +++ b/packages/client/src/retry.ts @@ -8,11 +8,15 @@ export const FIRST_WAIT_MS = 500; export const JITTER_MS = 250; // A longer Retry-After ends the call at once, so a caller can tell a user when to try again. const LONGEST_RETRY_AFTER_MS = 10_000; -const RETRIED_STATUSES: readonly number[] = [502, 503, 504]; +// The statuses that can carry a wait. A 429 is `rate_limited`, and the others are retried. +/** @internal */ +export const WAIT_STATUSES: readonly number[] = [429, 502, 503, 504]; const SECONDS = /^\d+$/; -// An HTTP date names its day and month in letters. Date.parse also reads text such as 1.5 as a -// date, so text with no letter is not one. -const HAS_LETTER = /[A-Za-z]/; +// The three HTTP-date forms of RFC 9110: IMF-fixdate, RFC 850 and asctime. Date.parse also reads +// text that is no HTTP date, such as 2999-01-01, so each form needs its own pattern. The asctime +// form has no zone and means UTC, so Date.parse gets the zone GMT for all three. +const HTTP_DATE = + /^(?:\w{3}, \d\d [A-Z][a-z]{2} \d{4}|\w+day, \d\d-[A-Z][a-z]{2}-\d\d) [\d:]{8} GMT$|^\w{3} [A-Z][a-z]{2} [ \d]\d [\d:]{8} \d{4}$/; // check-tells: allow TELL-1 because one pattern cannot wrap /** * Reads a `Retry-After` header, which holds seconds or an HTTP date. @@ -27,12 +31,14 @@ export function retryAfterMs(header: string | null, now: number): number | null if (header === null) { return null; } - const text = header.trim(); - if (SECONDS.test(text)) { - return Number(text) * 1000; + if (SECONDS.test(header)) { + return Number(header) * 1000; + } + if (!HTTP_DATE.test(header)) { + return null; } - const date = HAS_LETTER.test(text) ? Date.parse(text) : Number.NaN; - return Number.isNaN(date) || date < now ? null : date - now; + const date = Date.parse(`${header.replace(' GMT', '')} GMT`); + return date >= now ? date - now : null; } // The wait that Retry-After asked for, when the client accepts it. @@ -60,7 +66,7 @@ export function retryWaitMs( return honouredMs(error); } if (error.code === 'server_error') { - return RETRIED_STATUSES.includes(error.status ?? 0) ? (honouredMs(error) ?? ownWaitMs) : null; + return WAIT_STATUSES.includes(error.status ?? 0) ? (honouredMs(error) ?? ownWaitMs) : null; } const retried = error.code === 'network_error' || (error.code === 'timeout' && timeoutRetried); return retried ? ownWaitMs : null; diff --git a/packages/client/src/send.ts b/packages/client/src/send.ts index 9745092..43fe2a9 100644 --- a/packages/client/src/send.ts +++ b/packages/client/src/send.ts @@ -4,7 +4,7 @@ import { clientError, errorForResponse, PostcodeError } from './error.js'; import type { Queue } from './queue.js'; import type { GatewayRequest } from './requests.js'; import { apiCodeOf, dataOf } from './responses.js'; -import { FIRST_WAIT_MS, JITTER_MS, retryAfterMs, retryWaitMs } from './retry.js'; +import { FIRST_WAIT_MS, JITTER_MS, retryAfterMs, retryWaitMs, WAIT_STATUSES } from './retry.js'; /** * What every request of one client shares. @@ -64,7 +64,9 @@ async function attempt(settings: SendSettings, url: string, signal: AbortSignal) throw errorForResponse({ status: response.status, apiCode: apiCodeOf(body), - retryAfterMs: retryAfterMs(header, Date.now()), + retryAfterMs: WAIT_STATUSES.includes(response.status) + ? retryAfterMs(header, Date.now()) + : null, }); } catch (error: unknown) { throw failureOf(error, controller.signal); @@ -83,7 +85,7 @@ function failureOf(error: unknown, signal: AbortSignal): unknown { if (signal.aborted) { return signal.reason; } - return clientError('network_error', '', error); + return clientError('network_error', '', { cause: error }); } // A timer can fire up to a millisecond early on a finer clock. The client never waits less than diff --git a/packages/client/test/error.test.ts b/packages/client/test/error.test.ts index 4acdb51..ff7b35f 100644 --- a/packages/client/test/error.test.ts +++ b/packages/client/test/error.test.ts @@ -72,7 +72,7 @@ describe('PostcodeError', () => { describe('clientError', () => { it('adds the advice for a failure with no response, and keeps its cause', () => { const cause = new TypeError('fetch failed'); - const error = clientError('network_error', '', cause); + const error = clientError('network_error', '', { cause }); expect(error.message).toBe( 'The request did not reach the gateway. Check the network and the baseUrl.', ); @@ -87,7 +87,7 @@ describe('clientError', () => { const error = clientError('unexpected_response', 'The body has no data field.'); expect(error.code).toBe('unexpected_response'); expect(error.message).toBe( - 'The gateway sent a reply that this client cannot read. Try again, or update the client. ' + + 'The gateway sent a reply that the client cannot read. Try again, or update it. ' + 'The body has no data field.', ); }); diff --git a/packages/client/test/responses.test.ts b/packages/client/test/responses.test.ts index f2750ee..53395d5 100644 --- a/packages/client/test/responses.test.ts +++ b/packages/client/test/responses.test.ts @@ -222,7 +222,7 @@ describe('readAutocomplete', () => { const data = { segment: 'state', suggestions: [{ code: 'FC' }, item] }; const error = unexpected(() => readAutocomplete(data, 'F')); expect(error.status).toBe(200); - expect(error.message).toContain('Try again, or update the client.'); + expect(error.message).toContain('Try again, or update it.'); }, ); diff --git a/packages/client/test/retry.test.ts b/packages/client/test/retry.test.ts index 7c19421..633ff0f 100644 --- a/packages/client/test/retry.test.ts +++ b/packages/client/test/retry.test.ts @@ -13,7 +13,7 @@ afterEach(() => { describe('retryAfterMs', () => { it.each([ ['1', 1000], - [' 120 ', 120_000], + ['120', 120_000], ['0', 0], ['Wed, 14 Oct 2026 09:00:05 GMT', 5000], ['Wed, 14 Oct 2026 09:00:00 GMT', 0], @@ -21,12 +21,39 @@ describe('retryAfterMs', () => { expect(retryAfterMs(header, NOW)).toBe(waitMs); }); - it.each([null, '', '1.5', '-1', 'soon', 'Wed, 14 Oct 2026 08:59:59 GMT'])( - 'gives null for %j', - (header) => { - expect(retryAfterMs(header, NOW)).toBeNull(); - }, - ); + // The three HTTP-date forms of RFC 9110. The asctime form has no zone, and it means UTC. + it.each([ + ['Wed, 14 Oct 2026 09:00:05 GMT', 5000], + ['Wednesday, 14-Oct-26 09:00:05 GMT', 5000], + ['Wed Oct 14 09:00:05 2026', 5000], + ])('reads the date form %j', (header, waitMs) => { + expect(retryAfterMs(header, NOW)).toBe(waitMs); + }); + + it('pads the day of the asctime form', () => { + const now = Date.parse('2026-11-03T09:00:00Z'); + expect(retryAfterMs('Tue Nov 3 09:00:05 2026', now)).toBe(5000); + }); + + it.each([ + null, + '', + '1.5', + '-1', + 'soon', + 'soon 2027', + '2999-01-01', + '2999-01-01T00:00:00Z', + 'Wed, 14 Oct 2026 08:59:59 GMT', + 'Wed, 14 Oct 2026 09:00:05 +0000', + 'Wed, 14 Oct 2026 09:00:05', + 'Wed Oct 14 09:00:05 2026 GMT', + 'Wed, 14 oct 2026 09:00:05 GMT', + 'Sat, 31 Oct 2026 09:60:05 GMT', + 'Wed, 14 Oct 2026 09:00:05 GMT and more', + ])('gives null for %j', (header) => { + expect(retryAfterMs(header, NOW)).toBeNull(); + }); }); describe('retryWaitMs', () => { diff --git a/packages/client/test/send.test.ts b/packages/client/test/send.test.ts index 1b65a0c..316fef6 100644 --- a/packages/client/test/send.test.ts +++ b/packages/client/test/send.test.ts @@ -336,6 +336,26 @@ describe('Retry-After', () => { }); }); +describe('retryAfterMs of an error', () => { + const asking = (status: number): Answer => ({ + status, + body: {}, + headers: { 'Retry-After': '5' }, + }); + + it.each([429, 502, 503, 504])('holds the wait of a %i', async (status) => { + const { settings } = settingsWith({ maxRetries: 0 }, asking(status)); + const error = reasonOf(await settle(sendLookup(settings), 0)) as PostcodeError; + expect(error.retryAfterMs).toBe(5000); + }); + + it.each([400, 401, 402, 403, 404, 500, 501])('holds no wait for a %i', async (status) => { + const { settings } = settingsWith({ maxRetries: 0 }, asking(status)); + const error = reasonOf(await settle(sendLookup(settings), 0)) as PostcodeError; + expect([error.status, error.retryAfterMs]).toEqual([status, null]); + }); +}); + describe('a transport that fails with its own abort error', () => { it('still gives a timeout, and does not retry it for autocomplete', async () => { const attempts = vi.fn(); From 372939f15804bbb1581b76aa8416364dfe43eb0b Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 04:59:19 +0100 Subject: [PATCH 35/36] fix(client): refuse an empty API key as a bad option An empty apiKey is a configuration mistake, like a bad timeoutMs, maxRetries or cacheTtlMs. A caller who reads the key from an unset variable made the mistake in code, so the constructor now throws a RangeError, as it does for the other bad options. It no longer throws a PostcodeError with the code invalid_input. That code is for a bad input of a call, not for a bad option of the client. Signed-off-by: MobileMage --- packages/client/src/client.ts | 6 ++---- packages/client/test/client.test.ts | 13 +++---------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index b6a7fad..8831c0a 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { Postcode } from '@gatepost/core'; import { createCalls, type Calls } from './calls.js'; -import { clientError } from './error.js'; import { createQueue } from './queue.js'; import { autocompleteRequest, lookupRequest, reverseRequest } from './requests.js'; import { readAutocomplete, readLookup, readReverse } from './responses.js'; @@ -42,7 +41,7 @@ function checkOptions(options: ClientOptions): void { // SEC-1: anyone can read a key that a web page holds. The message names the key's kind only. const { apiKey } = options; if (apiKey === '') { - throw clientError('invalid_input', 'apiKey is empty.'); + throw new RangeError('apiKey must not be empty.'); } if (apiKey !== undefined && SECRET_KEY.test(apiKey) && 'document' in globalThis) { throw new TypeError('A web page must not hold a secret key. Use a publishable key here.'); @@ -76,8 +75,7 @@ export class PostcodeClient { * @param options - The key, the gateway's address, the transport, the timeout, the retries * and the cache time. Each one is optional. * @throws RangeError for a `timeoutMs` of 0 or less, a `maxRetries` that is not a whole - * number of 0 or more, or a `cacheTtlMs` that is negative or not finite. - * @throws PostcodeError `invalid_input` for an empty `apiKey`. + * number of 0 or more, a `cacheTtlMs` that is negative or not finite, or an empty `apiKey`. * @throws TypeError for a secret key in a web page. */ constructor(options: ClientOptions = {}) { diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index a12c9f6..cdac966 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -100,16 +100,9 @@ describe('the options', () => { expect(() => new PostcodeClient(options)).toThrow(message); }); - it('refuses an empty key with invalid_input, because an empty header is no key', () => { - let thrown: unknown; - try { - new PostcodeClient({ apiKey: '' }); - } catch (error) { - thrown = error; - } - expect(thrown).toBeInstanceOf(PostcodeError); - expect((thrown as PostcodeError).code).toBe('invalid_input'); - expect((thrown as PostcodeError).message).toContain('apiKey'); + it('refuses an empty key as a programmer error, because an empty header is no key', () => { + expect(() => new PostcodeClient({ apiKey: '' })).toThrow(RangeError); + expect(() => new PostcodeClient({ apiKey: '' })).toThrow('apiKey must not be empty.'); }); it('refuses a secret key in a web page, and allows a publishable key there', () => { From a5e29f794b4e4c5ed0689c55e637fbd929627dd0 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 11:58:49 +0100 Subject: [PATCH 36/36] build(client): raise the size limit to 6 KB The client sat a few bytes under its 5 KB limit, and some error messages had been cut short to fit. The owner chose 6 KB, which leaves room for the next feature without shorter messages. Signed-off-by: MobileMage --- packages/client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/package.json b/packages/client/package.json index 0253b3f..c5e7496 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -35,7 +35,7 @@ "size-limit": [ { "path": "dist/index.js", - "limit": "5 KB" + "limit": "6 KB" } ], "dependencies": {