Skip to content

Commit 0102716

Browse files
committed
test(service-settings): add settings-route-ledger conformance guard
registerSettingsRoutes is an exported, synchronous, top-level function (http, service, opts) => void that calls http.get/put/post directly and touches neither argument before a request arrives -- the same shape storage-routes.ts and datasource's admin-routes.ts export. This mirrors their conformance-test seam (a capturing mock IHttpServer) rather than i18n's plugin-lifecycle shape, since no lifecycle is needed to reach an already-exported registrar. Both directions verified red-then-restored before landing: deleting the PUT /api/settings/:namespace ledger row, and adding a mounted DELETE /api/settings/:namespace/:actionId route with no row, each fail the new test naming that exact route. The existing ledger was checked for drift first and found current (4 registrar calls, 4 ledger rows, 1:1). settings-route-ledger.ts's header comment previously said a per-package guard was deliberately omitted in favour of the dogfood live-mount-parity gate; updated now that this file exists alongside it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
1 parent e4fd55d commit 0102716

2 files changed

Lines changed: 158 additions & 6 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Settings route-ledger conformance (#17062) — the guard every OTHER
5+
* `*-route-ledger.ts` in the tree pairs with a `*-route-ledger.conformance.test.ts`,
6+
* missing here since the ledger itself landed at #7526.
7+
*
8+
* WHY THIS SHAPE. `registerSettingsRoutes` (`settings-routes.ts`) is a
9+
* standalone, synchronous, top-level function — `(http, service, opts) => void`
10+
* — that calls `http.get/put/post` directly and touches neither argument
11+
* before a request arrives. That is exactly the shape `storage-routes.ts` and
12+
* `admin-routes.ts` (datasource) export, and their conformance tests already
13+
* settled the right seam for it: drive the registrar against a capturing mock
14+
* `IHttpServer` and read its recorded calls as the route set. It is NOT the
15+
* i18n shape (`I18nServicePlugin.registerI18nRoutes` is a *private* method
16+
* reached only by driving the plugin's `init`→`start`→`kernel:ready`
17+
* lifecycle) — `registerSettingsRoutes` needs no lifecycle to reach because it
18+
* is already the exported seam, and driving one it does not require would
19+
* intercept nothing the direct call does not. It is also not a source-scan:
20+
* that shape (cli/metadata/trigger-api's second limb) earns its keep when the
21+
* mounting mechanism can't be driven behind a mock (a dispatcher table read at
22+
* import time, a scan of static bindings); registration here is an ordinary
23+
* function call.
24+
*
25+
* ⭐ THE DIRECTION THAT MATTERS (per the issue). The dogfood live-mount-parity
26+
* gate (`packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts`,
27+
* #7526) already checks that every ledgered settings row resolves on a real
28+
* boot — but only that direction. It says nothing about a route this package
29+
* mounts with NO ledger row: a boot that mounts an extra, unledgered path
30+
* still satisfies "every ledgered row resolves". This file's first `it` is the
31+
* missing direction — a route `registerSettingsRoutes` mounts that the ledger
32+
* does not know about fails HERE, by name, in a plain unit test that runs on
33+
* every `pnpm test`, not only in the dogfood suite.
34+
*
35+
* The second direction (a ledger row the registrar no longer mounts) is
36+
* already covered in spirit by the dogfood gate's direction 1 — reproduced
37+
* here too, in the #3636 / #7744 pattern every sibling follows, so this
38+
* package's ledger is guarded the same way regardless of which suite runs.
39+
*/
40+
41+
import { describe, it, expect, vi } from 'vitest';
42+
import { registerSettingsRoutes } from './settings-routes.js';
43+
import { SETTINGS_ROUTE_LEDGER } from './settings-route-ledger.js';
44+
45+
/** Minimal IHttpServer mock that records registrations. */
46+
function createMockServer() {
47+
return {
48+
get: vi.fn(),
49+
post: vi.fn(),
50+
put: vi.fn(),
51+
delete: vi.fn(),
52+
patch: vi.fn(),
53+
use: vi.fn(),
54+
listen: vi.fn().mockResolvedValue(undefined),
55+
close: vi.fn().mockResolvedValue(undefined),
56+
};
57+
}
58+
59+
/**
60+
* `VERB /path` keys for every route the registrar mounts at the DEFAULT base.
61+
*
62+
* Registration only closes over `service`/`opts` — nothing on either is
63+
* called until a request arrives (every read of `service` happens inside a
64+
* handler body), so a bare stub for each enumerates the full surface exactly
65+
* as the storage/datasource siblings' bare stubs do.
66+
*/
67+
function enumerateSettingsRoutes(): Set<string> {
68+
const server = createMockServer();
69+
registerSettingsRoutes(server as any, {} as any, {});
70+
const keys = new Set<string>();
71+
for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) {
72+
for (const call of server[verb].mock.calls) {
73+
keys.add(`${verb.toUpperCase()} ${call[0]}`);
74+
}
75+
}
76+
return keys;
77+
}
78+
79+
const ledgerKeys = (): Set<string> => new Set(SETTINGS_ROUTE_LEDGER.map((e) => e.route));
80+
81+
describe('settings route ledger ↔ registerSettingsRoutes enumeration', () => {
82+
it('every mounted settings route has a ledger entry', () => {
83+
const ledger = ledgerKeys();
84+
const missing = [...enumerateSettingsRoutes()].filter((k) => !ledger.has(k));
85+
expect(
86+
missing,
87+
`Settings routes with no settings-route-ledger entry: ${missing.join(', ')}. ` +
88+
'A new route needs a reviewed disposition in settings-route-ledger.ts (#17062).',
89+
).toEqual([]);
90+
});
91+
92+
it('every ledger entry is really mounted by the registrar', () => {
93+
const live = enumerateSettingsRoutes();
94+
const stale = [...ledgerKeys()].filter((k) => !live.has(k));
95+
expect(
96+
stale,
97+
`settings-route-ledger entries the registrar no longer mounts: ${stale.join(', ')}. ` +
98+
'Remove or reclassify them so the ledger stays truthful.',
99+
).toEqual([]);
100+
});
101+
102+
it('no route is ledgered twice', () => {
103+
const seen = new Set<string>();
104+
const dupes = SETTINGS_ROUTE_LEDGER.map((e) => e.route).filter((r) => !seen.add(r));
105+
expect(dupes, `duplicate settings-route-ledger rows: ${dupes.join(', ')}`).toEqual([]);
106+
});
107+
108+
it('the ledger is compared against a real enumeration, not an empty one', () => {
109+
// Absence must be loud (AGENTS.md, Route & surface ownership §3). Both
110+
// set-difference assertions above pass vacuously if the registrar ever
111+
// stops registering anything — a refactor that moves the mount elsewhere,
112+
// or a mock whose recorded calls stop being readable — leaving this file
113+
// green while guarding nothing. Assert the enumeration produced something,
114+
// and that the two sides are the same size rather than merely non-conflicting.
115+
const live = enumerateSettingsRoutes();
116+
expect(live.size).toBeGreaterThan(0);
117+
expect(live.size).toBe(ledgerKeys().size);
118+
});
119+
});
120+
121+
describe('settings route ledger hygiene', () => {
122+
it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => {
123+
const sdkWithout = SETTINGS_ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route);
124+
expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]);
125+
126+
const bareNonSdk = SETTINGS_ROUTE_LEDGER.filter((e) => e.disposition !== 'sdk' && !e.note).map((e) => e.route);
127+
expect(bareNonSdk, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]);
128+
});
129+
130+
it('gap and mismatch counts only shrink — update the ledger (and these numbers) when closing them', () => {
131+
// Ratchet, not aspiration. The settings surface is four reviewed
132+
// `server-only` rows (deployment configuration read/written by the
133+
// Setup/admin UI over plain HTTP — see settings-route-ledger.ts's own
134+
// header): `@objectstack/client` expresses no settings method, and
135+
// nothing has ever asked the SDK for one, so `gap` is not the disposition.
136+
// A new `gap` or `mismatch` row is a product decision that needs its own
137+
// review, so these bounds stay 0.
138+
const gaps = SETTINGS_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
139+
expect(gaps).toBeLessThanOrEqual(0);
140+
141+
const mismatches = SETTINGS_ROUTE_LEDGER.filter((e) => e.disposition === 'mismatch').length;
142+
expect(mismatches).toBeLessThanOrEqual(0);
143+
});
144+
});

