UNPKG

lambda-tdd

Version:
292 lines (277 loc) 11.9 kB
/* eslint-disable mocha/no-setup-in-describe */ import assert from 'assert'; import path from 'path'; import zlib from 'zlib'; import crypto from 'crypto'; import fs from 'smart-fs'; import get from 'lodash.get'; import yaml from 'js-yaml'; import Joi from 'joi-strict'; import { expect } from 'chai'; import { glob } from 'glob'; import { describe, EnvManager, TimeKeeper, LogRecorder, RandomSeeder } from 'node-tdd'; import ExpectService from './modules/expect-service.js'; import HandlerExecutor from './modules/handler-executor.js'; import ensureString from './util/ensure-string.js'; import dynamicApply from './util/dynamic-apply.cjs'; const { globSync } = glob; // eslint-disable-next-line mocha/no-exports export default (options) => { Joi.assert(options, Joi.object().keys({ cwd: Joi.string().optional(), name: Joi.string().optional(), verbose: Joi.boolean().optional(), timeout: Joi.number().min(0).integer().optional(), nockHeal: Joi.alternatives(Joi.boolean(), Joi.string()).optional(), testHeal: Joi.boolean().optional(), enabled: Joi.boolean().optional(), handlerFile: Joi.string().optional(), cassetteFolder: Joi.string().optional(), envVarYml: Joi.string().optional(), envVarYmlRecording: Joi.string().optional(), testFolder: Joi.string().optional(), modifiers: Joi.object().optional(), reqHeaderOverwrite: Joi.object().optional(), stripHeaders: Joi.boolean().optional(), callback: Joi.function().optional() })); const cwd = get(options, 'cwd', process.cwd()); const name = get(options, 'name', 'lambda-test'); const verbose = get(options, 'verbose', false); const timeout = get(options, 'timeout'); const nockHeal = get(options, 'nockHeal', false); const testHeal = get(options, 'testHeal', false); const enabled = get(options, 'enabled', true); const handlerFile = get(options, 'handlerFile', path.join(cwd, 'handler.js')); const cassetteFolder = get(options, 'cassetteFolder', path.join(cwd, '__cassettes')); const envVarYml = get(options, 'envVarYml', path.join(cwd, 'env-vars.yml')); const envVarYmlRecording = get(options, 'envVarYmlRecording', path.join(cwd, 'env-vars.recording.yml')); const testFolder = get(options, 'testFolder', cwd); const modifiers = get(options, 'modifiers', { toBase64: (input) => input.toString('base64'), toGzip: (input) => zlib.gzipSync(input, { level: 9 }), jsonStringify: (input) => JSON.stringify(input) }); const reqHeaderOverwrite = get(options, 'reqHeaderOverwrite', {}); const stripHeaders = get(options, 'stripHeaders', false); const callback = get(options, 'callback', () => {}); if (fs.existsSync(cassetteFolder)) { const invalidCassettes = fs.walkDir(cassetteFolder) .filter((e) => !fs.existsSync(path.join(testFolder, e.substring(0, e.length - 15)))); if (invalidCassettes.length !== 0) { throw new Error(`Rogue Cassette(s): ${invalidCassettes.join(', ')}`); } } fs.walkDir(testFolder) .map((f) => path.join(testFolder, f)) .filter((f) => { const relative = path.relative(cassetteFolder, f); return !relative || relative.startsWith('..') || path.isAbsolute(relative); }) .forEach((filePath) => { if (!filePath.endsWith('.spec.json')) { throw new Error(`Unexpected File: ${filePath}`); } }); let timeKeeper = null; let randomSeeder = null; const suiteEnvVarsWrapper = EnvManager({ envVars: { AWS_REGION: 'us-east-1', AWS_ACCESS_KEY_ID: 'XXXXXXXXXXXXXXXXXXXX', AWS_SECRET_ACCESS_KEY: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', ...yaml.load(fs.readFileSync(envVarYml, 'utf8')) }, allowOverwrite: false }); const suiteEnvVarsWrapperRecording = fs.existsSync(envVarYmlRecording) ? EnvManager({ envVars: yaml.load(fs.readFileSync(envVarYmlRecording, 'utf8')), allowOverwrite: true }) : null; return { execute: (modifier = '') => { if (enabled !== true) { return []; } const isPattern = typeof modifier === 'string' || modifier instanceof String; const testFiles = isPattern ? globSync('**/*.spec.json', { cwd: testFolder, nodir: true, ignore: ['**/*.spec.json_recording.json'] }).filter((e) => new RegExp(modifier, '').test(e)) : modifier; describe(`Testing Lambda Functions: ${name}`, () => { before(() => suiteEnvVarsWrapper.apply()); after(() => suiteEnvVarsWrapper.unapply()); testFiles.forEach((testFile) => { const testSeed = crypto.randomBytes(16).toString('hex'); // eslint-disable-next-line func-names it(`Test ${testFile}`, async function () { const test = JSON.parse(fs.readFileSync(path.join(testFolder, testFile), 'utf8')); const cassetteFile = `${testFile}_recording.json`; const isNewRecording = !fs.existsSync(path.join(cassetteFolder, cassetteFile)); if (suiteEnvVarsWrapperRecording !== null && isNewRecording) { suiteEnvVarsWrapperRecording.apply(); } const testEnvVarsWrapper = EnvManager({ envVars: test.envVars || {}, allowOverwrite: true }); testEnvVarsWrapper.apply(); if (test.timestamp !== undefined) { timeKeeper = TimeKeeper({ timestamp: test.timestamp }); timeKeeper.inject(); } if (test.seed !== undefined) { randomSeeder = RandomSeeder({ seed: test.seed, reseed: test.reseed || false }); randomSeeder.inject(); } const timeoutMax = Math.max( test.timeout !== undefined ? test.timeout : 0, timeout !== undefined ? timeout : 0 ); if (timeoutMax > 0) { this.timeout(timeoutMax); } const logRecorder = LogRecorder({ verbose, logger: console }); logRecorder.inject(); process.env.TEST_SEED = testSeed; const expectService = ExpectService({ replace: [ [cwd, '<root>'], [process.env.TEST_SEED, '<seed>'] ] }); try { const output = await HandlerExecutor({ handlerFile, cassetteFolder, verbose, nockHeal, handlerFunction: test.handler, event: test.event, context: test.context || {}, cassetteFile, lambdaTimeout: test.lambdaTimeout, modifiers, reqHeaderOverwrite, stripHeaders: get(test, 'stripHeaders', stripHeaders) }); const logs = { logs: logRecorder.levels() .reduce((p, level) => Object.assign(p, { [level]: logRecorder.get(level) }), logRecorder.get()) }; // evaluate test configuration expect(JSON.stringify(Object.keys(test).filter((e) => [ 'expect', 'handler', 'success', 'lambdaTimeout', 'response', 'timeout', 'event', 'context', 'envVars', 'logs', 'error', 'nock', 'timestamp', 'seed', 'reseed', 'body', 'defaultLogs', 'errorLogs', 'stripHeaders', 'allowedUnmatchedRecordings', 'allowedOutOfOrderRecordings' ].indexOf(e) === -1 && !e.match(/^(?:expect|logs|errorLogs|defaultLogs)\(.+\)$/g)))).to.equal('[]'); // test output if (test.success) { expect(output.err, `Error: ${get(output.err, 'stack', output.err)}`).to.equal(null); } else { expect(output.err, `Response: ${ensureString(output.response)}`).to.not.equal(null); } const keys = Object.keys(test); assert( keys.some((k) => k.startsWith('expect')) || keys.includes('response'), `Missing "expect" for test ${testFile}` ); let writeTest = false; keys .filter((k) => k.match(/^(?:expect|logs|errorLogs|defaultLogs)(?:\(.*?\)$)?/)) .forEach((k) => { let target; if (k.startsWith('expect')) { target = test.success ? output.response : output.err; } else { target = logs[k.split('(')[0]]; } if (k.indexOf('(') !== -1) { const apply = k.split('(', 2)[1].slice(0, -1).split('|'); target = apply[0] === '' ? target : get(target, apply[0]); if (apply.length > 1) { target = apply.slice(1).reduce((p, c) => dynamicApply(c, p, modifiers), target); } } if (testHeal !== false && 'to.deep.equal()' in test[k]) { test[k]['to.deep.equal()'] = expectService.prepare(target); writeTest = true; } expectService.evaluate(test[k], target); }); if (testHeal !== false && output.outOfOrderErrors.length !== 0) { test.allowedOutOfOrderRecordings = [...new Set([ ...get(test, 'allowedOutOfOrderRecordings', []), ...output.outOfOrderErrors ])]; writeTest = true; } if (writeTest === true) { fs.smartWrite(path.join(testFolder, testFile), test); } if (test.error !== undefined || test.response !== undefined || test.body !== undefined) { // eslint-disable-next-line no-console console.warn('Warning: "error", "response" and "body" are deprecated. Use "expect" instead!'); } expectService.evaluate(test.error, ensureString(output.err)); expectService.evaluate(test.response, ensureString(output.response)); expectService.evaluate(test.body, get(output.response, 'body')); expectService.evaluate(test.nock, ensureString(output.records)); expect( output.unmatchedRecordings.every((r) => get(test, 'allowedUnmatchedRecordings', []).includes(r)), `Unmatched Recording(s): ${JSON.stringify(output.unmatchedRecordings)}` ).to.equal(true); const allowedOutOfOrderRecordings = get(test, 'allowedOutOfOrderRecordings', []); expect( allowedOutOfOrderRecordings.includes('*') || output.outOfOrderErrors.every((r) => allowedOutOfOrderRecordings.includes(r)), `Out of Order Recording(s): ${JSON.stringify(output.outOfOrderErrors)}` ).to.equal(true); await callback({ test, output, expect }); return Promise.resolve(); } finally { // "close" test run logRecorder.release(); if (randomSeeder !== null) { randomSeeder.release(); randomSeeder = null; } if (timeKeeper !== null) { timeKeeper.release(); timeKeeper = null; } testEnvVarsWrapper.unapply(); if (suiteEnvVarsWrapperRecording !== null && isNewRecording) { suiteEnvVarsWrapperRecording.unapply(); } } }); }); }); return testFiles; } }; };