Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: treat thrown responses as mocked responses #553

Merged
merged 1 commit into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/interceptors/ClientRequest/NodeClientRequest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ClientRequest, IncomingMessage } from 'node:http'
import { ClientRequest, IncomingMessage, STATUS_CODES } from 'node:http'
import type { Logger } from '@open-draft/logger'
import { until } from '@open-draft/until'
import { DeferredPromise } from '@open-draft/deferred-promise'
Expand Down Expand Up @@ -266,6 +266,11 @@ export class NodeClientRequest extends ClientRequest {
resolverResult.error
)

if (resolverResult.error instanceof Response) {
this.respondWith(resolverResult.error)
return
}

// Allow throwing Node.js-like errors, like connection rejection errors.
// Treat them as request errors.
if (isNodeLikeError(resolverResult.error)) {
Expand Down Expand Up @@ -522,7 +527,7 @@ export class NodeClientRequest extends ClientRequest {

const { status, statusText, headers, body } = mockedResponse
this.response.statusCode = status
this.response.statusMessage = statusText
this.response.statusMessage = statusText || STATUS_CODES[status]

// Try extracting the raw headers from the headers instance.
// If not possible, fallback to the headers instance as-is.
Expand Down
2 changes: 1 addition & 1 deletion src/interceptors/ClientRequest/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ it('forbids calling "respondWith" multiple times for the same request', async ()

const response = await responseReceived
expect(response.statusCode).toBe(200)
expect(response.statusMessage).toBe('')
expect(response.statusMessage).toBe('OK')
})

it('abort the request if the abort signal is emitted', async () => {
Expand Down
5 changes: 5 additions & 0 deletions src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ export function createXMLHttpRequestProxy({
resolverResult.error
)

if (resolverResult.error instanceof Response) {
this.respondWith(resolverResult.error)
return
}

// Treat unhandled exceptions in the "request" listener
// as 500 server errors.
xhrRequestController.respondWith(
Expand Down
51 changes: 30 additions & 21 deletions src/interceptors/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
)
}

const respondWith = (response: Response): Response => {
// Clone the mocked response for the "response" event listener.
// This way, the listener can read the response and not lock its body
// for the actual fetch consumer.
const responseClone = response.clone()

this.emitter.emit('response', {
response: responseClone,
isMockedResponse: true,
request: interactiveRequest,
requestId,
})

// Set the "response.url" property to equal the intercepted request URL.
Object.defineProperty(response, 'url', {
writable: false,
enumerable: true,
configurable: false,
value: request.url,
})

return response
}

const resolverResult = await until(async () => {
const listenersFinished = emitAsync(this.emitter, 'request', {
request: interactiveRequest,
Expand Down Expand Up @@ -113,6 +137,11 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
}

if (resolverResult.error) {
// Treat thrown Responses as mocked responses.
if (resolverResult.error instanceof Response) {
return respondWith(resolverResult.error)
}

// Treat unhandled exceptions from the "request" listeners
// as 500 errors from the server. Fetch API doesn't respect
// Node.js internal errors so no special treatment for those.
Expand Down Expand Up @@ -157,27 +186,7 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
return Promise.reject(createNetworkError(mockedResponse))
}

// Clone the mocked response for the "response" event listener.
// This way, the listener can read the response and not lock its body
// for the actual fetch consumer.
const responseClone = mockedResponse.clone()

this.emitter.emit('response', {
response: responseClone,
isMockedResponse: true,
request: interactiveRequest,
requestId,
})

// Set the "response.url" property to equal the intercepted request URL.
Object.defineProperty(mockedResponse, 'url', {
writable: false,
enumerable: true,
configurable: false,
value: request.url,
})

return mockedResponse
return respondWith(mockedResponse)
}

this.logger.info('no mocked response received!')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,30 @@
/**
* @see https://github.com/mswjs/msw/issues/355
*/
import { it, expect, beforeAll, afterAll } from 'vitest'
import { it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import axios from 'axios'
import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest'
import { createXMLHttpRequest } from '../../../helpers'

const interceptor = new XMLHttpRequestInterceptor()
interceptor.on('request', () => {
throw new Error('Custom error message')
})

beforeAll(() => {
interceptor.apply()
})

afterEach(() => {
interceptor.removeAllListeners()
})

afterAll(() => {
interceptor.dispose()
})

it('XMLHttpRequest: treats unhandled interceptor exceptions as 500 responses', async () => {
interceptor.on('request', () => {
throw new Error('Custom error')
})

const request = await createXMLHttpRequest((request) => {
request.responseType = 'json'
request.open('GET', 'http://localhost/api')
Expand All @@ -31,12 +36,16 @@ it('XMLHttpRequest: treats unhandled interceptor exceptions as 500 responses', a
expect(request.statusText).toBe('Unhandled Exception')
expect(request.response).toEqual({
name: 'Error',
message: 'Custom error message',
message: 'Custom error',
stack: expect.any(String),
})
})

it('axios: unhandled interceptor exceptions are treated as 500 responses', async () => {
interceptor.on('request', () => {
throw new Error('Custom error')
})

const error = await axios.get('https://test.mswjs.io').catch((error) => error)

/**
Expand All @@ -48,7 +57,23 @@ it('axios: unhandled interceptor exceptions are treated as 500 responses', async
expect(error.response.statusText).toBe('Unhandled Exception')
expect(error.response.data).toEqual({
name: 'Error',
message: 'Custom error message',
message: 'Custom error',
stack: expect.any(String),
})
})

it('treats a thrown Response instance as a mocked response', async () => {
interceptor.on('request', () => {
throw new Response('hello world')
})

const request = await createXMLHttpRequest((request) => {
request.responseType = 'text'
request.open('GET', 'http://localhost/api')
request.send()
})

expect(request.status).toBe(200)
expect(request.response).toBe('hello world')
expect(request.responseText).toBe('hello world')
})
24 changes: 20 additions & 4 deletions test/modules/fetch/fetch-exception.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @vitest-environment node
import { vi, it, expect, beforeAll, afterAll } from 'vitest'
import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import { FetchInterceptor } from '../../../src/interceptors/fetch'

const interceptor = new FetchInterceptor()
Expand All @@ -8,9 +8,10 @@ beforeAll(() => {
vi.spyOn(console, 'error').mockImplementation(() => void 0)

interceptor.apply()
interceptor.on('request', () => {
throw new Error('Network error')
})
})

afterEach(() => {
interceptor.removeAllListeners()
})

afterAll(() => {
Expand All @@ -19,6 +20,10 @@ afterAll(() => {
})

it('treats middleware exceptions as 500 responses', async () => {
interceptor.on('request', () => {
throw new Error('Network error')
})

const response = await fetch('http://localhost:3001/resource')

expect(response.status).toBe(500)
Expand All @@ -29,3 +34,14 @@ it('treats middleware exceptions as 500 responses', async () => {
stack: expect.any(String),
})
})

it('treats a thrown Response as a mocked response', async () => {
interceptor.on('request', () => {
throw new Response('hello world')
})

const response = await fetch('http://localhost:3001/resource')

expect(response.status).toBe(200)
expect(await response.text()).toBe('hello world')
})
51 changes: 51 additions & 0 deletions test/modules/http/compliance/http-unhandled-exception.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* @vitest-environment node
*/
import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import http from 'node:http'
import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest'
import { waitForClientRequest } from '../../../helpers'

const interceptor = new ClientRequestInterceptor()

beforeAll(() => {
interceptor.apply()
})

afterEach(() => {
interceptor.removeAllListeners()
})

afterAll(() => {
interceptor.dispose()
})

it('handles a thrown Response as a mocked response', async () => {
interceptor.on('request', () => {
throw new Response('hello world')
})

const request = http.get('http://localhost/resource')
const { res, text } = await waitForClientRequest(request)

expect(res.statusCode).toBe(200)
expect(res.statusMessage).toBe('OK')
expect(await text()).toBe('hello world')
})

it('treats unhandled interceptor errors as 500 responses', async () => {
interceptor.on('request', () => {
throw new Error('Custom error')
})

const request = http.get('http://localhost/resource')
const { res, text } = await waitForClientRequest(request)

expect(res.statusCode).toBe(500)
expect(res.statusMessage).toBe('Unhandled Exception')
expect(JSON.parse(await text())).toEqual({
name: 'Error',
message: 'Custom error',
stack: expect.any(String),
})
})
Loading