Skip to content

Commit 6fd1b45

Browse files
committed
feat(runtime): allow registerEndpoint to work with native fetch
1 parent f37ebef commit 6fd1b45

File tree

3 files changed

+145
-12
lines changed

3 files changed

+145
-12
lines changed

examples/app-vitest-full/tests/nuxt/server.spec.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
33
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
44

55
import { listen } from 'listhen'
6-
import { createApp, eventHandler, toNodeListener } from 'h3'
6+
import { createApp, eventHandler, toNodeListener, readBody, getHeaders, getQuery } from 'h3'
77

88
import FetchComponent from '~/components/FetchComponent.vue'
99

@@ -71,4 +71,109 @@ describe('server mocks and data fetching', () => {
7171
})
7272
expect(await $fetch<unknown>('/method')).toMatchObject({ method: 'GET' })
7373
})
74+
75+
it('can mock native fetch requests', async () => {
76+
registerEndpoint('/native/1', {
77+
method: 'POST',
78+
handler: () => ({ path: '/native/1', method: 'POST' }),
79+
})
80+
registerEndpoint('/native/1', {
81+
method: 'GET',
82+
handler: () => ({ path: '/native/1', method: 'GET' }),
83+
})
84+
registerEndpoint('https://jsonplaceholder.typicode.com/native/1', {
85+
method: 'GET',
86+
handler: () => ({ path: 'https://jsonplaceholder.typicode.com/native/1', method: 'GET' }),
87+
})
88+
registerEndpoint('https://jsonplaceholder.typicode.com/native/1', {
89+
method: 'POST',
90+
handler: () => ({ path: 'https://jsonplaceholder.typicode.com/native/1', method: 'POST' }),
91+
})
92+
93+
expect(await fetch('/native/1').then(res => res.json())).toMatchObject({ path: '/native/1', method: 'GET' })
94+
expect(await fetch('/native/1', { method: 'POST' }).then(res => res.json())).toMatchObject({ path: '/native/1', method: 'POST' })
95+
expect(await fetch('https://jsonplaceholder.typicode.com/native/1').then(res => res.json()))
96+
.toMatchObject({ path: 'https://jsonplaceholder.typicode.com/native/1', method: 'GET' })
97+
expect(await fetch('https://jsonplaceholder.typicode.com/native/1', { method: 'POST' }).then(res => res.json()))
98+
.toMatchObject({ path: 'https://jsonplaceholder.typicode.com/native/1', method: 'POST' })
99+
})
100+
101+
it('can mock fetch requests with data', async () => {
102+
registerEndpoint('/with-data', {
103+
method: 'POST',
104+
handler: async (event) => {
105+
return {
106+
body: await readBody(event),
107+
headers: getHeaders(event),
108+
}
109+
},
110+
})
111+
112+
expect(await $fetch<unknown>('/with-data', {
113+
method: 'POST',
114+
body: { data: 'data' },
115+
headers: { 'x-test': 'test' },
116+
})).toMatchObject({
117+
body: { data: 'data' },
118+
headers: { 'x-test': 'test' },
119+
})
120+
121+
expect(await fetch('/with-data', {
122+
method: 'POST',
123+
body: JSON.stringify({ data: 'data' }),
124+
headers: { 'x-test': 'test', 'content-type': 'application/json' },
125+
}).then(res => res.json())).toMatchObject({
126+
body: { data: 'data' },
127+
headers: { 'x-test': 'test' },
128+
})
129+
})
130+
131+
it('can mock fetch requests with query', async () => {
132+
registerEndpoint('/with-data', {
133+
method: 'GET',
134+
handler: async (event) => {
135+
return {
136+
query: getQuery(event),
137+
}
138+
},
139+
})
140+
141+
expect(await $fetch<unknown>('/with-data', { query: { q: 1 } })).toMatchObject({ query: { q: '1' } })
142+
expect(await fetch('/with-data?q=1').then(res => res.json())).toMatchObject({ query: { q: '1' } })
143+
})
144+
145+
it('can mock fetch requests with Request', async () => {
146+
registerEndpoint('http://localhost:3000/with-request', {
147+
method: 'GET',
148+
handler: (event) => {
149+
return { title: 'with-request', data: getQuery(event) }
150+
},
151+
})
152+
registerEndpoint('http://localhost:3000/with-request', {
153+
method: 'POST',
154+
handler: async (event) => {
155+
return { title: 'with-request', data: await readBody(event) }
156+
},
157+
})
158+
159+
const request = new Request('/with-request?q=1', { headers: { 'content-type': 'application/json' } })
160+
161+
expect(await $fetch<unknown>(request)).toMatchObject({ title: 'with-request', data: { q: '1' } })
162+
expect(await fetch(request).then(res => res.json())).toMatchObject({ title: 'with-request', data: { q: '1' } })
163+
164+
expect(await $fetch<unknown>(request, { method: 'POST', body: [1] })).toMatchObject({ title: 'with-request', data: [1] })
165+
expect(await fetch(request, { method: 'POST', body: '[1]' }).then(res => res.json())).toMatchObject({ title: 'with-request', data: [1] })
166+
})
167+
168+
it('can mock fetch requests with URL', async () => {
169+
registerEndpoint('http://localhost:3000/with-url', {
170+
method: 'GET',
171+
handler: (event) => {
172+
return { title: 'with-url', data: getQuery(event) }
173+
},
174+
})
175+
176+
expect(await fetch(new URL('http://localhost:3000/with-url')).then(res => res.json())).toMatchObject({ title: 'with-url', data: {} })
177+
expect(await fetch(new URL('http://localhost:3000/with-url?q=1')).then(res => res.json())).toMatchObject({ title: 'with-url', data: { q: '1' } })
178+
})
74179
})

