/* global fetch Response */
import FactoryFarm from './FactoryFarm'
import { serverResponse } from './testUtils'
/**
* Interpret a `POST` request
*
* @param {object} store the store
* @param {string} type the type
* @param {string} body json encoded response body
* @returns {object|Array} a model or array created from the response
*/
const simulatePost = (store, type, body) => {
const { data } = JSON.parse(body.toString())
if (Array.isArray(data)) {
const records = data.map((record) => {
const { attributes, relationships = {} } = record
const id = String(store.getAll(type).length + 1)
const properties = { ...attributes, ...relationships.data, id }
return store.add(type, properties)
})
return records
} else {
const { attributes, relationships = {} } = data
const id = String(store.getAll(type).length + 1)
const properties = { ...attributes, ...relationships.data, id }
return store.add(type, properties)
}
}
/**
* Interpret a `PATCH` request
*
* @param {object} store the store
* @param {string} type the type
* @param {string} body json encoded response body
* @returns {object|Array} a model or array created from the response
*/
const simulatePatch = (store, type, body) => {
const { data } = JSON.parse(body.toString())
if (Array.isArray(data)) {
return store.createOrUpdateModelsFromData(data)
} else {
return store.createOrUpdateModelFromData(data)
}
}
/**
* Finds or creates a model that will match an id. This is useful for
* creating a response on the fly if no object already exists
*
* @param {object} _backendFactoryFarm the private factory farm
* @param {object} factory the the factory to use
* @param {string} type the model type
* @param {string} id the id to find
* @returns {object} a Model object
*/
const getOneFromFactory = (_backendFactoryFarm, factory, type, id) => {
factory =
factory ||
Object.keys(_backendFactoryFarm.factories).find(
(factoryName) => _backendFactoryFarm.factories[factoryName].type === type
)
if (!factory) {
throw new Error(`No default factory for ${type} exists`)
}
return _backendFactoryFarm.build(factory, { id })
}
/**
* Will throw an error if `fetch` is called from the mockServer, usually due to a `POST` or `PATCH` called by a `save`
*
* @param {string} url the url that is attempted
* @param {object} options options including the http method
*/
const circularFetchError = (url, options) => {
throw new Error(
`You tried to call fetch from MockServer with ${options.method} ${url}, which is circular and would call itself. This was caused by calling a method such as 'save' on a model that was created from MockServer. To fix the problem, use FactoryFarm without MockServer`
)
}
/**
* Throws an error if MockServer tries to `findOne` or `findAll` from itself.
*
* @param {string} type the model type
* @param {string} id the model id
*/
const circularFindError = (type, id) => {
const idText = id ? ` with id ${id}` : ''
throw new Error(
`You tried to find ${type}${idText} from MockServer which is circular and would call itself. To fix the problem, use FactoryFarm without MockServer`
)
}
/**
* Overrides store methods that could trigger a `fetch` to throw errors. MockServer should only provide data for fetches, never call a fetch itself.
*
* @param {object} store the internal store
*/
const disallowFetches = (store) => {
store.fetch = circularFetchError
store.findOne = circularFindError
store.findAll = circularFindError
store.findMany = circularFindError
store.fetchOne = circularFindError
store.fetchAll = circularFindError
store.fetchMany = circularFindError
}
/**
* Wraps response JSON or object in a Response object that is itself wrapped in a
* resolved Promise. If no status is given then it will fill in a default based on
* the method.
*
* @param {string} response JSON string
* @param {string} method the http method
* @param {number} status the http status
* @returns {Promise} a promise wrapping the response
*/
const wrapResponse = (response, method, status) => {
if (!status) {
status = method === 'POST' ? 201 : 200
}
return Promise.resolve(new Response(response, { status }))
}
/**
* A backend "server" to be used for creating jsonapi-compliant responses.
*/
class MockServer {
/**
* Sets properties needed internally
* - factoryFarm: a pre-existing factory to use on this server
* - responseOverrides: An array of alternative responses that can be used to override the ones that would be served
* from the internal store.
*
* @param {object} options currently `responseOverrides` and `factoriesForTypes`
*/
constructor (options = {}) {
this._backendFactoryFarm = options.factoryFarm || new FactoryFarm()
this._backendFactoryFarm.__usedForMockServer__ = true
this._backendFactoryFarm.store.__usedForMockServer__ = true
this.responseOverrides = options.responseOverrides || []
disallowFetches(this._backendFactoryFarm.store)
}
/**
* Adds a response override to the server
*
* @param {object} options path, method, status, and response to override
* - path
* - method: defaults to GET
* - status: defaults to 200
* - response: a method that takes the server as an argument and returns the body of the response
*/
respond (options) {
this.responseOverrides.push(options)
}
/**
* Sets up fetch mocking to intercept requests. It will then either use overrides, or use its own
* internal store to simulate serving JSON responses of new data.
* - responseOverrides: An array of alternative responses that can be used to override the ones that would be served
* from the internal store.
* - factoriesForTypes: A key map that can be used to build factories if a queried id does not exist
*
* @param {object} options currently `responseOverrides` and `factoriesForTypes`
*/
start (options = {}) {
const { factoriesForTypes } = options
const combinedOverrides = [...options.responseOverrides || [], ...this.responseOverrides || []]
fetch.resetMocks()
fetch.mockResponse((req) => {
const foundQuery = combinedOverrides.find((definition) => {
if (!definition?.path) {
throw new Error('No path defined for mock server override. Did you define a path?')
}
const method = definition.method || 'GET'
return req.url.match(definition.path) && req.method.match(method)
})
const response = foundQuery
? foundQuery.response(this, req)
: serverResponse(this._findFromStore(req, factoriesForTypes))
return wrapResponse(response, req.method, foundQuery?.status)
})
}
/**
* Clears mocks and the store
*/
stop () {
fetch.resetMocks()
this._backendFactoryFarm.store.reset()
}
/**
* Alias for `this._backendFactoryFarm.build`
*
* @param {string} factoryName the name of the factory to use
* @param {object} overrideOptions overrides for the factory
* @param {number} numberOfRecords optional number of models to build
* @returns {*} Object or Array
*/
build (factoryName, overrideOptions, numberOfRecords) {
return this._backendFactoryFarm.build(factoryName, overrideOptions, numberOfRecords)
}
/**
* Alias for `this._backendFactoryFarm.define`
*
* @param {string} name the name to use for the factory
* @param {object} options options for defining a factory
* @returns {*} Object or Array
*/
define (name, options) {
return this._backendFactoryFarm.define(name, options)
}
/**
* Alias for `this._backendFactoryFarm.add`
*
* @param {string} name the name to use for the factory
* @param {object} options properties and other options for adding a model to the store
* @returns {*} Object or Array
*/
add (name, options) {
return this._backendFactoryFarm.add(name, options)
}
/**
* Based on a request, simulates building a response, either using found data
* or a factory.
*
* @param {object} req a method, url and body
* @param {object} factoriesForTypes allows an override for a particular type
* @returns {object} the found or built store record(s)
* @private
*/
_findFromStore (req, factoriesForTypes = {}) {
const { _backendFactoryFarm } = this
const { method, url, body } = req
const { store } = _backendFactoryFarm
const { pathname } = new URL(url, 'http://example.com')
const type = Object.keys(store.data).find((model_type) => pathname.match(model_type))
let id = pathname.match(/\d+$/)
id = id && String(id)
if (method === 'POST') {
return simulatePost(store, type, body)
} else if (method === 'PATCH') {
return simulatePatch(store, type, body)
} else if (id) {
return store.getOne(type, id) || getOneFromFactory(_backendFactoryFarm, factoriesForTypes[type], type, id)
} else {
const records = store.getAll(type)
return records.length > 0
? records
: (factoriesForTypes[type] && [getOneFromFactory(_backendFactoryFarm, factoriesForTypes[type], type, '1')]) ||
[]
}
}
}
export default MockServer