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

test: add setTimeout tests #558

Merged
merged 2 commits into from
Apr 18, 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
12 changes: 11 additions & 1 deletion src/interceptors/ClientRequest/MockHttpSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export class MockHttpSocket extends MockSocket {
* its data/events through this Socket.
*/
public passthrough(): void {
if (this.destroyed) {
return
}

const socket = this.createConnection()
this.address = socket.address.bind(socket)

Expand Down Expand Up @@ -225,6 +229,12 @@ export class MockHttpSocket extends MockSocket {
* HTTP message and push it to the socket.
*/
public async respondWith(response: Response): Promise<void> {
// Ignore the mocked response if the socket has been destroyed
// (e.g. aborted or timed out),
if (this.destroyed) {
return
}

// Handle "type: error" responses.
if (isPropertyAccessible(response, 'type') && response.type === 'error') {
this.errorWith(new TypeError('Network error'))
Expand Down Expand Up @@ -326,7 +336,7 @@ export class MockHttpSocket extends MockSocket {
}

/**
* Close this Socket connection with the given error.
* Close this socket connection with the given error.
*/
public errorWith(error: Error): void {
this.destroy(error)
Expand Down
262 changes: 262 additions & 0 deletions test/modules/http/compliance/http-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/**
* @vitest-environment node
*/
import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import http from 'node:http'
import { HttpServer } from '@open-draft/test-server/http'
import { DeferredPromise } from '@open-draft/deferred-promise'
import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest'
import { sleep } from '../../../helpers'

const httpServer = new HttpServer((app) => {
app.get('/resource', async (req, res) => {
await sleep(200)
res.status(500).end()
})
})

const interceptor = new ClientRequestInterceptor()

beforeAll(async () => {
interceptor.apply()
await httpServer.listen()
})

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

afterAll(async () => {
interceptor.dispose()
await httpServer.close()
})

it('respects the "timeout" option for a handled request', async () => {
interceptor.on('request', async ({ request }) => {
await sleep(200)
request.respondWith(new Response('hello world'))
})

const errorListener = vi.fn()
const timeoutListener = vi.fn()
const responseListener = vi.fn()
const request = http.get('http://localhost/resource', {
timeout: 10,
})
request.on('error', errorListener)
request.on('timeout', () => {
timeoutListener()
// Request must be destroyed manually on timeout.
request.destroy()
})
request.on('response', responseListener)

const requestClosePromise = new DeferredPromise<void>()
request.on('close', () => requestClosePromise.resolve())
await requestClosePromise

expect(request.destroyed).toBe(true)
expect(timeoutListener).toHaveBeenCalledTimes(1)
expect(errorListener).toHaveBeenCalledWith(
expect.objectContaining({
code: 'ECONNRESET',
})
)
expect(responseListener).not.toHaveBeenCalled()
})

it('respects the "timeout" option for a bypassed request', async () => {
const errorListener = vi.fn()
const timeoutListener = vi.fn()
const responseListener = vi.fn()
const request = http.get(httpServer.http.url('/resource'), {
timeout: 10,
})
request.on('error', errorListener)
request.on('timeout', () => {
timeoutListener()
// Request must be destroyed manually on timeout.
request.destroy()
})
request.on('response', responseListener)

const requestClosePromise = new DeferredPromise<void>()
request.on('close', () => requestClosePromise.resolve())
await requestClosePromise

expect(request.destroyed).toBe(true)
expect(timeoutListener).toHaveBeenCalledTimes(1)
expect(errorListener).toHaveBeenCalledWith(
expect.objectContaining({
code: 'ECONNRESET',
})
)
expect(responseListener).not.toHaveBeenCalled()
})

it('respects a "setTimeout()" on a handled request', async () => {
interceptor.on('request', async ({ request }) => {
const stream = new ReadableStream({
async start(controller) {
// Emulate a long pending response stream
// to trigger the request timeout.
await sleep(200)
controller.enqueue(new TextEncoder().encode('hello'))
},
})
request.respondWith(new Response(stream))
})

const errorListener = vi.fn()
const timeoutListener = vi.fn()
const setTimeoutCallback = vi.fn()
const responseListener = vi.fn()
const request = http.get('http://localhost/resource')

/**
* @note `request.setTimeout(n)` is NOT equivalent to
* `{ timeout: n }` in request options.
*
* - { timeout: n } acts on the http.Agent level and
* sets the timeout on every socket once it's CREATED.
*
* - setTimeout(n) omits the http.Agent, and sets the
* timeout once the socket emits "connect".
* This timeout takes effect only after the connection,
* so in our case, the mock/bypassed response MUST start,
* and only if the response itself takes more than this timeout,
* the timeout will trigger.
*/
request.setTimeout(10, setTimeoutCallback)

request.on('error', errorListener)
request.on('timeout', () => {
timeoutListener()
request.destroy()
})
request.on('response', responseListener)

const requestClosePromise = new DeferredPromise<void>()
request.on('close', () => requestClosePromise.resolve())
await requestClosePromise

expect(request.destroyed).toBe(true)
expect(timeoutListener).toHaveBeenCalledTimes(1)
expect(setTimeoutCallback).toHaveBeenCalledTimes(1)
expect(errorListener).toHaveBeenCalledWith(
expect.objectContaining({
code: 'ECONNRESET',
})
)
expect(responseListener).not.toHaveBeenCalled()
})

it('respects a "setTimeout()" on a bypassed request', async () => {
const errorListener = vi.fn()
const timeoutListener = vi.fn()
const responseListener = vi.fn()
const request = http.get(httpServer.http.url('/resource'))
request.setTimeout(10)

request.on('error', errorListener)
request.on('timeout', () => {
timeoutListener()
request.destroy()
})
request.on('response', responseListener)

const requestClosePromise = new DeferredPromise<void>()
request.on('close', () => requestClosePromise.resolve())
await requestClosePromise

expect(request.destroyed).toBe(true)
expect(timeoutListener).toHaveBeenCalledTimes(1)
expect(errorListener).toHaveBeenCalledWith(
expect.objectContaining({
code: 'ECONNRESET',
})
)
expect(responseListener).not.toHaveBeenCalled()
})

it('respects the "socket.setTimeout()" for a handled request', async () => {
interceptor.on('request', async ({ request }) => {
const stream = new ReadableStream({
async start(controller) {
// Emulate a long pending response stream
// to trigger the request timeout.
await sleep(200)
controller.enqueue(new TextEncoder().encode('hello'))
},
})
request.respondWith(new Response(stream))
})

const errorListener = vi.fn()
const setTimeoutCallback = vi.fn()
const responseListener = vi.fn()
const request = http.get('http://localhost/resource')

request.on('socket', (socket) => {
/**
* @note Setting timeout on the socket directly
* will NOT add the "timeout" listener to the request,
* unlike "request.setTimeout()".
*/
socket.setTimeout(10, () => {
setTimeoutCallback()
request.destroy()
})
})

request.on('error', errorListener)
request.on('response', responseListener)

const requestClosePromise = new DeferredPromise<void>()
request.on('close', () => requestClosePromise.resolve())
await requestClosePromise

expect(request.destroyed).toBe(true)
expect(setTimeoutCallback).toHaveBeenCalledTimes(1)
expect(errorListener).toHaveBeenCalledWith(
expect.objectContaining({
code: 'ECONNRESET',
})
)
expect(responseListener).not.toHaveBeenCalled()
})

it('respects the "socket.setTimeout()" for a bypassed request', async () => {
const errorListener = vi.fn()
const setTimeoutCallback = vi.fn()
const responseListener = vi.fn()
const request = http.get(httpServer.http.url('/resource'))

request.on('socket', (socket) => {
/**
* @note Setting timeout on the socket directly
* will NOT add the "timeout" listener to the request,
* unlike "request.setTimeout()".
*/
socket.setTimeout(10, () => {
setTimeoutCallback()
request.destroy()
})
})

request.on('error', errorListener)
request.on('response', responseListener)

const requestClosePromise = new DeferredPromise<void>()
request.on('close', () => requestClosePromise.resolve())
await requestClosePromise

expect(request.destroyed).toBe(true)
expect(setTimeoutCallback).toHaveBeenCalledTimes(1)
expect(errorListener).toHaveBeenCalledWith(
expect.objectContaining({
code: 'ECONNRESET',
})
)
expect(responseListener).not.toHaveBeenCalled()
})
Loading