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

feat(mock): add support reply option callbacks #1211

Merged
merged 4 commits into from
Feb 8, 2022
Merged
Show file tree
Hide file tree
Changes from 3 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
35 changes: 34 additions & 1 deletion docs/api/MockPool.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ for await (const data of body) {
}
```

#### Example - Mocked request using reply callbacks
#### Example - Mocked request using reply data callbacks

```js
import { MockAgent, setGlobalDispatcher, request } from 'undici'
Expand Down Expand Up @@ -146,6 +146,39 @@ for await (const data of body) {
}
```

#### Example - Mocked request using reply options callback

```js
import { MockAgent, setGlobalDispatcher, request } from 'undici'

const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)

const mockPool = mockAgent.get('http://localhost:3000')

mockPool.intercept({
path: '/echo',
method: 'GET',
headers: {
'User-Agent': 'undici',
Host: 'example.com'
}
}).reply(({ headers }) => ({ statusCode: 200, data: { message: headers.get('message') }})))

const { statusCode, body, headers } = await request('http://localhost:3000', {
headers: {
message: 'hello world!'
}
})

console.log('response received', statusCode) // response received 200
console.log('headers', headers) // { 'content-type': 'application/json' }

for await (const data of body) {
console.log('data', data.toString('utf8')) // { "message":"hello world!" }
}
```

#### Example - Basic Mocked requests with multiple intercepts

```js
Expand Down
66 changes: 56 additions & 10 deletions lib/mock/mock-interceptor.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const {
kContentLength,
kMockDispatch
} = require('./mock-symbols')
const { InvalidArgumentError } = require('../core/errors')
const { InvalidArgumentError, InvalidReturnValueError } = require('../core/errors')

/**
* Defines the scope API for a interceptor reply
Expand Down Expand Up @@ -74,10 +74,16 @@ class MockInterceptor {
this[kContentLength] = false
}

/**
* Mock an undici request with a defined reply.
*/
reply (statusCode, data, responseOptions = {}) {
createMockScopeDispatchData(statusCode, data, responseOptions = {}) {
const responseData = getResponseData(data)
const contentLength = this[kContentLength] ? { 'content-length': responseData.length } : {}
const headers = { ...this[kDefaultHeaders], ...contentLength, ...responseOptions.headers }
const trailers = { ...this[kDefaultTrailers], ...responseOptions.trailers }

return { statusCode, data, headers, trailers };
}

validateReplyParameters(statusCode, data, responseOptions) {
if (typeof statusCode === 'undefined') {
throw new InvalidArgumentError('statusCode must be defined')
}
Expand All @@ -87,13 +93,53 @@ class MockInterceptor {
if (typeof responseOptions !== 'object') {
throw new InvalidArgumentError('responseOptions must be an object')
}
}

const responseData = getResponseData(data)
const contentLength = this[kContentLength] ? { 'content-length': responseData.length } : {}
const headers = { ...this[kDefaultHeaders], ...contentLength, ...responseOptions.headers }
const trailers = { ...this[kDefaultTrailers], ...responseOptions.trailers }
const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], { statusCode, data, headers, trailers })
/**
* Mock an undici request with a defined reply.
*/
reply (replyData) {
// Values of reply aren't available right now as they
// can only be available when the reply callback is invoked.
if (typeof replyData === 'function') {
// We'll first wrap the provided callback in another function,
// this function will properly resolve the data from the callback
// when invoked.
const wrappedDefaultsCallback = (opts) => {
// Our reply options callback contains the parameter for statusCode, data and options.
const resolvedData = replyData(opts);

// Check if it is in the right format
if (typeof resolvedData !== 'object') {
throw new InvalidArgumentError('reply options callback must return an object')
}

const { statusCode, data, responseOptions = {}} = resolvedData;
this.validateReplyParameters(statusCode, data, responseOptions);
// Since the values can be obtained immediately we return them
// from this higher order function that will be resolved later.
return {
...this.createMockScopeDispatchData(statusCode, data, responseOptions)
}
}

// Add usual dispatch data, but this time set the data parameter to function that will eventually provide data.
const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], wrappedDefaultsCallback)
return new MockScope(newMockDispatch);
}

