metaowl 0.1.3 → 0.2.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,617 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import {
3
+ processRoutes,
4
+ beforeEach as beforeEachGuard,
5
+ afterEach as afterEachHook,
6
+ getCurrentRoute,
7
+ getPreviousRoute,
8
+ isNavigating,
9
+ cancelNavigation,
10
+ navigate,
11
+ push,
12
+ replace,
13
+ back,
14
+ forward,
15
+ go,
16
+ router
17
+ } from '../modules/router.js'
18
+
19
+ // Mock document.location
20
+ describe('Router Guards', () => {
21
+ let originalLocation
22
+ let mockRoutes
23
+
24
+ beforeEach(() => {
25
+ // Save original location
26
+ originalLocation = window.location
27
+
28
+ // Mock location
29
+ delete window.location
30
+ window.location = {
31
+ pathname: '/',
32
+ search: '',
33
+ href: 'http://localhost/',
34
+ replace: vi.fn(),
35
+ assign: vi.fn()
36
+ }
37
+
38
+ // Mock history
39
+ window.history = {
40
+ back: vi.fn(),
41
+ forward: vi.fn(),
42
+ go: vi.fn()
43
+ }
44
+
45
+ // Reset router state
46
+ vi.clearAllMocks()
47
+
48
+ // Define test routes
49
+ mockRoutes = [
50
+ { name: 'index', path: ['/'], component: class Index {} },
51
+ { name: 'about', path: ['/about'], component: class About {} },
52
+ { name: 'user', path: ['/user/:id'], component: class User {} },
53
+ {
54
+ name: 'admin',
55
+ path: ['/admin'],
56
+ component: class Admin {},
57
+ meta: { requiresAuth: true },
58
+ beforeEnter: null
59
+ },
60
+ { name: 'login', path: ['/login'], component: class Login {} }
61
+ ]
62
+ })
63
+
64
+ afterEach(() => {
65
+ // Restore original location
66
+ window.location = originalLocation
67
+ })
68
+
69
+ describe('beforeEach guards', () => {
70
+ it('registers and calls global beforeEach guard', async () => {
71
+ const guard = vi.fn((to, from, next) => next())
72
+
73
+ beforeEachGuard(guard)
74
+ window.location.pathname = '/about'
75
+
76
+ await processRoutes(mockRoutes)
77
+
78
+ expect(guard).toHaveBeenCalled()
79
+ expect(guard.mock.calls[0][0].name).toBe('about')
80
+ expect(guard.mock.calls[0][0].fullPath).toBe('/about')
81
+ })
82
+
83
+ it('provides to, from, and next to guard', async () => {
84
+ const guard = vi.fn((to, from, next) => {
85
+ expect(to).toHaveProperty('name')
86
+ expect(to).toHaveProperty('path')
87
+ expect(to).toHaveProperty('fullPath')
88
+ expect(to).toHaveProperty('meta')
89
+ expect(typeof next).toBe('function')
90
+ next()
91
+ })
92
+
93
+ beforeEachGuard(guard)
94
+ window.location.pathname = '/about'
95
+
96
+ await processRoutes(mockRoutes)
97
+
98
+ expect(guard).toHaveBeenCalled()
99
+ })
100
+
101
+ it('allows navigation with next()', async () => {
102
+ const guard = vi.fn((to, from, next) => next())
103
+
104
+ beforeEachGuard(guard)
105
+ window.location.pathname = '/about'
106
+
107
+ const result = await processRoutes(mockRoutes)
108
+
109
+ expect(result).toBeDefined()
110
+ expect(result[0].name).toBe('about')
111
+ })
112
+
113
+ it('blocks navigation with next(false)', async () => {
114
+ const guard = vi.fn((to, from, next) => next(false))
115
+
116
+ beforeEachGuard(guard)
117
+ window.location.pathname = '/about'
118
+
119
+ await expect(processRoutes(mockRoutes)).rejects.toThrow('Navigation cancelled')
120
+ })
121
+
122
+ it('redirects with next(path)', async () => {
123
+ const guard = vi.fn((to, from, next) => {
124
+ if (to.meta.requiresAuth) {
125
+ next('/login')
126
+ } else {
127
+ next()
128
+ }
129
+ })
130
+
131
+ beforeEachGuard(guard)
132
+
133
+ // Mock window.location.href setter
134
+ const hrefSetter = vi.fn()
135
+ Object.defineProperty(window, 'location', {
136
+ value: {
137
+ ...window.location,
138
+ pathname: '/admin',
139
+ href: '',
140
+ get href() { return '' },
141
+ set href(val) { hrefSetter(val) }
142
+ },
143
+ writable: true
144
+ })
145
+
146
+ mockRoutes[3].beforeEnter = guard
147
+ await expect(processRoutes(mockRoutes)).rejects.toThrow('Navigation redirect')
148
+ })
149
+
150
+ it('allows returning path directly from guard', async () => {
151
+ const guard = vi.fn((to, from, next) => {
152
+ return '/login'
153
+ })
154
+
155
+ beforeEachGuard(guard)
156
+ window.location.pathname = '/admin'
157
+ mockRoutes[3].meta = { requiresAuth: true }
158
+
159
+ await expect(processRoutes(mockRoutes)).rejects.toThrow('Navigation redirect')
160
+ })
161
+
162
+ it('allows returning false directly from guard', async () => {
163
+ const guard = vi.fn((to, from, next) => {
164
+ return false
165
+ })
166
+
167
+ beforeEachGuard(guard)
168
+ window.location.pathname = '/about'
169
+
170
+ await expect(processRoutes(mockRoutes)).rejects.toThrow('Navigation cancelled')
171
+ })
172
+
173
+ it('supports async guards', async () => {
174
+ const guard = vi.fn(async (to, from, next) => {
175
+ await new Promise(resolve => setTimeout(resolve, 10))
176
+ next()
177
+ })
178
+
179
+ beforeEachGuard(guard)
180
+ window.location.pathname = '/about'
181
+
182
+ const result = await processRoutes(mockRoutes)
183
+
184
+ expect(guard).toHaveBeenCalled()
185
+ expect(result[0].name).toBe('about')
186
+ })
187
+
188
+ it('handles errors in guards', async () => {
189
+ const guard = vi.fn((to, from, next) => {
190
+ throw new Error('Guard error')
191
+ })
192
+
193
+ beforeEachGuard(guard)
194
+ window.location.pathname = '/about'
195
+
196
+ await expect(processRoutes(mockRoutes)).rejects.toThrow('Guard error')
197
+ })
198
+
199
+ it('removes guard when unsubscribe is called', async () => {
200
+ const guard = vi.fn((to, from, next) => next())
201
+
202
+ const unsubscribe = beforeEachGuard(guard)
203
+ unsubscribe()
204
+
205
+ window.location.pathname = '/about'
206
+ await processRoutes(mockRoutes)
207
+
208
+ // Guard was called only once before unsubscribe
209
+ expect(guard).not.toHaveBeenCalled()
210
+ })
211
+
212
+ it('calls multiple guards in order', async () => {
213
+ const order = []
214
+ const guard1 = vi.fn((to, from, next) => { order.push(1); next() })
215
+ const guard2 = vi.fn((to, from, next) => { order.push(2); next() })
216
+ const guard3 = vi.fn((to, from, next) => { order.push(3); next() })
217
+
218
+ beforeEachGuard(guard1)
219
+ beforeEachGuard(guard2)
220
+ beforeEachGuard(guard3)
221
+
222
+ window.location.pathname = '/about'
223
+ await processRoutes(mockRoutes)
224
+
225
+ expect(order).toEqual([1, 2, 3])
226
+ })
227
+ })
228
+
229
+ describe('afterEach hooks', () => {
230
+ it('calls afterEach hooks after navigation', async () => {
231
+ const hook = vi.fn()
232
+
233
+ afterEachHook(hook)
234
+ window.location.pathname = '/about'
235
+
236
+ await processRoutes(mockRoutes)
237
+
238
+ expect(hook).toHaveBeenCalled()
239
+ expect(hook.mock.calls[0][0].name).toBe('about')
240
+ })
241
+
242
+ it('provides to and from to hook', async () => {
243
+ const hook = vi.fn((to, from) => {
244
+ expect(to).toHaveProperty('name')
245
+ expect(to).toHaveProperty('path')
246
+ expect(from).toBeNull() // No previous route on initial
247
+ })
248
+
249
+ afterEachHook(hook)
250
+ window.location.pathname = '/about'
251
+
252
+ await processRoutes(mockRoutes)
253
+
254
+ expect(hook).toHaveBeenCalled()
255
+ })
256
+
257
+ it('provides previous route on second navigation', async () => {
258
+ let capturedFrom = null
259
+
260
+ afterEachHook((to, from) => {
261
+ capturedFrom = from
262
+ })
263
+
264
+ // First navigation
265
+ window.location.pathname = '/'
266
+ await processRoutes(mockRoutes)
267
+
268
+ // Second navigation
269
+ window.location.pathname = '/about'
270
+ await processRoutes(mockRoutes)
271
+
272
+ expect(capturedFrom).not.toBeNull()
273
+ expect(capturedFrom.name).toBe('index')
274
+ })
275
+
276
+ it('removes hook when unsubscribe is called', async () => {
277
+ const hook = vi.fn()
278
+
279
+ const unsubscribe = afterEachHook(hook)
280
+ unsubscribe()
281
+
282
+ window.location.pathname = '/about'
283
+ await processRoutes(mockRoutes)
284
+
285
+ expect(hook).not.toHaveBeenCalled()
286
+ })
287
+ })
288
+
289
+ describe('per-route beforeEnter', () => {
290
+ it('calls beforeEnter when defined on route', async () => {
291
+ const beforeEnter = vi.fn((to, from, next) => next())
292
+
293
+ mockRoutes[3].beforeEnter = beforeEnter
294
+ window.location.pathname = '/admin'
295
+
296
+ await processRoutes(mockRoutes)
297
+
298
+ expect(beforeEnter).toHaveBeenCalled()
299
+ })
300
+
301
+ it('runs per-route guard after global guards', async () => {
302
+ const order = []
303
+ const globalGuard = vi.fn((to, from, next) => { order.push('global'); next() })
304
+ const routeGuard = vi.fn((to, from, next) => { order.push('route'); next() })
305
+
306
+ beforeEachGuard(globalGuard)
307
+ mockRoutes[3].beforeEnter = routeGuard
308
+
309
+ window.location.pathname = '/admin'
310
+ await processRoutes(mockRoutes)
311
+
312
+ expect(order).toEqual(['global', 'route'])
313
+ })
314
+
315
+ it('blocks navigation in beforeEnter', async () => {
316
+ const beforeEnter = vi.fn((to, from, next) => next(false))
317
+
318
+ mockRoutes[3].beforeEnter = beforeEnter
319
+ window.location.pathname = '/admin'
320
+
321
+ await expect(processRoutes(mockRoutes)).rejects.toThrow('Navigation cancelled')
322
+ })
323
+ })
324
+
325
+ describe('route metadata', () => {
326
+ it('provides meta object on route', async () => {
327
+ const guard = vi.fn((to, from, next) => {
328
+ expect(to.meta).toEqual({ requiresAuth: true })
329
+ next()
330
+ })
331
+
332
+ beforeEachGuard(guard)
333
+
334
+ window.location.pathname = '/admin'
335
+ mockRoutes[3].meta = { requiresAuth: true }
336
+
337
+ await processRoutes(mockRoutes)
338
+
339
+ expect(guard).toHaveBeenCalled()
340
+ })
341
+
342
+ it('meta is empty object when not defined', async () => {
343
+ const guard = vi.fn((to, from, next) => {
344
+ expect(to.meta).toEqual({})
345
+ next()
346
+ })
347
+
348
+ beforeEachGuard(guard)
349
+ window.location.pathname = '/about'
350
+
351
+ await processRoutes(mockRoutes)
352
+
353
+ expect(guard).toHaveBeenCalled()
354
+ })
355
+ })
356
+
357
+ describe('query string parsing', () => {
358
+ it('parses query parameters into route', async () => {
359
+ const guard = vi.fn((to, from, next) => {
360
+ expect(to.query).toEqual({ foo: 'bar', baz: 'qux' })
361
+ next()
362
+ })
363
+
364
+ beforeEachGuard(guard)
365
+
366
+ window.location.pathname = '/about'
367
+ window.location.search = '?foo=bar&baz=qux'
368
+
369
+ await processRoutes(mockRoutes)
370
+
371
+ expect(guard).toHaveBeenCalled()
372
+ })
373
+
374
+ it('handles empty query string', async () => {
375
+ const guard = vi.fn((to, from, next) => {
376
+ expect(to.query).toEqual({})
377
+ next()
378
+ })
379
+
380
+ beforeEachGuard(guard)
381
+
382
+ window.location.pathname = '/about'
383
+ window.location.search = ''
384
+
385
+ await processRoutes(mockRoutes)
386
+
387
+ expect(guard).toHaveBeenCalled()
388
+ })
389
+
390
+ it('handles repeated query parameters as array', async () => {
391
+ const guard = vi.fn((to, from, next) => {
392
+ expect(to.query.tag).toEqual(['foo', 'bar'])
393
+ next()
394
+ })
395
+
396
+ beforeEachGuard(guard)
397
+
398
+ window.location.pathname = '/about'
399
+ window.location.search = '?tag=foo&tag=bar'
400
+
401
+ await processRoutes(mockRoutes)
402
+
403
+ expect(guard).toHaveBeenCalled()
404
+ })
405
+ })
406
+
407
+ describe('dynamic route parameters', () => {
408
+ it('extracts params from dynamic routes', async () => {
409
+ const guard = vi.fn((to, from, next) => {
410
+ expect(to.params).toEqual({ id: '123' })
411
+ next()
412
+ })
413
+
414
+ beforeEachGuard(guard)
415
+
416
+ window.location.pathname = '/user/123'
417
+
418
+ await processRoutes(mockRoutes)
419
+
420
+ expect(guard).toHaveBeenCalled()
421
+ })
422
+
423
+ it('params is empty object for static routes', async () => {
424
+ const guard = vi.fn((to, from, next) => {
425
+ expect(to.params).toEqual({})
426
+ next()
427
+ })
428
+
429
+ beforeEachGuard(guard)
430
+
431
+ window.location.pathname = '/about'
432
+
433
+ await processRoutes(mockRoutes)
434
+
435
+ expect(guard).toHaveBeenCalled()
436
+ })
437
+ })
438
+
439
+ describe('route state tracking', () => {
440
+ it('tracks current route', async () => {
441
+ window.location.pathname = '/about'
442
+
443
+ await processRoutes(mockRoutes)
444
+
445
+ expect(getCurrentRoute().name).toBe('about')
446
+ expect(getCurrentRoute().fullPath).toBe('/about')
447
+ })
448
+
449
+ it('tracks previous route after second navigation', async () => {
450
+ window.location.pathname = '/'
451
+ await processRoutes(mockRoutes)
452
+
453
+ window.location.pathname = '/about'
454
+ await processRoutes(mockRoutes)
455
+
456
+ expect(getPreviousRoute().name).toBe('index')
457
+ expect(getCurrentRoute().name).toBe('about')
458
+ })
459
+ })
460
+
461
+ describe('navigation state', () => {
462
+ it('tracks navigation in progress', async () => {
463
+ let wasNavigating = false
464
+
465
+ beforeEachGuard((to, from, next) => {
466
+ wasNavigating = isNavigating()
467
+ next()
468
+ })
469
+
470
+ window.location.pathname = '/about'
471
+ await processRoutes(mockRoutes)
472
+
473
+ expect(wasNavigating).toBe(true)
474
+ expect(isNavigating()).toBe(false)
475
+ })
476
+
477
+ it('can cancel navigation', async () => {
478
+ const guard = vi.fn((to, from, next) => {
479
+ cancelNavigation()
480
+ next()
481
+ })
482
+
483
+ beforeEachGuard(guard)
484
+ window.location.pathname = '/about'
485
+
486
+ // Should still complete because cancel just sets flag
487
+ const result = await processRoutes(mockRoutes)
488
+ expect(result).toBeDefined()
489
+ })
490
+ })
491
+
492
+ describe('router singleton', () => {
493
+ it('exposes beforeEach method', () => {
494
+ expect(typeof router.beforeEach).toBe('function')
495
+ })
496
+
497
+ it('exposes afterEach method', () => {
498
+ expect(typeof router.afterEach).toBe('function')
499
+ })
500
+
501
+ it('exposes currentRoute getter', () => {
502
+ expect(router.currentRoute).toBeNull()
503
+ })
504
+
505
+ it('exposes previousRoute getter', () => {
506
+ expect(router.previousRoute).toBeNull()
507
+ })
508
+
509
+ it('exposes isNavigating getter', () => {
510
+ expect(router.isNavigating).toBe(false)
511
+ })
512
+
513
+ it('exposes navigation methods', () => {
514
+ expect(typeof router.push).toBe('function')
515
+ expect(typeof router.replace).toBe('function')
516
+ expect(typeof router.back).toBe('function')
517
+ expect(typeof router.forward).toBe('function')
518
+ expect(typeof router.go).toBe('function')
519
+ expect(typeof router.cancel).toBe('function')
520
+ })
521
+ })
522
+
523
+ describe('navigation helpers', () => {
524
+ it('push navigates to path', () => {
525
+ push('/new-path')
526
+
527
+ expect(window.location.href).toBe('/new-path')
528
+ })
529
+
530
+ it('replace replaces current history entry', () => {
531
+ replace('/new-path')
532
+
533
+ expect(window.location.replace).toHaveBeenCalledWith('/new-path')
534
+ })
535
+
536
+ it('back calls history.back', () => {
537
+ back()
538
+ expect(window.history.back).toHaveBeenCalled()
539
+ })
540
+
541
+ it('forward calls history.forward', () => {
542
+ forward()
543
+ expect(window.history.forward).toHaveBeenCalled()
544
+ })
545
+
546
+ it('go calls history.go', () => {
547
+ go(-2)
548
+ expect(window.history.go).toHaveBeenCalledWith(-2)
549
+ })
550
+ })
551
+
552
+ describe('auth guard pattern', () => {
553
+ it('implements typical auth guard pattern', async () => {
554
+ // Mock auth state
555
+ const auth = { isLoggedIn: false }
556
+
557
+ const authGuard = vi.fn((to, from, next) => {
558
+ if (to.meta.requiresAuth && !auth.isLoggedIn) {
559
+ next('/login')
560
+ } else {
561
+ next()
562
+ }
563
+ })
564
+
565
+ beforeEachGuard(authGuard)
566
+
567
+ // Try to access protected route while not logged in
568
+ window.location.pathname = '/admin'
569
+ mockRoutes[3].meta = { requiresAuth: true }
570
+
571
+ await expect(processRoutes(mockRoutes)).rejects.toThrow('Navigation redirect')
572
+
573
+ // Now login and try again
574
+ auth.isLoggedIn = true
575
+
576
+ // Reset the mock to allow navigation
577
+ authGuard.mockImplementation((to, from, next) => {
578
+ if (to.meta.requiresAuth && !auth.isLoggedIn) {
579
+ next('/login')
580
+ } else {
581
+ next()
582
+ }
583
+ })
584
+
585
+ const result = await processRoutes(mockRoutes)
586
+ expect(result[0].name).toBe('admin')
587
+ })
588
+ })
589
+
590
+ describe('role-based guard pattern', () => {
591
+ it('implements role-based access control', async () => {
592
+ const auth = { user: { role: 'user' } }
593
+
594
+ const roleGuard = vi.fn((to, from, next) => {
595
+ if (to.meta.requiredRole && to.meta.requiredRole !== auth.user.role) {
596
+ next('/unauthorized')
597
+ } else {
598
+ next()
599
+ }
600
+ })
601
+
602
+ beforeEachGuard(roleGuard)
603
+
604
+ // Try to access admin route as regular user
605
+ window.location.pathname = '/admin'
606
+ mockRoutes[3].meta = { requiredRole: 'admin' }
607
+
608
+ await expect(processRoutes(mockRoutes)).rejects.toThrow('Navigation redirect')
609
+
610
+ // Change to admin role
611
+ auth.user.role = 'admin'
612
+
613
+ const result = await processRoutes(mockRoutes)
614
+ expect(result[0].name).toBe('admin')
615
+ })
616
+ })
617
+ })