@nextrush/router 3.0.7 → 4.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +250 -492
- package/dist/index.d.ts +227 -178
- package/dist/index.js +1075 -657
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/__tests__/allowed-methods.test.ts +144 -0
- package/src/__tests__/audit-fixes.test.ts +59 -0
- package/src/__tests__/canonical-path-security.test.ts +198 -0
- package/src/__tests__/canonicalize-memo.test.ts +108 -0
- package/src/__tests__/dispatch-deasync.test.ts +292 -0
- package/src/__tests__/find-node-differential.test.ts +220 -0
- package/src/__tests__/fixtures/match-golden.json +556 -0
- package/src/__tests__/head-auto-registration.test.ts +197 -0
- package/src/__tests__/helpers/differential-corpus.ts +314 -0
- package/src/__tests__/match-differential.test.ts +46 -0
- package/src/__tests__/match-hotpath-guard.test.ts +71 -0
- package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
- package/src/__tests__/match-normalize-fastpath.test.ts +64 -0
- package/src/__tests__/match-prenormalized.test.ts +95 -0
- package/src/__tests__/match-safety.test.ts +223 -0
- package/src/__tests__/match-single-alloc.test.ts +82 -0
- package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
- package/src/__tests__/middleware-pipeline.test.ts +306 -0
- package/src/__tests__/param-decoding.test.ts +78 -0
- package/src/__tests__/public-surface.test.ts +72 -0
- package/src/__tests__/registration-max-depth.test.ts +56 -0
- package/src/__tests__/route-metadata.test.ts +172 -0
- package/src/__tests__/router-audit.test.ts +315 -0
- package/src/__tests__/router-edge-cases.test.ts +2 -7
- package/src/__tests__/router.test.ts +148 -7
- package/src/__tests__/static-map-reset.test.ts +48 -0
- package/src/__tests__/walk-pool-sizing.test.ts +72 -0
- package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
- package/src/canonicalize.ts +137 -0
- package/src/composition.ts +79 -0
- package/src/constants.ts +43 -0
- package/src/dispatch.ts +145 -0
- package/src/find-node.ts +125 -0
- package/src/group-router.ts +208 -0
- package/src/index.ts +14 -5
- package/src/match-route.ts +241 -0
- package/src/matching.ts +246 -0
- package/src/middleware-adapter.ts +59 -0
- package/src/redirect.ts +97 -0
- package/src/registration.ts +343 -0
- package/src/route-metadata.ts +68 -0
- package/src/router.ts +219 -872
- package/src/segment-trie.ts +227 -0
- package/src/state.ts +53 -0
- package/src/walk-pool.ts +208 -0
- package/src/radix-tree.ts +0 -184
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Comprehensive correctness & edge-case audit
|
|
3
|
+
*
|
|
4
|
+
* Proves routing correctness by exhaustive assertion, not assumption. Organized
|
|
5
|
+
* by audit phase. Tests that assert *actual* behavior of an unsupported feature
|
|
6
|
+
* (brace syntax, regex constraints, param decoding) are marked CHARACTERIZATION
|
|
7
|
+
* and documented in the audit report as limitations, not bugs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { HttpMethod, RouteHandler } from '@nextrush/types';
|
|
11
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { createRouter, Router } from '../router';
|
|
13
|
+
|
|
14
|
+
const h = (): RouteHandler => vi.fn();
|
|
15
|
+
|
|
16
|
+
describe('Router audit', () => {
|
|
17
|
+
let router: Router;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
router = createRouter();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('phase 1 — path syntax support', () => {
|
|
23
|
+
it('supports the colon param syntax /users/:id', () => {
|
|
24
|
+
router.get('/users/:id', h());
|
|
25
|
+
expect(router.match('GET', '/users/42')?.params).toEqual({ id: '42' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('CHARACTERIZATION: brace syntax /users/{id} is a LITERAL static segment (not a param)', () => {
|
|
29
|
+
router.get('/users/{id}', h());
|
|
30
|
+
expect(router.match('GET', '/users/{id}')).not.toBeNull();
|
|
31
|
+
expect(router.match('GET', '/users/{id}')?.params).toEqual({});
|
|
32
|
+
expect(router.match('GET', '/users/42')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('CHARACTERIZATION: regex constraint /:id(\\d+) is NOT a regex — the param is literally named "id(\\d+)"', () => {
|
|
36
|
+
router.get('/n/:id(\\d+)', h());
|
|
37
|
+
const m = router.match('GET', '/n/abc'); // matches any segment (no regex enforcement)
|
|
38
|
+
expect(m).not.toBeNull();
|
|
39
|
+
expect(m?.params).toHaveProperty('id(\\d+)');
|
|
40
|
+
expect(m?.params['id']).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('phase 2 — correctness', () => {
|
|
45
|
+
it('matches the root route', () => {
|
|
46
|
+
router.get('/', h());
|
|
47
|
+
expect(router.match('GET', '/')).not.toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('matches static routes exactly', () => {
|
|
51
|
+
router.get('/a/b/c', h());
|
|
52
|
+
expect(router.match('GET', '/a/b/c')).not.toBeNull();
|
|
53
|
+
expect(router.match('GET', '/a/b')).toBeNull();
|
|
54
|
+
expect(router.match('GET', '/a/b/c/d')).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('extracts multiple params', () => {
|
|
58
|
+
router.get('/:a/:b/:c', h());
|
|
59
|
+
expect(router.match('GET', '/1/2/3')?.params).toEqual({ a: '1', b: '2', c: '3' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('matches a param between static segments', () => {
|
|
63
|
+
router.get('/files/:name/download', h());
|
|
64
|
+
expect(router.match('GET', '/files/report.pdf/download')?.params).toEqual({
|
|
65
|
+
name: 'report.pdf',
|
|
66
|
+
});
|
|
67
|
+
expect(router.match('GET', '/files/report.pdf')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('captures the rest of the path with a wildcard', () => {
|
|
71
|
+
router.get('/static/*', h());
|
|
72
|
+
expect(router.match('GET', '/static/css/app.css')?.params['*']).toBe('css/app.css');
|
|
73
|
+
expect(router.match('GET', '/static/x')?.params['*']).toBe('x');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('normalizes duplicate slashes', () => {
|
|
77
|
+
router.get('/a/b', h());
|
|
78
|
+
expect(router.match('GET', '/a//b')).not.toBeNull();
|
|
79
|
+
expect(router.match('GET', '//a///b')).not.toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('ignores a trailing slash in non-strict mode (default)', () => {
|
|
83
|
+
router.get('/users', h());
|
|
84
|
+
expect(router.match('GET', '/users/')).not.toBeNull();
|
|
85
|
+
router.get('/p/:id', h());
|
|
86
|
+
expect(router.match('GET', '/p/7/')?.params).toEqual({ id: '7' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('handles long param values', () => {
|
|
90
|
+
router.get('/x/:v', h());
|
|
91
|
+
const long = 'a'.repeat(5000);
|
|
92
|
+
expect(router.match('GET', `/x/${long}`)?.params.v).toBe(long);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('handles a param value with dots and dashes', () => {
|
|
96
|
+
router.get('/v/:name', h());
|
|
97
|
+
expect(router.match('GET', '/v/my-file.tar.gz')?.params.name).toBe('my-file.tar.gz');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns null for a completely unknown path', () => {
|
|
101
|
+
router.get('/known', h());
|
|
102
|
+
expect(router.match('GET', '/unknown/deep/path')).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('phase 3 — priority (static > param > wildcard)', () => {
|
|
107
|
+
it('prefers a static route over a param route', () => {
|
|
108
|
+
const staticH = h();
|
|
109
|
+
const paramH = h();
|
|
110
|
+
router.get('/users/:id', paramH);
|
|
111
|
+
router.get('/users/me', staticH);
|
|
112
|
+
expect(router.match('GET', '/users/me')?.handler).toBe(staticH);
|
|
113
|
+
expect(router.match('GET', '/users/42')?.handler).toBe(paramH);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('prefers a param route over a wildcard', () => {
|
|
117
|
+
const paramH = h();
|
|
118
|
+
const wildH = h();
|
|
119
|
+
router.get('/a/*', wildH);
|
|
120
|
+
router.get('/a/:id', paramH);
|
|
121
|
+
expect(router.match('GET', '/a/x')?.handler).toBe(paramH);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('backtracks: static subtree miss falls through to the param subtree', () => {
|
|
125
|
+
const meProfile = h();
|
|
126
|
+
const idPosts = h();
|
|
127
|
+
router.get('/users/me/profile', meProfile);
|
|
128
|
+
router.get('/users/:id/posts', idPosts);
|
|
129
|
+
// /users/me/posts must fall back to the :id branch (me is not a dead end)
|
|
130
|
+
expect(router.match('GET', '/users/me/posts')?.handler).toBe(idPosts);
|
|
131
|
+
expect(router.match('GET', '/users/me/profile')?.handler).toBe(meProfile);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('is deterministic regardless of registration order', () => {
|
|
135
|
+
const r1 = createRouter();
|
|
136
|
+
r1.get('/users/me', h());
|
|
137
|
+
r1.get('/users/:id', h());
|
|
138
|
+
const r2 = createRouter();
|
|
139
|
+
r2.get('/users/:id', h());
|
|
140
|
+
r2.get('/users/me', h());
|
|
141
|
+
expect(r1.match('GET', '/users/me')).not.toBeNull();
|
|
142
|
+
expect(r2.match('GET', '/users/me')?.params).toEqual({});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('phase 4 — nested & deep routes', () => {
|
|
147
|
+
it('matches deeply nested param chains', () => {
|
|
148
|
+
router.get('/api/v1/orgs/:orgId/teams/:teamId/members/:memberId', h());
|
|
149
|
+
expect(
|
|
150
|
+
router.match('GET', '/api/v1/orgs/1/teams/2/members/3')?.params
|
|
151
|
+
).toEqual({ orgId: '1', teamId: '2', memberId: '3' });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('supports many sibling nested routes without ambiguity', () => {
|
|
155
|
+
router.get('/api/users', h());
|
|
156
|
+
router.get('/api/users/:id', h());
|
|
157
|
+
router.get('/api/users/:id/posts', h());
|
|
158
|
+
router.get('/api/users/:id/posts/:postId', h());
|
|
159
|
+
expect(router.match('GET', '/api/users')?.params).toEqual({});
|
|
160
|
+
expect(router.match('GET', '/api/users/9')?.params).toEqual({ id: '9' });
|
|
161
|
+
expect(router.match('GET', '/api/users/9/posts')?.params).toEqual({ id: '9' });
|
|
162
|
+
expect(router.match('GET', '/api/users/9/posts/5')?.params).toEqual({
|
|
163
|
+
id: '9',
|
|
164
|
+
postId: '5',
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('phase 6 — query strings are ignored during matching', () => {
|
|
170
|
+
it('matches a static route with a query string', () => {
|
|
171
|
+
router.get('/users', h());
|
|
172
|
+
expect(router.match('GET', '/users?page=5')).not.toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('matches a param route and excludes the query from the param value', () => {
|
|
176
|
+
router.get('/users/:id', h());
|
|
177
|
+
expect(router.match('GET', '/users/42?expand=true')?.params).toEqual({ id: '42' });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('matches the root route with a query string', () => {
|
|
181
|
+
router.get('/', h());
|
|
182
|
+
expect(router.match('GET', '/?a=1&b=2')).not.toBeNull();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('phase 7 — decoding & unicode', () => {
|
|
187
|
+
it('matches unicode path segments (raw)', () => {
|
|
188
|
+
router.get('/u/:name', h());
|
|
189
|
+
expect(router.match('GET', '/u/josé')?.params.name).toBe('josé');
|
|
190
|
+
expect(router.match('GET', '/u/日本語')?.params.name).toBe('日本語');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('percent-encoded params are DECODED by default (raw available via decode:false)', () => {
|
|
194
|
+
router.get('/u/:name', h());
|
|
195
|
+
expect(router.match('GET', '/u/hello%20world')?.params.name).toBe('hello world');
|
|
196
|
+
|
|
197
|
+
const raw = createRouter({ decode: false });
|
|
198
|
+
raw.get('/u/:name', h());
|
|
199
|
+
expect(raw.match('GET', '/u/hello%20world')?.params.name).toBe('hello%20world');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('phase 8 — HTTP methods', () => {
|
|
204
|
+
it('registers and matches all standard methods', () => {
|
|
205
|
+
const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
206
|
+
for (const m of methods) {
|
|
207
|
+
const r = createRouter();
|
|
208
|
+
r.all('/x', h());
|
|
209
|
+
expect(r.match(m, '/x')).not.toBeNull();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('distinguishes the same path by method', () => {
|
|
214
|
+
const getH = h();
|
|
215
|
+
const postH = h();
|
|
216
|
+
router.get('/r', getH);
|
|
217
|
+
router.post('/r', postH);
|
|
218
|
+
expect(router.match('GET', '/r')?.handler).toBe(getH);
|
|
219
|
+
expect(router.match('POST', '/r')?.handler).toBe(postH);
|
|
220
|
+
expect(router.match('DELETE', '/r')).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('reports allowed methods for a path (via the public allowedMethods() middleware)', async () => {
|
|
224
|
+
router.get('/r', h());
|
|
225
|
+
router.post('/r', h());
|
|
226
|
+
const ctx = {
|
|
227
|
+
method: 'OPTIONS',
|
|
228
|
+
path: '/r',
|
|
229
|
+
status: 404,
|
|
230
|
+
set: vi.fn(),
|
|
231
|
+
} as unknown as import('@nextrush/types').Context;
|
|
232
|
+
await router.allowedMethods()(ctx, async () => {});
|
|
233
|
+
expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('GET'));
|
|
234
|
+
expect(ctx.set).toHaveBeenCalledWith('Allow', expect.stringContaining('POST'));
|
|
235
|
+
expect(ctx.set).not.toHaveBeenCalledWith('Allow', expect.stringContaining('DELETE'));
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('phase 9 — large scale', () => {
|
|
240
|
+
it('stays correct with 1000 mixed static/param routes', () => {
|
|
241
|
+
for (let i = 0; i < 1000; i++) {
|
|
242
|
+
router.get(`/r${i}/static`, h());
|
|
243
|
+
router.get(`/r${i}/:id`, h());
|
|
244
|
+
}
|
|
245
|
+
expect(router.match('GET', '/r0/static')?.params).toEqual({});
|
|
246
|
+
expect(router.match('GET', '/r500/:id'.replace(':id', 'abc'))?.params).toEqual({ id: 'abc' });
|
|
247
|
+
expect(router.match('GET', '/r999/xyz')?.params).toEqual({ id: 'xyz' });
|
|
248
|
+
expect(router.match('GET', '/r1000/nope')).toBeNull();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('phase 10 — failure & graceful handling', () => {
|
|
253
|
+
it('throws a clear error on duplicate route registration', () => {
|
|
254
|
+
router.get('/dup', h());
|
|
255
|
+
expect(() => router.get('/dup', h())).toThrow(/already registered|conflict/i);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('throws a clear error on conflicting param names at the same position', () => {
|
|
259
|
+
router.get('/u/:id', h());
|
|
260
|
+
expect(() => router.get('/u/:name/x', h())).toThrow(/param name conflict/i);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('treats an empty path as the root route', () => {
|
|
264
|
+
router.get('', h());
|
|
265
|
+
expect(router.match('GET', '/')).not.toBeNull();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('throws a clear TypeError for a non-string path', () => {
|
|
269
|
+
expect(() => router.get(null as unknown as string, h())).toThrow(TypeError);
|
|
270
|
+
expect(() => router.get(undefined as unknown as string, h())).toThrow(TypeError);
|
|
271
|
+
expect(() => router.get(123 as unknown as string, h())).toThrow(TypeError);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('requires at least one handler', () => {
|
|
275
|
+
expect(() => router.get('/no-handler')).toThrow(/at least one handler/i);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('phase 12 — hardening against known routing bugs', () => {
|
|
280
|
+
it('does not confuse a param value with a similarly-named static route', () => {
|
|
281
|
+
router.get('/search', h());
|
|
282
|
+
router.get('/:term', h());
|
|
283
|
+
expect(router.match('GET', '/search')?.params).toEqual({}); // static wins
|
|
284
|
+
expect(router.match('GET', '/anything')?.params).toEqual({ term: 'anything' });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('does not leak params across non-matching branches (backtracking cleanup)', () => {
|
|
288
|
+
router.get('/a/:x/b', h());
|
|
289
|
+
router.get('/a/fixed', h());
|
|
290
|
+
// /a/fixed matches the static branch and must NOT carry an :x param
|
|
291
|
+
expect(router.match('GET', '/a/fixed')?.params).toEqual({});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('handles adjacent params without a static separator', () => {
|
|
295
|
+
router.get('/:a/:b', h());
|
|
296
|
+
expect(router.match('GET', '/x/y')?.params).toEqual({ a: 'x', b: 'y' });
|
|
297
|
+
expect(router.match('GET', '/x')).toBeNull();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('wildcard does not swallow a more specific static sibling', () => {
|
|
301
|
+
const wild = h();
|
|
302
|
+
const exact = h();
|
|
303
|
+
router.get('/assets/*', wild);
|
|
304
|
+
router.get('/assets/favicon.ico', exact);
|
|
305
|
+
expect(router.match('GET', '/assets/favicon.ico')?.handler).toBe(exact);
|
|
306
|
+
expect(router.match('GET', '/assets/img/logo.png')?.handler).toBe(wild);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('does not match a prefix of a longer static route', () => {
|
|
310
|
+
router.get('/api/users/list', h());
|
|
311
|
+
expect(router.match('GET', '/api/users')).toBeNull();
|
|
312
|
+
expect(router.match('GET', '/api')).toBeNull();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -9,9 +9,6 @@ import type { Context, RouteHandler } from '@nextrush/types';
|
|
|
9
9
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
10
|
import { createRouter, Router } from '../router';
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Create mock context for testing
|
|
14
|
-
*/
|
|
15
12
|
function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
16
13
|
return {
|
|
17
14
|
method: 'GET',
|
|
@@ -90,10 +87,10 @@ describe('Router Edge Cases', () => {
|
|
|
90
87
|
expect(match?.params.filename).toBe('document.pdf');
|
|
91
88
|
});
|
|
92
89
|
|
|
93
|
-
it('should
|
|
90
|
+
it('should decode URL-encoded special characters by default', () => {
|
|
94
91
|
router.get('/search/:query', vi.fn());
|
|
95
92
|
const match = router.match('GET', '/search/hello%20world');
|
|
96
|
-
expect(match?.params.query).toBe('hello
|
|
93
|
+
expect(match?.params.query).toBe('hello world');
|
|
97
94
|
});
|
|
98
95
|
|
|
99
96
|
it('should extract parameters with plus signs', () => {
|
|
@@ -260,7 +257,6 @@ describe('Router Edge Cases', () => {
|
|
|
260
257
|
|
|
261
258
|
it('should not match wildcard if path ends at parent', () => {
|
|
262
259
|
router.get('/files/*', vi.fn());
|
|
263
|
-
// Path doesn't have anything after /files
|
|
264
260
|
expect(router.match('GET', '/files')).toBeNull();
|
|
265
261
|
});
|
|
266
262
|
});
|
|
@@ -387,7 +383,6 @@ describe('Router Edge Cases', () => {
|
|
|
387
383
|
const ctx = createMockContext({ method: 'GET', path: '/chain' });
|
|
388
384
|
await router.routes()(ctx, async () => {});
|
|
389
385
|
|
|
390
|
-
// Middleware should execute before handler
|
|
391
386
|
expect(order).toEqual([1, 2, 0, 3, 4]);
|
|
392
387
|
});
|
|
393
388
|
});
|
|
@@ -6,9 +6,6 @@ import type { Context, HttpMethod, RouteHandler } from '@nextrush/types';
|
|
|
6
6
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
7
|
import { createRouter, Router } from '../router';
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* Create mock context for testing
|
|
11
|
-
*/
|
|
12
9
|
function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
13
10
|
return {
|
|
14
11
|
method: 'GET',
|
|
@@ -34,6 +31,22 @@ function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
|
34
31
|
} as Context;
|
|
35
32
|
}
|
|
36
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Mock context whose setNext/next behave like a real adapter context:
|
|
36
|
+
* `setNext` stores the next fn and `ctx.next()` invokes it. Used to exercise the
|
|
37
|
+
* modern `ctx.next()` middleware syntax through the router executor.
|
|
38
|
+
*/
|
|
39
|
+
function createNextAwareContext(overrides: Partial<Context> = {}): Context {
|
|
40
|
+
let stored: () => Promise<void> = () => Promise.resolve();
|
|
41
|
+
return createMockContext({
|
|
42
|
+
setNext: (fn: () => Promise<void>) => {
|
|
43
|
+
stored = fn;
|
|
44
|
+
},
|
|
45
|
+
next: () => stored(),
|
|
46
|
+
...overrides,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
describe('Router', () => {
|
|
38
51
|
let router: Router;
|
|
39
52
|
|
|
@@ -105,6 +118,22 @@ describe('Router', () => {
|
|
|
105
118
|
}
|
|
106
119
|
});
|
|
107
120
|
|
|
121
|
+
it('should register .all() as a single route-table entry, not one per method (T016)', () => {
|
|
122
|
+
const handler: RouteHandler = vi.fn();
|
|
123
|
+
router.all('/any', handler);
|
|
124
|
+
|
|
125
|
+
const routes = router.getRoutes();
|
|
126
|
+
const anyRoutes = routes.filter((r) => r.path === '/any');
|
|
127
|
+
|
|
128
|
+
// The introspection registry (getRoutes()) must show exactly ONE entry
|
|
129
|
+
// for an .all() route, not one row per HTTP method — this is the
|
|
130
|
+
// observable, spec-mandated behavior change (T016). Matching behavior
|
|
131
|
+
// (every method still dispatches correctly) is covered separately by
|
|
132
|
+
// 'should register all methods with .all()' above and is unaffected.
|
|
133
|
+
expect(anyRoutes).toHaveLength(1);
|
|
134
|
+
expect(anyRoutes[0]?.isAnyMethod).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
108
137
|
it('should allow method chaining', () => {
|
|
109
138
|
const result = router.get('/a', vi.fn()).post('/b', vi.fn()).put('/c', vi.fn());
|
|
110
139
|
|
|
@@ -160,11 +189,11 @@ describe('Router', () => {
|
|
|
160
189
|
expect(match?.params).toEqual({ userId: '1', postId: '2' });
|
|
161
190
|
});
|
|
162
191
|
|
|
163
|
-
it('should
|
|
192
|
+
it('should decode URL-encoded parameters by default', () => {
|
|
164
193
|
router.get('/search/:query', vi.fn());
|
|
165
194
|
|
|
166
195
|
const match = router.match('GET', '/search/hello%20world');
|
|
167
|
-
expect(match?.params).toEqual({ query: 'hello
|
|
196
|
+
expect(match?.params).toEqual({ query: 'hello world' });
|
|
168
197
|
});
|
|
169
198
|
});
|
|
170
199
|
|
|
@@ -237,7 +266,6 @@ describe('Router', () => {
|
|
|
237
266
|
// the path normalization removes trailing slashes during split
|
|
238
267
|
expect(r.match('GET', '/users')).not.toBeNull();
|
|
239
268
|
expect(r.match('GET', '/users/')).not.toBeNull();
|
|
240
|
-
// Note: Full strict mode differentiation is a future enhancement
|
|
241
269
|
});
|
|
242
270
|
});
|
|
243
271
|
|
|
@@ -256,7 +284,6 @@ describe('Router', () => {
|
|
|
256
284
|
|
|
257
285
|
await middleware(ctx, async () => {});
|
|
258
286
|
|
|
259
|
-
// Handler is called with ctx and a next function
|
|
260
287
|
expect(handler).toHaveBeenCalled();
|
|
261
288
|
expect(handler.mock.calls[0]?.[0]).toBe(ctx);
|
|
262
289
|
});
|
|
@@ -317,6 +344,104 @@ describe('Router', () => {
|
|
|
317
344
|
});
|
|
318
345
|
});
|
|
319
346
|
|
|
347
|
+
describe('ctx.next() modern middleware syntax', () => {
|
|
348
|
+
it('runs a single per-route middleware that uses ctx.next()', async () => {
|
|
349
|
+
const order: number[] = [];
|
|
350
|
+
const mw = async (ctx: Context) => {
|
|
351
|
+
order.push(1);
|
|
352
|
+
await ctx.next();
|
|
353
|
+
};
|
|
354
|
+
const handler: RouteHandler = async () => {
|
|
355
|
+
order.push(2);
|
|
356
|
+
};
|
|
357
|
+
router.get('/one', mw, handler);
|
|
358
|
+
|
|
359
|
+
const ctx = createNextAwareContext({ method: 'GET', path: '/one' });
|
|
360
|
+
await router.routes()(ctx, async () => {});
|
|
361
|
+
|
|
362
|
+
expect(order).toEqual([1, 2]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('runs two per-route middleware that use ctx.next()', async () => {
|
|
366
|
+
const order: number[] = [];
|
|
367
|
+
const mw0 = async (ctx: Context) => {
|
|
368
|
+
order.push(1);
|
|
369
|
+
await ctx.next();
|
|
370
|
+
};
|
|
371
|
+
const mw1 = async (ctx: Context) => {
|
|
372
|
+
order.push(2);
|
|
373
|
+
await ctx.next();
|
|
374
|
+
};
|
|
375
|
+
const handler: RouteHandler = async () => {
|
|
376
|
+
order.push(3);
|
|
377
|
+
};
|
|
378
|
+
router.get('/two', mw0, mw1, handler);
|
|
379
|
+
|
|
380
|
+
const ctx = createNextAwareContext({ method: 'GET', path: '/two' });
|
|
381
|
+
await router.routes()(ctx, async () => {});
|
|
382
|
+
|
|
383
|
+
expect(order).toEqual([1, 2, 3]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('runs a chain of 5 per-route middleware that use ctx.next() (general path)', async () => {
|
|
387
|
+
const order: number[] = [];
|
|
388
|
+
const layers = [1, 2, 3, 4, 5].map((n) => async (ctx: Context) => {
|
|
389
|
+
order.push(n);
|
|
390
|
+
await ctx.next();
|
|
391
|
+
});
|
|
392
|
+
const handler: RouteHandler = async () => {
|
|
393
|
+
order.push(6);
|
|
394
|
+
};
|
|
395
|
+
router.get('/five', ...layers, handler);
|
|
396
|
+
|
|
397
|
+
const ctx = createNextAwareContext({ method: 'GET', path: '/five' });
|
|
398
|
+
await router.routes()(ctx, async () => {});
|
|
399
|
+
|
|
400
|
+
expect(order).toEqual([1, 2, 3, 4, 5, 6]);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('lets ctx.next() middleware observe side effects of every layer (all headers set)', async () => {
|
|
404
|
+
const headers: Record<string, string> = {};
|
|
405
|
+
const setHeader = (name: string, value: string) => (ctx: Context) => {
|
|
406
|
+
headers[name] = value;
|
|
407
|
+
return ctx.next();
|
|
408
|
+
};
|
|
409
|
+
router.get(
|
|
410
|
+
'/headers',
|
|
411
|
+
setHeader('X-A', '1'),
|
|
412
|
+
setHeader('X-B', '2'),
|
|
413
|
+
setHeader('X-C', '3'),
|
|
414
|
+
async (ctx: Context) => ctx.json({ ok: true })
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const ctx = createNextAwareContext({ method: 'GET', path: '/headers' });
|
|
418
|
+
await router.routes()(ctx, async () => {});
|
|
419
|
+
|
|
420
|
+
expect(headers).toEqual({ 'X-A': '1', 'X-B': '2', 'X-C': '3' });
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('interoperates with the traditional (ctx, next) signature in the same chain', async () => {
|
|
424
|
+
const order: number[] = [];
|
|
425
|
+
const modern = async (ctx: Context) => {
|
|
426
|
+
order.push(1);
|
|
427
|
+
await ctx.next();
|
|
428
|
+
};
|
|
429
|
+
const traditional = async (_ctx: Context, next: () => Promise<void>) => {
|
|
430
|
+
order.push(2);
|
|
431
|
+
await next();
|
|
432
|
+
};
|
|
433
|
+
const handler: RouteHandler = async () => {
|
|
434
|
+
order.push(3);
|
|
435
|
+
};
|
|
436
|
+
router.get('/mixed', modern, traditional, handler);
|
|
437
|
+
|
|
438
|
+
const ctx = createNextAwareContext({ method: 'GET', path: '/mixed' });
|
|
439
|
+
await router.routes()(ctx, async () => {});
|
|
440
|
+
|
|
441
|
+
expect(order).toEqual([1, 2, 3]);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
320
445
|
describe('route groups', () => {
|
|
321
446
|
it('should create a group with prefix', () => {
|
|
322
447
|
router.group('/api', (r) => {
|
|
@@ -447,6 +572,22 @@ describe('Router', () => {
|
|
|
447
572
|
expect(router.match('POST', '/api/any')).not.toBeNull();
|
|
448
573
|
expect(router.match('PUT', '/api/any')).not.toBeNull();
|
|
449
574
|
});
|
|
575
|
+
|
|
576
|
+
it('should register a group\'s .all() as a single route-table entry, not one per method (T016)', () => {
|
|
577
|
+
// GroupRouter.all() forwards through _addGroupRoute rather than
|
|
578
|
+
// Router.all() directly — a second, independent call site for the same
|
|
579
|
+
// 7-registrations bug, found during T016's own implementation (not a
|
|
580
|
+
// pre-planned test) and fixed alongside Router.all() itself.
|
|
581
|
+
router.group('/api', (r) => {
|
|
582
|
+
r.all('/any', vi.fn());
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const routes = router.getRoutes();
|
|
586
|
+
const anyRoutes = routes.filter((r) => r.path === '/api/any');
|
|
587
|
+
|
|
588
|
+
expect(anyRoutes).toHaveLength(1);
|
|
589
|
+
expect(anyRoutes[0]?.isAnyMethod).toBe(true);
|
|
590
|
+
});
|
|
450
591
|
});
|
|
451
592
|
|
|
452
593
|
describe('redirect', () => {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - HP-9 static-map reset contract
|
|
3
|
+
*
|
|
4
|
+
* The differential golden (`match-differential.test.ts`) already pins the
|
|
5
|
+
* behavior-preserving HP-9 scenarios that live on a freshly-built router:
|
|
6
|
+
* static-over-trie precedence, method-miss, trailing-slash, `all()`, and the
|
|
7
|
+
* prefix/mount/group registration flows. The one contract it CANNOT cover —
|
|
8
|
+
* because it always builds fresh routers — is that `reset()` fully clears the
|
|
9
|
+
* (now method-nested) static map with no ghost entries. This file pins that.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { RouteHandler } from '@nextrush/types';
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import { createRouter } from '../router';
|
|
15
|
+
|
|
16
|
+
const noop: RouteHandler = async () => {};
|
|
17
|
+
|
|
18
|
+
describe('HP-9 — reset() clears the method-nested static map fully', () => {
|
|
19
|
+
it('clears static entries across every method and leaves no ghost matches', () => {
|
|
20
|
+
const router = createRouter();
|
|
21
|
+
router.get('/a', noop);
|
|
22
|
+
router.post('/a', noop);
|
|
23
|
+
router.get('/b/c', noop);
|
|
24
|
+
router.put('/b/c', noop);
|
|
25
|
+
|
|
26
|
+
expect(router.match('GET', '/a')).not.toBeNull();
|
|
27
|
+
expect(router.match('POST', '/a')).not.toBeNull();
|
|
28
|
+
expect(router.match('PUT', '/b/c')).not.toBeNull();
|
|
29
|
+
|
|
30
|
+
router.reset();
|
|
31
|
+
|
|
32
|
+
// Every method's inner map must be gone — no ghost entries.
|
|
33
|
+
expect(router.match('GET', '/a')).toBeNull();
|
|
34
|
+
expect(router.match('POST', '/a')).toBeNull();
|
|
35
|
+
expect(router.match('GET', '/b/c')).toBeNull();
|
|
36
|
+
expect(router.match('PUT', '/b/c')).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('allows re-registering the same static routes after reset without a conflict throw', () => {
|
|
40
|
+
const router = createRouter();
|
|
41
|
+
router.get('/x', noop);
|
|
42
|
+
router.reset();
|
|
43
|
+
|
|
44
|
+
// A lingering inner-map entry would make this throw "Route conflict".
|
|
45
|
+
expect(() => router.get('/x', noop)).not.toThrow();
|
|
46
|
+
expect(router.match('GET', '/x')).not.toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|