Skip to content

Commit ce5703a

Browse files
committed
fix(mock): preserve lifecycle abort semantics
Signed-off-by: marko1olo <barsukdana@gmail.com>
1 parent a38ca0f commit ce5703a

2 files changed

Lines changed: 231 additions & 19 deletions

File tree

lib/mock/mock-utils.js

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -295,13 +295,9 @@ function mockDispatch (opts, handler) {
295295

296296
mockDispatch.timesInvoked++
297297

298-
// Here's where we resolve a callback if a callback is present for the dispatch data.
299-
if (mockDispatch.data.callback) {
300-
mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) }
301-
}
302-
303298
// Parse mockDispatch data
304-
const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch
299+
const { data: response, delay, persist } = mockDispatch
300+
const { error } = response
305301
const { timesInvoked, times } = mockDispatch
306302

307303
// If it's used up and not persistent, mark as consumed
@@ -392,19 +388,27 @@ function mockDispatch (opts, handler) {
392388
}
393389
}
394390

395-
function handleReply (mockDispatches, _data = data) {
391+
function handleReply (mockDispatches, _response = response) {
396392
// Don't send response if the request was aborted
397393
if (aborted) {
398394
return
399395
}
400396

397+
if (_response.callback) {
398+
const { callback, ...responseDefaults } = _response
399+
mockDispatch.data = { ...responseDefaults, ...callback(replyOpts) }
400+
return handleReply(mockDispatches, mockDispatch.data)
401+
}
402+
403+
const { statusCode, data, headers, trailers } = _response
404+
401405
// fetch's HeadersList is a 1D string array
402406
const optsHeaders = Array.isArray(opts.headers)
403407
? buildHeadersFromArray(opts.headers)
404408
: opts.headers
405-
const body = typeof _data === 'function'
406-
? _data({ ...replyOpts, headers: optsHeaders })
407-
: _data
409+
const body = typeof data === 'function'
410+
? data({ ...replyOpts, headers: optsHeaders })
411+
: data
408412

409413
// util.types.isPromise is likely needed for jest.
410414
if (isPromise(body)) {
@@ -413,7 +417,7 @@ function mockDispatch (opts, handler) {
413417
// synchronously throw the error, which breaks some tests.
414418
// Rather, we wait for the callback to resolve if it is a
415419
// promise, and then re-run handleReply with the new body.
416-
return body.then((newData) => handleReply(mockDispatches, newData))
420+
return body.then((newData) => handleReply(mockDispatches, { ..._response, data: newData }))
417421
}
418422

419423
// Check again if aborted after async body resolution
@@ -444,7 +448,7 @@ function dispatchRequestBody (body, handler, controller, isAborted) {
444448
}
445449

446450
if (body == null) {
447-
return callOnRequestSent(handler, controller) ? body : null
451+
return callOnRequestSent(handler, controller, isAborted) ? body : null
448452
}
449453

450454
if (body && typeof body[Symbol.asyncIterator] === 'function') {
@@ -459,12 +463,12 @@ function dispatchRequestBody (body, handler, controller, isAborted) {
459463
return null
460464
}
461465
chunks.push(chunk)
462-
if (!callOnBodySent(handler, controller, chunk)) {
466+
if (!callOnBodySent(handler, controller, chunk) || isAborted()) {
463467
return null
464468
}
465469
}
466470

467-
return !isAborted() && callOnRequestSent(handler, controller) ? chunks : null
471+
return callOnRequestSent(handler, controller, isAborted) ? chunks : null
468472
}
469473

470474
if (isAborted()) {
@@ -474,7 +478,7 @@ function dispatchRequestBody (body, handler, controller, isAborted) {
474478
return null
475479
}
476480

477-
return !isAborted() && callOnRequestSent(handler, controller) ? body : null
481+
return callOnRequestSent(handler, controller, isAborted) ? body : null
478482
}
479483

480484
async function dispatchAsyncIterableBody (body, handler, controller, isAborted) {
@@ -485,12 +489,12 @@ async function dispatchAsyncIterableBody (body, handler, controller, isAborted)
485489
return null
486490
}
487491
chunks.push(chunk)
488-
if (!callOnBodySent(handler, controller, chunk)) {
492+
if (!callOnBodySent(handler, controller, chunk) || isAborted()) {
489493
return null
490494
}
491495
}
492496

493-
if (isAborted() || !callOnRequestSent(handler, controller)) {
497+
if (!callOnRequestSent(handler, controller, isAborted)) {
494498
return null
495499
}
496500

@@ -511,10 +515,10 @@ function callOnBodySent (handler, controller, chunk) {
511515
}
512516
}
513517

514-
function callOnRequestSent (handler, controller) {
518+
function callOnRequestSent (handler, controller, isAborted) {
515519
try {
516520
handler.onRequestSent?.()
517-
return true
521+
return !isAborted()
518522
} catch (error) {
519523
controller.abort(error)
520524
return false

test/mock-agent.js

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,105 @@ describe('MockAgent - dispatch', () => {
414414
t.assert.deepStrictEqual(events, ['body:he'])
415415
})
416416

417+
test('should pass replayed async iterable bodies to reply option callbacks', async (t) => {
418+
const baseUrl = 'http://localhost:9999'
419+
const events = []
420+
let callbackBody
421+
422+
const mockAgent = new MockAgent()
423+
after(() => mockAgent.close())
424+
425+
const mockPool = mockAgent.get(baseUrl)
426+
mockPool.intercept({
427+
path: '/foo',
428+
method: 'POST'
429+
}).reply(({ body }) => {
430+
events.push('callback')
431+
callbackBody = body
432+
return { statusCode: 200, data: 'hello' }
433+
})
434+
435+
async function * body () {
436+
yield Buffer.from('he')
437+
yield Buffer.from('llo')
438+
}
439+
440+
await new Promise((resolve, reject) => {
441+
mockAgent.dispatch({
442+
origin: baseUrl,
443+
path: '/foo',
444+
method: 'POST',
445+
body: body()
446+
}, {
447+
onBodySent (chunk) {
448+
events.push(`body:${chunk.toString()}`)
449+
},
450+
onRequestSent () {
451+
events.push('sent')
452+
},
453+
onResponseStart () {},
454+
onResponseData () {},
455+
onResponseEnd () {
456+
resolve()
457+
},
458+
onResponseError (_controller, error) {
459+
reject(error)
460+
}
461+
})
462+
})
463+
464+
const chunks = []
465+
for await (const chunk of callbackBody) {
466+
chunks.push(chunk)
467+
}
468+
469+
t.assert.deepStrictEqual(events, ['body:he', 'body:llo', 'sent', 'callback'])
470+
t.assert.strictEqual(Buffer.concat(chunks).toString(), 'hello')
471+
})
472+
473+
test('should match request bodies when lifecycle hooks are present', async (t) => {
474+
const baseUrl = 'http://localhost:9999'
475+
const events = []
476+
477+
const mockAgent = new MockAgent()
478+
after(() => mockAgent.close())
479+
480+
const mockPool = mockAgent.get(baseUrl)
481+
mockPool.intercept({
482+
path: '/foo',
483+
method: 'POST',
484+
body: /hello/
485+
}).reply(200, 'matched')
486+
487+
await new Promise((resolve, reject) => {
488+
mockAgent.dispatch({
489+
origin: baseUrl,
490+
path: '/foo',
491+
method: 'POST',
492+
body: 'hello=there'
493+
}, {
494+
onBodySent (chunk) {
495+
events.push(`body:${chunk.toString()}`)
496+
},
497+
onRequestSent () {
498+
events.push('sent')
499+
},
500+
onResponseStart () {},
501+
onResponseData (_controller, chunk) {
502+
events.push(`response:${chunk.toString()}`)
503+
},
504+
onResponseEnd () {
505+
resolve()
506+
},
507+
onResponseError (_controller, error) {
508+
reject(error)
509+
}
510+
})
511+
})
512+
513+
t.assert.deepStrictEqual(events, ['body:hello=there', 'sent', 'response:matched'])
514+
})
515+
417516
test('should replay async iterable request bodies to reply callbacks after lifecycle hooks', async (t) => {
418517
const baseUrl = 'http://localhost:9999'
419518
const events = []
@@ -514,6 +613,115 @@ describe('MockAgent - dispatch', () => {
514613
})
515614
})
516615

616+
test('should not send delayed replies after request sent aborts', async (t) => {
617+
const baseUrl = 'http://localhost:9999'
618+
const expected = new Error('fail')
619+
const events = []
620+
let requestController
621+
622+
const mockAgent = new MockAgent()
623+
after(() => mockAgent.close())
624+
625+
const mockPool = mockAgent.get(baseUrl)
626+
mockPool.intercept({
627+
path: '/foo',
628+
method: 'POST'
629+
}).reply(200, 'hello').delay(20)
630+
631+
await new Promise((resolve, reject) => {
632+
mockAgent.dispatch({
633+
origin: baseUrl,
634+
path: '/foo',
635+
method: 'POST',
636+
body: 'hello'
637+
}, {
638+
onRequestStart (controller) {
639+
requestController = controller
640+
},
641+
onBodySent (chunk) {
642+
events.push(`body:${chunk.toString()}`)
643+
},
644+
onRequestSent () {
645+
events.push('sent')
646+
requestController.abort(expected)
647+
},
648+
onResponseStart () {
649+
reject(new Error('response should not start'))
650+
},
651+
onResponseData () {},
652+
onResponseEnd () {},
653+
onResponseError (_controller, error) {
654+
try {
655+
t.assert.strictEqual(error, expected)
656+
setTimeout(resolve, 40)
657+
} catch (assertionError) {
658+
reject(assertionError)
659+
}
660+
}
661+
})
662+
})
663+
664+
t.assert.deepStrictEqual(events, ['body:hello', 'sent'])
665+
})
666+
667+
test('should stop reading async iterable request bodies after body hook aborts', async (t) => {
668+
const baseUrl = 'http://localhost:9999'
669+
const expected = new Error('fail')
670+
const events = []
671+
let requestController
672+
673+
const mockAgent = new MockAgent()
674+
after(() => mockAgent.close())
675+
676+
const mockPool = mockAgent.get(baseUrl)
677+
mockPool.intercept({
678+
path: '/foo',
679+
method: 'POST'
680+
}).reply(200, 'hello')
681+
682+
async function * body () {
683+
events.push('pull:he')
684+
yield Buffer.from('he')
685+
events.push('pull:llo')
686+
yield Buffer.from('llo')
687+
}
688+
689+
await new Promise((resolve, reject) => {
690+
mockAgent.dispatch({
691+
origin: baseUrl,
692+
path: '/foo',
693+
method: 'POST',
694+
body: body()
695+
}, {
696+
onRequestStart (controller) {
697+
requestController = controller
698+
},
699+
onBodySent (chunk) {
700+
events.push(`body:${chunk.toString()}`)
701+
requestController.abort(expected)
702+
},
703+
onRequestSent () {
704+
reject(new Error('request should not be marked sent'))
705+
},
706+
onResponseStart () {
707+
reject(new Error('response should not start'))
708+
},
709+
onResponseData () {},
710+
onResponseEnd () {},
711+
onResponseError (_controller, error) {
712+
try {
713+
t.assert.strictEqual(error, expected)
714+
resolve()
715+
} catch (assertionError) {
716+
reject(assertionError)
717+
}
718+
}
719+
})
720+
})
721+
722+
t.assert.deepStrictEqual(events, ['pull:he', 'body:he'])
723+
})
724+
517725
test('should not send request body lifecycle hooks after request start aborts', async (t) => {
518726
const baseUrl = 'http://localhost:9999'
519727
const expected = new Error('fail')

0 commit comments

Comments
 (0)