UNPKG

fetch-mock

Version:

Mock http requests made using fetch (or isomorphic-fetch)

468 lines (406 loc) 12.6 kB
'use strict'; var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; }; function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } var Headers = undefined; var Request = undefined; var Response = undefined; var stream = undefined; var theGlobal = undefined; var statusTextMap = undefined; /** * mockResponse * Constructs a Response object to return from the mocked fetch * @param {String} url url parameter fetch was called with * @param {Object} config configuration for the response to be constructed * @return {Promise} Promise for a Response object (or a rejected response to imitate network failure) */ function mockResponse(url, responseConfig, fetchOpts) { // It seems odd to call this in here even though it's already called within fetchMock // It's to handle the fact that because we want to support making it very easy to add a // delay to any sort of response (including responses which are defined with a function) // while also allowing function responses to return a Promise for a response config. if (typeof responseConfig === 'function') { responseConfig = responseConfig(url, fetchOpts); } if (responseConfig.throws) { return Promise.reject(responseConfig.throws); } if (typeof responseConfig === 'number') { responseConfig = { status: responseConfig }; } else if (typeof responseConfig === 'string' || !(responseConfig.body || responseConfig.headers || responseConfig.throws || responseConfig.status)) { responseConfig = { body: responseConfig }; } var opts = responseConfig.opts || {}; opts.url = url; opts.sendAsJson = responseConfig.sendAsJson === undefined ? true : responseConfig.sendAsJson; opts.status = responseConfig.status || 200; opts.statusText = statusTextMap['' + opts.status]; // The ternary operator is to cope with new Headers(undefined) throwing in Chrome // https://code.google.com/p/chromium/issues/detail?id=335871 opts.headers = responseConfig.headers ? new Headers(responseConfig.headers) : new Headers(); var body = responseConfig.body; if (opts.sendAsJson && responseConfig.body != null && (typeof body === 'undefined' ? 'undefined' : _typeof(body)) === 'object') { //eslint-disable-line body = JSON.stringify(body); } if (stream) { var s = new stream.Readable(); if (body != null) { //eslint-disable-line s.push(body, 'utf-8'); } s.push(null); body = s; } return Promise.resolve(new Response(body, opts)); } /** * normalizeRequest * Given the parameters fetch was called with, normalises Request or url + options pairs * to a standard container object passed to matcher functions * @param {String|Request} url * @param {Object} options * @return {Object} {url, method} */ function normalizeRequest(url, options) { if (Request.prototype.isPrototypeOf(url)) { return { url: url.url, method: url.method }; } else { return { url: url, method: options && options.method || 'GET' }; } } /** * compileRoute * Given a route configuration object, validates the object structure and compiles * the object into a {name, matcher, response} triple * @param {Object} route route config * @return {Object} {name, matcher, response} */ function compileRoute(route) { if (typeof route.response === 'undefined') { throw new Error('Each route must define a response'); } if (!route.matcher) { throw new Error('each route must specify a string, regex or function to match calls to fetch'); } if (!route.name) { route.name = route.matcher.toString(); route.__unnamed = true; } // If user has provided a function as a matcher we assume they are handling all the // matching logic they need if (typeof route.matcher === 'function') { return route; } var expectedMethod = route.method && route.method.toLowerCase(); function matchMethod(method) { return !expectedMethod || expectedMethod === (method ? method.toLowerCase() : 'get'); }; var matchUrl = undefined; if (typeof route.matcher === 'string') { if (route.matcher.indexOf('^') === 0) { (function () { var expectedUrl = route.matcher.substr(1); matchUrl = function matchUrl(url) { return url.indexOf(expectedUrl) === 0; }; })(); } else { (function () { var expectedUrl = route.matcher; matchUrl = function matchUrl(url) { return url === expectedUrl; }; })(); } } else if (route.matcher instanceof RegExp) { (function () { var urlRX = route.matcher; matchUrl = function matchUrl(url) { return urlRX.test(url); }; })(); } route.matcher = function (url, options) { var req = normalizeRequest(url, options); return matchMethod(req.method) && matchUrl(req.url); }; return route; } var FetchMock = function () { /** * constructor * Sets up scoped references to configuration passed in from client/server bootstrappers * @param {Object} opts */ function FetchMock(opts) { _classCallCheck(this, FetchMock); Headers = opts.Headers; Request = opts.Request; Response = opts.Response; stream = opts.stream; theGlobal = opts.theGlobal; statusTextMap = opts.statusTextMap; this.routes = []; this._calls = {}; this._matchedCalls = []; this._unmatchedCalls = []; this.fetchMock = this.fetchMock.bind(this); this.restore = this.restore.bind(this); this.reMock = this.reMock.bind(this); this.reset = this.reset.bind(this); } /** * useNonGlobalFetch * Sets fetchMock's default internal reference to native fetch to the given function * @param {Function} func */ _createClass(FetchMock, [{ key: 'useNonGlobalFetch', value: function useNonGlobalFetch(func) { this.mockedContext = this; this.realFetch = func; return this; } /** * mock * Replaces fetch with a stub which attempts to match calls against configured routes * See README for details of parameters * @return {FetchMock} Returns the FetchMock instance, so can be chained */ }, { key: 'mock', value: function mock(matcher, method, response) { // Do this here rather than in the constructor to ensure it's scoped to the test this.realFetch = this.realFetch || theGlobal.fetch && theGlobal.fetch.bind(theGlobal); var config = undefined; // Handle the variety of parameters accepted by mock (see README) if (response) { config = { routes: [{ matcher: matcher, method: method, response: response }] }; } else if (method) { config = { routes: [{ matcher: matcher, response: method }] }; } else if (matcher instanceof Array) { config = { routes: matcher }; } else if (matcher && matcher.matcher) { config = { routes: [matcher] }; } else { config = matcher; } this.addRoutes(config.routes); this.greed = config.greed || this.greed || 'none'; theGlobal.fetch = this.fetchMock; return this; } /** * constructMock * Constructs a function which attempts to match fetch calls against routes (see constructRouter) * and handles success or failure of that attempt accordingly * @param {Object} config See README * @return {Function} Function expecting url + options or a Request object, and returning * a promise of a Response, or forwading to native fetch */ }, { key: 'fetchMock', value: function fetchMock(url, opts) { var response = this.router(url, opts); if (response) { if (typeof response === 'function') { response = response(url, opts); } if (response instanceof Promise) { return response.then(function (response) { return mockResponse(url, response, opts); }); } else { return mockResponse(url, response, opts); } } else { console.warn('unmatched call to ' + url); this.push(null, [url, opts]); if (this.greed === 'good') { return mockResponse(url, { body: 'unmocked url: ' + url }); } else if (this.greed === 'bad') { return mockResponse(url, { throws: 'unmocked url: ' + url }); } else { if (!this.realFetch) { throw new Error('fetch not defined in this environment. To mock unmatched calls set `greed: good` in your options'); } return this.realFetch(url, opts); } } } /** * router * Given url + options or a Request object, checks to see if ait is matched by any routes and returns * config for a response or undefined. * @param {String|Request} url * @param {Object} * @return {Object} */ }, { key: 'router', value: function router(url, opts) { var route = undefined; for (var i = 0, il = this.routes.length; i < il; i++) { route = this.routes[i]; if (route.matcher(url, opts)) { this.push(route.name, [url, opts]); return route.response; } } } /** * addRoutes * Adds routes to those used by fetchMock to match fetch calls * @param {Object|Array} routes route configurations */ }, { key: 'addRoutes', value: function addRoutes(routes) { if (!routes) { throw new Error('.mock() must be passed configuration for routes'); } if (!(routes instanceof Array)) { routes = [routes]; } // Allows selective application of some of the preregistered routes this.routes = this.routes.concat(routes.map(compileRoute)); } /** * push * Records history of fetch calls * @param {String} name Name of the route matched by the call * @param {Array} call [url, opts] pair */ }, { key: 'push', value: function push(name, call) { if (name) { this._calls[name] = this._calls[name] || []; this._calls[name].push(call); this._matchedCalls.push(call); } else { this._unmatchedCalls.push(call); } } /** * restore * Restores global fetch to its initial state and resets call history */ }, { key: 'restore', value: function restore() { if (this.realFetch) { theGlobal.fetch = this.realFetch; } this.reset(); this.routes = []; } /** * reMock * Same as .mock(), but also calls .restore() internally * @return {FetchMock} Returns the FetchMock instance, so can be chained */ }, { key: 'reMock', value: function reMock() { this.restore(); return this.mock.apply(this, [].slice.apply(arguments)); } /** * getMock * Returns a reference to the stub function used to mock fetch * @return {Function} */ }, { key: 'getMock', value: function getMock() { return this.fetchMock; } /** * reset * Resets call history */ }, { key: 'reset', value: function reset() { this._calls = {}; this._matchedCalls = []; this._unmatchedCalls = []; } /** * calls * Returns call history. See README */ }, { key: 'calls', value: function calls(name) { return name ? this._calls[name] || [] : { matched: this._matchedCalls, unmatched: this._unmatchedCalls }; } }, { key: 'lastCall', value: function lastCall(name) { var calls = name ? this.calls(name) : this.calls().matched; if (calls && calls.length) { return calls[calls.length - 1]; } else { return undefined; } } }, { key: 'lastUrl', value: function lastUrl(name) { var call = this.lastCall(name); return call && call[0]; } }, { key: 'lastOptions', value: function lastOptions(name) { var call = this.lastCall(name); return call && call[1]; } /** * called * Returns whether fetch has been called matching a configured route. See README */ }, { key: 'called', value: function called(name) { if (!name) { return !!this._matchedCalls.length; } return !!(this._calls[name] && this._calls[name].length); } }]); return FetchMock; }(); module.exports = FetchMock;