src/environments/vitest/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export default <Environment>{
4848
const teardownWindow = await setupWindow(win, environmentOptions as any)
4949
const { keys, originals } = populateGlobal(global, win, {
5050
bindFunctions: true,
51+
additionalKeys: ['fetch', 'Request'],
5152
})
5253

5354
return {

src/runtime/shared/environment.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,28 +47,55 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N
4747

4848
const h3App = createApp()
4949

50-
if (!win.fetch) {
50+
if (!win.fetch || !('Request' in win)) {
5151
await import('node-fetch-native/polyfill')
5252
// @ts-expect-error fetch polyfill
5353
win.URLSearchParams = globalThis.URLSearchParams
54+
// @ts-expect-error fetch polyfill
55+
win.Request ??= class Request extends globalThis.Request {
56+
constructor(input: RequestInfo, init?: RequestInit) {
57+
if (typeof input === 'string') {
58+
super(new URL(input, win.location.origin), init)
59+
}
60+
else {
61+
super(input, init)
62+
}
63+
}
64+
}
5465
}
5566

5667
const nodeHandler = toNodeListener(h3App)
5768

5869
const registry = new Set<string>()
5970

60-
win.fetch = async (url, init) => {
61-
if (typeof url === 'string') {
62-
const base = url.split('?')[0]!
63-
if (registry.has(base) || registry.has(url)) {
64-
url = '/_' + url
65-
}
66-
if (url.startsWith('/')) {
67-
const response = await fetchNodeRequestHandler(nodeHandler, url, init)
68-
return normalizeFetchResponse(response)
71+
const _fetch = fetch
72+
win.fetch = async (input, _init) => {
73+
let url: string
74+
let init = _init
75+
if (typeof input === 'string') {
76+
url = input
77+
}
78+
else if (input instanceof URL) {
79+
url = input.toString()
80+
}
81+
else {
82+
url = input.url
83+
init = {
84+
method: init?.method ?? input.method,
85+
body: init?.body ?? input.body,
86+
headers: init?.headers ?? input.headers,
6987
}
7088
}
71-
return fetch(url, init)
89+
90+
const base = url.split('?')[0]!
91+
if (registry.has(base) || registry.has(url)) {
92+
url = '/_' + url
93+
}
94+
if (url.startsWith('/')) {
95+
const response = await fetchNodeRequestHandler(nodeHandler, url, init)
96+
return normalizeFetchResponse(response)
97+
}
98+
return _fetch(input, _init)
7299
}
73100

74101
// @ts-expect-error fetch types differ slightly

0 commit comments

Comments
 (0)