@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.
Files changed (51) hide show
  1. package/README.md +250 -492
  2. package/dist/index.d.ts +227 -178
  3. package/dist/index.js +1075 -657
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -6
  6. package/src/__tests__/allowed-methods.test.ts +144 -0
  7. package/src/__tests__/audit-fixes.test.ts +59 -0
  8. package/src/__tests__/canonical-path-security.test.ts +198 -0
  9. package/src/__tests__/canonicalize-memo.test.ts +108 -0
  10. package/src/__tests__/dispatch-deasync.test.ts +292 -0
  11. package/src/__tests__/find-node-differential.test.ts +220 -0
  12. package/src/__tests__/fixtures/match-golden.json +556 -0
  13. package/src/__tests__/head-auto-registration.test.ts +197 -0
  14. package/src/__tests__/helpers/differential-corpus.ts +314 -0
  15. package/src/__tests__/match-differential.test.ts +46 -0
  16. package/src/__tests__/match-hotpath-guard.test.ts +71 -0
  17. package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
  18. package/src/__tests__/match-normalize-fastpath.test.ts +64 -0
  19. package/src/__tests__/match-prenormalized.test.ts +95 -0
  20. package/src/__tests__/match-safety.test.ts +223 -0
  21. package/src/__tests__/match-single-alloc.test.ts +82 -0
  22. package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
  23. package/src/__tests__/middleware-pipeline.test.ts +306 -0
  24. package/src/__tests__/param-decoding.test.ts +78 -0
  25. package/src/__tests__/public-surface.test.ts +72 -0
  26. package/src/__tests__/registration-max-depth.test.ts +56 -0
  27. package/src/__tests__/route-metadata.test.ts +172 -0
  28. package/src/__tests__/router-audit.test.ts +315 -0
  29. package/src/__tests__/router-edge-cases.test.ts +2 -7
  30. package/src/__tests__/router.test.ts +148 -7
  31. package/src/__tests__/static-map-reset.test.ts +48 -0
  32. package/src/__tests__/walk-pool-sizing.test.ts +72 -0
  33. package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
  34. package/src/canonicalize.ts +137 -0
  35. package/src/composition.ts +79 -0
  36. package/src/constants.ts +43 -0
  37. package/src/dispatch.ts +145 -0
  38. package/src/find-node.ts +125 -0
  39. package/src/group-router.ts +208 -0
  40. package/src/index.ts +14 -5
  41. package/src/match-route.ts +241 -0
  42. package/src/matching.ts +246 -0
  43. package/src/middleware-adapter.ts +59 -0
  44. package/src/redirect.ts +97 -0
  45. package/src/registration.ts +343 -0
  46. package/src/route-metadata.ts +68 -0
  47. package/src/router.ts +219 -872
  48. package/src/segment-trie.ts +227 -0
  49. package/src/state.ts +53 -0
  50. package/src/walk-pool.ts +208 -0
  51. 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 extract URL-encoded special characters', () => {
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%20world');
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 handle URL-encoded parameters', () => {
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%20world' });
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
+ });