src/MockRequests.js

/** @typedef {import('./types').JsonPrimitive} JsonPrimitive */
/** @typedef {import('./types').DynamicResponseModFn} DynamicResponseModFn */
/** @typedef {import('./types').MockResponseConfig} MockResponseConfig */

/**
 * MockRequests will mock both `XMLHttpRequest` and `fetch` such that
 * one single configure call, and the entire app is provided with mocks.
 *
 * URLs not configured will be unaffected and still trigger an
 * async request as normal.
 *
 * @namespace MockRequests
 */
const MockRequests = (function MockRequestsFactory() {
    /**
     * @name {@link JsonPrimitive}
     * @kind typedef
     * @memberOf MockRequests
     */
    /**
     * @name {@link DynamicResponseModFn}
     * @kind typedef
     * @memberOf MockRequests
     */
    /**
     * @name {@link MockResponseConfig}
     * @kind typedef
     * @memberOf MockRequests
     */

    /**
     * Key (URL string) - Value ({@link MockResponseConfig}) pairs for network mocks
     *
     * @type {Object<string, MockResponseConfig>}
     */
    let urlResponseMap = {};

    /**
     * Original XMLHttpRequest class, as defined in the global environment.
     *
     * @type {(XMLHttpRequest|undefined)}
     * @memberOf MockRequests
     */
    let OriginalXHR;

    /**
     * Original fetch function, as defined in the global environment.
     *
     * @type {(function|undefined)}
     * @memberOf MockRequests
     */
    let originalFetch;

    const globalScope = (
        typeof window !== 'undefined'
            ? window
            : typeof self !== 'undefined'
                ? self
                : global
    );

    /**
     * Initialize the mock with response objects.
     *
     * @param  {Object<string, JsonPrimitive>} apiUrlResponseConfig - Config object containing URL strings as keys and respective mock response objects as values
     * @param {boolean} [overwritePreviousConfig=true] - If the map from a previous configure call should be overwritten by this call (true) or not (false)
     * @memberOf MockRequests
     */
    function configure(apiUrlResponseConfig = {}, overwritePreviousConfig = true) {
        const newUrlResponseMap = mapStaticConfigToDynamic(apiUrlResponseConfig);

        if (overwritePreviousConfig) {
            urlResponseMap = newUrlResponseMap;
        } else {
            urlResponseMap = { ...urlResponseMap, ...newUrlResponseMap };
        }
    }

    /**
     * Initialize the mock with response objects and their dynamic update functions
     *
     * @param {Object<string, MockResponseConfig>} dynamicApiUrlResponseConfig - URL-MockResponseConfig mappings
     * @param {boolean} [overwritePreviousConfig=true] - If the map from a previous configure call should be overwritten by this call (true) or not (false)
     * @memberOf MockRequests
     */
    function configureDynamicResponses(dynamicApiUrlResponseConfig = {}, overwritePreviousConfig = true) {
        const newUrlResponseMap = Object.keys(dynamicApiUrlResponseConfig).reduce((mockResponses, url) => {
            const config = createConfigObj(dynamicApiUrlResponseConfig[url]);

            if (config.usePathnameForAllQueries) {
                const { pathname } = getPathnameAndQueryParams(url);
                mockResponses[pathname] = config;
            } else {
                mockResponses[url] = config;
            }

            return mockResponses;
        }, {});

        if (overwritePreviousConfig) {
            urlResponseMap = newUrlResponseMap;
        } else {
            urlResponseMap = { ...urlResponseMap, ...newUrlResponseMap };
        }
    }

    /**
     * Mock any network requests to the given URL using the given responseObject
     *
     * @param {string} url - URL to mock
     * @param {JsonPrimitive} response - Mock response object
     * @memberOf MockRequests
     */
    function setMockUrlResponse(url, response = null) {
        urlResponseMap[url] = createConfigObj({ response });
    }

    /**
     * Mock any network requests to the given URL using the given responseObject
     * and dynamic response modification function
     *
     * @param {string} url - URL to mock
     * @param {MockResponseConfig} mockResponseConfig - Config object with the fields desired to be configured
     * @memberOf MockRequests
     */
    function setDynamicMockUrlResponse(url, mockResponseConfig) {
        const config = createConfigObj(mockResponseConfig);

        if (config.usePathnameForAllQueries) {
            const { pathname } = getPathnameAndQueryParams(url);
            urlResponseMap[pathname] = config;
        } else {
            urlResponseMap[url] = config;
        }
    }

    /**
     * Get the mock response object associated with the passed URL
     *
     * @param {string} url - URL that was previously mocked
     * @returns {JsonPrimitive} - Configured response object
     * @memberOf MockRequests
     */
    function getResponse(url) {
        const config = getConfig(url);

        if (!config) {
            return undefined;
        }

        return config.response;
    }

    /**
     * Deletes the URL and respective mock object
     *
     * @param {string} url - URL that was previously mocked
     * @returns {boolean} - Value returned from `delete Object.url`
     * @memberOf MockRequests
     */
    function deleteMockUrlResponse(url) {
        const config = getConfig(url);

        if (config.usePathnameForAllQueries) {
            const { pathname } = getPathnameAndQueryParams(url);
            return delete urlResponseMap[pathname];
        }

        return delete urlResponseMap[url];
    }

    /**
     * Deletes all entries in the MockRequests configuration
     *
     * @memberOf MockRequests
     */
    function clearAllMocks() {
        urlResponseMap = {};
    }

    /**
     * Gets the config object for a specified URL or its pathname if the URL itself isn't mocked
     *
     * @param {string} url
     * @returns {(MockResponseConfig|null)}
     */
    function getConfig(url) {
        const isMocked = urlIsMocked(url);

        if (!isMocked) {
            return null;
        }

        const { pathname } = getPathnameAndQueryParams(url);
        const config = urlResponseMap[url] || urlResponseMap[pathname];

        return config;
    }

    /**
     * Create the default MockResponseConfig object structure, ensuring all fields exist and populating with default
     * values as necessary.
     *
     * @param {MockResponseConfig} mockResponseConfig - Config object with the fields desired to be configured
     * @returns {MockResponseConfig}
     */
    function createConfigObj({
        response = null,
        dynamicResponseModFn = null,
        delay = 0,
        usePathnameForAllQueries = false,
        responseProperties = {},
    } = {}) {
        const mockResponseConfig = {
            response: null,
            dynamicResponseModFn: null,
            delay: 0,
            usePathnameForAllQueries: false,
            responseProperties,
        };

        mockResponseConfig.response = deepCopyObject(response);

        if (dynamicResponseModFn && typeof dynamicResponseModFn === 'function') {
            mockResponseConfig.dynamicResponseModFn = dynamicResponseModFn;
        }

        if (delay) {
            mockResponseConfig.delay = delay;
        }

        mockResponseConfig.usePathnameForAllQueries = Boolean(usePathnameForAllQueries);

        return mockResponseConfig;
    }

    /**
     * Deep copies a JS object
     *
     * @param {JsonPrimitive} [obj=null]
     * @returns {JsonPrimitive}
     */
    function deepCopyObject(obj = null) {
        return JSON.parse(JSON.stringify(obj));
    }

    /**
     * Reformats a static URL-response config object to match the dynamic MockResponseConfig object structure
     *
     * @param {Object<string, JsonPrimitive>} staticConfig - URL-staticResponse map
     * @returns {Object<string, MockResponseConfig>} - URL-MockResponseConfig object with default configuration fields
     * @memberOf MockRequests
     */
    function mapStaticConfigToDynamic(staticConfig) {
        return Object.keys(staticConfig).reduce((dynamicMockConfig, staticUrl) => {
            dynamicMockConfig[staticUrl] = createConfigObj({ response: staticConfig[staticUrl] });

            return dynamicMockConfig;
        }, {});
    }

    /**
     * Gets the `responseText` for XHR or `res.text()` for fetch.
     *
     * @param {JsonPrimitive} response
     */
    function castToString(response) {
        return (typeof response === typeof {}) ? JSON.stringify(response) : `${response}`;
    }

    /**
     * Parses a URL for query parameters/hash entry and extracts the pathname/query parameter map respectively.
     *
     * @param {string} url - URL to parse for query parameters
     * @returns {{hasQueryParams: boolean, queryParamMap: Object<string, string>, pathname: string}} - Pathname, query parameter map, and if query params/hash exist
     */
    function getPathnameAndQueryParams(url) {
        const queryIndex = url.indexOf('?');
        const hasQueryParams = queryIndex >= 0;
        const hashIndex = url.indexOf('#');
        const hasHash = hashIndex >= 0;
        const pathname = hasQueryParams ?
            url.substring(0, queryIndex)
            : hasHash ?
                url.substring(0, hashIndex)
                : url;
        const queryString = hasQueryParams ?
            hasHash ?
                url.substring(queryIndex + 1, hashIndex)
                : url.substring(queryIndex + 1)
            : '';
        const hashString = hasHash ? url.substring(hashIndex + 1) : '';
        const queryParamMap = queryString.length === 0 ? {} : queryString.split('&').reduce((queryParamObj, query) => {
            const unparsedKeyVal = query.split('=');
            const key = decodeURIComponent(unparsedKeyVal[0]);
            const val = decodeURIComponent(unparsedKeyVal[1]);

            queryParamObj[key] = val;

            return queryParamObj;
        }, {});

        if (hashString.length > 0) {
            queryParamMap.hash = decodeURIComponent(hashString);
        }

        return {
            pathname,
            queryParamMap,
            hasQueryParams: hasQueryParams || hasHash
        };
    }

    function urlIsMocked(url) {
        const urlIsMocked = urlResponseMap.hasOwnProperty(url);
        const { pathname, hasQueryParams } = getPathnameAndQueryParams(url);
        const pathnameIsMocked = urlResponseMap.hasOwnProperty(pathname);

        return urlIsMocked || (hasQueryParams && pathnameIsMocked && urlResponseMap[pathname].usePathnameForAllQueries);
    }

    /**
     * Parse payload content from fetch/XHR such that if it's a stringified object,
     * the object is returned. Otherwise, return the content as-is.
     *
     * @param {*} content
     * @returns {(JsonPrimitive|*)} - Object if the content is a stringified object, otherwise the passed content
     */
    function attemptParseJson(content) {
        let parsedContent;

        try {
            parsedContent = JSON.parse(content);
        } catch (e) {
            parsedContent = content;
        }

        return parsedContent;
    }

    /**
     * Returns the configured mock response. If a dynamic response modification function exists, then modify the
     * response before returning it and save it to the urlRequestMap.
     *
     * @param {string} url
     * @param {JsonPrimitive} requestPayload
     * @returns {JsonPrimitive} - Configured response after the dynamic modification function has been run (if it exists)
     */
    async function getResponseAndDynamicallyUpdate(url, requestPayload) {
        const mockResponseConfig = getConfig(url);

        if (mockResponseConfig.dynamicResponseModFn && typeof mockResponseConfig.dynamicResponseModFn === 'function') {
            const { queryParamMap } = getPathnameAndQueryParams(url);
            const newResponse = deepCopyObject(
                await mockResponseConfig.dynamicResponseModFn(
                    attemptParseJson(requestPayload),
                    mockResponseConfig.response,
                    queryParamMap
                )
            );

            mockResponseConfig.response = newResponse;
        }

        return mockResponseConfig.response;
    }

    /**
     * Composes the passed function with a timeout delay if it exists
     *
     * @param {number} delay - Milliseconds delay
     * @param {function} func - Function to wrap
     * @returns {function} - Original function if no delay or same function to be called after a delay
     */
    function withOptionalDelay(delay, func = () => {}) {
        if (delay) {
            return (...args) => {
                setTimeout(() => {
                    func(...args);
                }, delay);
            };
        }

        return func;
    }

    /**
     * Creates an event with the desired properties.
     *
     * Supported on:
     *  - Modern browsers.
     *  - IE >= 9 (`Event` constructor and `CustomEvent` aren't polyfilled by Babel).
     *  - NodeJS (with polyfills).
     *
     * @param {string} eventType - Event to create.
     * @param {Object} [options]
     * @param {HTMLElement} [options.element=self] - Target element on which to dispatch the event.
     * @param {boolean} [options.bubbles=false] - If the event bubbles.
     * @param {boolean} [options.cancelable=false] - If the event is cancellable.
     * @param {boolean} [options.composed=false] - If the event can trigger listeners outside of a shadow DOM.
     * @param {Object} [options.properties] - Custom fields to add to the event object.
     * @returns {function} - Function to dispatch the event when desired.
     * @see {@link https://developer.mozilla.org/en-US/docs/Web/Events/Creating_and_triggering_events}
     */
    function createEvent(
        eventType,
        {
            element = globalScope,
            bubbles = false,
            cancelable = false,
            composed = false,
            properties = {},
        } = {}
    ) {
        /**
         * Event to dispatch for event listeners added via `addEventListener()` to receive.
         *
         * @type {Event}
         * @see [MDN Event docs]{@link https://developer.mozilla.org/en-US/docs/Web/API/Event}
         */
        let event;

        try {
            // Only supported in NodeJS >= 15 and modern browsers
            event = new Event(eventType, { bubbles, cancelable, composed });
        } catch (eventConstructorNotSupported) {
            try {
                // Deprecated, but required to support IE >= 9
                event = document.createEvent('Event');

                event.initEvent(eventType, bubbles, cancelable);
            } catch (documentNotInGlobalScope) {
                event = {
                    type: eventType,
                    bubbles,
                    cancelable,
                    composed,
                    target: element,
                    currentTarget: element,
                    isTrusted: false,
                    defaultPrevented: false,
                    eventPhase: 0,
                    path: [], // Path from root to element
                    cancelBubble: false, // Deprecated
                    returnValue: true, // Deprecated
                    srcElement: element, // Deprecated
                    timeStamp: 1234,
                    toString: () => '[object Event]',
                    initEvent(type, bubbles = false, cancelable = false) {
                        this.type = type;
                        this.bubbles = bubbles;
                        this.cancelable = cancelable;
                    },
                    preventDefault() {
                        this.defaultPrevented = true;
                    },
                    stopPropagation() {
                        this.bubbles = false;
                        this.cancelBubble = true;
                    },
                    stopImmediatePropagation() {
                        this.stopPropagation();
                    },
                };
            }
        }

        // There are no built-in `Event` functions to add custom event properties,
        // so they must be attached to the event object directly
        Object.entries(properties).forEach(([ key, value ]) => {
            Object.defineProperty(event, key, {
                configurable: false,
                writable: false,
                enumerable: true,
                value,
            });
        });

        return () => element && element.dispatchEvent && element.dispatchEvent(event);
    }

    /**
     * Overwrites the XMLHttpRequest function with a wrapper that
     * mocks the readyState, status, statusText, and various other
     * fields that depend on the status of the request, and applies
     * the mock object response to the `xhr.response` field.
     *
     * The wrapper always marks the request as successful,
     * e.g. status = 200 and statusText = 'OK'
     */
    function overwriteXmlHttpRequestObject() {
        const globalXhrExists = !!(globalScope.XMLHttpRequest);
        const localXhrExists = typeof XMLHttpRequest !== typeof undefined;

        if (!globalXhrExists && !localXhrExists) {
            return;
        }

        OriginalXHR = XMLHttpRequest;

        XMLHttpRequest = function() {
            const xhr = new OriginalXHR();

            async function mockXhrRequest(requestPayload) {
                const config = getConfig(xhr.url);
                const mockedResponse = await getResponseAndDynamicallyUpdate(xhr.url, requestPayload);
                const mockedValues = {
                    readyState: 4,
                    response: mockedResponse,
                    responseText: castToString(mockedResponse),
                    responseUrl: xhr.url,
                    status: 200,
                    statusText: 'OK',
                    timeout: 0,
                    ...config.responseProperties,
                    headers: {
                        status: '200',
                        ...config.responseProperties.headers,
                    },
                };

                Object.entries(mockedValues).forEach(([ key, value ]) => {
                    Object.defineProperties(xhr, {
                        [key]: {
                            configurable: true,
                            enumerable: true,
                            // Must use getter/setter because `Object.defineProperty(xhr, ...)` fails if the field only uses
                            // a getter/unset setter. Properties with `writable`/`value` still work as expected.
                            get() {
                                return this[`_${key}`];
                            },
                            set(newValue) {
                                this[`_${key}`] = newValue;
                                return this;
                            },
                        },
                        [`_${key}`]: {
                            configurable: true,
                            enumerable: false,
                            writable: true,
                            value: mockedValues[key],
                        },
                    });
                });

                function getResponseHeader(headerKey) {
                    const headerVal = this.headers[headerKey];

                    if (headerVal != null) {
                        return headerVal;
                    }

                    return null;
                }

                function getAllResponseHeaders() {
                    return Object.entries(this.headers)
                        .map(([ headerKey, headerVal ]) => `${headerKey}: ${headerVal}`)
                        .join('\r\n');
                }

                xhr.getResponseHeader = getResponseHeader.bind(xhr);
                xhr.getAllResponseHeaders = getAllResponseHeaders.bind(xhr);
            }

            xhr.originalOpen = xhr.open;
            xhr.open = function(method, url, ...args) {
                xhr.url = url;
                xhr.originalOpen(method, url, ...args);
            };

            xhr.originalSend = xhr.send;
            xhr.send = async function(requestPayload) {
                if (urlIsMocked(xhr.url)) {
                    const config = getConfig(xhr.url);

                    await mockXhrRequest(requestPayload);

                    const resolveEvents = [
                        {
                            eventType: 'readystatechange',
                        },
                        {
                            eventType: 'load',
                            // `ProgressEvent` properties: https://developer.mozilla.org/en-US/docs/Web/API/ProgressEvent/ProgressEvent
                            properties: {
                                lengthComputable: true,
                                loaded: xhr.responseText.length,
                                total: xhr.responseText.length,
                            }
                        },
                        {
                            // Used by Axios
                            eventType: 'loadend',
                            properties: {
                                lengthComputable: true,
                                loaded: xhr.responseText.length,
                                total: xhr.responseText.length,
                            }
                        },
                    ];

                    resolveEvents.forEach(({ eventType, properties }) => {
                        const resolveRequest = () => {
                            const resolveOnHandler = xhr[`on${eventType}`] || (() => {});
                            const resolveEvent = createEvent(eventType, {
                                element: xhr,
                                properties,
                            });

                            resolveOnHandler(properties);
                            resolveEvent();
                        };
                        const resolveAfterDelay = withOptionalDelay(config.delay, resolveRequest);

                        resolveAfterDelay();
                    });
                } else {
                    xhr.originalSend(requestPayload);
                }
            };

            return xhr;
        }
    }

    /**
     * Overwrites the fetch() function with a wrapper that mocks
     * the response value after the configured delay has passed.
     */
    function overwriteFetch() {
        const globalFetchExists = !!(globalScope.fetch);
        const localFetchExists = typeof fetch !== typeof undefined;

        if (!globalFetchExists && !localFetchExists) {
            return;
        }

        originalFetch = globalScope.fetch.bind(globalScope);

        globalScope.fetch = function(resource, init) {
            const isUsingRequestObject = typeof resource === typeof {};
            const url = isUsingRequestObject ? resource.url : resource;

            if (urlIsMocked(url)) {
                return (async () => {
                    const config = getConfig(url);
                    const requestPayload = isUsingRequestObject
                        ? attemptParseJson(await resource.text())
                        : (init && init.hasOwnProperty('body') && init.body)
                            ? attemptParseJson(init.body)
                            : undefined;
                    const responseBody = await getResponseAndDynamicallyUpdate(url, requestPayload);
                    const response = {
                        clone() {
                            return this;
                        },
                        json: () => Promise.resolve(responseBody),
                        text: () => Promise.resolve(castToString(responseBody)),
                        status: 200,
                        statusText: '',
                        ok: true,
                        redirected: false,
                        type: 'basic',
                        url,
                        ...config.responseProperties,
                        headers: new Headers({
                            status: '200',
                            ...config.responseProperties?.headers,
                        }),
                    };

                    return await new Promise(resolve => {
                        const resolveAfterDelay = withOptionalDelay(config.delay, resolve);
                        resolveAfterDelay(response);
                    });
                })();
            } else {
                return originalFetch(resource, init);
            }
        }
    }

    overwriteXmlHttpRequestObject();
    overwriteFetch();

    return {
        configure,
        configureDynamicResponses,
        setMockUrlResponse,
        setDynamicMockUrlResponse,
        getResponse,
        deleteMockUrlResponse,
        clearAllMocks,
        mapStaticConfigToDynamic,
        OriginalXHR,
        originalFetch
    };
})();


export const {
    configure,
    configureDynamicResponses,
    setMockUrlResponse,
    setDynamicMockUrlResponse,
    getResponse,
    deleteMockUrlResponse,
    clearAllMocks,
    mapStaticConfigToDynamic,
    OriginalXHR,
    originalFetch
} = MockRequests;