node-tdd
Version:
Drop in extension for mocha to abstract commonly used test setups
327 lines (306 loc) • 12.8 kB
JavaScript
import assert from 'assert';
import http from 'http';
import https from 'https';
import path from 'path';
import fs from 'smart-fs';
import Joi from 'joi-strict';
import nock from 'nock';
import get from 'lodash.get';
import cloneDeep from 'lodash.clonedeep';
import nockCommon from 'nock/lib/common.js';
import compareUrls from '../util/compare-urls.js';
import nockListener from './request-recorder/nock-listener.js';
import nockMock from './request-recorder/nock-mock.js';
import healSqs from './request-recorder/heal-sqs.js';
import applyModifiers from './request-recorder/apply-modifiers.js';
import requestInjector from './request-recorder/request-injector.js';
import updateAndRestoreModifiers from './request-recorder/update-and-restore-modifiers.js';
import healResponseHeaders from './request-recorder/heal-response-headers.js';
import {
buildKey,
tryParseJson,
nullAsString,
rewriteHeaders
} from './request-recorder/util.js';
const nockBack = nock.back;
const nockRecorder = nock.recorder;
export default (opts) => {
Joi.assert(opts, Joi.object().keys({
cassetteFolder: Joi.string(),
stripHeaders: Joi.boolean(),
reqHeaderOverwrite: Joi.object().pattern(
Joi.string().case('lower'),
Joi.alternatives(Joi.string(), Joi.function())
),
strict: Joi.boolean(),
heal: Joi.alternatives(Joi.boolean(), Joi.string()),
modifiers: Joi.object().pattern(
Joi.string(),
Joi.alternatives(Joi.function(), Joi.link('#modifiers'))
)
}), 'Invalid Options Provided');
let nockDone = null;
let cassetteFilePath = null;
const knownCassetteNames = [];
const records = [];
const outOfOrderErrors = [];
const expectedCassette = [];
const pendingMocks = [];
const anyFlagPresent = (flags) => {
assert(Array.isArray(flags) && flags.length !== 0);
if (typeof opts.heal !== 'string') {
return false;
}
const needleFlags = opts.heal.split(',');
return flags.some((flag) => needleFlags.includes(flag));
};
const overwriteHeaders = (key, value, headers) => {
if (key in opts.reqHeaderOverwrite) {
return typeof opts.reqHeaderOverwrite[key] === 'function'
? opts.reqHeaderOverwrite[key]({ key, value, headers })
: opts.reqHeaderOverwrite[key];
}
return value;
};
return ({
inject: async (cassetteFile) => {
assert(nockDone === null);
knownCassetteNames.push(cassetteFile);
records.length = 0;
outOfOrderErrors.length = 0;
expectedCassette.length = 0;
pendingMocks.length = 0;
cassetteFilePath = path.join(opts.cassetteFolder, cassetteFile);
const hasCassette = fs.existsSync(cassetteFilePath);
if (hasCassette) {
const cassetteContent = fs.smartRead(cassetteFilePath);
pendingMocks.push(...nock
.define(cassetteContent)
.map((e, idx) => ({
idx,
key: buildKey(e.interceptors[0]),
record: cassetteContent[idx]
})));
}
nockBack.setMode(hasCassette ? 'lockdown' : 'record');
nockBack.fixtures = opts.cassetteFolder;
nockMock.patch();
nockListener.subscribe('no match', (req) => {
assert(hasCassette === true);
// convert 404 response code to 500
const destroyOriginal = req.destroy;
req.destroy = (err) => {
if (err?.status === 404 && err?.statusCode === 404 && err?.code === 'ERR_NOCK_NO_MATCH') {
// eslint-disable-next-line no-param-reassign
err.statusCode = 500;
// eslint-disable-next-line no-param-reassign
err.status = 500;
}
return destroyOriginal.call(req, err);
};
const { protocol, options, body } = requestInjector.getLast();
if (anyFlagPresent(['record'])) {
expectedCassette.push(async () => {
nockRecorder.rec({
output_objects: true,
dont_print: true,
enable_reqheaders_recording: true
});
await new Promise((resolve) => {
options.protocol = `${protocol}:`;
const r = { http, https }[protocol].request(options, (response) => {
response.on('data', () => {});
response.on('end', resolve);
});
if (body !== undefined) {
r.write(body);
}
r.end();
});
const recorded = nockRecorder.play();
nockRecorder.clear();
return recorded.map((record) => Object.assign(record, {
headers: opts.stripHeaders === true ? undefined : rewriteHeaders(record.rawHeaders),
rawHeaders: undefined,
reqheaders: rewriteHeaders(record.reqheaders, overwriteHeaders)
}));
});
} else if (anyFlagPresent(['stub'])) {
const host = options.host || options.hostname;
const port = get(options, 'port', { http: 80, https: 443 }[protocol]);
const scope = `${protocol}://${host}:${port}`;
expectedCassette.push({
scope,
method: options.method,
path: options.path,
body: tryParseJson(body),
status: 200,
reqheaders: rewriteHeaders(options.headers, overwriteHeaders),
response: {},
responseIsBinary: false
});
}
});
nockDone = await new Promise((resolve) => {
nockBack(cassetteFile, {
before: (scope, scopeIdx) => {
records.push(cloneDeep(scope));
applyModifiers(scope, opts.modifiers);
// eslint-disable-next-line no-param-reassign
scope.reqheaders = rewriteHeaders(
scope.reqheaders,
(k, valueRecording) => (valueRequest) => {
const match = nockCommon.matchStringOrRegexp(
valueRequest,
/^\^.*\$$/.test(valueRecording) ? new RegExp(valueRecording) : valueRecording
);
if (!match && anyFlagPresent(['magic', 'headers'])) {
const idx = pendingMocks.findIndex((m) => m.idx === scopeIdx);
// overwrite existing headers
pendingMocks[idx].record.reqheaders[k] = valueRequest;
return true;
}
return match;
}
);
// eslint-disable-next-line no-param-reassign
scope.filteringRequestBody = (body) => {
if (anyFlagPresent(['magic', 'body'])) {
const idx = pendingMocks.findIndex((m) => m.idx === scopeIdx);
const requestBody = nullAsString(tryParseJson(body));
updateAndRestoreModifiers(pendingMocks[idx].record, 'body', scope.body, requestBody);
return scope.body;
}
return body;
};
// eslint-disable-next-line no-param-reassign
scope.filteringPath = (requestPath) => {
if (anyFlagPresent(['magic', 'path'])) {
const idx = pendingMocks.findIndex((m) => m.idx === scopeIdx);
if (!compareUrls(pendingMocks[idx].record.path, requestPath)) {
pendingMocks[idx].record.path = requestPath;
}
if (compareUrls(pendingMocks[idx].record.path, scope.path)) {
pendingMocks[idx].record.path = scope.path;
}
return scope.path;
}
return requestPath;
};
return scope;
},
after: (scope, scopeIdx) => {
scope.on('request', (req, interceptor, requestBodyString) => {
const idx = pendingMocks.findIndex((e) => e.idx === scopeIdx);
// https://github.com/nock/nock/blob/79ee0429050af929c525ae21a326d22796344bfc/lib/interceptor.js#L616
if (Number.isInteger(pendingMocks[idx]?.record?.delayConnection)) {
interceptor.delayConnection(pendingMocks[idx].record.delayConnection);
}
if (Number.isInteger(pendingMocks[idx]?.record?.delayBody)) {
interceptor.delayBody(pendingMocks[idx].record.delayBody);
}
const hasSentBody = requestBodyString !== undefined;
// eslint-disable-next-line no-underscore-dangle
const hasRecordBody = interceptor._requestBody !== undefined;
if (hasSentBody !== hasRecordBody) {
if (anyFlagPresent(['magic', 'body'])) {
pendingMocks[idx].record.body = nullAsString(tryParseJson(requestBodyString));
} else {
// eslint-disable-next-line no-param-reassign
interceptor.errorMessage = 'Recording body mismatch';
}
}
if (anyFlagPresent(['magic', 'headers'])) {
// add new headers
const reqheaders = {
...rewriteHeaders(req.headers),
...rewriteHeaders(pendingMocks[idx].record.reqheaders)
};
pendingMocks[idx].record.reqheaders = rewriteHeaders(reqheaders, overwriteHeaders);
// heal response headers
const newHeaders = healResponseHeaders(interceptor);
if (newHeaders.length === 0) {
delete pendingMocks[idx].record.rawHeaders;
} else {
pendingMocks[idx].record.rawHeaders = newHeaders;
}
// eslint-disable-next-line no-param-reassign
interceptor.rawHeaders = newHeaders; // ensure response handled correctly
}
if (anyFlagPresent(['magic', 'response'])) {
const responseBody = tryParseJson([
healSqs,
(_, b) => (b instanceof Buffer ? b.toString('hex') : b)
].reduce(
(respBody, fn) => fn(requestBodyString, respBody, scope, req),
interceptor.body
));
const interceptorBody = tryParseJson(interceptor.body);
// eslint-disable-next-line no-param-reassign
interceptor.body = responseBody;
updateAndRestoreModifiers(pendingMocks[idx].record, 'response', interceptorBody, responseBody);
}
expectedCassette.push(pendingMocks[idx].record);
if (idx !== 0) {
outOfOrderErrors.push(pendingMocks[idx].key);
}
pendingMocks.splice(idx, 1);
});
},
afterRecord: (recordings) => JSON.stringify(recordings.map((r) => ({
...r,
body: tryParseJson(r.body),
rawHeaders: opts.stripHeaders === true ? undefined : r.rawHeaders,
reqheaders: rewriteHeaders(r.reqheaders, overwriteHeaders)
})), null, 2)
}, resolve);
});
requestInjector.inject();
},
release: async () => {
assert(nockDone !== null);
requestInjector.release();
for (let idx = 0; idx < expectedCassette.length; idx += 1) {
if (typeof expectedCassette[idx] === 'function') {
// eslint-disable-next-line no-await-in-loop
expectedCassette.splice(idx, 1, ...await expectedCassette[idx]());
idx -= 1;
}
}
nockDone();
nockDone = null;
nockListener.unsubscribeAll('no match');
nockMock.unpatch();
if (opts.heal !== false) {
fs.smartWrite(
cassetteFilePath,
anyFlagPresent(['prune'])
? expectedCassette
: [...expectedCassette, ...pendingMocks.map(({ record }) => record)],
{ keepOrder: outOfOrderErrors.length === 0 && pendingMocks.length === 0 }
);
}
if (opts.strict !== false) {
if (outOfOrderErrors.length !== 0) {
throw new Error(`Out of Order Recordings: ${outOfOrderErrors.join(', ')}`);
}
if (pendingMocks.length !== 0) {
throw new Error(`Unmatched Recordings: ${pendingMocks.map((e) => e.key).join(', ')}`);
}
}
},
shutdown: () => {
const unexpectedFiles = fs.walkDir(opts.cassetteFolder).filter((f) => !knownCassetteNames.includes(f));
if (unexpectedFiles.length !== 0) {
throw new Error(`Unexpected file(s) in cassette folder: ${unexpectedFiles.join(', ')}`);
}
},
get: () => ({
records: records.slice(),
outOfOrderErrors: outOfOrderErrors.slice(),
unmatchedRecordings: pendingMocks.map((e) => e.key).slice(),
expectedCassette: expectedCassette.slice(),
cassetteFilePath
})
});
};