Skip to content

Commit 3e18574

Browse files
baozhoutaoclaude
andauthored
test(rest): getDiscovery 测试替身发 routes,不再发 #4828 已退役的 endpoints (#5674) (#5788)
* test(rest): getDiscovery 替身发 `routes`,不再发已退役的 `endpoints` (#5674) `packages/rest/src` 下 15 个测试文件的 `getDiscovery` 替身返回 `endpoints: { data, metadata, ui, auth }`,但真实生产者 (`packages/metadata-protocol/src/protocol.ts` 的 `getDiscovery()`)发的是 `routes`(`ApiRoutesSchema`,`DiscoverySchema` 里是必填键),从来没有发过 `endpoints`。`endpoints` 只在 dispatcher 那条路径上作为 `routes` 的逐字副本 存在过,#4828 已按 ADR-0049 删除,并在 `discovery-schema-conformance.test.ts` 钉死「no `features`, no `endpoints`」。 按键名(`data`/`metadata`/`ui`/`auth`)可知这些替身本意就是 `routes`,只是 拼错了对象;纯键名翻正,值保持作者原样(handler 每次请求都会用服务端配置 覆写 `data`/`metadata`/`ui`,这些槽位按构造就是占位符)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ * test(rest): 同一批里 `endpoints: {}` 的 10 个替身一并翻正 (#5674) 同一个已退役键的退化写法:另有 10 个文件写 `endpoints: {}`。issue 与派发单 的清单来自 `endpoints: { data:` 这个 grep,看不见它们,但它们拼的是同一个 #4828 已删除的 discovery 键,拷贝危害完全相同 —— 只修 15 个会让 issue 自己 写下的第一条理由(「唯一还在拼写已退役键的地方」)原样留存。 翻正为 `routes: { data: '', metadata: '' }`:`ApiRoutesSchema` 里 `data`/`metadata` 必填,所以 `routes: {}` 不是合法生产者形状;这个字面量与 `rest.test.ts:2601` 早已存在的正确替身(唯一真正调用 /discovery handler 的 那处)完全一致,取仓库内已有先例而非另发明一种。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ * test(rest): 钉死「getDiscovery 替身不得拼 `endpoints`」(#5674) 前两个 commit 把 25 个替身翻正了,但没有任何东西阻止它回来:生产面的 `discovery-schema-conformance.test.ts` 只看真实 producer 发出的 body,fixture 层它看不见 —— 而 issue 记这一笔的第一条理由就是「下一个写 rest 测试的人照抄 这个替身,退役键就在 fixture 层复活」。 新增 pin 扫描 `packages/rest/src/*.test.ts` 里 `mockResolvedValue({ … })` 形态的 getDiscovery 替身(当前 28 处 / 27 个文件,含两处本来就没拼错的),断言没有一处 带 `endpoints` 键,并带一条 anti-vacuity 下限(#4642 的教训:扫不到东西的扫描器 会以「全部干净」的样子通过)。 只断言否定面,不要求必须有 `routes`:`rest-server` 的 handler 是 `if (discovery.routes)`,将来有测试要驱动这条假分支是正当的。 反向验证(方向为事前预测的 RED):把 rest-batch-size-cap.test.ts 改回 `endpoints` → pin 立即失败并点名该文件 (expected [ 'rest-batch-size-cap.test.ts' ] to deeply equal []),改回后转绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ * test(rest): pin 显式排除自身,不再靠 docblock 的 `* ` 前缀侥幸过关 (#5674) 自查发现的自身缺陷:pin 扫描目录下所有 `*.test.ts`,**包括它自己** —— 而它的 docblock 为了说明问题,原样引用了带 `endpoints:` 的坏替身。它之所以没红,只是 因为 docblock 行把键前缀成了 `* `,而 `[{,]\s*` 这个 lead-in 不接受 `*`。也就是说 这条钉子的绿是由注释排版决定的:重排一次 docblock,它就会在自己的散文上失败。 改为显式排除自身文件名,并把理由写在常量旁边。同时把 anti-vacuity 注释里的 计数改成排除自身后的实测值:27 处 / 26 个文件(#5674 翻正的 26 处,加上 `rest-route-ledger.conformance.test.ts` —— 它 resolve 成裸 `{}`,从来没拼过退役键, 正是「只断言否定面」让它不受影响)。 反向验证(RED,事前预测)在两种拼法上都复验过: - `rest-batch-size-cap.test.ts` 改回 `endpoints: { data: … }` → 点名该文件; - `rest-dropped-fields.test.ts` 改回 `endpoints: {}` → 点名该文件。 两次改回后均转绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b720de commit 3e18574

26 files changed

Lines changed: 167 additions & 26 deletions

‎packages/rest/src/analytics-dataset-dimension-gate.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ function mockServer() {
7272
}
7373
function mockProtocol() {
7474
return {
75-
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }),
75+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }),
7676
getMetaTypes: vi.fn().mockResolvedValue([]),
7777
getMetaItems: vi.fn().mockResolvedValue([]),
7878
};

