-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathrest-route-ledger.conformance.test.ts
More file actions
156 lines (142 loc) · 6.53 KB
/
Copy pathrest-route-ledger.conformance.test.ts
File metadata and controls
156 lines (142 loc) · 6.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* REST route-ledger conformance (#3587) — the guard that keeps the REST
* server's route surface and `@objectstack/client` from drifting apart
* silently, mirroring the dispatcher guard (#3569).
*
* Directions made loud here:
*
* 1. A route mounted by `@objectstack/rest` with no ledger entry — a new
* route landed without a reviewed SDK disposition.
* 2. A ledger entry for a route the server no longer mounts — the ledger
* went stale.
*
* Enumeration is real on BOTH sources: `route-manager` rows against
* `RestServer.getRoutes()` (the introspection seam RouteManager already
* provides), and `direct-mount` rows against the registration calls the two
* bypass registrars make on a mock `IHttpServer` — no pinned-by-hand list.
*
* The third direction — "every `sdk` row names a client method that exists" —
* lives in `packages/client/src/rest-route-ledger-coverage.test.ts`, next to
* the SDK it introspects, for the same build-cycle reason as tranche 1: a
* rest→client edge is unbuildable (client's devDeps already reach back into
* the server packages, and CI's per-package test tasks build only their own
* dependency closure).
*/
import { describe, it, expect, vi } from 'vitest';
import { RestServer } from './rest-server';
import { registerPackageRoutes } from './package-routes';
import { registerExternalDatasourceRoutes } from './external-datasource-routes';
import { REST_ROUTE_LEDGER } from './rest-route-ledger';
/** Minimal IHttpServer mock that records registrations. */
function createMockServer() {
return {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
use: vi.fn(),
listen: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
}
/**
* Protocol mock with the batch capabilities present so the four
* protocol-capability-gated batch routes register (rest-server.ts).
*/
function createCapableProtocol() {
return {
getDiscovery: vi.fn().mockResolvedValue({}),
getMetaTypes: vi.fn().mockResolvedValue([]),
getMetaItems: vi.fn().mockResolvedValue([]),
getMetaItem: vi.fn().mockResolvedValue({}),
findData: vi.fn().mockResolvedValue([]),
getData: vi.fn().mockResolvedValue({}),
createData: vi.fn().mockResolvedValue({ id: '1' }),
updateData: vi.fn().mockResolvedValue({}),
deleteData: vi.fn().mockResolvedValue({ success: true }),
batchData: vi.fn().mockResolvedValue({}),
createManyData: vi.fn().mockResolvedValue([]),
updateManyData: vi.fn().mockResolvedValue([]),
deleteManyData: vi.fn().mockResolvedValue([]),
};
}
/** `VERB /path` keys for every route RouteManager holds at default config. */
function enumerateRouteManagerRoutes(): Set<string> {
const rest = new RestServer(createMockServer() as any, createCapableProtocol() as any, {} as any);
rest.registerRoutes();
return new Set(rest.getRoutes().map((r) => `${r.method.toUpperCase()} ${r.path}`));
}
/** `VERB /path` keys captured from the two RouteManager-bypassing registrars. */
function enumerateDirectMountRoutes(): Set<string> {
const server = createMockServer();
registerPackageRoutes(server as any, {} as any);
registerExternalDatasourceRoutes(server as any, { getService: () => undefined } as any);
const keys = new Set<string>();
for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) {
for (const call of (server[verb] as any).mock.calls) {
keys.add(`${verb.toUpperCase()} ${call[0]}`);
}
}
return keys;
}
function ledgerKeys(source: 'route-manager' | 'direct-mount'): Set<string> {
return new Set(REST_ROUTE_LEDGER.filter((e) => e.source === source).map((e) => e.route));
}
describe('REST route ledger ↔ RouteManager enumeration', () => {
it('every RouteManager-registered route has a ledger entry', () => {
const ledger = ledgerKeys('route-manager');
const missing = [...enumerateRouteManagerRoutes()].filter((k) => !ledger.has(k));
expect(
missing,
`REST routes with no rest-route-ledger entry: ${missing.join(', ')}. ` +
'A new route needs a reviewed disposition in rest-route-ledger.ts (#3587).',
).toEqual([]);
});
it('every route-manager ledger entry is a live RouteManager route', () => {
const live = enumerateRouteManagerRoutes();
const stale = [...ledgerKeys('route-manager')].filter((k) => !live.has(k));
expect(
stale,
`rest-route-ledger entries the server no longer mounts: ${stale.join(', ')}. ` +
'Remove or reclassify them so the ledger stays truthful.',
).toEqual([]);
});
});
describe('REST route ledger ↔ direct-mount registrars', () => {
it('every directly-mounted route has a ledger entry', () => {
const ledger = ledgerKeys('direct-mount');
const missing = [...enumerateDirectMountRoutes()].filter((k) => !ledger.has(k));
expect(
missing,
`Directly-mounted routes with no rest-route-ledger entry: ${missing.join(', ')}.`,
).toEqual([]);
});
it('every direct-mount ledger entry is really registered by its registrar', () => {
const live = enumerateDirectMountRoutes();
const stale = [...ledgerKeys('direct-mount')].filter((k) => !live.has(k));
expect(
stale,
`direct-mount rest-route-ledger entries no registrar mounts: ${stale.join(', ')}.`,
).toEqual([]);
});
});
describe('REST route ledger hygiene', () => {
it('every `sdk` entry names its client method; every non-sdk entry carries a rationale', () => {
const sdkWithout = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'sdk' && !e.client).map((e) => e.route);
expect(sdkWithout, 'sdk-disposition entries missing a client method name').toEqual([]);
const bareNonSdk = REST_ROUTE_LEDGER.filter((e) => e.disposition !== 'sdk' && !e.note).map((e) => e.route);
expect(bareNonSdk, 'non-sdk entries must say WHY they are not SDK surface').toEqual([]);
});
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
// Ratchet, not aspiration: 43 audited gaps at #3587 PR-1; 26 after the
// metadata batch closed its 9 and the reports batch its 8 (data-actions 2,
// search 1, email 1, analytics 1, security-explain 2, record-shares 3,
// sharing-rules 5, approvals 6, external-datasource 5 remain). Closing a
// gap = reclassify to `sdk` AND lower this bound. Raising it demands an
// explicit, reviewed decision.
const gaps = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
expect(gaps).toBeLessThanOrEqual(26);
});
});