Skip to content

Commit 92760e4

Browse files
committed
Add some missing router tests
1 parent 3614b7a commit 92760e4

File tree

1 file changed

+372
-1
lines changed

1 file changed

+372
-1
lines changed

packages/fetch-router/src/lib/router.test.ts

Lines changed: 372 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as assert from 'node:assert/strict'
22
import { describe, it, mock } from 'node:test'
33

4-
import type { RequestContext } from './request-context.ts'
4+
import { RegExpMatcher, RoutePattern } from '@remix-run/route-pattern'
5+
import { createStorageKey } from './app-storage.ts'
6+
import { RequestContext } from './request-context.ts'
57
import { createRoutes } from './route-map.ts'
68
import { createRouter } from './router.ts'
79

@@ -683,3 +685,372 @@ describe('per-route middleware', () => {
683685
assert.deepEqual(requestLog, ['m1', 'm2-short-circuit'])
684686
})
685687
})
688+
689+
describe('404 handling', () => {
690+
it('returns a 404 response when no route matches', async () => {
691+
let router = createRouter()
692+
router.get('/home', () => new Response('Home'))
693+
694+
let response = await router.fetch('https://remix.run/nonexistent')
695+
696+
assert.equal(response.status, 404)
697+
assert.equal(await response.text(), 'Not Found: /nonexistent')
698+
})
699+
700+
it('supports a custom defaultHandler', async () => {
701+
let router = createRouter({
702+
defaultHandler: ({ url }) => {
703+
return new Response(`Custom 404: ${url.pathname}`, {
704+
status: 404,
705+
headers: { 'X-Custom': 'true' },
706+
})
707+
},
708+
})
709+
710+
router.get('/home', () => new Response('Home'))
711+
712+
let response = await router.fetch('https://remix.run/missing')
713+
714+
assert.equal(response.status, 404)
715+
assert.equal(await response.text(), 'Custom 404: /missing')
716+
assert.equal(response.headers.get('X-Custom'), 'true')
717+
})
718+
719+
it('calls defaultHandler only when no routes match', async () => {
720+
let defaultCalls = 0
721+
let router = createRouter({
722+
defaultHandler: () => {
723+
defaultCalls++
724+
return new Response('Not Found', { status: 404 })
725+
},
726+
})
727+
728+
router.get('/', () => new Response('Home'))
729+
router.get('/about', () => new Response('About'))
730+
731+
await router.fetch('https://remix.run/')
732+
assert.equal(defaultCalls, 0)
733+
734+
await router.fetch('https://remix.run/about')
735+
assert.equal(defaultCalls, 0)
736+
737+
await router.fetch('https://remix.run/missing')
738+
assert.equal(defaultCalls, 1)
739+
})
740+
})
741+
742+
describe('error handling', () => {
743+
it('propagates errors thrown in route handlers', async () => {
744+
let router = createRouter()
745+
router.get('/', () => {
746+
throw new Error('Handler error')
747+
})
748+
749+
await assert.rejects(async () => {
750+
await router.fetch('https://remix.run/')
751+
}, new Error('Handler error'))
752+
})
753+
754+
it('propagates async errors thrown in route handlers', async () => {
755+
let router = createRouter()
756+
router.get('/', async () => {
757+
await Promise.resolve()
758+
throw new Error('Async handler error')
759+
})
760+
761+
await assert.rejects(async () => {
762+
await router.fetch('https://remix.run/')
763+
}, new Error('Async handler error'))
764+
})
765+
766+
it('propagates errors thrown in global middleware', async () => {
767+
let router = createRouter()
768+
769+
router.use(() => {
770+
throw new Error('Global middleware error')
771+
})
772+
773+
router.get('/', () => new Response('OK'))
774+
775+
await assert.rejects(async () => {
776+
await router.fetch('https://remix.run/')
777+
}, new Error('Global middleware error'))
778+
})
779+
780+
it('propagates errors thrown in per-route middleware', async () => {
781+
let router = createRouter()
782+
783+
router.get('/', {
784+
use: [
785+
() => {
786+
throw new Error('Per-route middleware error')
787+
},
788+
],
789+
handler() {
790+
return new Response('OK')
791+
},
792+
})
793+
794+
await assert.rejects(async () => {
795+
await router.fetch('https://remix.run/')
796+
}, new Error('Per-route middleware error'))
797+
})
798+
799+
it('propagates errors thrown in defaultHandler', async () => {
800+
let router = createRouter({
801+
defaultHandler: () => {
802+
throw new Error('Default handler error')
803+
},
804+
})
805+
806+
router.get('/home', () => new Response('Home'))
807+
808+
await assert.rejects(async () => {
809+
await router.fetch('https://remix.run/missing')
810+
}, new Error('Default handler error'))
811+
})
812+
813+
it('allows middleware to catch and handle errors from downstream', async () => {
814+
let router = createRouter()
815+
816+
router.use(async (_, next) => {
817+
try {
818+
return await next()
819+
} catch (error) {
820+
return new Response(`Caught: ${(error as Error).message}`, { status: 500 })
821+
}
822+
})
823+
824+
router.get('/', () => {
825+
throw new Error('Handler error')
826+
})
827+
828+
let response = await router.fetch('https://remix.run/')
829+
assert.equal(response.status, 500)
830+
assert.equal(await response.text(), 'Caught: Handler error')
831+
})
832+
})
833+
834+
describe('router.dispatch()', () => {
835+
it('returns null when no route matches', async () => {
836+
let router = createRouter()
837+
router.get('/home', () => new Response('Home'))
838+
839+
let response = await router.dispatch(new Request('https://remix.run/missing'))
840+
841+
assert.equal(response, null)
842+
})
843+
844+
it('returns a response when a route matches', async () => {
845+
let router = createRouter()
846+
router.get('/home', () => new Response('Home'))
847+
848+
let response = await router.dispatch(new Request('https://remix.run/home'))
849+
850+
assert.ok(response)
851+
assert.equal(response.status, 200)
852+
assert.equal(await response.text(), 'Home')
853+
})
854+
855+
it('does not call the defaultHandler', async () => {
856+
let defaultHandlerCalled = false
857+
let router = createRouter({
858+
defaultHandler: () => {
859+
defaultHandlerCalled = true
860+
return new Response('Default', { status: 404 })
861+
},
862+
})
863+
864+
router.get('/home', () => new Response('Home'))
865+
866+
let response = await router.dispatch(new Request('https://remix.run/missing'))
867+
868+
assert.equal(response, null)
869+
assert.equal(defaultHandlerCalled, false)
870+
})
871+
872+
it('accepts a RequestContext instead of a Request', async () => {
873+
let storageKey = createStorageKey<string>()
874+
let router = createRouter()
875+
router.get('/:id', ({ params, storage }) => {
876+
return new Response(`ID: ${params.id}, Storage: ${storage.get(storageKey)}`)
877+
})
878+
879+
let request = new Request('https://remix.run/123')
880+
let context = new RequestContext({ request, params: {} })
881+
context.storage.set(storageKey, 'value')
882+
883+
let response = await router.dispatch(context)
884+
885+
assert.ok(response)
886+
assert.equal(await response.text(), 'ID: 123, Storage: value')
887+
})
888+
889+
it('passes upstream middleware to nested routes', async () => {
890+
let requestLog: string[] = []
891+
892+
let router = createRouter()
893+
router.get('/', () => {
894+
requestLog.push('handler')
895+
return new Response('OK')
896+
})
897+
898+
let request = new Request('https://remix.run/')
899+
let upstreamMiddleware = [
900+
() => {
901+
requestLog.push('upstream')
902+
},
903+
]
904+
905+
let response = await router.dispatch(request, upstreamMiddleware)
906+
907+
assert.ok(response)
908+
assert.deepEqual(requestLog, ['upstream', 'handler'])
909+
})
910+
})
911+
912+
describe('trailing slash handling', () => {
913+
it('matches routes with and without trailing slashes for single-path routes', async () => {
914+
let router = createRouter()
915+
router.get('/about', () => new Response('About'))
916+
router.get('/contact/', () => new Response('Contact'))
917+
918+
// Route defined without trailing slash
919+
let response1 = await router.fetch('https://remix.run/about')
920+
assert.equal(response1.status, 200)
921+
assert.equal(await response1.text(), 'About')
922+
923+
let response2 = await router.fetch('https://remix.run/about/')
924+
assert.equal(response2.status, 404) // Trailing slash doesn't match
925+
926+
// Route defined with trailing slash
927+
let response3 = await router.fetch('https://remix.run/contact/')
928+
assert.equal(response3.status, 200)
929+
assert.equal(await response3.text(), 'Contact')
930+
931+
let response4 = await router.fetch('https://remix.run/contact')
932+
assert.equal(response4.status, 404) // Without trailing slash doesn't match
933+
})
934+
935+
it('matches routes with and without trailing slashes for createRoutes', async () => {
936+
let routes = createRoutes('api', {
937+
users: '/users',
938+
posts: '/posts/',
939+
})
940+
941+
let router = createRouter()
942+
router.get(routes.users, () => new Response('Users'))
943+
router.get(routes.posts, () => new Response('Posts'))
944+
945+
// Route defined without trailing slash in createRoutes
946+
let response1 = await router.fetch('https://remix.run/api/users')
947+
assert.equal(response1.status, 200)
948+
assert.equal(await response1.text(), 'Users')
949+
950+
let response2 = await router.fetch('https://remix.run/api/users/')
951+
assert.equal(response2.status, 404) // Trailing slash doesn't match
952+
953+
// Route defined with trailing slash in createRoutes
954+
let response3 = await router.fetch('https://remix.run/api/posts/')
955+
assert.equal(response3.status, 200)
956+
assert.equal(await response3.text(), 'Posts')
957+
958+
let response4 = await router.fetch('https://remix.run/api/posts')
959+
assert.equal(response4.status, 404) // Without trailing slash doesn't match
960+
})
961+
962+
it('handles root path with and without trailing slash', async () => {
963+
let router = createRouter()
964+
router.get('/', () => new Response('Home'))
965+
966+
// Root with trailing slash
967+
let response1 = await router.fetch('https://remix.run/')
968+
assert.equal(response1.status, 200)
969+
assert.equal(await response1.text(), 'Home')
970+
971+
// Root without trailing slash (edge case)
972+
let response2 = await router.fetch('https://remix.run')
973+
assert.equal(response2.status, 200)
974+
assert.equal(await response2.text(), 'Home')
975+
})
976+
977+
it('handles nested routes with trailing slash combinations', async () => {
978+
let routes = createRoutes('admin', {
979+
dashboard: '/',
980+
users: {
981+
index: '/users',
982+
show: '/users/:id',
983+
},
984+
})
985+
986+
let router = createRouter()
987+
router.get(routes.dashboard, () => new Response('Admin Dashboard'))
988+
router.get(routes.users.index, () => new Response('Users List'))
989+
router.get(routes.users.show, ({ params }) => new Response(`User ${params.id}`))
990+
991+
// Dashboard (base path - createRoutes('admin', { dashboard: '/' }) produces '/admin')
992+
let response1 = await router.fetch('https://remix.run/admin')
993+
assert.equal(response1.status, 200)
994+
assert.equal(await response1.text(), 'Admin Dashboard')
995+
996+
let response2 = await router.fetch('https://remix.run/admin/')
997+
assert.equal(response2.status, 404) // Trailing slash doesn't match '/admin'
998+
999+
// Nested users index
1000+
let response3 = await router.fetch('https://remix.run/admin/users')
1001+
assert.equal(response3.status, 200)
1002+
assert.equal(await response3.text(), 'Users List')
1003+
1004+
let response4 = await router.fetch('https://remix.run/admin/users/')
1005+
assert.equal(response4.status, 404) // Trailing slash doesn't match
1006+
1007+
// Nested users show
1008+
let response5 = await router.fetch('https://remix.run/admin/users/123')
1009+
assert.equal(response5.status, 200)
1010+
assert.equal(await response5.text(), 'User 123')
1011+
1012+
let response6 = await router.fetch('https://remix.run/admin/users/123/')
1013+
assert.equal(response6.status, 404) // Trailing slash doesn't match
1014+
})
1015+
})
1016+
1017+
describe('custom matcher', () => {
1018+
it('uses a custom matcher when provided', async () => {
1019+
let matchAllCalls = 0
1020+
1021+
// Create a custom matcher that tracks calls
1022+
class CustomMatcher extends RegExpMatcher {
1023+
*matchAll(url: string | URL) {
1024+
matchAllCalls++
1025+
yield* super.matchAll(url)
1026+
}
1027+
}
1028+
1029+
let customMatcher = new CustomMatcher()
1030+
let router = createRouter({ matcher: customMatcher })
1031+
router.get('/', () => new Response('Home'))
1032+
1033+
await router.fetch('https://remix.run/')
1034+
1035+
assert.ok(matchAllCalls > 0, 'Custom matcher should be called')
1036+
})
1037+
1038+
it('adds routes to the custom matcher', async () => {
1039+
let addedPatterns: string[] = []
1040+
1041+
class CustomMatcher extends RegExpMatcher {
1042+
add<P extends string>(pattern: P | RoutePattern<P>, data: any): void {
1043+
let routePattern = typeof pattern === 'string' ? new RoutePattern(pattern) : pattern
1044+
addedPatterns.push(routePattern.source)
1045+
super.add(pattern, data)
1046+
}
1047+
}
1048+
1049+
let customMatcher = new CustomMatcher()
1050+
let router = createRouter({ matcher: customMatcher })
1051+
router.get('/home', () => new Response('Home'))
1052+
router.get('/about', () => new Response('About'))
1053+
1054+
assert.deepEqual(addedPatterns, ['/home', '/about'])
1055+
})
1056+
})

0 commit comments

Comments
 (0)