// We can have either one or three parameters, if we get here,
// we should have 2-3 parameters. So we spread the arguments of
// this function to obtain the parameters, since replyData will always
// just be the statusCode.
const [statusCode, data, responseOptions = {}] = [...arguments];
this.validateReplyParameters(statusCode, data, responseOptions);

// Send in-already provided data like usual
const dispatchData = this.createMockScopeDispatchData(statusCode, data, responseOptions);
const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], dispatchData)
return new MockScope(newMockDispatch)

}

/**
Expand Down
10 changes: 8 additions & 2 deletions lib/mock/mock-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ function getMockDispatch (mockDispatches, key) {

function addMockDispatch (mockDispatches, key, data) {
const baseData = { times: null, persist: false, consumed: false }
const newMockDispatch = { ...baseData, ...key, data: { error: null, ...data } }
const replyData = typeof data === 'function' ? { callback: data } : { ...data };
const newMockDispatch = { ...baseData, ...key, data: { error: null, ...replyData } }
mockDispatches.push(newMockDispatch)
return newMockDispatch
}
Expand Down Expand Up @@ -135,7 +136,12 @@ async function getResponse (body) {
function mockDispatch (opts, handler) {
// Get mock dispatch from built key
const key = buildKey(opts)
const mockDispatch = getMockDispatch(this[kDispatches], key)
let mockDispatch = getMockDispatch(this[kDispatches], key)

// Here's where we resolve a callback if a callback is present for the dispatch data.
if (mockDispatch.data.callback) {
mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) }
}

// Parse mockDispatch data
const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch
Expand Down
120 changes: 120 additions & 0 deletions test/mock-interceptor.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const { test } = require('tap')
const { MockInterceptor, MockScope } = require('../lib/mock/mock-interceptor')
const MockAgent = require('../lib/mock/mock-agent');
const { InvalidArgumentError } = require('../lib/core/errors')

test('MockInterceptor - reply', t => {
Expand Down Expand Up @@ -53,6 +54,125 @@ test('MockInterceptor - reply callback', t => {
t.throws(() => mockInterceptor.reply(), new InvalidArgumentError('statusCode must be defined'))
t.throws(() => mockInterceptor.reply(200, () => {}, 'hello'), new InvalidArgumentError('responseOptions must be an object'))
})
})

test('MockInterceptor - reply options callback', t => {
t.plan(2)

t.test('should return MockScope', t => {
t.plan(2)

const mockInterceptor = new MockInterceptor({
path: '',
method: ''
}, [])
const result = mockInterceptor.reply((options) => ({
statusCode: 200,
data: 'hello'
}))
t.type(result, MockScope)

// Test parameters

const baseUrl = 'http://localhost:9999'
const mockAgent = new MockAgent()
t.teardown(mockAgent.close.bind(mockAgent))

const mockPool = mockAgent.get(baseUrl)

mockPool.intercept({
path: '/test',
method: 'GET',
}).reply((options) => {
t.strictSame(options, { path: '/test', method: 'GET', headers: { foo: 'bar' }})
return { statusCode: 200, data: 'hello' }
});

mockPool.dispatch({
path: '/test',
method: 'GET',
headers: { foo: 'bar' }
}, {
onHeaders: () => {},
onData: () => {},
onComplete: () => {},
})
})

t.test('should error if passed options invalid', async (t) => {
t.plan(4)

const baseUrl = 'http://localhost:9999'
const mockAgent = new MockAgent()
t.teardown(mockAgent.close.bind(mockAgent))

const mockPool = mockAgent.get(baseUrl)

mockPool.intercept({
path: '/test',
method: 'GET',
}).reply(() => {});

mockPool.intercept({
path: '/test2',
method: 'GET',
}).reply(() => ({
statusCode: 200,
}));

mockPool.intercept({
path: '/test3',
method: 'GET',
}).reply(() => ({
statusCode: 200,
data: 'hello',
responseOptions: 42,
}));

mockPool.intercept({
path: '/test4',
method: 'GET',
}).reply(() => ({
data: 'hello',
responseOptions: 42,
}));

t.throws(() => mockPool.dispatch({
path: '/test',
method: 'GET',
}, {
onHeaders: () => {},
onData: () => {},
onComplete: () => {},
}), new InvalidArgumentError('reply options callback must return an object'))

t.throws(() => mockPool.dispatch({
path: '/test2',
method: 'GET',
}, {
onHeaders: () => {},
onData: () => {},
onComplete: () => {},
}), new InvalidArgumentError('data must be defined'))

t.throws(() => mockPool.dispatch({
path: '/test3',
method: 'GET',
}, {
onHeaders: () => {},
onData: () => {},
onComplete: () => {},
}), new InvalidArgumentError('responseOptions must be an object'))

t.throws(() => mockPool.dispatch({
path: '/test4',
method: 'GET',
}, {
onHeaders: () => {},
onData: () => {},
onComplete: () => {},
}), new InvalidArgumentError('statusCode must be defined'))
})
})

test('MockInterceptor - replyWithError', t => {
Expand Down
11 changes: 11 additions & 0 deletions test/types/mock-interceptor.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ import { MockInterceptor, MockScope } from '../../types/mock-interceptor'
expectAssignable<MockScope>(mockInterceptor.reply(200, () => ({}), { trailers: { foo: 'bar' }}))
expectAssignable<MockScope<{ foo: string }>>(mockInterceptor.reply<{ foo: string }>(200, { foo: 'bar' }))
expectAssignable<MockScope<{ foo: string }>>(mockInterceptor.reply<{ foo: string }>(200, () => ({ foo: 'bar' })))
expectAssignable<MockScope>(mockInterceptor.reply(() => ({ statusCode: 200, data: { foo: 'bar' }})))
expectAssignable<MockScope>(mockInterceptor.reply(() => ({ statusCode: 200, data: { foo: 'bar' }, responseOptions: {
headers: { foo: 'bar' }
}})))
expectAssignable<MockScope>(mockInterceptor.reply((options) => {
expectAssignable<MockInterceptor.MockResponseCallbackOptions>(options);
return { statusCode: 200, data: { foo: 'bar'}
}}))
expectAssignable<MockScope>(mockInterceptor.reply(() => ({ statusCode: 200, data: { foo: 'bar' }, responseOptions: {
trailers: { foo: 'bar' }
}})))
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved

// replyWithError
class CustomError extends Error {
Expand Down
5 changes: 5 additions & 0 deletions types/mock-interceptor.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ declare class MockScope<TData extends object = object> {
declare class MockInterceptor {
constructor(options: MockInterceptor.Options, mockDispatches: MockInterceptor.MockDispatch[]);
/** Mock an undici request with the defined reply. */
reply<TData extends object = object>(replyOptionsCallback: MockInterceptor.MockReplyOptionsCallback<TData>): MockScope<TData>;
reply<TData extends object = object>(
statusCode: number,
data: TData | Buffer | string | MockInterceptor.MockResponseDataHandler<TData>,
Expand Down Expand Up @@ -76,6 +77,10 @@ declare namespace MockInterceptor {
export type MockResponseDataHandler<TData extends object = object> = (
opts: MockResponseCallbackOptions
) => TData | Buffer | string;

export type MockReplyOptionsCallback<TData extends object = object> = (
opts: MockResponseCallbackOptions
) => { statusCode: number, data: TData | Buffer | string, responseOptions?: MockResponseOptions }
}

interface Interceptable {
Expand Down