fetch-mock
Version:
Mock http requests made using fetch (or isomorphic-fetch)
352 lines (296 loc) • 8.14 kB
JavaScript
'use strict';
let Headers;
let Response;
let stream;
let Blob;
let theGlobal;
let debug;
function mockResponse (url, config) {
debug('mocking response for ' + url);
// allow just body to be passed in as this is the commonest use case
if (typeof config === 'number') {
debug('status response detected for ' + url);
config = {
status: config
};
} else if (typeof config === 'string' || !(config.body || config.headers || config.throws || config.status)) {
debug('body response detected for ' + url);
config = {
body: config
};
} else {
debug('full config response detected for ' + url);
}
if (config.throws) {
debug('mocking failed request for ' + url);
return Promise.reject(config.throws);
}
const opts = config.opts || {};
opts.url = url;
opts.status = config.status || 200;
// the ternary oprator is to cope with new Headers(undefined) throwing in chrome
// (unclear to me if this is a bug or if the specification says this is correct behaviour)
opts.headers = config.headers ? new Headers(config.headers) : new Headers();
let body = config.body;
if (config.body != null && typeof body === 'object') {
body = JSON.stringify(body);
}
debug('sending body "' + body + '"" for ' + url);
if (stream) {
let s = new stream.Readable();
if (body != null) {
s.push(body, 'utf-8');
}
s.push(null);
body = s;
}
return Promise.resolve(new Response(body, opts));
}
function compileRoute (route) {
var method = route.method;
var matchMethod;
if(method) {
method = method.toLowerCase();
matchMethod = function(options) {
var m = options && options.method ? options.method.toLowerCase() : 'get';
return m === method;
};
} else {
matchMethod = function(){ return true; };
}
debug('compiling route: ' + route.name);
if (!route.name) {
throw 'each route must be named';
}
if (!route.matcher) {
throw 'each route must specify a string, regex or function to match calls to fetch';
}
if (typeof route.response === 'undefined') {
throw 'each route must define a response';
}
if (typeof route.matcher === 'string') {
let expectedUrl = route.matcher;
if (route.matcher.indexOf('^') === 0) {
debug('constructing starts with string matcher for route: ' + route.name);
expectedUrl = expectedUrl.substr(1);
route.matcher = function (url, options) {
return matchMethod(options) && url.indexOf(expectedUrl) === 0;
};
} else {
debug('constructing string matcher for route: ' + route.name);
route.matcher = function (url, options) {
return matchMethod(options) && url === expectedUrl;
};
}
} else if (route.matcher instanceof RegExp) {
debug('constructing regex matcher for route: ' + route.name);
const urlRX = route.matcher;
route.matcher = function (url, options) {
return matchMethod(options) && urlRX.test(url);
};
}
return route;
}
class FetchMock {
constructor (opts) {
Headers = opts.Headers;
Response = opts.Response;
stream = opts.stream;
Blob = opts.Blob;
theGlobal = opts.theGlobal;
debug = opts.debug;
this.routes = [];
this._calls = {};
this.mockedContext = theGlobal;
this.realFetch = theGlobal.fetch;
}
useNonGlobalFetch (func) {
this.mockedContext = this;
this.realFetch = func;
}
registerRoute (name, matcher, response) {
debug('registering routes');
let routes;
if (name instanceof Array) {
routes = name;
} else if (arguments.length === 3 ) {
routes = [{
name,
matcher,
response,
}];
} else {
routes = [name];
}
debug('registering routes: ' + routes.map(r => r.name));
this.routes = this.routes.concat(routes.map(compileRoute));
}
unregisterRoute (names) {
if (!names) {
debug('unregistering all routes');
this.routes = [];
return;
}
if (!(names instanceof Array)) {
names = [names];
}
debug('unregistering routes: ' + names);
this.routes = this.routes.filter(route => {
const keep = names.indexOf(route.name) === -1;
if (!keep) {
debug('unregistering route ' + route.name);
}
return keep;
});
}
getRouter (config) {
debug('building router');
let routes;
if (config.routes) {
debug('applying one time only routes');
if (!(config.routes instanceof Array)) {
config.routes = [config.routes];
}
const preRegisteredRoutes = {};
this.routes.forEach(route => {
preRegisteredRoutes[route.name] = route;
});
routes = config.routes.map(route => {
if (typeof route === 'string') {
debug('applying preregistered route ' + route);
return preRegisteredRoutes[route];
} else {
debug('applying one time route ' + route.name);
return compileRoute(route);
}
});
} else {
debug('no one time only routes defined. Using preregistered routes only');
routes = this.routes;
}
const routeNames = {};
routes.forEach(route => {
if (routeNames[route.name]) {
throw 'Route names must be unique';
}
routeNames[route.name] = true;
});
config.responses = config.responses || {};
return (url, opts) => {
let response;
debug('searching for matching route for ' + url);
routes.some(route => {
if (route.matcher(url, opts)) {
debug('Found matching route (' + route.name + ') for ' + url);
this.push(route.name, [url, opts]);
if (config.responses[route.name]) {
debug('Overriding response for ' + route.name);
response = config.responses[route.name];
} else {
debug('Using default response for ' + route.name);
response = route.response;
}
if (typeof response === 'function') {
debug('Constructing dynamic response for ' + route.name);
response = response(url, opts);
}
return true;
}
});
debug('returning response for ' + url);
return response;
};
}
push (name, call) {
this._calls[name] = this._calls[name] || [];
this._calls[name].push(call);
}
mock (matcher, method, response) {
let config;
if (response) {
config = {
routes: [{
name: '_mock',
matcher,
method,
response
}]
}
} else if (method) {
config = {
routes: [{
name: '_mock',
matcher,
response: method
}]
}
} else if (matcher instanceof Array) {
config = {
routes: matcher
}
} else if (matcher && matcher.matcher) {
config = {
routes: [matcher]
}
} else {
config = matcher;
}
debug('mocking fetch');
if (this.isMocking) {
throw 'fetch-mock is already mocking routes. Call .restore() before mocking again or use .reMock() if this is intentional';
}
this.isMocking = true;
return this.mockedContext.fetch = this.constructMock(config);
}
constructMock (config) {
debug('constructing mock function');
config = config || {};
const router = this.getRouter(config);
config.greed = config.greed || 'none';
return (url, opts) => {
const response = router(url, opts);
if (response) {
debug('response found for ' + url);
return mockResponse(url, response);
} else {
debug('response not found for ' + url);
this.push('__unmatched', [url, opts]);
if (config.greed === 'good') {
debug('sending default good response');
return mockResponse(url, {body: 'unmocked url: ' + url});
} else if (config.greed === 'bad') {
debug('sending default bad response');
return mockResponse(url, {throws: 'unmocked url: ' + url});
} else {
debug('forwarding to default fetch');
return this.realFetch(url, opts);
}
}
};
}
restore () {
debug('restoring fetch');
this.isMocking = false;
this.mockedContext.fetch = this.realFetch;
this.reset();
debug('fetch restored');
}
reMock () {
this.restore();
this.mock.apply(this, [].slice.apply(arguments));
}
reset () {
debug('resetting call logs');
this._calls = {};
}
calls (name) {
return name ? (this._calls[name] || []) : (this._calls._mock || this._calls);
}
called (name) {
if (!name) {
return !!Object.keys(this._calls).length;
}
return !!(this._calls[name] && this._calls[name].length);
}
}
module.exports = FetchMock;