@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,486 @@
1
+ /**
2
+ * @nextrush/router - Edge Case Tests
3
+ *
4
+ * Comprehensive tests for edge cases, special characters,
5
+ * unicode, overlapping routes, and performance scenarios.
6
+ */
7
+
8
+ import type { Context, RouteHandler } from '@nextrush/types';
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { createRouter, Router } from '../router';
11
+
12
+ /**
13
+ * Create mock context for testing
14
+ */
15
+ function createMockContext(overrides: Partial<Context> = {}): Context {
16
+ return {
17
+ method: 'GET',
18
+ path: '/',
19
+ params: {},
20
+ query: {},
21
+ body: undefined,
22
+ headers: {},
23
+ status: 200,
24
+ state: {},
25
+ json: vi.fn(),
26
+ send: vi.fn(),
27
+ html: vi.fn(),
28
+ redirect: vi.fn(),
29
+ set: vi.fn(),
30
+ get: vi.fn(),
31
+ next: vi.fn(),
32
+ raw: {
33
+ req: {} as never,
34
+ res: {} as never,
35
+ },
36
+ ...overrides,
37
+ } as Context;
38
+ }
39
+
40
+ describe('Router Edge Cases', () => {
41
+ let router: Router;
42
+
43
+ beforeEach(() => {
44
+ router = createRouter();
45
+ });
46
+
47
+ describe('Special Characters in Paths', () => {
48
+ it('should handle hyphens in path segments', () => {
49
+ router.get('/my-api/user-profile', vi.fn());
50
+ expect(router.match('GET', '/my-api/user-profile')).not.toBeNull();
51
+ });
52
+
53
+ it('should handle underscores in path segments', () => {
54
+ router.get('/my_api/user_profile', vi.fn());
55
+ expect(router.match('GET', '/my_api/user_profile')).not.toBeNull();
56
+ });
57
+
58
+ it('should handle dots in path segments', () => {
59
+ router.get('/api/v1.0/users', vi.fn());
60
+ expect(router.match('GET', '/api/v1.0/users')).not.toBeNull();
61
+ });
62
+
63
+ it('should handle tildes in path segments', () => {
64
+ router.get('/~user/home', vi.fn());
65
+ expect(router.match('GET', '/~user/home')).not.toBeNull();
66
+ });
67
+
68
+ it('should handle mixed special characters', () => {
69
+ router.get('/api-v1_test.route~special', vi.fn());
70
+ expect(router.match('GET', '/api-v1_test.route~special')).not.toBeNull();
71
+ });
72
+ });
73
+
74
+ describe('Special Characters in Parameters', () => {
75
+ it('should extract parameters with hyphens', () => {
76
+ router.get('/users/:id', vi.fn());
77
+ const match = router.match('GET', '/users/user-123-abc');
78
+ expect(match?.params.id).toBe('user-123-abc');
79
+ });
80
+
81
+ it('should extract parameters with underscores', () => {
82
+ router.get('/files/:name', vi.fn());
83
+ const match = router.match('GET', '/files/my_file_name');
84
+ expect(match?.params.name).toBe('my_file_name');
85
+ });
86
+
87
+ it('should extract parameters with dots', () => {
88
+ router.get('/files/:filename', vi.fn());
89
+ const match = router.match('GET', '/files/document.pdf');
90
+ expect(match?.params.filename).toBe('document.pdf');
91
+ });
92
+
93
+ it('should extract URL-encoded special characters', () => {
94
+ router.get('/search/:query', vi.fn());
95
+ const match = router.match('GET', '/search/hello%20world');
96
+ expect(match?.params.query).toBe('hello%20world');
97
+ });
98
+
99
+ it('should extract parameters with plus signs', () => {
100
+ router.get('/calc/:expr', vi.fn());
101
+ const match = router.match('GET', '/calc/1+2');
102
+ expect(match?.params.expr).toBe('1+2');
103
+ });
104
+ });
105
+
106
+ describe('Unicode and International Characters', () => {
107
+ it('should handle unicode in static paths', () => {
108
+ router.get('/api/日本語', vi.fn());
109
+ expect(router.match('GET', '/api/日本語')).not.toBeNull();
110
+ });
111
+
112
+ it('should handle emoji in paths', () => {
113
+ router.get('/emoji/🚀', vi.fn());
114
+ expect(router.match('GET', '/emoji/🚀')).not.toBeNull();
115
+ });
116
+
117
+ it('should extract unicode parameters', () => {
118
+ router.get('/users/:name', vi.fn());
119
+ const match = router.match('GET', '/users/田中');
120
+ expect(match?.params.name).toBe('田中');
121
+ });
122
+
123
+ it('should handle cyrillic characters', () => {
124
+ router.get('/привет/:имя', vi.fn());
125
+ expect(router.match('GET', '/привет/мир')).not.toBeNull();
126
+ });
127
+
128
+ it('should handle arabic characters', () => {
129
+ router.get('/مرحبا/:اسم', vi.fn());
130
+ expect(router.match('GET', '/مرحبا/عالم')).not.toBeNull();
131
+ });
132
+ });
133
+
134
+ describe('Deeply Nested Routes', () => {
135
+ it('should handle 5 levels of nesting', () => {
136
+ router.get('/a/b/c/d/e', vi.fn());
137
+ expect(router.match('GET', '/a/b/c/d/e')).not.toBeNull();
138
+ });
139
+
140
+ it('should handle 10 levels of nesting', () => {
141
+ router.get('/1/2/3/4/5/6/7/8/9/10', vi.fn());
142
+ expect(router.match('GET', '/1/2/3/4/5/6/7/8/9/10')).not.toBeNull();
143
+ });
144
+
145
+ it('should handle deeply nested parameters', () => {
146
+ router.get('/org/:orgId/team/:teamId/project/:projectId/task/:taskId', vi.fn());
147
+ const match = router.match('GET', '/org/1/team/2/project/3/task/4');
148
+ expect(match?.params).toEqual({
149
+ orgId: '1',
150
+ teamId: '2',
151
+ projectId: '3',
152
+ taskId: '4',
153
+ });
154
+ });
155
+
156
+ it('should handle mixed static and param segments at depth', () => {
157
+ router.get('/api/v1/users/:userId/posts/:postId/comments/:commentId/replies', vi.fn());
158
+ const match = router.match('GET', '/api/v1/users/u1/posts/p2/comments/c3/replies');
159
+ expect(match).not.toBeNull();
160
+ expect(match?.params).toEqual({
161
+ userId: 'u1',
162
+ postId: 'p2',
163
+ commentId: 'c3',
164
+ });
165
+ });
166
+ });
167
+
168
+ describe('Overlapping Routes', () => {
169
+ it('should prefer static over param at same position', () => {
170
+ const staticHandler = vi.fn();
171
+ const paramHandler = vi.fn();
172
+
173
+ router.get('/users/profile', staticHandler);
174
+ router.get('/users/:id', paramHandler);
175
+
176
+ const staticMatch = router.match('GET', '/users/profile');
177
+ const paramMatch = router.match('GET', '/users/123');
178
+
179
+ expect(staticMatch?.handler).toBe(staticHandler);
180
+ expect(paramMatch?.handler).toBe(paramHandler);
181
+ });
182
+
183
+ it('should handle multiple param routes at different levels', () => {
184
+ const handler1 = vi.fn();
185
+ const handler2 = vi.fn();
186
+
187
+ router.get('/users/:id', handler1);
188
+ router.get('/users/:id/posts', handler2);
189
+
190
+ expect(router.match('GET', '/users/123')?.handler).toBe(handler1);
191
+ expect(router.match('GET', '/users/123/posts')?.handler).toBe(handler2);
192
+ });
193
+
194
+ it('should differentiate similar patterns', () => {
195
+ const userHandler = vi.fn();
196
+ const postHandler = vi.fn();
197
+
198
+ router.get('/users/:userId/profile', userHandler);
199
+ router.get('/users/:userId/settings', postHandler);
200
+
201
+ expect(router.match('GET', '/users/1/profile')?.handler).toBe(userHandler);
202
+ expect(router.match('GET', '/users/1/settings')?.handler).toBe(postHandler);
203
+ });
204
+
205
+ it('should handle overlapping wildcards', () => {
206
+ const apiHandler = vi.fn();
207
+ const catchAllHandler = vi.fn();
208
+
209
+ router.get('/api/*', apiHandler);
210
+ router.get('/*', catchAllHandler);
211
+
212
+ // More specific wildcard wins
213
+ expect(router.match('GET', '/api/anything')?.handler).toBe(apiHandler);
214
+ expect(router.match('GET', '/other/path')?.handler).toBe(catchAllHandler);
215
+ });
216
+ });
217
+
218
+ describe('Empty and Edge Paths', () => {
219
+ it('should handle root path', () => {
220
+ router.get('/', vi.fn());
221
+ expect(router.match('GET', '/')).not.toBeNull();
222
+ });
223
+
224
+ it('should handle empty string path', () => {
225
+ router.get('', vi.fn());
226
+ expect(router.match('GET', '/')).not.toBeNull();
227
+ });
228
+
229
+ it('should normalize multiple slashes', () => {
230
+ router.get('/api/users', vi.fn());
231
+ // Double slashes are normalized - this is the correct behavior
232
+ // Most web servers normalize //api//users to /api/users
233
+ expect(router.match('GET', '/api//users')).not.toBeNull();
234
+ });
235
+
236
+ it('should handle paths ending with slash (non-strict)', () => {
237
+ router.get('/users', vi.fn());
238
+ expect(router.match('GET', '/users/')).not.toBeNull();
239
+ });
240
+ });
241
+
242
+ describe('Wildcard Edge Cases', () => {
243
+ it('should capture entire remaining path in wildcard', () => {
244
+ router.get('/files/*', vi.fn());
245
+ const match = router.match('GET', '/files/a/b/c/d/e.txt');
246
+ expect(match?.params['*']).toBe('a/b/c/d/e.txt');
247
+ });
248
+
249
+ it('should handle wildcard with single segment', () => {
250
+ router.get('/catch/*', vi.fn());
251
+ const match = router.match('GET', '/catch/single');
252
+ expect(match?.params['*']).toBe('single');
253
+ });
254
+
255
+ it('should handle wildcard with special characters', () => {
256
+ router.get('/assets/*', vi.fn());
257
+ const match = router.match('GET', '/assets/images/logo-v2.png');
258
+ expect(match?.params['*']).toBe('images/logo-v2.png');
259
+ });
260
+
261
+ it('should not match wildcard if path ends at parent', () => {
262
+ router.get('/files/*', vi.fn());
263
+ // Path doesn't have anything after /files
264
+ expect(router.match('GET', '/files')).toBeNull();
265
+ });
266
+ });
267
+
268
+ describe('Many Routes Performance', () => {
269
+ it('should handle 100 routes efficiently', () => {
270
+ for (let i = 0; i < 100; i++) {
271
+ router.get(`/route${i}`, vi.fn());
272
+ }
273
+
274
+ // All routes should be matchable
275
+ for (let i = 0; i < 100; i++) {
276
+ expect(router.match('GET', `/route${i}`)).not.toBeNull();
277
+ }
278
+ });
279
+
280
+ it('should handle 100 param routes efficiently', () => {
281
+ for (let i = 0; i < 100; i++) {
282
+ router.get(`/entity${i}/:id`, vi.fn());
283
+ }
284
+
285
+ for (let i = 0; i < 100; i++) {
286
+ const match = router.match('GET', `/entity${i}/123`);
287
+ expect(match).not.toBeNull();
288
+ expect(match?.params.id).toBe('123');
289
+ }
290
+ });
291
+ });
292
+
293
+ describe('Method Handling', () => {
294
+ it('should differentiate GET and POST on same path', () => {
295
+ const getHandler = vi.fn();
296
+ const postHandler = vi.fn();
297
+
298
+ router.get('/api/data', getHandler);
299
+ router.post('/api/data', postHandler);
300
+
301
+ expect(router.match('GET', '/api/data')?.handler).toBe(getHandler);
302
+ expect(router.match('POST', '/api/data')?.handler).toBe(postHandler);
303
+ });
304
+
305
+ it('should handle HEAD method', () => {
306
+ router.head('/api/check', vi.fn());
307
+ expect(router.match('HEAD', '/api/check')).not.toBeNull();
308
+ });
309
+
310
+ it('should handle OPTIONS method', () => {
311
+ router.options('/api/cors', vi.fn());
312
+ expect(router.match('OPTIONS', '/api/cors')).not.toBeNull();
313
+ });
314
+
315
+ it('should return null for unregistered method', () => {
316
+ router.get('/api/only-get', vi.fn());
317
+ expect(router.match('DELETE', '/api/only-get')).toBeNull();
318
+ });
319
+ });
320
+
321
+ describe('Route Chaining', () => {
322
+ it('should support fluent API', () => {
323
+ const result = router
324
+ .get('/a', vi.fn())
325
+ .post('/b', vi.fn())
326
+ .put('/c', vi.fn())
327
+ .delete('/d', vi.fn())
328
+ .patch('/e', vi.fn())
329
+ .head('/f', vi.fn())
330
+ .options('/g', vi.fn());
331
+
332
+ expect(result).toBe(router);
333
+ expect(router.match('GET', '/a')).not.toBeNull();
334
+ expect(router.match('POST', '/b')).not.toBeNull();
335
+ expect(router.match('PUT', '/c')).not.toBeNull();
336
+ expect(router.match('DELETE', '/d')).not.toBeNull();
337
+ expect(router.match('PATCH', '/e')).not.toBeNull();
338
+ expect(router.match('HEAD', '/f')).not.toBeNull();
339
+ expect(router.match('OPTIONS', '/g')).not.toBeNull();
340
+ });
341
+ });
342
+
343
+ describe('Prefix Edge Cases', () => {
344
+ it('should handle prefix with trailing slash', () => {
345
+ const r = createRouter({ prefix: '/api/' });
346
+ r.get('/users', vi.fn());
347
+ expect(r.match('GET', '/api/users')).not.toBeNull();
348
+ });
349
+
350
+ it('should handle prefix without leading slash', () => {
351
+ const r = createRouter({ prefix: 'api' });
352
+ r.get('/users', vi.fn());
353
+ expect(r.match('GET', '/api/users')).not.toBeNull();
354
+ });
355
+
356
+ it('should handle nested prefixes', () => {
357
+ const r = createRouter({ prefix: '/api/v1' });
358
+ r.get('/users/:id', vi.fn());
359
+ const match = r.match('GET', '/api/v1/users/123');
360
+ expect(match).not.toBeNull();
361
+ expect(match?.params.id).toBe('123');
362
+ });
363
+ });
364
+
365
+ describe('Middleware Chain', () => {
366
+ it('should execute middleware in order', async () => {
367
+ const order: number[] = [];
368
+
369
+ const mw1: RouteHandler = async (_ctx, next) => {
370
+ order.push(1);
371
+ if (next) await next();
372
+ order.push(4);
373
+ };
374
+
375
+ const mw2: RouteHandler = async (_ctx, next) => {
376
+ order.push(2);
377
+ if (next) await next();
378
+ order.push(3);
379
+ };
380
+
381
+ const handler: RouteHandler = async () => {
382
+ order.push(0);
383
+ };
384
+
385
+ router.get('/chain', mw1, mw2, handler);
386
+
387
+ const ctx = createMockContext({ method: 'GET', path: '/chain' });
388
+ await router.routes()(ctx, async () => {});
389
+
390
+ // Middleware should execute before handler
391
+ expect(order).toEqual([1, 2, 0, 3, 4]);
392
+ });
393
+ });
394
+
395
+ describe('Sub-Router Mounting', () => {
396
+ it('should mount sub-router at prefix', () => {
397
+ const subRouter = createRouter();
398
+ subRouter.get('/items', vi.fn());
399
+ subRouter.get('/items/:id', vi.fn());
400
+
401
+ router.use('/api', subRouter);
402
+
403
+ expect(router.match('GET', '/api/items')).not.toBeNull();
404
+ expect(router.match('GET', '/api/items/123')).not.toBeNull();
405
+ });
406
+
407
+ it('should preserve sub-router params', () => {
408
+ const subRouter = createRouter();
409
+ subRouter.get('/:itemId', vi.fn());
410
+
411
+ router.use('/items', subRouter);
412
+
413
+ const match = router.match('GET', '/items/456');
414
+ expect(match?.params.itemId).toBe('456');
415
+ });
416
+ });
417
+
418
+ describe('mount() method (Hono-style)', () => {
419
+ it('should mount sub-router at prefix using mount()', () => {
420
+ const subRouter = createRouter();
421
+ subRouter.get('/items', vi.fn());
422
+ subRouter.get('/items/:id', vi.fn());
423
+
424
+ router.mount('/api', subRouter);
425
+
426
+ expect(router.match('GET', '/api/items')).not.toBeNull();
427
+ expect(router.match('GET', '/api/items/123')).not.toBeNull();
428
+ });
429
+
430
+ it('should be equivalent to use(path, router)', () => {
431
+ const subRouter1 = createRouter();
432
+ subRouter1.get('/test', vi.fn());
433
+
434
+ const subRouter2 = createRouter();
435
+ subRouter2.get('/test', vi.fn());
436
+
437
+ const router1 = createRouter();
438
+ const router2 = createRouter();
439
+
440
+ router1.use('/api', subRouter1);
441
+ router2.mount('/api', subRouter2);
442
+
443
+ const match1 = router1.match('GET', '/api/test');
444
+ const match2 = router2.match('GET', '/api/test');
445
+
446
+ expect(match1).not.toBeNull();
447
+ expect(match2).not.toBeNull();
448
+ });
449
+
450
+ it('should support chaining', () => {
451
+ const users = createRouter();
452
+ users.get('/', vi.fn());
453
+
454
+ const posts = createRouter();
455
+ posts.get('/', vi.fn());
456
+
457
+ router.mount('/users', users).mount('/posts', posts);
458
+
459
+ expect(router.match('GET', '/users')).not.toBeNull();
460
+ expect(router.match('GET', '/posts')).not.toBeNull();
461
+ });
462
+
463
+ it('should preserve route params from sub-router', () => {
464
+ const subRouter = createRouter();
465
+ subRouter.get('/:userId/posts/:postId', vi.fn());
466
+
467
+ router.mount('/api', subRouter);
468
+
469
+ const match = router.match('GET', '/api/123/posts/456');
470
+ expect(match?.params.userId).toBe('123');
471
+ expect(match?.params.postId).toBe('456');
472
+ });
473
+
474
+ it('should support nested mounting', () => {
475
+ const deepRouter = createRouter();
476
+ deepRouter.get('/deep', vi.fn());
477
+
478
+ const midRouter = createRouter();
479
+ midRouter.mount('/mid', deepRouter);
480
+
481
+ router.mount('/top', midRouter);
482
+
483
+ expect(router.match('GET', '/top/mid/deep')).not.toBeNull();
484
+ });
485
+ });
486
+ });