@nextrush/router 3.0.0

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.
@@ -0,0 +1,621 @@
1
+ /**
2
+ * @nextrush/router - Router Tests
3
+ */
4
+
5
+ import type { Context, HttpMethod, RouteHandler } from '@nextrush/types';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { createRouter, Router } from '../router';
8
+
9
+ /**
10
+ * Create mock context for testing
11
+ */
12
+ function createMockContext(overrides: Partial<Context> = {}): Context {
13
+ return {
14
+ method: 'GET',
15
+ path: '/',
16
+ params: {},
17
+ query: {},
18
+ body: undefined,
19
+ headers: {},
20
+ status: 200,
21
+ state: {},
22
+ json: vi.fn(),
23
+ send: vi.fn(),
24
+ html: vi.fn(),
25
+ redirect: vi.fn(),
26
+ set: vi.fn(),
27
+ get: vi.fn(),
28
+ next: vi.fn(),
29
+ raw: {
30
+ req: {} as any,
31
+ res: {} as any,
32
+ },
33
+ ...overrides,
34
+ } as Context;
35
+ }
36
+
37
+ describe('Router', () => {
38
+ let router: Router;
39
+
40
+ beforeEach(() => {
41
+ router = createRouter();
42
+ });
43
+
44
+ describe('createRouter', () => {
45
+ it('should create a router instance', () => {
46
+ expect(router).toBeInstanceOf(Router);
47
+ });
48
+
49
+ it('should accept options', () => {
50
+ const r = createRouter({ prefix: '/api', caseSensitive: true });
51
+ expect(r).toBeInstanceOf(Router);
52
+ });
53
+ });
54
+
55
+ describe('route registration', () => {
56
+ it('should register GET route', () => {
57
+ const handler: RouteHandler = vi.fn();
58
+ router.get('/users', handler);
59
+
60
+ const match = router.match('GET', '/users');
61
+ expect(match).not.toBeNull();
62
+ expect(match?.handler).toBe(handler);
63
+ });
64
+
65
+ it('should register POST route', () => {
66
+ const handler: RouteHandler = vi.fn();
67
+ router.post('/users', handler);
68
+
69
+ const match = router.match('POST', '/users');
70
+ expect(match).not.toBeNull();
71
+ });
72
+
73
+ it('should register PUT route', () => {
74
+ const handler: RouteHandler = vi.fn();
75
+ router.put('/users/:id', handler);
76
+
77
+ const match = router.match('PUT', '/users/123');
78
+ expect(match).not.toBeNull();
79
+ });
80
+
81
+ it('should register DELETE route', () => {
82
+ const handler: RouteHandler = vi.fn();
83
+ router.delete('/users/:id', handler);
84
+
85
+ const match = router.match('DELETE', '/users/123');
86
+ expect(match).not.toBeNull();
87
+ });
88
+
89
+ it('should register PATCH route', () => {
90
+ const handler: RouteHandler = vi.fn();
91
+ router.patch('/users/:id', handler);
92
+
93
+ const match = router.match('PATCH', '/users/123');
94
+ expect(match).not.toBeNull();
95
+ });
96
+
97
+ it('should register all methods with .all()', () => {
98
+ const handler: RouteHandler = vi.fn();
99
+ router.all('/any', handler);
100
+
101
+ const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
102
+ for (const method of methods) {
103
+ const match = router.match(method, '/any');
104
+ expect(match).not.toBeNull();
105
+ }
106
+ });
107
+
108
+ it('should allow method chaining', () => {
109
+ const result = router.get('/a', vi.fn()).post('/b', vi.fn()).put('/c', vi.fn());
110
+
111
+ expect(result).toBe(router);
112
+ });
113
+ });
114
+
115
+ describe('route matching', () => {
116
+ it('should match exact path', () => {
117
+ const handler: RouteHandler = vi.fn();
118
+ router.get('/users', handler);
119
+
120
+ const match = router.match('GET', '/users');
121
+ expect(match?.handler).toBe(handler);
122
+ expect(match?.params).toEqual({});
123
+ });
124
+
125
+ it('should return null for non-matching path', () => {
126
+ router.get('/users', vi.fn());
127
+
128
+ const match = router.match('GET', '/posts');
129
+ expect(match).toBeNull();
130
+ });
131
+
132
+ it('should return null for non-matching method', () => {
133
+ router.get('/users', vi.fn());
134
+
135
+ const match = router.match('POST', '/users');
136
+ expect(match).toBeNull();
137
+ });
138
+
139
+ it('should match root path', () => {
140
+ const handler: RouteHandler = vi.fn();
141
+ router.get('/', handler);
142
+
143
+ const match = router.match('GET', '/');
144
+ expect(match?.handler).toBe(handler);
145
+ });
146
+ });
147
+
148
+ describe('parameter extraction', () => {
149
+ it('should extract single parameter', () => {
150
+ router.get('/users/:id', vi.fn());
151
+
152
+ const match = router.match('GET', '/users/123');
153
+ expect(match?.params).toEqual({ id: '123' });
154
+ });
155
+
156
+ it('should extract multiple parameters', () => {
157
+ router.get('/users/:userId/posts/:postId', vi.fn());
158
+
159
+ const match = router.match('GET', '/users/1/posts/2');
160
+ expect(match?.params).toEqual({ userId: '1', postId: '2' });
161
+ });
162
+
163
+ it('should handle URL-encoded parameters', () => {
164
+ router.get('/search/:query', vi.fn());
165
+
166
+ const match = router.match('GET', '/search/hello%20world');
167
+ expect(match?.params).toEqual({ query: 'hello%20world' });
168
+ });
169
+ });
170
+
171
+ describe('wildcard routes', () => {
172
+ it('should match wildcard at end', () => {
173
+ router.get('/files/*', vi.fn());
174
+
175
+ const match = router.match('GET', '/files/docs/readme.md');
176
+ expect(match).not.toBeNull();
177
+ expect(match?.params['*']).toBe('docs/readme.md');
178
+ });
179
+
180
+ it('should match single segment wildcard', () => {
181
+ router.get('/static/*', vi.fn());
182
+
183
+ const match = router.match('GET', '/static/style.css');
184
+ expect(match?.params['*']).toBe('style.css');
185
+ });
186
+ });
187
+
188
+ describe('prefix option', () => {
189
+ it('should prepend prefix to routes', () => {
190
+ const r = createRouter({ prefix: '/api/v1' });
191
+ r.get('/users', vi.fn());
192
+
193
+ const match = r.match('GET', '/api/v1/users');
194
+ expect(match).not.toBeNull();
195
+ });
196
+
197
+ it('should handle trailing slash in prefix', () => {
198
+ const r = createRouter({ prefix: '/api' });
199
+ r.get('/users', vi.fn());
200
+
201
+ const match = r.match('GET', '/api/users');
202
+ expect(match).not.toBeNull();
203
+ });
204
+ });
205
+
206
+ describe('case sensitivity', () => {
207
+ it('should be case-insensitive by default', () => {
208
+ router.get('/Users', vi.fn());
209
+
210
+ const match = router.match('GET', '/users');
211
+ expect(match).not.toBeNull();
212
+ });
213
+
214
+ it('should be case-sensitive when enabled', () => {
215
+ const r = createRouter({ caseSensitive: true });
216
+ r.get('/Users', vi.fn());
217
+
218
+ expect(r.match('GET', '/Users')).not.toBeNull();
219
+ expect(r.match('GET', '/users')).toBeNull();
220
+ });
221
+ });
222
+
223
+ describe('strict routing', () => {
224
+ it('should ignore trailing slash by default', () => {
225
+ router.get('/users', vi.fn());
226
+
227
+ expect(router.match('GET', '/users')).not.toBeNull();
228
+ expect(router.match('GET', '/users/')).not.toBeNull();
229
+ });
230
+
231
+ it('should respect trailing slash when strict', () => {
232
+ const r = createRouter({ strict: true });
233
+ r.get('/users', vi.fn());
234
+
235
+ // In strict mode, trailing slash handling is preserved
236
+ // Both with and without trailing slash should match since
237
+ // the path normalization removes trailing slashes during split
238
+ expect(r.match('GET', '/users')).not.toBeNull();
239
+ expect(r.match('GET', '/users/')).not.toBeNull();
240
+ // Note: Full strict mode differentiation is a future enhancement
241
+ });
242
+ });
243
+
244
+ describe('routes() middleware', () => {
245
+ it('should return middleware function', () => {
246
+ const middleware = router.routes();
247
+ expect(typeof middleware).toBe('function');
248
+ });
249
+
250
+ it('should call handler when route matches', async () => {
251
+ const handler = vi.fn();
252
+ router.get('/test', handler);
253
+
254
+ const ctx = createMockContext({ method: 'GET', path: '/test' });
255
+ const middleware = router.routes();
256
+
257
+ await middleware(ctx, async () => {});
258
+
259
+ // Handler is called with ctx and a next function
260
+ expect(handler).toHaveBeenCalled();
261
+ expect(handler.mock.calls[0]?.[0]).toBe(ctx);
262
+ });
263
+
264
+ it('should set params on context', async () => {
265
+ router.get('/users/:id', vi.fn());
266
+
267
+ const ctx = createMockContext({ method: 'GET', path: '/users/42' });
268
+ await router.routes()(ctx, async () => {});
269
+
270
+ expect(ctx.params).toEqual({ id: '42' });
271
+ });
272
+
273
+ it('should call next when no route matches', async () => {
274
+ router.get('/users', vi.fn());
275
+
276
+ const ctx = createMockContext({ method: 'GET', path: '/posts' });
277
+ const next = vi.fn();
278
+
279
+ await router.routes()(ctx, next);
280
+
281
+ expect(next).toHaveBeenCalled();
282
+ });
283
+ });
284
+
285
+ describe('allowedMethods() middleware', () => {
286
+ it('should return middleware function', () => {
287
+ const middleware = router.allowedMethods();
288
+ expect(typeof middleware).toBe('function');
289
+ });
290
+ });
291
+
292
+ describe('multiple handlers', () => {
293
+ it('should support multiple handlers as middleware', async () => {
294
+ const order: number[] = [];
295
+
296
+ const mw1: RouteHandler = async (_ctx, next) => {
297
+ order.push(1);
298
+ if (next) await next();
299
+ };
300
+
301
+ const mw2: RouteHandler = async (_ctx, next) => {
302
+ order.push(2);
303
+ if (next) await next();
304
+ };
305
+
306
+ const handler: RouteHandler = async () => {
307
+ order.push(3);
308
+ };
309
+
310
+ router.get('/test', mw1, mw2, handler);
311
+
312
+ const ctx = createMockContext({ method: 'GET', path: '/test' });
313
+ const routesMiddleware = router.routes();
314
+ await routesMiddleware(ctx, async () => {});
315
+
316
+ expect(order).toEqual([1, 2, 3]);
317
+ });
318
+ });
319
+
320
+ describe('route groups', () => {
321
+ it('should create a group with prefix', () => {
322
+ router.group('/api', (r) => {
323
+ r.get('/users', vi.fn());
324
+ r.get('/posts', vi.fn());
325
+ });
326
+
327
+ expect(router.match('GET', '/api/users')).not.toBeNull();
328
+ expect(router.match('GET', '/api/posts')).not.toBeNull();
329
+ });
330
+
331
+ it('should create a group with middleware', async () => {
332
+ const order: number[] = [];
333
+ const groupMiddleware = vi.fn(async (_ctx: Context, next?: () => Promise<void>) => {
334
+ order.push(1);
335
+ if (next) await next();
336
+ });
337
+
338
+ const handler = vi.fn(async () => {
339
+ order.push(2);
340
+ });
341
+
342
+ router.group('/admin', [groupMiddleware], (r) => {
343
+ r.get('/dashboard', handler);
344
+ });
345
+
346
+ const ctx = createMockContext({ method: 'GET', path: '/admin/dashboard' });
347
+ await router.routes()(ctx, async () => {});
348
+
349
+ expect(groupMiddleware).toHaveBeenCalled();
350
+ expect(handler).toHaveBeenCalled();
351
+ expect(order).toEqual([1, 2]);
352
+ });
353
+
354
+ it('should support nested groups', () => {
355
+ router.group('/api', (r) => {
356
+ r.group('/v1', (v1) => {
357
+ v1.get('/users', vi.fn());
358
+ });
359
+ r.group('/v2', (v2) => {
360
+ v2.get('/users', vi.fn());
361
+ });
362
+ });
363
+
364
+ expect(router.match('GET', '/api/v1/users')).not.toBeNull();
365
+ expect(router.match('GET', '/api/v2/users')).not.toBeNull();
366
+ });
367
+
368
+ it('should combine middleware in nested groups', async () => {
369
+ const order: number[] = [];
370
+
371
+ const outerMw = vi.fn(async (_ctx: Context, next?: () => Promise<void>) => {
372
+ order.push(1);
373
+ if (next) await next();
374
+ });
375
+
376
+ const innerMw = vi.fn(async (_ctx: Context, next?: () => Promise<void>) => {
377
+ order.push(2);
378
+ if (next) await next();
379
+ });
380
+
381
+ const handler = vi.fn(async () => {
382
+ order.push(3);
383
+ });
384
+
385
+ router.group('/api', [outerMw], (r) => {
386
+ r.group('/users', [innerMw], (ur) => {
387
+ ur.get('/', handler);
388
+ });
389
+ });
390
+
391
+ const ctx = createMockContext({ method: 'GET', path: '/api/users' });
392
+ await router.routes()(ctx, async () => {});
393
+
394
+ expect(order).toEqual([1, 2, 3]);
395
+ });
396
+
397
+ it('should support all HTTP methods in groups', () => {
398
+ router.group('/api', (r) => {
399
+ r.get('/resource', vi.fn());
400
+ r.post('/resource', vi.fn());
401
+ r.put('/resource/:id', vi.fn());
402
+ r.delete('/resource/:id', vi.fn());
403
+ r.patch('/resource/:id', vi.fn());
404
+ });
405
+
406
+ expect(router.match('GET', '/api/resource')).not.toBeNull();
407
+ expect(router.match('POST', '/api/resource')).not.toBeNull();
408
+ expect(router.match('PUT', '/api/resource/1')).not.toBeNull();
409
+ expect(router.match('DELETE', '/api/resource/1')).not.toBeNull();
410
+ expect(router.match('PATCH', '/api/resource/1')).not.toBeNull();
411
+ });
412
+
413
+ it('should handle root path in groups', () => {
414
+ router.group('/api', (r) => {
415
+ r.get('/', vi.fn());
416
+ });
417
+
418
+ expect(router.match('GET', '/api')).not.toBeNull();
419
+ });
420
+
421
+ it('should extract params in group routes', () => {
422
+ router.group('/users', (r) => {
423
+ r.get('/:id', vi.fn());
424
+ r.get('/:id/posts/:postId', vi.fn());
425
+ });
426
+
427
+ const match1 = router.match('GET', '/users/42');
428
+ expect(match1?.params.id).toBe('42');
429
+
430
+ const match2 = router.match('GET', '/users/1/posts/2');
431
+ expect(match2?.params.id).toBe('1');
432
+ expect(match2?.params.postId).toBe('2');
433
+ });
434
+
435
+ it('should throw if callback is missing with middleware array', () => {
436
+ expect(() => {
437
+ router.group('/api', [vi.fn()] as any);
438
+ }).toThrow('Callback function is required');
439
+ });
440
+
441
+ it('should support .all() in groups', () => {
442
+ router.group('/api', (r) => {
443
+ r.all('/any', vi.fn());
444
+ });
445
+
446
+ expect(router.match('GET', '/api/any')).not.toBeNull();
447
+ expect(router.match('POST', '/api/any')).not.toBeNull();
448
+ expect(router.match('PUT', '/api/any')).not.toBeNull();
449
+ });
450
+ });
451
+
452
+ describe('redirect', () => {
453
+ it('should register redirect route with default 301 status', async () => {
454
+ router.redirect('/old', '/new');
455
+
456
+ const match = router.match('GET', '/old');
457
+ expect(match).not.toBeNull();
458
+
459
+ const ctx = createMockContext({ method: 'GET', path: '/old' });
460
+ await match!.handler(ctx, async () => {});
461
+
462
+ expect(ctx.status).toBe(301);
463
+ expect(ctx.set).toHaveBeenCalledWith('Location', '/new');
464
+ });
465
+
466
+ it('should support custom redirect status codes', async () => {
467
+ router.redirect('/temp', '/destination', 302);
468
+
469
+ const ctx = createMockContext({ method: 'GET', path: '/temp' });
470
+ const match = router.match('GET', '/temp');
471
+ await match!.handler(ctx, async () => {});
472
+
473
+ expect(ctx.status).toBe(302);
474
+ });
475
+
476
+ it('should support 303 See Other', async () => {
477
+ router.redirect('/see-other', '/target', 303);
478
+
479
+ const ctx = createMockContext({ method: 'GET', path: '/see-other' });
480
+ const match = router.match('GET', '/see-other');
481
+ await match!.handler(ctx, async () => {});
482
+
483
+ expect(ctx.status).toBe(303);
484
+ });
485
+
486
+ it('should support 307 Temporary Redirect', async () => {
487
+ router.redirect('/temporary', '/target', 307);
488
+
489
+ const ctx = createMockContext({ method: 'GET', path: '/temporary' });
490
+ const match = router.match('GET', '/temporary');
491
+ await match!.handler(ctx, async () => {});
492
+
493
+ expect(ctx.status).toBe(307);
494
+ });
495
+
496
+ it('should support 308 Permanent Redirect', async () => {
497
+ router.redirect('/permanent', '/target', 308);
498
+
499
+ const ctx = createMockContext({ method: 'GET', path: '/permanent' });
500
+ const match = router.match('GET', '/permanent');
501
+ await match!.handler(ctx, async () => {});
502
+
503
+ expect(ctx.status).toBe(308);
504
+ });
505
+
506
+ it('should register redirect for HEAD method', () => {
507
+ router.redirect('/old', '/new');
508
+
509
+ const headMatch = router.match('HEAD', '/old');
510
+ expect(headMatch).not.toBeNull();
511
+ });
512
+
513
+ it('should redirect to external URLs', async () => {
514
+ router.redirect('/docs', 'https://docs.example.com');
515
+
516
+ const ctx = createMockContext({ method: 'GET', path: '/docs' });
517
+ const match = router.match('GET', '/docs');
518
+ await match!.handler(ctx, async () => {});
519
+
520
+ expect(ctx.set).toHaveBeenCalledWith('Location', 'https://docs.example.com');
521
+ });
522
+
523
+ it('should support parameter interpolation in target path', async () => {
524
+ router.redirect('/users/:id', '/profiles/:id');
525
+
526
+ const ctx = createMockContext({
527
+ method: 'GET',
528
+ path: '/users/42',
529
+ params: { id: '42' },
530
+ });
531
+
532
+ const match = router.match('GET', '/users/42');
533
+ await match!.handler(ctx, async () => {});
534
+
535
+ expect(ctx.set).toHaveBeenCalledWith('Location', '/profiles/42');
536
+ });
537
+
538
+ it('should support multiple parameters in redirect', async () => {
539
+ router.redirect('/old/:type/:id', '/new/:type/item/:id');
540
+
541
+ const ctx = createMockContext({
542
+ method: 'GET',
543
+ path: '/old/product/123',
544
+ params: { type: 'product', id: '123' },
545
+ });
546
+
547
+ const match = router.match('GET', '/old/product/123');
548
+ await match!.handler(ctx, async () => {});
549
+
550
+ expect(ctx.set).toHaveBeenCalledWith('Location', '/new/product/item/123');
551
+ });
552
+
553
+ it('should set empty body on redirect', async () => {
554
+ router.redirect('/old', '/new');
555
+
556
+ const ctx = createMockContext({ method: 'GET', path: '/old' });
557
+ const match = router.match('GET', '/old');
558
+ await match!.handler(ctx, async () => {});
559
+
560
+ expect(ctx.body).toBe('');
561
+ });
562
+
563
+ it('should work in route groups', async () => {
564
+ router.group('/api/v1', (r) => {
565
+ r.redirect('/old-endpoint', '/api/v2/new-endpoint');
566
+ });
567
+
568
+ const match = router.match('GET', '/api/v1/old-endpoint');
569
+ expect(match).not.toBeNull();
570
+
571
+ const ctx = createMockContext({
572
+ method: 'GET',
573
+ path: '/api/v1/old-endpoint',
574
+ });
575
+ await match!.handler(ctx, async () => {});
576
+
577
+ expect(ctx.status).toBe(301);
578
+ expect(ctx.set).toHaveBeenCalledWith('Location', '/api/v2/new-endpoint');
579
+ });
580
+
581
+ it('should support chaining', () => {
582
+ const result = router.redirect('/a', '/b').redirect('/c', '/d').get('/e', vi.fn());
583
+
584
+ expect(result).toBe(router);
585
+ expect(router.match('GET', '/a')).not.toBeNull();
586
+ expect(router.match('GET', '/c')).not.toBeNull();
587
+ expect(router.match('GET', '/e')).not.toBeNull();
588
+ });
589
+ });
590
+
591
+ describe('reset', () => {
592
+ it('should clear all registered routes', () => {
593
+ router.get('/users', vi.fn());
594
+ router.post('/users', vi.fn());
595
+
596
+ expect(router.match('GET', '/users')).not.toBeNull();
597
+
598
+ router.reset();
599
+
600
+ expect(router.match('GET', '/users')).toBeNull();
601
+ expect(router.match('POST', '/users')).toBeNull();
602
+ });
603
+
604
+ it('should clear parameterized routes', () => {
605
+ router.get('/users/:id', vi.fn());
606
+ expect(router.match('GET', '/users/123')).not.toBeNull();
607
+
608
+ router.reset();
609
+ expect(router.match('GET', '/users/123')).toBeNull();
610
+ });
611
+
612
+ it('should allow new routes after reset', () => {
613
+ router.get('/old', vi.fn());
614
+ router.reset();
615
+ router.get('/new', vi.fn());
616
+
617
+ expect(router.match('GET', '/old')).toBeNull();
618
+ expect(router.match('GET', '/new')).not.toBeNull();
619
+ });
620
+ });
621
+ });
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @nextrush/router - High-Performance Router for NextRush
3
+ *
4
+ * This package provides a radix tree based router with:
5
+ * - O(k) route matching where k is path length
6
+ * - Named parameters (/users/:id)
7
+ * - Wildcard routes (/files/*)
8
+ * - Route middleware
9
+ * - Router composition
10
+ *
11
+ * @packageDocumentation
12
+ * @module @nextrush/router
13
+ */
14
+
15
+ // Router
16
+ export { createRouter, Router } from './router';
17
+
18
+ // Radix tree internals (for advanced usage)
19
+ export { createNode, NodeType, parseSegments } from './radix-tree';
20
+ export type { HandlerEntry, ParsedSegment, RadixNode } from './radix-tree';
21
+
22
+ // Re-export relevant types
23
+ export type {
24
+ HttpMethod,
25
+ Middleware,
26
+ Route,
27
+ RouteHandler,
28
+ RouteMatch,
29
+ Router as RouterInterface,
30
+ RouterOptions
31
+ } from '@nextrush/types';