packages/services/service-settings/src/settings-route-ledger.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,20 @@
1414
* asking which rows nobody claims (PENDING-GAPS §E; the gate is
1515
* `packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts`).
1616
*
17-
* WHAT GUARDS IT. That parity gate, in both directions: a row here whose
18-
* route the plugin stops mounting fails it, and a fifth route mounted without
19-
* a row here fails it too. Deliberately NOT a fifth per-package conformance
20-
* test — the sibling ledgers each grew one because nothing else could see
21-
* their registrar, and the gate now can. A second guard over the same fact
22-
* would be the "two places to remember" shape this issue is about.
17+
* WHAT GUARDS IT. Two layers, since #17062. The dogfood parity gate above
18+
* checks both directions too — a row here whose route the plugin stops
19+
* mounting fails it, and any live mount without a row in the union of the
20+
* ledgers it reads fails it — but only as part of a full boot, in a
21+
* different package's suite, gated on whatever plugins that specific boot
22+
* composes. `settings-route-ledger.conformance.test.ts`, alongside this
23+
* file, is the package-local guard every OTHER `*-route-ledger.ts` in the
24+
* tree already pairs itself with: it drives `registerSettingsRoutes` against
25+
* a capturing mock `IHttpServer` (the same seam `storage-routes.ts` and
26+
* datasource's `admin-routes.ts` use — `registerSettingsRoutes` is an
27+
* exported, synchronous, top-level function that touches neither argument
28+
* before a request arrives, so a bare stub of each enumerates the real
29+
* surface), runs on every `pnpm test` for this package alone, and fails by
30+
* name in both directions without needing a boot.
2331
*
2432
* SCOPE & SHAPE. Rows carry full wire paths at the DEFAULT base
2533
* (`/api/settings` — note NOT under `/api/v1`; this surface predates the

0 commit comments

Comments
 (0)