Skip to content

Commit

Permalink
fix: treat thrown responses as mocked responses (#553)
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito authored Apr 17, 2024
1 parent 1913599 commit 58a089a
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 34 deletions.
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),
})
})

0 comments on commit 58a089a

Please sign in to comment.