Skip to content

Commit f8d6cbb

Browse files
authored
fix: allow unmocked when providing literal search params. (#1614)
fixes: #1421 There was inconsistent behavior around how search params were handled when they were provided as part of a URI string to new Interceptors. The issue happen to come to light when `allowUnmocked` was set. The fix normalizes the behavior by refactoring the constructor of Interceptor to look for literal search params. If present, they're stripped from the `path` attribute and set via the `query` method. As part of the work, `Interceptor.query` was modified to accept `URLSearchParams` instances as valid input.
1 parent 2a0c7bd commit f8d6cbb

File tree

4 files changed

+109
-18
lines changed

4 files changed

+109
-18
lines changed

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,15 @@ nock('http://www.example.com')
254254

255255
### Specifying request query string
256256

257-
Nock understands query strings. Instead of placing the entire URL, you can specify the query part as an object:
257+
Nock understands query strings. Search parameters can be included as part of the path:
258+
259+
```js
260+
nock('http://example.com')
261+
.get('/users?foo=bar')
262+
.reply(200)
263+
```
264+
265+
Instead of placing the entire URL, you can specify the query part as an object:
258266

259267
```js
260268
nock('http://example.com')
@@ -278,6 +286,17 @@ nock('http://example.com')
278286
.reply(200, { results: [{ id: 'pgte' }] })
279287
```
280288

289+
A `URLSearchParams` instance can be provided.
290+
291+
```js
292+
const params = new URLSearchParams({ foo: 'bar' })
293+
294+
nock('http://example.com')
295+
.get('/')
296+
.query(params)
297+
.reply(200)
298+
```
299+
281300
Nock supports passing a function to query. The function determines if the actual query matches or not.
282301

283302
```js

lib/interceptor.js

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const _ = require('lodash')
66
const debug = require('debug')('nock.interceptor')
77
const stringify = require('json-stringify-safe')
88
const qs = require('qs')
9+
const url = require('url')
910

1011
let fs
1112
try {
@@ -16,30 +17,40 @@ try {
1617

1718
module.exports = Interceptor
1819

20+
/**
21+
*
22+
* Valid argument types for `uri`:
23+
* - A string used for strict comparisons with pathname.
24+
* The search portion of the URI may also be postfixed, in which case the search params
25+
* are striped and added via the `query` method.
26+
* - A RegExp instance that tests against only the pathname of requests.
27+
* - A synchronous function bound to this Interceptor instance. It's provided the pathname
28+
* of requests and must return a boolean denoting if the request is considered a match.
29+
*/
1930
function Interceptor(scope, uri, method, requestBody, interceptorOptions) {
31+
const uriIsStr = typeof uri === 'string'
2032
// Check for leading slash. Uri can be either a string or a regexp, but
2133
// we only need to check strings.
22-
if (typeof uri === 'string' && /^[^/*]/.test(uri)) {
34+
if (uriIsStr && /^[^/*]/.test(uri)) {
2335
throw Error(
2436
"Non-wildcard URL path strings must begin with a slash (otherwise they won't match anything)"
2537
)
2638
}
2739

28-
this.scope = scope
29-
this.interceptorMatchHeaders = []
30-
31-
if (typeof method === 'undefined' || !method) {
40+
if (!method) {
3241
throw new Error('The "method" parameter is required for an intercept call.')
3342
}
43+
44+
this.scope = scope
45+
this.interceptorMatchHeaders = []
3446
this.method = method.toUpperCase()
3547
this.uri = uri
3648
this._key = `${this.method} ${scope.basePath}${scope.basePathname}${
37-
typeof uri === 'string' ? '' : '/'
49+
uriIsStr ? '' : '/'
3850
}${uri}`
3951
this.basePath = this.scope.basePath
40-
this.path = typeof uri === 'string' ? scope.basePathname + uri : uri
52+
this.path = uriIsStr ? scope.basePathname + uri : uri
4153

42-
this.baseUri = `${this.method} ${scope.basePath}${scope.basePathname}`
4354
this.options = interceptorOptions || {}
4455
this.counter = 1
4556
this._requestBody = requestBody
@@ -56,6 +67,15 @@ function Interceptor(scope, uri, method, requestBody, interceptorOptions) {
5667
this.delayConnectionInMs = 0
5768

5869
this.optional = false
70+
71+
// strip off literal query parameters if they were provided as part of the URI
72+
if (uriIsStr && uri.includes('?')) {
73+
// localhost is a dummy value because the URL constructor errors for only relative inputs
74+
const parsedURL = new url.URL(this.path, 'http://localhost')
75+
this.path = parsedURL.pathname
76+
this.query(parsedURL.searchParams)
77+
this._key = `${this.method} ${scope.basePath}${this.path}`
78+
}
5979
}
6080

6181
Interceptor.prototype.optionally = function optionally(value) {
@@ -463,18 +483,17 @@ Interceptor.prototype.query = function query(queries) {
463483
return this
464484
}
465485

466-
let stringFormattingFn
486+
let strFormattingFn
467487
if (this.scope.scopeOptions.encodedQueryParams) {
468-
stringFormattingFn = common.percentDecode
488+
strFormattingFn = common.percentDecode
469489
}
470490

471-
for (const key in queries) {
472-
if (_.isUndefined(this.queries[key])) {
473-
const formattedPair = common.formatQueryValue(
474-
key,
475-
queries[key],
476-
stringFormattingFn
477-
)
491+
const entries =
492+
queries instanceof url.URLSearchParams ? queries : Object.entries(queries)
493+
494+
for (const [key, value] of entries) {
495+
if (this.queries[key] === undefined) {
496+
const formattedPair = common.formatQueryValue(key, value, strFormattingFn)
478497
this.queries[formattedPair[0]] = formattedPair[1]
479498
}
480499
}

tests/test_allow_unmocked.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,17 @@ test('match multiple paths to domain using regexp with allowUnmocked', async t =
259259
scope2.done()
260260
})
261261

262+
test('match domain and path with literal query params and allowUnmocked', async t => {
263+
const scope = nock('http://example.test', { allowUnmocked: true })
264+
.get('/foo?bar=baz')
265+
.reply()
266+
267+
const { statusCode } = await got('http://example.test/foo?bar=baz')
268+
269+
t.is(statusCode, 200)
270+
scope.done()
271+
})
272+
262273
test('match domain and path using regexp with query params and allowUnmocked', t => {
263274
const imgResponse = 'Matched Images Page'
264275
const opts = { allowUnmocked: true }

tests/test_query.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
const mikealRequest = require('request')
44
const { test } = require('tap')
5+
const url = require('url')
56
const nock = require('..')
7+
const got = require('./got_client')
68

79
require('./cleanup_after_each')()
810

@@ -32,6 +34,17 @@ test('query() matches multiple query strings of the same name=value', t => {
3234
})
3335
})
3436

37+
test('literal query params have the same behavior as calling query() directly', async t => {
38+
const scope = nock('http://example.test')
39+
.get('/foo?bar=baz')
40+
.reply()
41+
42+
const { statusCode } = await got('http://example.test/foo?bar=baz')
43+
44+
t.is(statusCode, 200)
45+
scope.done()
46+
})
47+
3548
test('query() matches multiple query strings of the same name=value regardless of order', t => {
3649
nock('http://example.test')
3750
.get('/')
@@ -92,6 +105,35 @@ test('query() matches a query string using regexp', t => {
92105
})
93106
})
94107

108+
test('query() accepts URLSearchParams as input', async t => {
109+
const params = new url.URLSearchParams({
110+
foo: 'bar',
111+
})
112+
113+
const scope = nock('http://example.test')
114+
.get('/')
115+
.query(params)
116+
.reply()
117+
118+
const { statusCode } = await got('http://example.test?foo=bar')
119+
120+
t.is(statusCode, 200)
121+
scope.done()
122+
})
123+
124+
test('multiple set query keys use the first occurrence', async t => {
125+
const scope = nock('http://example.test')
126+
.get('/')
127+
.query({ foo: 'bar' })
128+
.query({ foo: 'baz' })
129+
.reply()
130+
131+
const { statusCode } = await got('http://example.test?foo=bar')
132+
133+
t.is(statusCode, 200)
134+
scope.done()
135+
})
136+
95137
test('query() matches a query string that contains special RFC3986 characters', t => {
96138
nock('http://example.test')
97139
.get('/')

0 commit comments

Comments
 (0)