From 3962e2c031354776c92da4e1d697cb333b8b35e1 Mon Sep 17 00:00:00 2001 From: delvedor Date: Tue, 30 Oct 2018 17:27:07 +0100 Subject: [PATCH] Added integration test --- package.json | 4 +- test/integration/README.md | 52 +++ test/integration/index.js | 251 ++++++++++++ test/integration/test-runner.js | 676 ++++++++++++++++++++++++++++++++ 4 files changed, 981 insertions(+), 2 deletions(-) create mode 100644 test/integration/README.md create mode 100644 test/integration/index.js create mode 100644 test/integration/test-runner.js diff --git a/package.json b/package.json index ab933f5db..c67f1e845 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "search" ], "scripts": { - "test": "npm run test:unit && npm run test:integration", - "test:unit": "tap test/unit/*.test.js -J -T --harmony", + "test": "npm run lint && npm run test:unit", + "test:unit": "tap test/unit/*.test.js -J -T", "test:integration": "tap test/integration/index.js -T --harmony", "lint": "standard", "lint:fix": "standard --fix", diff --git a/test/integration/README.md b/test/integration/README.md new file mode 100644 index 000000000..0861dd8b9 --- /dev/null +++ b/test/integration/README.md @@ -0,0 +1,52 @@ +# `elasticsearch-js` integration test suite + +> What? A README to explain how the integration test work?? + +Yes. + +## Background +Elasticsearch offers its entire API via HTTP REST endpoints. You can find the whole API specification for every version [here](https://github.com/elastic/elasticsearch/tree/master/rest-api-spec/src/main/resources/rest-api-spec/api).
+To support different languages at the same time, the Elasticsearch team decided to provide a [YAML specification](https://github.com/elastic/elasticsearch/tree/master/rest-api-spec/src/main/resources/rest-api-spec/test) to test every endpoint, body, headers, warning, error and so on.
+This testing suite uses that specification to generate the test for the specified version of Elasticsearch on the fly. + +## Run +Run the testing suite is very easy, you just need to run the preconfigured npm script: +```sh +npm run test:integration +``` + +The first time you run this command, the Elasticsearch repository will be cloned inside the integration test folder, to be able to access the YAML specification, so it might take some time *(luckily, only the first time)*.
+Once the Elasticsearch repository has been cloned, the testing suite will connect to the provided Elasticsearch instance and then checkout the build hash in the repository. Finally, it will start running every test. + +The specification does not allow the test to be run in parallel, so it might take a while to run the entire testing suite; on my machine, `MacBookPro15,2 core i7 2.7GHz 16GB of RAM` it takes around four minutes. + +### Exit on the first failure +Bu default the suite will run all the test, even if one assertion has failed. If you want to stop the test at the first failure, use the bailout option: +```sh +npm run test:integration -- --bail +``` + +### Calculate the code coverage +If you want to calculate the code coverage just run the testing suite with the following parameters, once the test ends, it will open a browser window with the results. +```sh +npm run test:integration -- --cov --coverage-report=html +``` + +## How does this thing work? +At first sight, it might seem complicated, but once you understand what the moving parts are, it's quite easy. +1. Connects to the given Elasticsearch instance +1. Gets the ES version and build hash +1. Checkout to the given hash (and clone the repository if it is not present) +1. Reads the folder list and for each folder the yaml file list +1. Starts running folder by folder every file + 1. Read and parse the yaml files + 1. Creates a subtest structure to have a cleaner output + 1. Runs the assertions + 1. Repeat! + +Inside the `index.js` file, you will find the connection, cloning, reading and parsing part of the test, while inside the `test-runner.js` file you will find the function to handle the assertions. Inside `test-runner.js`, we use a [queue](https://github.com/delvedor/workq) to be sure that everything is run in the correct order. + +Checkout the [rest-api-spec readme](https://github.com/elastic/elasticsearch/blob/master/rest-api-spec/src/main/resources/rest-api-spec/test/README.asciidoc) if you want to know more about how the assertions work. + +#### Why are we running the test with the `--harmony` flag? +Because on Node v6 the regex lookbehinds are not supported. diff --git a/test/integration/index.js b/test/integration/index.js new file mode 100644 index 000000000..6492e18a1 --- /dev/null +++ b/test/integration/index.js @@ -0,0 +1,251 @@ +'use strict' + +const assert = require('assert') +const { readFileSync, accessSync, mkdirSync, readdirSync } = require('fs') +const { join } = require('path') +const yaml = require('js-yaml') +const Git = require('simple-git') +const ora = require('ora') +const minimist = require('minimist') +const tap = require('tap') +const elasticsearch = require('../../index') +const TestRunner = require('./test-runner') + +const esRepo = 'https://github.com/elastic/elasticsearch.git' +const esFolder = join(__dirname, '..', '..', 'elasticsearch') +const yamlFolder = join(esFolder, 'rest-api-spec', 'src', 'main', 'resources', 'rest-api-spec', 'test') + +function Runner (opts) { + if (!(this instanceof Runner)) { + return new Runner(opts) + } + opts = opts || {} + + assert(opts.host, 'Missing host') + this.bailout = opts.bailout + this.client = new elasticsearch.Client({ + host: opts.host + }) + this.log = ora('Loading yaml suite').start() +} + +/** + * Runs the test suite + */ +Runner.prototype.start = function () { + const getTest = this.getTest.bind(this) + const parse = this.parse.bind(this) + const client = this.client + + // client.on('response', (request, response) => { + // console.log('\n\n') + // console.log('REQUEST', request) + // console.log('\n') + // console.log('RESPONSE', response) + // }) + + // Get the build hash of Elasticsearch + client.info((err, { body }) => { + if (err) { + this.log.fail(err.message) + return + } + const { number: version, build_hash: sha } = body.version + // Set the repository to the given sha and run the test suite + this.withSHA(sha, () => { + this.log.succeed('Done!') + runTest.call(this, version) + }) + }) + + function runTest (version) { + const testFolders = getTest() + testFolders.forEach(runTestFolder.bind(this)) + function runTestFolder (testFolder) { + // if (testFolder !== 'tasks.get') return + // create a subtest for the specific folder + tap.test(testFolder, { jobs: 1 }, tap1 => { + const files = getTest(testFolder) + files.forEach(file => { + // if (file !== '20_typed_keys.yml') return + // create a subtest for the specific folder + test file + tap1.test(file.slice(0, -4), { jobs: 1 }, tap2 => { + const path = join(yamlFolder, testFolder, file) + // read the yaml file + const data = readFileSync(path, 'utf8') + // get the test yaml (as object), some file has multiple yaml documents inside, + // every document is separated by '---', so we split on the separator + // and then we remove the empty strings, finally we parse them + const tests = data + .split('---') + .map(s => s.trim()) + .filter(Boolean) + .map(parse) + + // get setup and teardown if present + var setupTest = null + var teardownTest = null + tests.forEach(test => { + if (test.setup) setupTest = test.setup + if (test.teardown) teardownTest = test.teardown + }) + + // run the tests + tests.forEach(test => { + const name = Object.keys(test)[0] + if (name === 'setup' || name === 'teardown') return + // create a subtest for the specific folder + test file + test name + tap2.test(name, { jobs: 1, bail: this.bailout }, tap3 => { + const testRunner = TestRunner({ client, version, tap: tap3 }) + testRunner.run(setupTest, test[name], teardownTest, () => tap3.end()) + }) + }) + + tap2.end() + }) + }) + + tap1.end() + }) + } + } +} + +/** + * Parses a given yaml document + * @param {string} yaml document + * @returns {object} + */ +Runner.prototype.parse = function (data) { + try { + var doc = yaml.safeLoad(data) + } catch (err) { + this.log.fail(err.message) + return + } + return doc +} + +/** + * Returns the content of the `yamlFolder`. + * If a folder name is given as parameter, it will return + * the content of join(yamlFolder, folder) + * @param {string} folder + * @returns {Array} The content of the given folder + */ +Runner.prototype.getTest = function (folder) { + const tests = readdirSync(join(yamlFolder, folder || '')) + return tests.filter(t => !/(README|TODO)/g.test(t)) +} + +/** + * Sets the elasticsearch repository to the given sha. + * If the repository is not present in `esFolder` it will + * clone the repository and the checkout the sha. + * If the repository is already present but it cannot checkout to + * the given sha, it will perform a pull and then try again. + * @param {string} sha + * @param {function} callback + */ +Runner.prototype.withSHA = function (sha, callback) { + var fresh = false + var retry = 0 + var log = this.log + + if (!this.pathExist(esFolder)) { + if (!this.createFolder(esFolder)) { + log.fail('Failed folder creation') + return + } + fresh = true + } + + const git = Git(esFolder) + + if (fresh) { + clone(checkout) + } else { + checkout() + } + + function checkout () { + log.text = `Checking out sha '${sha}'` + git.checkout(sha, err => { + if (err) { + if (retry++ > 0) { + log.fail(`Cannot checkout sha '${sha}'`) + return + } + return pull(checkout) + } + callback() + }) + } + + function pull (cb) { + log.text = 'Pulling elasticsearch repository...' + git.pull(err => { + if (err) { + log.fail(err.message) + return + } + cb() + }) + } + + function clone (cb) { + log.text = 'Cloning elasticsearch repository...' + git.clone(esRepo, esFolder, err => { + if (err) { + log.fail(err.message) + return + } + cb() + }) + } +} + +/** + * Checks if the given path exists + * @param {string} path + * @returns {boolean} true if exists, false if not + */ +Runner.prototype.pathExist = function (path) { + try { + accessSync(path) + return true + } catch (err) { + return false + } +} + +/** + * Creates the given folder + * @param {string} name + * @returns {boolean} true on success, false on failure + */ +Runner.prototype.createFolder = function (name) { + try { + mkdirSync(name) + return true + } catch (err) { + return false + } +} + +if (require.main === module) { + const opts = minimist(process.argv.slice(2), { + string: ['host', 'version'], + boolean: ['bailout'], + default: { + host: 'http://localhost:9200', + version: '6.4', + bailout: false + } + }) + + const runner = Runner(opts) + runner.start() +} + +module.exports = Runner diff --git a/test/integration/test-runner.js b/test/integration/test-runner.js new file mode 100644 index 000000000..de076c023 --- /dev/null +++ b/test/integration/test-runner.js @@ -0,0 +1,676 @@ +'use strict' + +const t = require('tap') +const semver = require('semver') +const workq = require('workq') +const { ConfigurationError } = require('../../lib/errors') + +const supportedFeatures = [ + // 'gtelte', + // 'regex', + // 'benchmark', + // 'stash_in_path', + // 'groovy_scripting', + // 'headers' +] + +function TestRunner (opts) { + if (!(this instanceof TestRunner)) { + return new TestRunner(opts) + } + opts = opts || {} + + this.client = opts.client + this.esVersion = opts.version + this.response = null + this.stash = new Map() + this.tap = opts.tap || t + this.q = opts.q || workq() +} + +/** + * Runs a cleanup, removes all indices and templates + * @param {queue} + * @param {function} done + */ +TestRunner.prototype.cleanup = function (q, done) { + this.tap.comment('Cleanup') + + this.response = null + this.stash = new Map() + + q.add((q, done) => { + this.client.indices.delete({ index: '*', ignore: 404 }, err => { + this.tap.error(err, 'should not error: indices.delete') + done() + }) + }) + + q.add((q, done) => { + this.client.indices.deleteTemplate({ name: '*', ignore: 404 }, err => { + this.tap.error(err, 'should not error: indices.deleteTemplate') + done() + }) + }) + + done() +} + +/** + * Runs the given test. + * It runs the test components in the following order: + * - setup + * - the actual test + * - teardown + * - cleanup + * Internally uses a queue to guarantee the order of the test sections. + * @param {object} setup (null if not needed) + * @param {object} test + * @oaram {object} teardown (null if not needed) + * @param {function} end + */ +TestRunner.prototype.run = function (setup, test, teardown, end) { + // if we should skip a feature in the setup/teardown section + // we should skip the entire test file + const skip = getSkip(setup) || getSkip(teardown) + if (skip && this.shouldSkip(skip)) { + this.skip(skip) + return end() + } + + if (setup) { + this.q.add((q, done) => { + this.exec('Setup', setup, q, done) + }) + } + + this.q.add((q, done) => { + this.exec('Test', test, q, done) + }) + + if (teardown) { + this.q.add((q, done) => { + this.exec('Teardown', teardown, q, done) + }) + } + + this.q.add((q, done) => { + this.cleanup(q, done) + }) + + this.q.add((q, done) => end() && done()) +} + +/** + * Logs a skip + * @param {object} the actions + * @returns {TestRunner} + */ +TestRunner.prototype.skip = function (action) { + if (action.reason && action.version) { + this.tap.skip(`Skip: ${action.reason} (${action.version})`) + } else if (action.features) { + this.tap.skip(`Skip: ${JSON.stringify(action.features)})`) + } else { + this.tap.skip('Skipped') + } + return this +} + +/** + * Decides if a test should be skipped + * @param {object} the actions + * @returns {boolean} + */ +TestRunner.prototype.shouldSkip = function (action) { + var shouldSkip = false + // skip based on the version + if (action.version) { + if (action.version.trim() === 'all') return true + const [min, max] = action.version.split('-').map(v => v.trim()) + // if both `min` and `max` are specified + if (min && max) { + shouldSkip = semver.satisfies(this.esVersion, action.version) + // if only `min` is specified + } else if (min) { + shouldSkip = semver.gte(this.esVersion, min) + // if only `max` is specified + } else if (max) { + shouldSkip = semver.lte(this.esVersion, max) + // something went wrong! + } else { + throw new Error(`skip: Bad version range: ${action.version}`) + } + } + + if (shouldSkip) return true + + if (action.features) { + if (!Array.isArray(action.features)) action.features = [action.features] + // returns true if one of the features is not present in the supportedFeatures + shouldSkip = !!action.features.filter(f => !~supportedFeatures.indexOf(f)).length + } + + if (shouldSkip) return true + + return false +} + +/** + * Updates the array syntax of keys and values + * eg: 'hits.hits.1.stuff' to 'hits.hits[1].stuff' + * @param {object} the action to update + * @returns {obj} the updated action + */ +TestRunner.prototype.updateArraySyntax = function (obj) { + const newObj = {} + + for (const key in obj) { + const newKey = key.replace(/\.\d{1,}\./g, v => `[${v.slice(1, -1)}].`) + const val = obj[key] + + if (typeof val === 'string') { + newObj[newKey] = val.replace(/\.\d{1,}\./g, v => `[${v.slice(1, -1)}].`) + } else if (val !== null && typeof val === 'object') { + newObj[newKey] = this.updateArraySyntax(val) + } else { + newObj[newKey] = val + } + } + + return newObj +} + +/** + * Fill the stashed values of a command + * let's say the we have stashed the `master` value, + * is_true: nodes.$master.transport.profiles + * becomes + * is_true: nodes.new_value.transport.profiles + * @param {object|string} the action to update + * @returns {object|string} the updated action + */ +TestRunner.prototype.fillStashedValues = function (obj) { + if (typeof obj === 'string') { + return getStashedValues.call(this, obj) + } + // iterate every key of the object + for (const key in obj) { + const val = obj[key] + // if the key value is a string, and the string includes '$' + // we run the "update value" code + if (typeof val === 'string' && val.includes('$')) { + // update the key value + obj[key] = getStashedValues.call(this, val) + } + + // go deep in the object + if (val !== null && typeof val === 'object') { + this.fillStashedValues(val) + } + } + + return obj + + function getStashedValues (str) { + return str + // we split the string on the dots + // handle the key with a dot inside that is not a part of the path + .split(/(? { + if (part[0] === '$') { + const stashed = this.stash.get(part.slice(1)) + if (stashed == null) { + throw new Error(`Cannot find stashed value '${part}' for '${JSON.stringify(obj)}'`) + } + return stashed + } + return part + }) + // recreate the string value + .join('.') + } +} + +/** + * Stashes a value + * @param {string} the key to search in the previous response + * @param {string} the name to identify the stashed value + * @returns {TestRunner} + */ +TestRunner.prototype.set = function (key, name) { + this.stash.set(name, delve(this.response, key)) + return this +} + +/** + * Runs a client command + * TODO: handle `action.warning`, `action.catch`... + * @param {object} the action to perform + * @param {Queue} + */ +TestRunner.prototype.do = function (action, done) { + const cmd = this.parseDo(action) + const api = delve(this.client, cmd.method).bind(this.client) + api(cmd.params, (err, { body }) => { + if (action.catch) { + this.tap.true( + parseDoError(err, action.catch), + `the error should be: ${action.catch}` + ) + try { + this.response = JSON.parse(err.body) + } catch (e) { + this.response = err.body + } + } else { + this.tap.error(err, `should not error: ${cmd.method}`, action) + this.response = body + } + + if (action.warning) { + this.tap.todo('Handle warnings') + } + + done() + }) +} + +/** + * Runs an actual test + * @param {string} the name of the test + * @param {object} the actions to perform + * @param {Queue} + */ +TestRunner.prototype.exec = function (name, actions, q, done) { + this.tap.comment(name) + for (var i = 0; i < actions.length; i++) { + const action = actions[i] + + if (action.skip) { + if (this.shouldSkip(action.skip)) { + this.skip(this.fillStashedValues(action.skip)) + break + } + } + + if (action.do) { + q.add((q, done) => { + this.do(this.fillStashedValues(action.do), done) + }) + } + + if (action.set) { + q.add((q, done) => { + const key = Object.keys(action.set)[0] + this.set(key, action.set[key]) + done() + }) + } + + if (action.match) { + q.add((q, done) => { + const key = Object.keys(action.match)[0] + this.match( + // in some cases, the yaml refers to the body with an empty string + key === '$body' || key === '' + ? this.response + : delve(this.response, this.fillStashedValues(key)), + key === '$body' + ? action.match[key] + : this.fillStashedValues(action.match)[key] + ) + done() + }) + } + + if (action.lt) { + q.add((q, done) => { + const key = Object.keys(action.lt)[0] + this.lt( + delve(this.response, this.fillStashedValues(key)), + this.fillStashedValues(action.lt)[key] + ) + done() + }) + } + + if (action.gt) { + q.add((q, done) => { + const key = Object.keys(action.gt)[0] + this.gt( + delve(this.response, this.fillStashedValues(key)), + this.fillStashedValues(action.gt)[key] + ) + done() + }) + } + + if (action.lte) { + q.add((q, done) => { + const key = Object.keys(action.lte)[0] + this.lte( + delve(this.response, this.fillStashedValues(key)), + this.fillStashedValues(action.lte)[key] + ) + done() + }) + } + + if (action.gte) { + q.add((q, done) => { + const key = Object.keys(action.gte)[0] + this.gte( + delve(this.response, this.fillStashedValues(key)), + this.fillStashedValues(action.gte)[key] + ) + done() + }) + } + + if (action.length) { + q.add((q, done) => { + const key = Object.keys(action.length)[0] + this.length( + delve(this.response, this.fillStashedValues(key)), + this.fillStashedValues(action.length)[key] + ) + done() + }) + } + + if (action.is_true) { + q.add((q, done) => { + const isTrue = this.fillStashedValues(action.is_true) + this.is_true( + delve(this.response, isTrue), + isTrue + ) + done() + }) + } + + if (action.is_false) { + q.add((q, done) => { + const isFalse = this.fillStashedValues(action.is_false) + this.is_false( + delve(this.response, isFalse), + isFalse + ) + done() + }) + } + } + done() +} + +/** + * Asserts that the given value is truthy + * @param {any} the value to check + * @param {string} an optional message + */ +TestRunner.prototype.is_true = function (val, msg) { + this.tap.true(val, `expect truthy value: ${msg} - value: ${JSON.stringify(val)}`) + return this +} + +/** + * Asserts that the given value is falsey + * @param {any} the value to check + * @param {string} an optional message + */ +TestRunner.prototype.is_false = function (val, msg) { + this.tap.false(val, `expect falsey value: ${msg} - value: ${JSON.stringify(val)}`) + return this +} + +/** + * Asserts that two values are the same + * @param {any} the first value + * @param {any} the second value + * @returns {TestRunner} + */ +TestRunner.prototype.match = function (val1, val2) { + // both values are objects + if (typeof val1 === 'object' && typeof val2 === 'object') { + this.tap.strictDeepEqual(val1, val2) + // the first value is the body as string and the second a pattern string + } else if ( + typeof val1 === 'string' && typeof val2 === 'string' && + val2.startsWith('/') && (val2.endsWith('/\n') || val2.endsWith('/')) + ) { + const regStr = val2 + // match all comments within a "regexp" match arg + .replace(/([\S\s]?)#[^\n]*\n/g, (match, prevChar) => { + return prevChar === '\\' ? match : `${prevChar}\n` + }) + // remove all whitespace from the expression, all meaningful + // whitespace is represented with \s + .replace(/\s/g, '') + .slice(1, -1) + // 'm' adds the support for multiline regex + this.tap.match(val1, new RegExp(regStr, 'm'), `should match pattern provided: ${val2}`) + // everything else + } else { + this.tap.strictEqual(val1, val2, `should be equal: ${val1} - ${val2}`) + } + return this +} + +/** + * Asserts that the first value is less than the second + * It also verifies that the two values are numbers + * @param {any} the first value + * @param {any} the second value + * @returns {TestRunner} + */ +TestRunner.prototype.lt = function (val1, val2) { + ;[val1, val2] = getNumbers(val1, val2) + this.tap.true(val1 < val2) + return this +} + +/** + * Asserts that the first value is greater than the second + * It also verifies that the two values are numbers + * @param {any} the first value + * @param {any} the second value + * @returns {TestRunner} + */ +TestRunner.prototype.gt = function (val1, val2) { + ;[val1, val2] = getNumbers(val1, val2) + this.tap.true(val1 > val2) + return this +} + +/** + * Asserts that the first value is less than or equal the second + * It also verifies that the two values are numbers + * @param {any} the first value + * @param {any} the second value + * @returns {TestRunner} + */ +TestRunner.prototype.lte = function (val1, val2) { + ;[val1, val2] = getNumbers(val1, val2) + this.tap.true(val1 <= val2) + return this +} + +/** + * Asserts that the first value is greater than or equal the second + * It also verifies that the two values are numbers + * @param {any} the first value + * @param {any} the second value + * @returns {TestRunner} +*/ +TestRunner.prototype.gte = function (val1, val2) { + ;[val1, val2] = getNumbers(val1, val2) + this.tap.true(val1 >= val2) + return this +} + +/** + * Asserts that the given value has the specified length + * @param {string|object|array} the object to check + * @param {number} the expected length + * @returns {TestRunner} + */ +TestRunner.prototype.length = function (val, len) { + if (typeof val === 'string' || Array.isArray(val)) { + this.tap.strictEqual(val.length, len) + } else if (typeof val === 'object' && val !== null) { + this.tap.strictEqual(Object.keys(val).length, len) + } else { + this.tap.fail(`length: the given value is invalid: ${val}`) + } + return this +} + +/** + * Gets a `do` action object and returns a structured object, + * where the action is the key and the parameter is the value. + * Eg: + * { + * 'indices.create': { + * 'index': 'test' + * }, + * 'warnings': [ + * '[index] is deprecated' + * ] + * } + * becomes + * { + * method: 'indices.create', + * params: { + * index: 'test' + * }, + * warnings: [ + * '[index] is deprecated' + * ] + * } + * @param {object} + * @returns {object} + */ +TestRunner.prototype.parseDo = function (action) { + return Object.keys(action).reduce((acc, val) => { + switch (val) { + case 'catch': + acc.catch = action.catch + break + case 'warnings': + acc.warnings = action.warnings + break + case 'node_selector': + acc.node_selector = action.node_selector + break + default: + // converts underscore to camelCase + // eg: put_mapping => putMapping + acc.method = val.replace(/_([a-z])/g, g => g[1].toUpperCase()) + acc.params = camelify(action[val]) + } + return acc + }, {}) + + function camelify (obj) { + const newObj = {} + + // TODO: add camelCase support for this fields + const doNotCamelify = ['copy_settings'] + + for (const key in obj) { + const val = obj[key] + var newKey = key + if (!~doNotCamelify.indexOf(key)) { + // if the key starts with `_` we should not camelify the first occurence + // eg: _source_include => _sourceInclude + newKey = key[0] === '_' + ? '_' + key.slice(1).replace(/_([a-z])/g, k => k[1].toUpperCase()) + : key.replace(/_([a-z])/g, k => k[1].toUpperCase()) + } + + if ( + val !== null && + typeof val === 'object' && + !Array.isArray(val) && + key !== 'body' + ) { + newObj[newKey] = camelify(val) + } else { + newObj[newKey] = val + } + } + + return newObj + } +} + +function parseDoError (err, spec) { + const httpErrors = { + bad_request: 400, + unauthorized: 401, + forbidden: 403, + missing: 404, + request_timeout: 408, + conflict: 409, + unavailable: 503 + } + + if (httpErrors[spec]) { + return err.statusCode === httpErrors[spec] + } + + if (spec === 'request') { + return err.statusCode >= 400 && err.statusCode < 600 + } + + if (spec.startsWith('/') && spec.endsWith('/')) { + return new RegExp(spec.slice(1, -1), 'g').test(JSON.stringify(err.body)) + } + + if (spec === 'param') { + return err instanceof ConfigurationError + } + + return false +} + +function getSkip (arr) { + if (!Array.isArray(arr)) return null + for (var i = 0; i < arr.length; i++) { + if (arr[i].skip) return arr[i].skip + } + return null +} + +// code from https://github.com/developit/dlv +// needed to support an edge case: `a\.b` +// where `a.b` is a single field: { 'a.b': true } +function delve (obj, key, def, p) { + p = 0 + // handle the key with a dot inside that is not a part of the path + // and removes the backslashes from the key + key = key.split + ? key.split(/(? k.replace(/\\/g, '')) + : key.replace(/\\/g, '') + while (obj && p < key.length) obj = obj[key[p++]] + return (obj === undefined || p < key.length) ? def : obj +} + +// Gets two *maybe* numbers and returns two valida numbers +// it throws if one or both are not a valid number +// the returned value is an array with the new values +function getNumbers (val1, val2) { + const val1Numeric = Number(val1) + if (isNaN(val1Numeric)) { + throw new TypeError(`val1 is not a valid number: ${val1}`) + } + const val2Numeric = Number(val2) + if (isNaN(val2Numeric)) { + throw new TypeError(`val2 is not a valid number: ${val2}`) + } + return [val1Numeric, val2Numeric] +} + +module.exports = TestRunner