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/.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/.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..9917feb 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-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"
+ 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/.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/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/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/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/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/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/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/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..3084524
--- /dev/null
+++ b/packages/mock-server/README.md
@@ -0,0 +1,88 @@
+
+
+
+
+
+# @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.
+
+[](https://github.com/gatepost-dev/js/actions/workflows/ci.yml)
+[](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 |
+
+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 |
+| ------------- | ------------------------------------------ |
+| 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/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/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..d3aa5e5
--- /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(key.rateLimited ? 0 : Math.max(left, 0)),
+ });
+ };
+}
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/lookup.ts b/packages/mock-server/src/lookup.ts
new file mode 100644
index 0000000..609d950
--- /dev/null
+++ b/packages/mock-server/src/lookup.ts
@@ -0,0 +1,66 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+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';
+
+/**
+ * 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;
+}
+
+// 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.
+ *
+ * @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 = levelIn(request.query);
+ if (compactCode(code) === '' || level === null) {
+ return fixtureReply(files, 'errors/invalid-request');
+ }
+ 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-${String(level)}`;
+ }
+ return fixtureReply(files, name, { postcode: code.toUpperCase() });
+}
diff --git a/packages/mock-server/src/main.ts b/packages/mock-server/src/main.ts
new file mode 100644
index 0000000..e1a2169
--- /dev/null
+++ b/packages/mock-server/src/main.ts
@@ -0,0 +1,46 @@
+// 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;
+}
+
+// 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: hostFromEnvironment(),
+ 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/postcodes.ts b/packages/mock-server/src/postcodes.ts
new file mode 100644
index 0000000..62f3982
--- /dev/null
+++ b/packages/mock-server/src/postcodes.ts
@@ -0,0 +1,75 @@
+// 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, '');
+}
+
+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);
+}
+
+/**
+ * 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
new file mode 100644
index 0000000..299cf53
--- /dev/null
+++ b/packages/mock-server/src/reply.ts
@@ -0,0 +1,78 @@
+// 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,
+ };
+}
+
+/**
+ * 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/scenarios.ts b/packages/mock-server/src/scenarios.ts
new file mode 100644
index 0000000..9ff0d8e
--- /dev/null
+++ b/packages/mock-server/src/scenarios.ts
@@ -0,0 +1,37 @@
+// 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. 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
+ * 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/search.ts b/packages/mock-server/src/search.ts
new file mode 100644
index 0000000..6983c4d
--- /dev/null
+++ b/packages/mock-server/src/search.ts
@@ -0,0 +1,110 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { knownUnit } from './lookup.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';
+
+/** 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 {
+ return decimalNumber(query.get(name) ?? '');
+}
+
+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;
+}
+
+// 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. 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.
+ * @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 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 });
+}
+
+/**
+ * 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/server.ts b/packages/mock-server/src/server.ts
new file mode 100644
index 0000000..8659155
--- /dev/null
+++ b/packages/mock-server/src/server.ts
@@ -0,0 +1,188 @@
+// 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'),
+ };
+}
+
+// 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,
+ wait: { readonly extraMs: number; readonly stopping: AbortSignal },
+): Promise {
+ if (reply.kind === 'hang') {
+ return;
+ }
+ if (!(await waited(reply.delayMs + wait.extraMs, outgoing, wait.stopping))) {
+ return;
+ }
+ 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);
+}
+
+// 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);
+ 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 stopping = new AbortController();
+ 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, 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, { 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);
+ 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/src/spec-files.ts b/packages/mock-server/src/spec-files.ts
new file mode 100644
index 0000000..cd1fab0
--- /dev/null
+++ b/packages/mock-server/src/spec-files.ts
@@ -0,0 +1,325 @@
+// 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[];
+}
+
+// A response has exactly one of these fields.
+const RESPONSE_KINDS = ['body', 'text', 'fixture', 'hang', 'drop'] as const;
+
+type JsonObject = Readonly>;
+
+// `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 {
+ 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 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[] =>
+ 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())
+ .map((entry) => entry.name)
+ .sort();
+ for (const group of groups) {
+ 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'] });
+ }
+ }
+ return fixtures;
+}
+
+function readKeys(folder: string): { keys: Map; requestsPerMinute: number } {
+ const where = 'fixtures/keys.json';
+ const document = objectIn(readJson(folder, 'keys.json', where), where);
+ const keys = new Map();
+ 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', 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, isCount) };
+}
+
+function scenarioReply(
+ value: unknown,
+ fixtures: ReadonlyMap,
+ 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, isDelay);
+ 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, 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 };
+ }
+ return { ...jsonReply(status, response['body'], headers), delayMs };
+}
+
+function readScenarios(
+ folder: string,
+ fixtures: ReadonlyMap,
+): Map {
+ const scenarios = new Map();
+ 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), 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, index) =>
+ scenarioReply(response, fixtures, `${where} (response ${String(index + 1)})`),
+ ),
+ );
+ }
+ return scenarios;
+}
+
+function readSegments(folder: string): SegmentRule[] {
+ const where = 'data/format.json';
+ const document = objectIn(readJson(folder, 'format.json', where), 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), 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 } });
+}
+
+/**
+ * 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..fe1f2ba
--- /dev/null
+++ b/packages/mock-server/test/gateway.test.ts
@@ -0,0 +1,179 @@
+// 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) {
+ 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([
+ 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');
+ 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', () => {
+ 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);
+ });
+
+ 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
new file mode 100644
index 0000000..afea4e0
--- /dev/null
+++ b/packages/mock-server/test/lookup.test.ts
@@ -0,0 +1,128 @@
+// 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']);
+ });
+
+ 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/main.test.ts b/packages/mock-server/test/main.test.ts
new file mode 100644
index 0000000..81fef3f
--- /dev/null
+++ b/packages/mock-server/test/main.test.ts
@@ -0,0 +1,89 @@
+// 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('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'],
+ ])('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');
+ });
+
+ 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/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/openapi.test.ts b/packages/mock-server/test/openapi.test.ts
new file mode 100644
index 0000000..8b414ea
--- /dev/null
+++ b/packages/mock-server/test/openapi.test.ts
@@ -0,0 +1,159 @@
+// 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;
+ readonly headers?: Readonly>;
+ readonly 'x-gatepost-error-codes'?: Readonly>;
+}
+
+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;
+ 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 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 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;
+}
+
+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' },
+ { 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: 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 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);
+ });
+});
+
+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/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);
+ });
+});
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/search.test.ts b/packages/mock-server/test/search.test.ts
new file mode 100644
index 0000000..b74f34c
--- /dev/null
+++ b/packages/mock-server/test/search.test.ts
@@ -0,0 +1,163 @@
+// 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: 'low',
+ },
+ 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.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 });
+ });
+
+ 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.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);
+ });
+});
+
+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);
+ });
+});
+
+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' }],
+ });
+ });
+});
diff --git a/packages/mock-server/test/server.test.ts b/packages/mock-server/test/server.test.ts
new file mode 100644
index 0000000..90c23d2
--- /dev/null
+++ b/packages/mock-server/test/server.test.ts
@@ -0,0 +1,234 @@
+// 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('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 });
+ 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);
+ });
+
+ 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+$/);
+ });
+});
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..e948eb1
--- /dev/null
+++ b/packages/mock-server/test/spec-files.test.ts
@@ -0,0 +1,264 @@
+// 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 (response 1): no fixture is named lookup/valid-level-9.',
+ );
+ });
+
+ 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));
+ });
+});
+
+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.',
+ );
+ });
+
+ 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/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/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:
diff --git a/scripts/eslint-mock-server.test.mjs b/scripts/eslint-mock-server.test.mjs
new file mode 100644
index 0000000..5ba1b3f
--- /dev/null
+++ b/scripts/eslint-mock-server.test.mjs
@@ -0,0 +1,64 @@
+// 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('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. */',
+ 'export function hello(): void {',
+ " console.info('hi');",
+ '}',
+ );
+ assert.ok(found.includes('no-console'), JSON.stringify(found));
+ });
+});
diff --git a/spec b/spec
index ca4afdd..359db01 160000
--- a/spec
+++ b/spec
@@ -1 +1 @@
-Subproject commit ca4afddf65e2897c71bb49ef7bed71b1a9741408
+Subproject commit 359db01aa97f2d5b183db431d14dbccb793af9f3