current commit
260801.1 · fix(mock): non-string path matchers under ignoreTrailingSlash, and DataView reply bodies (#5619)
'use strict'

const { test, describe } = require('node:test')
const { MockNotMatchedError } = require('../lib/mock/mock-errors')
const {
  deleteMockDispatch,
  getMockDispatch,
  getResponseData,
  getStatusText,
  getHeaderByName,
  buildHeadersFromArray,
  normalizeSearchParams,
  normalizeOrigin
} = require('../lib/mock/mock-utils')

test('deleteMockDispatch - should do nothing if not able to find mock dispatch', (t) => {
  t.plan(1)

  const key = {
    url: 'url',
    path: 'path',
    method: 'method',
    body: 'body'
  }

  t.assert.doesNotThrow(() => deleteMockDispatch([], key))
})

describe('getMockDispatch', () => {
  test('it should find a mock dispatch', (t) => {
    t.plan(1)
    const dispatches = [
      {
        path: 'path',
        method: 'method',
        consumed: false
      }
    ]

    const result = getMockDispatch(dispatches, {
      path: 'path',
      method: 'method'
    })
    t.assert.deepStrictEqual(result, {
      path: 'path',
      method: 'method',
      consumed: false
    })
  })

  test('it should skip consumed dispatches', (t) => {
    t.plan(1)
    const dispatches = [
      {
        path: 'path',
        method: 'method',
        consumed: true
      },
      {
        path: 'path',
        method: 'method',
        consumed: false
      }
    ]

    const result = getMockDispatch(dispatches, {
      path: 'path',
      method: 'method'
    })
    t.assert.deepStrictEqual(result, {
      path: 'path',
      method: 'method',
      consumed: false
    })
  })

  test('it should throw if dispatch not found', (t) => {
    t.plan(1)
    const dispatches = [
      {
        path: 'path',
        method: 'method',
        consumed: false
      }
    ]

    t.assert.throws(() => getMockDispatch(dispatches, {
      path: 'wrong',
      method: 'wrong'
    }), new MockNotMatchedError('Mock dispatch not matched for path \'wrong\''))
  })

  test('it should throw if no dispatch matches method', (t) => {
    t.plan(1)
    const dispatches = [
      {
        path: 'path',
        method: 'method',
        consumed: false
      }
    ]

    t.assert.throws(() => getMockDispatch(dispatches, {
      path: 'path',
      method: 'wrong'
    }), new MockNotMatchedError('Mock dispatch not matched for method \'wrong\' on path \'path\''))
  })

  test('it should throw if no dispatch matches body', (t) => {
    t.plan(1)
    const dispatches = [
      {
        path: 'path',
        method: 'method',
        body: 'body',
        consumed: false
      }
    ]

    t.assert.throws(() => getMockDispatch(dispatches, {
      path: 'path',
      method: 'method',
      body: 'wrong'
    }), new MockNotMatchedError('Mock dispatch not matched for body \'wrong\' on path \'path\''))
  })

  test('it should match a RegExp path with ignoreTrailingSlash', (t) => {
    t.plan(2)
    const dispatch = {
      path: /^\/path$/,
      method: 'method',
      ignoreTrailingSlash: true,
      consumed: false
    }

    t.assert.deepStrictEqual(getMockDispatch([dispatch], {
      path: '/path',
      method: 'method'
    }), dispatch)
    t.assert.deepStrictEqual(getMockDispatch([dispatch], {
      path: '/path/',
      method: 'method'
    }), dispatch)
  })

  test('it should match a function path with ignoreTrailingSlash', (t) => {
    t.plan(2)
    const dispatch = {
      path: (path) => path === '/path',
      method: 'method',
      ignoreTrailingSlash: true,
      consumed: false
    }

    t.assert.deepStrictEqual(getMockDispatch([dispatch], {
      path: '/path',
      method: 'method'
    }), dispatch)
    t.assert.deepStrictEqual(getMockDispatch([dispatch], {
      path: '/path/',
      method: 'method'
    }), dispatch)
  })

  test('it should throw if no dispatch matches headers', (t) => {
    t.plan(1)
    const dispatches = [
      {
        path: 'path',
        method: 'method',
        body: 'body',
        headers: { key: 'value' },
        consumed: false
      }
    ]

    t.assert.throws(() => getMockDispatch(dispatches, {
      path: 'path',
      method: 'method',
      body: 'body',
      headers: { key: 'wrong' }
    }), new MockNotMatchedError('Mock dispatch not matched for headers \'{"key":"wrong"}\' on path \'path\''))
  })
})

describe('getResponseData', () => {
  test('it should stringify objects', (t) => {
    t.plan(1)
    const responseData = getResponseData({ str: 'string', num: 42 })
    t.assert.strictEqual(responseData, '{"str":"string","num":42}')
  })

  test('it should return strings untouched', (t) => {
    t.plan(1)
    const responseData = getResponseData('test')
    t.assert.strictEqual(responseData, 'test')
  })

  test('it should return buffers untouched', (t) => {
    t.plan(1)
    const responseData = getResponseData(Buffer.from('test'))
    t.assert.ok(Buffer.isBuffer(responseData))
  })

  test('it should return Uint8Array untouched', (t) => {
    t.plan(1)
    const responseData = getResponseData(new TextEncoder().encode('{"test":true}'))
    t.assert.ok(responseData instanceof Uint8Array)
  })

  test('it should return ArrayBuffers untouched', (t) => {
    t.plan(1)
    const responseData = getResponseData(new TextEncoder().encode('{"test":true}').buffer)
    t.assert.ok(responseData instanceof ArrayBuffer)
  })

  test('it should return the bytes of a DataView', (t) => {
    t.plan(2)
    const responseData = getResponseData(new DataView(new TextEncoder().encode('{"test":true}').buffer))
    t.assert.ok(responseData instanceof Uint8Array)
    t.assert.strictEqual(Buffer.from(responseData).toString('utf8'), '{"test":true}')
  })

  test('it should return only the bytes a DataView covers', (t) => {
    t.plan(1)
    const buffer = new TextEncoder().encode('xx{"test":true}yy').buffer
    const responseData = getResponseData(new DataView(buffer, 2, 13))
    t.assert.strictEqual(Buffer.from(responseData).toString('utf8'), '{"test":true}')
  })

  test('it should return the bytes of a typed array that is not a Uint8Array', (t) => {
    t.plan(2)
    const responseData = getResponseData(new Uint8ClampedArray([1, 2, 3]))
    t.assert.ok(responseData instanceof Uint8Array)
    t.assert.deepStrictEqual([...responseData], [1, 2, 3])
  })

  test('it should handle undefined', (t) => {
    t.plan(1)
    const responseData = getResponseData(undefined)
    t.assert.strictEqual(responseData, '')
  })

  test('it should handle null', (t) => {
    t.plan(1)
    const responseData = getResponseData(null)
    t.assert.strictEqual(responseData, 'null')
  })
})

test('getStatusText', (t) => {
  t.plan(64)

  for (const statusCode of [
    100, 101, 102, 103, 200, 201, 202, 203,
    204, 205, 206, 207, 208, 226, 300, 301,
    302, 303, 304, 305, 306, 307, 308, 400,
    401, 402, 403, 404, 405, 406, 407, 408,
    409, 410, 411, 412, 413, 414, 415, 416,
    417, 418, 421, 422, 423, 424, 425, 426,
    428, 429, 431, 451, 500, 501, 502, 503,
    504, 505, 506, 507, 508, 510, 511
  ]) {
    t.assert.ok(getStatusText(statusCode))
  }

  t.assert.strictEqual(getStatusText(420), 'unknown')
})

test('getHeaderByName', (t) => {
  t.plan(6)

  const headersRecord = {
    key: 'value'
  }

  t.assert.strictEqual(getHeaderByName(headersRecord, 'key'), 'value')
  t.assert.strictEqual(getHeaderByName(headersRecord, 'anotherKey'), undefined)

  const headersArray = ['key', 'value']

  t.assert.strictEqual(getHeaderByName(headersArray, 'key'), 'value')
  t.assert.strictEqual(getHeaderByName(headersArray, 'anotherKey'), undefined)

  const { Headers } = require('../index')

  const headers = new Headers([
    ['key', 'value']
  ])

  t.assert.strictEqual(getHeaderByName(headers, 'key'), 'value')
  t.assert.strictEqual(getHeaderByName(headers, 'anotherKey'), null)
})

describe('buildHeadersFromArray', () => {
  test('it should build headers from array', (t) => {
    t.plan(2)

    const headers = buildHeadersFromArray([
      'key', 'value'
    ])

    t.assert.deepStrictEqual(Object.keys(headers).length, 1)
    t.assert.strictEqual(headers.key, 'value')
  })
})

describe('normalizeQueryParams', () => {
  test('it should handle basic cases', (t) => {
    t.plan(4)

    t.assert.deepStrictEqual(normalizeSearchParams('').toString(), '')
    t.assert.deepStrictEqual(normalizeSearchParams('a').toString(), 'a=')
    t.assert.deepStrictEqual(normalizeSearchParams('b=2&c=3&a=1').toString(), 'b=2&c=3&a=1')
    t.assert.deepStrictEqual(normalizeSearchParams('lang=en_EN&id=123').toString(), 'lang=en_EN&id=123')
  })

  // https://github.com/nodejs/undici/issues/4146
  test('it should handle multiple values set using different syntaxes', (t) => {
    t.plan(3)

    t.assert.deepStrictEqual(normalizeSearchParams('a=1&a=2&a=3').toString(), 'a=1&a=2&a=3')
    t.assert.deepStrictEqual(normalizeSearchParams('a[]=1&a[]=2&a[]=3').toString(), 'a=1&a=2&a=3')
    t.assert.deepStrictEqual(normalizeSearchParams('a=1,2,3').toString(), 'a=1&a=2&a=3')
  })

  test('should handle edge case scenarios', (t) => {
    t.plan(4)

    t.assert.deepStrictEqual(normalizeSearchParams('a="b[]"').toString(), `a=${encodeURIComponent('"b[]"')}`)
    t.assert.deepStrictEqual(normalizeSearchParams('a="1,2,3"').toString(), `a=${encodeURIComponent('"1,2,3"')}`)
    const encodedSingleQuote = '%27'
    t.assert.deepStrictEqual(normalizeSearchParams("a='b[]'").toString(), `a=${encodedSingleQuote}${encodeURIComponent('b[]')}${encodedSingleQuote}`)
    t.assert.deepStrictEqual(normalizeSearchParams("a='1,2,3'").toString(), `a=${encodedSingleQuote}${encodeURIComponent('1,2,3')}${encodedSingleQuote}`)
  })
})

describe('normalizeOrigin', () => {
  test('should normalize hostname to lowercase for string origins', (t) => {
    t.plan(4)

    t.assert.strictEqual(normalizeOrigin('http://Example.com'), 'http://example.com')
    t.assert.strictEqual(normalizeOrigin('http://EXAMPLE.COM'), 'http://example.com')
    t.assert.strictEqual(normalizeOrigin('https://Api.Example.com'), 'https://api.example.com')
    t.assert.strictEqual(normalizeOrigin('http://MyEndpoint'), 'http://myendpoint')
  })

  test('should normalize hostname to lowercase for URL objects', (t) => {
    t.plan(4)

    t.assert.strictEqual(normalizeOrigin(new URL('http://Example.com')), 'http://example.com')
    t.assert.strictEqual(normalizeOrigin(new URL('http://EXAMPLE.COM')), 'http://example.com')
    t.assert.strictEqual(normalizeOrigin(new URL('https://Api.Example.com')), 'https://api.example.com')
    t.assert.strictEqual(normalizeOrigin(new URL('http://MyEndpoint')), 'http://myendpoint')
  })

  test('should preserve port numbers', (t) => {
    t.plan(3)

    t.assert.strictEqual(normalizeOrigin('http://Example.com:8080'), 'http://example.com:8080')
    t.assert.strictEqual(normalizeOrigin(new URL('http://Example.com:3000')), 'http://example.com:3000')
    t.assert.strictEqual(normalizeOrigin(new URL('https://Test.com:8443')), 'https://test.com:8443')
  })

  test('should return RegExp matchers as-is', (t) => {
    t.plan(1)

    const regex = /http:\/\/example\.com/
    t.assert.strictEqual(normalizeOrigin(regex), regex)
  })

  test('should return function matchers as-is', (t) => {
    t.plan(1)

    const fn = (origin) => origin === 'http://example.com'
    t.assert.strictEqual(normalizeOrigin(fn), fn)
  })

  test('should return other non-string, non-URL types as-is', (t) => {
    t.plan(4)

    const obj = { origin: 'http://example.com' }
    const num = 123
    const bool = true
    const nullValue = null

    t.assert.strictEqual(normalizeOrigin(obj), obj)
    t.assert.strictEqual(normalizeOrigin(num), num)
    t.assert.strictEqual(normalizeOrigin(bool), bool)
    t.assert.strictEqual(normalizeOrigin(nullValue), nullValue)
  })

  test('should handle invalid URLs gracefully', (t) => {
    t.plan(2)

    // Invalid URL strings should be returned as-is
    t.assert.strictEqual(normalizeOrigin('not-a-url'), 'not-a-url')
    t.assert.strictEqual(normalizeOrigin('://invalid'), '://invalid')
  })

  test('should handle IPv4 addresses', (t) => {
    t.plan(2)

    t.assert.strictEqual(normalizeOrigin('http://192.168.1.1'), 'http://192.168.1.1')
    t.assert.strictEqual(normalizeOrigin('http://127.0.0.1:3000'), 'http://127.0.0.1:3000')
  })

  test('should handle IPv6 addresses', (t) => {
    t.plan(2)

    t.assert.strictEqual(normalizeOrigin('http://[::1]'), 'http://[::1]')
    t.assert.strictEqual(normalizeOrigin('http://[2001:db8::1]:8080'), 'http://[2001:db8::1]:8080')
  })

  test('should handle localhost with different cases', (t) => {
    t.plan(3)

    t.assert.strictEqual(normalizeOrigin('http://LocalHost'), 'http://localhost')
    t.assert.strictEqual(normalizeOrigin('http://LOCALHOST:3000'), 'http://localhost:3000')
    t.assert.strictEqual(normalizeOrigin(new URL('http://LocalHost')), 'http://localhost')
  })

  test('should handle subdomains with mixed case', (t) => {
    t.plan(3)

    t.assert.strictEqual(normalizeOrigin('http://Api.Example.Com'), 'http://api.example.com')
    t.assert.strictEqual(normalizeOrigin('https://WWW.Example.COM'), 'https://www.example.com')
    t.assert.strictEqual(normalizeOrigin(new URL('http://Sub.Domain.Example.Com')), 'http://sub.domain.example.com')
  })

  test('should handle paths in URL objects (should only normalize origin part)', (t) => {
    t.plan(2)

    // URL objects with paths should still only return the origin
    const url1 = new URL('http://Example.com/path/to/resource')
    t.assert.strictEqual(normalizeOrigin(url1), 'http://example.com')

    const url2 = new URL('https://Api.Example.com:8080/api/v1')
    t.assert.strictEqual(normalizeOrigin(url2), 'https://api.example.com:8080')
  })
})