current commit
261021.1 · fix: let interim 1xx responses through DecoratorHandler (#6028)
'use strict'

const { tspl } = require('@matteo.collina/tspl')
const { describe, test } = require('node:test')
const DecoratorHandler = require('../lib/handler/decorator-handler')

describe('DecoratorHandler', () => {
  test('should throw if provided handler is not an object', t => {
    t = tspl(t, { plan: 4 })
    t.throws(
      () => new DecoratorHandler(null),
      new TypeError('handler must be an object')
    )
    t.throws(
      () => new DecoratorHandler('string'),
      new TypeError('handler must be an object')
    )

    t.throws(
      () => new DecoratorHandler(null),
      new TypeError('handler must be an object')
    )
    t.throws(
      () => new DecoratorHandler('string'),
      new TypeError('handler must be an object')
    )
  })

  describe('wrap', () => {
    const Handler = class {
      #handler = null
      constructor (handler) {
        this.#handler = handler
      }

      onRequestStart (controller, context) {
        return this.#handler?.onRequestStart?.(controller, context)
      }

      onResponseStart (controller, statusCode, headers, statusMessage) {
        return this.#handler?.onResponseStart?.(controller, statusCode, headers, statusMessage)
      }

      onRequestUpgrade (controller, statusCode, headers, socket) {
        return this.#handler?.onRequestUpgrade?.(controller, statusCode, headers, socket)
      }

      onResponseData (controller, data) {
        return this.#handler?.onResponseData?.(controller, data)
      }

      onResponseEnd (controller, trailers) {
        return this.#handler?.onResponseEnd?.(controller, trailers)
      }

      onResponseError (controller, err) {
        return this.#handler?.onResponseError?.(controller, err)
      }
    }
    const Controller = class {
      #controller = null
      constructor (controller) {
        this.#controller = controller
      }

      abort (reason) {
        return this.#controller?.abort?.(reason)
      }

      resume () {
        return this.#controller?.resume?.()
      }

      pause () {
        return this.#controller?.pause?.()
      }
    }

    describe('#onRequestStart', () => {
      test('should delegate onRequestStart-method', t => {
        t = tspl(t, { plan: 3 })
        const handler = new Handler(
          {
            onRequestStart: (controller, ctx) => {
              t.equal(typeof controller, 'object')
              t.equal(typeof controller.abort, 'function')
              t.equal(typeof ctx, 'object')
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onRequestStart(new Controller(), {})
      })

      test('should not throw if onRequestStart-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onRequestStart())
      })
    })

    describe('#onResponseStart', () => {
      test('should delegate onResponseStart-method', t => {
        t = tspl(t, { plan: 4 })
        const handler = new Handler(
          {
            onResponseStart: (controller, statusCode, headers, statusMessage) => {
              t.equal(statusCode, 200)
              t.equal(headers['content-type'], 'application/json')
              t.equal(typeof controller.resume, 'function')
              t.equal(statusMessage, 'OK')
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onResponseStart(new Controller(), 200, {
          'content-type': 'application/json'
        }, 'OK')
      })

      test('should not throw if onResponseStart-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onResponseStart(new Controller(), 200, {
          'content-type': 'application/json'
        }))
      })
    })

    describe('#onRequestUpgrade', () => {
      test('should delegate onRequestUpgrade-method', t => {
        t = tspl(t, { plan: 3 })
        const handler = new Handler(
          {
            onRequestUpgrade: (_controller, statusCode, headers, socket) => {
              t.equal(statusCode, 301)
              t.equal(headers['content-type'], 'application/json')
              t.equal(typeof socket, 'object')
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onRequestUpgrade(new Controller(), 301, {
          'content-type': 'application/json'
        }, {})
      })

      test('should not throw if onRequestUpgrade-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onRequestUpgrade(new Controller(), 301, {
          'content-type': 'application/json'
        }))
      })
    })

    describe('#onResponseData', () => {
      test('should delegate onResponseData-method', t => {
        t = tspl(t, { plan: 1 })
        const handler = new Handler(
          {
            onResponseData: (_controller, chunk) => {
              t.equal('chunk', chunk)
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onResponseData(new Controller(), 'chunk')
      })

      test('should not throw if onResponseData-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onResponseData(new Controller(), 'chunk'))
      })
    })

    describe('#onResponseEnd', () => {
      test('should delegate onResponseEnd-method', t => {
        t = tspl(t, { plan: 1 })
        const handler = new Handler(
          {
            onResponseEnd: (_controller, trailers) => {
              t.equal(trailers['x-trailer'], 'trailer')
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' })
      })

      test('should not throw if onResponseEnd-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' }))
      })
    })

    describe('#onBodySent', () => {
      test('should delegate onBodySent-method', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({
          onBodySent: (chunk) => {
            t.equal(chunk, 'chunk')
          }
        })
        decorator.onBodySent('chunk')
      })

      test('should not throw if onBodySent-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onBodySent('chunk'))
      })
    })

    describe('#onRequestSent', () => {
      test('should delegate onRequestSent-method', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({
          onRequestSent: () => {
            t.ok(true)
          }
        })
        decorator.onRequestSent()
      })

      test('should not throw if onRequestSent-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onRequestSent())
      })
    })

    describe('#onResponseError', () => {
      test('should delegate onResponseError-method', t => {
        t = tspl(t, { plan: 1 })
        const handler = new Handler(
          {
            onResponseError: (_controller, err) => {
              t.equal(err.message, 'Oops!')
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onResponseError(new Controller(), new Error('Oops!'))
      })

      test('should not throw if onResponseError-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onResponseError(new Controller(), new Error('Oops!')))
      })
    })
  })

  describe('no-wrap', () => {
    const Handler = class {
      #handler = null
      constructor (handler) {
        this.#handler = handler
      }

      onRequestStart (controller, context) {
        return this.#handler?.onRequestStart?.(controller, context)
      }

      onRequestUpgrade (controller, statusCode, headers, socket) {
        return this.#handler?.onRequestUpgrade?.(controller, statusCode, headers, socket)
      }

      onResponseStart (controller, statusCode, headers, statusMessage) {
        return this.#handler?.onResponseStart?.(controller, statusCode, headers, statusMessage)
      }

      onResponseData (controller, data) {
        return this.#handler?.onResponseData?.(controller, data)
      }

      onResponseEnd (controller, trailers) {
        return this.#handler?.onResponseEnd?.(controller, trailers)
      }

      onResponseError (controller, err) {
        return this.#handler?.onResponseError?.(controller, err)
      }
    }
    const Controller = class {
      #controller = null
      constructor (controller) {
        this.#controller = controller
      }

      abort (reason) {
        return this.#controller?.abort?.(reason)
      }

      resume () {
        return this.#controller?.resume?.()
      }

      pause () {
        return this.#controller?.pause?.()
      }
    }

    describe('#onRequestStart', () => {
      test('should delegate onRequestStart-method', t => {
        t = tspl(t, { plan: 2 })
        const handler = new Handler(
          {
            onRequestStart: (controller, ctx) => {
              t.equal(controller.constructor, Controller)
              t.equal(typeof ctx, 'object')
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onRequestStart(new Controller(), {})
      })

      test('should not throw if onRequestStart-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onRequestStart())
      })
    })

    describe('#onRequestUpgrade', () => {
      test('should delegate onRequestUpgrade-method', t => {
        t = tspl(t, { plan: 4 })
        const handler = new Handler(
          {
            onRequestUpgrade: (controller, statusCode, headers, socket) => {
              t.equal(controller.constructor, Controller)
              t.equal(statusCode, 301)
              t.equal(headers['content-type'], 'application/json')
              t.equal(typeof socket, 'object')
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onRequestUpgrade(new Controller(), 301, {
          'content-type': 'application/json'
        }, {})
      })

      test('should not throw if onRequestUpgrade-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onRequestUpgrade(new Controller(), 301, {
          'content-type': 'application/json'
        }, {}))
      })
    })

    describe('#onResponseStart', () => {
      test('should delegate onResponseStart-method', t => {
        t = tspl(t, { plan: 4 })
        const handler = new Handler(
          {
            onResponseStart: (controller, statusCode, headers, message) => {
              t.equal(controller.constructor, Controller)
              t.equal(statusCode, 200)
              t.equal(headers['content-type'], 'application/json')
              t.equal(message, 'OK')
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onResponseStart(new Controller(), 200, {
          'content-type': 'application/json'
        }, 'OK')
      })

      test('should not throw if onResponseStart-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onResponseStart(new Controller(), 200, {
          'content-type': 'application/json'
        }, 'OK'))
      })
    })

    describe('#onResponseData', () => {
      test('should delegate onResponseData-method', t => {
        t = tspl(t, { plan: 2 })
        const handler = new Handler(
          {
            onResponseData: (controller, chunk) => {
              t.equal(controller.constructor, Controller)
              t.equal('chunk', chunk)
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onResponseData(new Controller(), 'chunk')
      })

      test('should not throw if onResponseData-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onResponseData(new Controller(), 'chunk'))
      })
    })

    describe('#onResponseEnd', () => {
      test('should delegate onResponseEnd-method', t => {
        t = tspl(t, { plan: 2 })
        const handler = new Handler(
          {
            onResponseEnd: (controller, trailers) => {
              t.equal(controller.constructor, Controller)
              t.equal(trailers['x-trailer'], 'trailer')
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' })
      })

      test('should not throw if onResponseEnd-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({})
        t.doesNotThrow(() => decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' }))
      })
    })

    describe('#onResponseError', () => {
      test('should delegate onResponseError-method', t => {
        t = tspl(t, { plan: 2 })
        const handler = new Handler(
          {
            onResponseError: (controller, err) => {
              t.equal(controller.constructor, Controller)
              t.equal(err.message, 'Oops!')
            }
          })
        const decorator = new DecoratorHandler(handler)
        decorator.onResponseError(new Controller(), new Error('Oops!'))
      })

      test('should throw if onResponseError-method is not defined in the handler', t => {
        t = tspl(t, { plan: 1 })
        const decorator = new DecoratorHandler({
          // To hin and not wrap the instance
          onRequestStart: () => {}
        })
        t.doesNotThrow(() => decorator.onResponseError(new Controller()))
      })
    })
  })
})

describe('DecoratorHandler interim responses', () => {
  test('forwards 1xx responses without counting them as the response start', t => {
    t = tspl(t, { plan: 2 })

    const statusCodes = []
    const handler = new DecoratorHandler({
      onResponseStart (controller, statusCode) {
        statusCodes.push(statusCode)
      }
    })

    handler.onResponseStart(null, 103, { link: '</style.css>; rel=preload; as=style' }, 'Early Hints')
    handler.onResponseStart(null, 200, {}, 'OK')
    t.deepStrictEqual(statusCodes, [103, 200])

    // a second final response is still rejected
    t.throws(() => handler.onResponseStart(null, 200, {}, 'OK'), { code: 'ERR_ASSERTION' })
  })
})