‎packages/rest/src/analytics-dataset-refusal-envelope.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ function mockServer() {
7070
}
7171
function mockProtocol() {
7272
return {
73-
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }),
73+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }),
7474
getMetaTypes: vi.fn().mockResolvedValue([]),
7575
getMetaItems: vi.fn().mockResolvedValue([]),
7676
};

‎packages/rest/src/analytics-dataset-where-gate.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function mockServer() {
6464
}
6565
function mockProtocol() {
6666
return {
67-
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }),
67+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }),
6868
getMetaTypes: vi.fn().mockResolvedValue([]),
6969
getMetaItems: vi.fn().mockResolvedValue([]),
7070
};

‎packages/rest/src/analytics-filter-refusal-envelope.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ function mockServer() {
5858
}
5959
function mockProtocol() {
6060
return {
61-
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }),
61+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }),
6262
getMetaTypes: vi.fn().mockResolvedValue([]),
6363
getMetaItems: vi.fn().mockResolvedValue([]),
6464
};

‎packages/rest/src/analytics-routes.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function mockServer() {
1212
};
1313
}
1414
function mockProtocol() {
15-
return { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }), getMetaTypes: vi.fn().mockResolvedValue([]), getMetaItems: vi.fn().mockResolvedValue([]) };
15+
return { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), getMetaTypes: vi.fn().mockResolvedValue([]), getMetaItems: vi.fn().mockResolvedValue([]) };
1616
}
1717
function mockRes() {
1818
const res: any = { statusCode: 200, body: undefined };
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#5674] No `getDiscovery` test double in this package may spell `endpoints`.
5+
*
6+
* ## What was wrong
7+
*
8+
* 25 test files in `packages/rest/src` mocked the protocol like this:
9+
*
10+
* ```ts
11+
* getDiscovery: vi.fn().mockResolvedValue({
12+
* version: 'v0',
13+
* endpoints: { data: '', metadata: '', ui: '', auth: '/auth' },
14+
* })
15+
* ```
16+
*
17+
* The real producer (`ObjectStackProtocolImplementation.getDiscovery()` in
18+
* `packages/metadata-protocol/src/protocol.ts`) emits `routes` — declared as
19+
* `ApiRoutesSchema` and REQUIRED by `DiscoverySchema` — and has never emitted
20+
* `endpoints`. That key existed only on the dispatcher path, as a verbatim
21+
* copy of `routes`, and #4828 removed it under ADR-0049. The producer side is
22+
* already pinned next door: `discovery-schema-conformance.test.ts` asserts the
23+
* live `/discovery` body has no `endpoints` property.
24+
*
25+
* Those doubles were inert — `rest-server`'s discovery handler reads
26+
* `discovery.routes`, so `if (discovery.routes)` was simply false and the
27+
* route-augmentation block was skipped — which is exactly why they survived
28+
* the retirement: nothing they asserted depended on the key at all.
29+
*
30+
* ## Why a pin, and why THIS pin
31+
*
32+
* The harm is authoring-time, not runtime: these were the last places in the
33+
* repo still spelling a retired key, and a test double is the most-copied
34+
* artifact there is. Copy one and the key is back in the fixture layer, still
35+
* inert, still teaching a producer shape that never existed. A conformance
36+
* test over the live producer cannot see that — no producer is involved.
37+
*
38+
* Scope, stated honestly:
39+
*
40+
* - It scans the `mockResolvedValue({ … })` form only, which is the form all
41+
* 26 doubles in this package use and therefore the form that gets copied. A
42+
* double written some other way (`vi.fn(async () => ({ … }))`) is not read.
43+
* - It asserts only the NEGATIVE — no `endpoints` key. It deliberately does
44+
* not require `routes`, because `rest-server` guards with
45+
* `if (discovery.routes)` and a future test is entitled to drive that guard's
46+
* falsy branch with a double that omits it.
47+
* - The floor below is anti-vacuity, in the sense of #4642: a scanner that
48+
* silently stops matching would otherwise pass by finding nothing.
49+
*/
50+
51+
import { describe, it, expect } from 'vitest';
52+
import { readdirSync, readFileSync } from 'node:fs';
53+
import { join } from 'node:path';
54+
import { fileURLToPath } from 'node:url';
55+
56+
const SRC_DIR = fileURLToPath(new URL('.', import.meta.url));
57+
58+
/**
59+
* This file is excluded from its own scan, EXPLICITLY.
60+
*
61+
* The docblock above quotes the defective double verbatim — that is the point
62+
* of it — so the scanner does match a "double" here. It happens to escape the
63+
* key check only because a docblock line prefixes the key with `* `, which the
64+
* `[{,]\s*` lead-in does not accept. Depending on that is depending on comment
65+
* formatting: re-wrap the docblock and this pin fails on its own prose. Name
66+
* the exclusion instead of inheriting the luck.
67+
*/
68+
const SELF = 'discovery-double-retired-key.test.ts';
69+
70+
/** Matches an `endpoints` key at the start of a line or after `{` / `,`. */
71+
const ENDPOINTS_KEY = /(^|[{,])\s*endpoints\s*:/;
72+
73+
interface Double {
74+
file: string;
75+
/** The source text of the object the double resolves to, braces included. */
76+
literal: string;
77+
}
78+
79+
/**
80+
* Capture the balanced `{ … }` that follows `getDiscovery: …mockResolvedValue(`.
81+
*
82+
* Brace counting is enough here: the doubles are plain data literals, and a
83+
* `{` inside a string would only ever make this capture MORE text, i.e. fail
84+
* loud rather than pass quiet.
85+
*/
86+
function collectDoubles(file: string, source: string): Double[] {
87+
const found: Double[] = [];
88+
const opener = /getDiscovery\s*:[\s\S]{0,80}?mockResolvedValue\(\s*\{/g;
89+
let m: RegExpExecArray | null;
90+
while ((m = opener.exec(source)) !== null) {
91+
const start = m.index + m[0].length - 1; // index of the `{`
92+
let depth = 0;
93+
let end = -1;
94+
for (let i = start; i < source.length; i++) {
95+
const ch = source[i];
96+
if (ch === '{') depth++;
97+
else if (ch === '}') {
98+
depth--;
99+
if (depth === 0) { end = i; break; }
100+
}
101+
}
102+
if (end === -1) throw new Error(`${file}: unbalanced getDiscovery double literal`);
103+
found.push({ file, literal: source.slice(start, end + 1) });
104+
}
105+
return found;
106+
}
107+
108+
const DOUBLES: Double[] = readdirSync(SRC_DIR)
109+
.filter((f) => f.endsWith('.test.ts') && f !== SELF)
110+
.flatMap((f) => collectDoubles(f, readFileSync(join(SRC_DIR, f), 'utf8')));
111+
112+
describe('[#5674] getDiscovery doubles carry the producer key, not the retired one', () => {
113+
it('finds the doubles it claims to police (anti-vacuity)', () => {
114+
// 27 doubles across 26 files when this pin was written (the 26 #5674
115+
// corrected, plus `rest-route-ledger.conformance.test.ts`, which
116+
// resolves to a bare `{}` and never spelled the retired key). The floor
117+
// is deliberately slack — it exists so a scanner that matches NOTHING
118+
// fails instead of reporting a clean sweep of an empty set.
119+
expect(
120+
DOUBLES.length,
121+
'the scanner found (almost) no getDiscovery doubles — it has drifted from how this package writes them, so its verdict below means nothing',
122+
).toBeGreaterThanOrEqual(20);
123+
});
124+
125+
it('no double resolves to an object carrying `endpoints` (retired in #4828)', () => {
126+
const offenders = DOUBLES
127+
.filter((d) => ENDPOINTS_KEY.test(d.literal))
128+
.map((d) => d.file);
129+
130+
expect(
131+
Array.from(new Set(offenders)),
132+
'the discovery producer emits `routes` (ApiRoutesSchema) and never emitted `endpoints`; '
133+
+ '`endpoints` was the dispatcher-only copy retired by #4828 (ADR-0049). A double that spells '
134+
+ 'it teaches a shape no producer ever had, and is the seed the next copy-paste grows from (#5674).',
135+
).toEqual([]);
136+
});
137+
});

‎packages/rest/src/meta-app-area-nav-gate.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function makeRes() {
6565
*/
6666
function setup(perms: string[], services: string[] = ['org-scoping']) {
6767
const protocol: any = {
68-
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' } }),
68+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }),
6969
getMetaTypes: vi.fn().mockResolvedValue([]),
7070
// Deep-clone per call: the filter must never mutate stored metadata, and
7171
// a shared object would hide that by carrying a prior call's damage.

‎packages/rest/src/meta-audience-plural.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function makeRes() {
3030

3131
function setup() {
3232
const protocol: any = {
33-
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' } }),
33+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }),
3434
getMetaTypes: vi.fn().mockResolvedValue([]),
3535
// The real implementation normalizes singular↔plural, so BOTH spellings
3636
// resolve to the same items.

‎packages/rest/src/meta-public-book-grant.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function makeRes() {
3636
/** Secure-by-default: `requireAuth` is ON for every case below. */
3737
function setup() {
3838
const protocol: any = {
39-
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' } }),
39+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }),
4040
getMetaTypes: vi.fn().mockResolvedValue([]),
4141
getMetaItems: vi.fn(async ({ type }: any) => {
4242
const t = RestServerTypes.singular(String(type ?? ''));

‎packages/rest/src/public-form-routes.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const ticketObject = {
5959
function buildServer(sections: any[]) {
6060
const createData = vi.fn().mockResolvedValue({ object: 'ticket', id: 'rec_1', record: {} });
6161
const protocol: any = {
62-
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }),
62+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }),
6363
getMetaTypes: vi.fn().mockResolvedValue([]),
6464
getMetaItems: vi.fn(async ({ type }: { type: string }) => {
6565
if (type === 'view') return [formView(sections)];

0 commit comments

Comments
 (0)