|
| 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 | +}); |
0 commit comments