Jest for server

- Introduced the Jest testing framework into our server side code so
plugins can now have tests that run
This commit is contained in:
Wyatt Johnson
2018-04-11 19:02:38 -06:00
parent 7f9503d6aa
commit 86385e0d86
21 changed files with 774 additions and 772 deletions
+3
View File
@@ -1,3 +1,6 @@
{
"env": {
"jest": true
},
"extends": "@coralproject/eslint-config-talk"
}
+36
View File
@@ -0,0 +1,36 @@
const { pluginsPath } = require('../plugins');
const buildTargets = ['coral-admin'];
const buildEmbeds = ['stream'];
const specPattern = 'client/**/__tests__/**/*.spec.js?(x)';
module.exports = {
rootDir: '../',
testMatch: [
`<rootDir>/${specPattern}`,
`<rootDir>/plugins/**/${specPattern}`,
],
setupTestFrameworkScriptFile: '<rootDir>/test/client/setupJest.js',
modulePaths: [
'<rootDir>/plugins',
'<rootDir>/client',
...buildTargets.map(target => `<rootDir>/client/${target}/src`),
...buildEmbeds.map(embed => `<rootDir>/client/coral-embed-${embed}/src`),
],
moduleFileExtensions: ['js', 'jsx', 'json', 'yaml', 'yml'],
moduleDirectories: ['node_modules'],
transform: {
'^.+\\.jsx?$': 'babel-jest',
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
},
testResultsProcessor: process.env.JEST_REPORTER,
moduleNameMapper: {
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
'^pluginsConfig$': pluginsPath,
'\\.(scss|css|less)$': 'identity-obj-proxy',
'\\.(gif|ttf|eot|svg)$': '<rootDir>/test/client/fileMock.js',
},
};
+5 -37
View File
@@ -1,40 +1,8 @@
const path = require('path');
const { pluginsPath } = require('./plugins');
const buildTargets = ['coral-admin'];
const buildEmbeds = ['stream'];
// jest.config.js
module.exports = {
testMatch: ['**/client/**/__tests__/**/*.js?(x)'],
setupTestFrameworkScriptFile: '<rootDir>/test/client/setupJest.js',
modulePaths: [
'<rootDir>/plugins',
'<rootDir>/client',
...buildTargets.map(target =>
path.join('<rootDir>', 'client', target, 'src')
),
...buildEmbeds.map(embed =>
path.join('<rootDir>', 'client', `coral-embed-${embed}`, 'src')
),
],
moduleFileExtensions: ['js', 'jsx', 'json', 'yaml', 'yml'],
moduleDirectories: ['node_modules'],
transform: {
'^.+\\.jsx?$': 'babel-jest',
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
},
projects: ['<rootDir>', '<rootDir>/client'],
testPathIgnorePatterns: ['client'],
setupTestFrameworkScriptFile: '<rootDir>/test/setupJest.js',
testResultsProcessor: process.env.JEST_REPORTER,
moduleNameMapper: {
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
'^pluginsConfig$': pluginsPath,
'\\.(scss|css|less)$': 'identity-obj-proxy',
'\\.(gif|ttf|eot|svg)$': '<rootDir>/test/client/fileMock.js',
},
testEnvironment: 'node',
modulePaths: ['<rootDir>'],
};
+6 -5
View File
@@ -18,10 +18,11 @@
"lint:js": "eslint bin/cli* .",
"lint": "npm-run-all lint:*",
"plugins:reconcile": "./bin/cli plugins reconcile",
"test": "npm-run-all test:client test:server",
"test:server": "TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
"test:client": "TEST_MODE=unit NODE_ENV=test jest",
"test:client:watch": "TEST_MODE=unit NODE_ENV=test jest --watch",
"test": "npm-run-all test:jest test:mocha",
"test:jest": "TEST_MODE=unit NODE_ENV=test jest --runInBand",
"test:client": "TEST_MODE=unit NODE_ENV=test jest --projects client",
"test:mocha": "TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
"test:server:jest": "TEST_MODE=unit NODE_ENV=test jest --runInBand --projects .",
"e2e": "./scripts/e2e.js",
"e2e:ci": "./scripts/e2e-ci.sh",
"heroku-postbuild": "npm-run-all plugins:reconcile build",
@@ -127,6 +128,7 @@
"inquirer-autocomplete-prompt": "^0.12.1",
"ioredis": "3.1.4",
"ip": "^1.1.5",
"jest": "^22.4.3",
"joi": "^13.0.0",
"jsonwebtoken": "^8.0.0",
"jwt-decode": "^2.2.0",
@@ -226,7 +228,6 @@
"eslint-plugin-mocha": "^4.11.0",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^21.2.1",
"jest-junit": "^3.6.0",
"lint-staged": "^7.0.0",
"mocha": "^3.1.2",
+4 -16
View File
@@ -239,26 +239,14 @@ function getReactionConfig(reaction) {
hooks: {
Action: {
__resolveType: {
post({ action_type }) {
switch (action_type) {
case REACTION:
return `${Reaction}Action`;
default:
return undefined;
}
},
post: ({ action_type }) =>
action_type === REACTION ? `${Reaction}Action` : undefined,
},
},
ActionSummary: {
__resolveType: {
post({ action_type }) {
switch (action_type) {
case REACTION:
return `${Reaction}ActionSummary`;
default:
return undefined;
}
},
post: ({ action_type = '' } = {}) =>
action_type === REACTION ? `${Reaction}ActionSummary` : undefined,
},
},
},
@@ -0,0 +1,51 @@
const getReactionConfig = require('./getReactionConfig');
describe('plugins-api', () => {
describe('getReactionConfig', () => {
let config;
beforeEach(() => {
config = getReactionConfig('heart');
});
describe('context', () => {
it('provides a sort function', () => {
expect(config.context.Sort).toBeInstanceOf(Function);
const sort = config.context.Sort();
expect(sort.Comments).toHaveProperty('hearts');
});
});
describe('hooks', () => {
it('handles the __resolveType properly', () => {
expect(config.hooks.ActionSummary.__resolveType).toHaveProperty('post');
expect(config.hooks.ActionSummary.__resolveType.post).toBeInstanceOf(
Function
);
expect(
config.hooks.ActionSummary.__resolveType.post({})
).toBeUndefined();
expect(
config.hooks.ActionSummary.__resolveType.post({ action_type: 'LOVE' })
).toBeUndefined();
expect(
config.hooks.ActionSummary.__resolveType.post({
action_type: 'HEART',
})
).toEqual('HeartActionSummary');
});
it('handles the __resolveType properly', () => {
expect(config.hooks.Action.__resolveType).toHaveProperty('post');
expect(config.hooks.Action.__resolveType.post).toBeInstanceOf(Function);
expect(config.hooks.Action.__resolveType.post({})).toBeUndefined();
expect(
config.hooks.Action.__resolveType.post({ action_type: 'LOVE' })
).toBeUndefined();
expect(
config.hooks.Action.__resolveType.post({
action_type: 'HEART',
})
).toEqual('HeartAction');
});
});
});
});
+4 -125
View File
@@ -1,126 +1,5 @@
const debug = require('debug')('talk:plugin:akismet');
const { ErrSpam } = require('./errors');
const akismet = require('akismet-api');
const { get, merge } = require('lodash');
const { KEY, SITE } = require('./config');
const client = akismet.client({
key: KEY,
blog: SITE,
});
const typeDefs = require('./server/typeDefs');
const hooks = require('./server/hooks');
const resolvers = require('./server/resolvers');
let enabled = true;
// TODO: when using a developer key, this is possible, the plus plan does not
// allow us to check the key.
// let enabled = false;
// client.verifyKey((err, valid) => {
// if (err) {
// throw err;
// }
// if (valid) {
// enabled = true;
// } else {
// throw new Error('Akismet key is invalid');
// }
// });
module.exports = {
typeDefs: `
input CreateCommentInput {
# If true, the mutation will fail when the
# body contains detected spam.
checkSpam: Boolean
}
type Comment {
spam: Boolean
}
`,
hooks: {
RootMutation: {
createComment: {
async pre(_, { input }, { loaders, parent: req }) {
// If the key validation failed, then we can't run with the client.
if (!enabled) {
debug('not enabled, passing');
return;
}
let spam = false;
try {
const user_ip = get(req, 'ip', false);
if (!user_ip) {
debug('no ip on request');
return;
}
// Get some headers from the request.
const user_agent = req.get('User-Agent');
if (!user_agent || user_agent.length === 0) {
debug('no user agent on request');
return;
}
const referrer = req.get('Referrer');
if (!referrer || referrer.length === 0) {
debug('no referrer on request');
return;
}
// Get the Asset that the comment is being made against.
const asset = await loaders.Assets.getByID.load(input.asset_id);
if (!asset) {
debug('asset not found for new comment');
return;
}
// Send off the comment to Akismet to check to see what they say.
spam = await client.checkSpam({
user_ip,
user_agent,
referrer,
permalink: asset.url,
comment_type: 'comment',
comment_content: input.body,
is_test: true,
});
debug(`comment analyzed as ${spam ? 'being' : 'not being'} spam`);
} catch (err) {
console.trace(err);
return;
}
// Attach scores to metadata.
input.metadata = merge({}, input.metadata || {}, {
akismet: spam,
});
if (spam) {
if (input.checkSpam) {
throw new ErrSpam();
}
// Attach reason information for the flag being added.
input.status = 'SYSTEM_WITHHELD';
input.actions =
input.actions && input.actions.length >= 0 ? input.actions : [];
input.actions.push({
action_type: 'FLAG',
user_id: null,
group_id: 'SPAM_COMMENT',
metadata: {},
});
}
},
},
},
},
resolvers: {
Comment: {
spam: comment => get(comment, 'metadata.akismet', null),
},
},
};
module.exports = { typeDefs, hooks, resolvers };
+107
View File
@@ -0,0 +1,107 @@
const debug = require('debug')('talk:plugin:akismet');
const { ErrSpam } = require('./errors');
const akismet = require('akismet-api');
const { get, merge } = require('lodash');
const { KEY, SITE } = require('./config');
const client = akismet.client({
key: KEY,
blog: SITE,
});
let enabled = true;
// TODO: when using a developer key, this is possible, the plus plan does not
// allow us to check the key.
// let enabled = false;
// client.verifyKey((err, valid) => {
// if (err) {
// throw err;
// }
// if (valid) {
// enabled = true;
// } else {
// throw new Error('Akismet key is invalid');
// }
// });
module.exports = {
RootMutation: {
createComment: {
async pre(_, { input }, { loaders, parent: req }) {
// If the key validation failed, then we can't run with the client.
if (!enabled) {
debug('not enabled, passing');
return;
}
let spam = false;
try {
const user_ip = get(req, 'ip', false);
if (!user_ip) {
debug('no ip on request');
return;
}
// Get some headers from the request.
const user_agent = req.get('User-Agent');
if (!user_agent || user_agent.length === 0) {
debug('no user agent on request');
return;
}
const referrer = req.get('Referrer');
if (!referrer || referrer.length === 0) {
debug('no referrer on request');
return;
}
// Get the Asset that the comment is being made against.
const asset = await loaders.Assets.getByID.load(input.asset_id);
if (!asset) {
debug('asset not found for new comment');
return;
}
// Send off the comment to Akismet to check to see what they say.
spam = await client.checkSpam({
user_ip,
user_agent,
referrer,
permalink: asset.url,
comment_type: 'comment',
comment_content: input.body,
is_test: true,
});
debug(`comment analyzed as ${spam ? 'being' : 'not being'} spam`);
} catch (err) {
console.trace(err);
return;
}
// Attach scores to metadata.
input.metadata = merge({}, input.metadata || {}, {
akismet: spam,
});
if (spam) {
if (input.checkSpam) {
throw new ErrSpam();
}
// Attach reason information for the flag being added.
input.status = 'SYSTEM_WITHHELD';
input.actions =
input.actions && input.actions.length >= 0 ? input.actions : [];
input.actions.push({
action_type: 'FLAG',
user_id: null,
group_id: 'SPAM_COMMENT',
metadata: {},
});
}
},
},
},
};
@@ -0,0 +1,7 @@
const { get } = require('lodash');
module.exports = {
Comment: {
spam: comment => get(comment, 'metadata.akismet', null),
},
};
@@ -0,0 +1,14 @@
const resolvers = require('./resolvers');
describe('talk-plugin-akismet', () => {
describe('resolvers', () => {
it('resolves when there is a akismet value', () => {
const spam = resolvers.Comment.spam({ metadata: { akismet: true } });
expect(spam).toEqual(true);
});
it('resolves when there not is a akismet value', () => {
const spam = resolvers.Comment.spam({});
expect(spam).toEqual(null);
});
});
});
@@ -0,0 +1,10 @@
input CreateCommentInput {
# If true, the mutation will fail when the
# body contains detected spam.
checkSpam: Boolean
}
type Comment {
spam: Boolean
}
@@ -0,0 +1,7 @@
const fs = require('fs');
const path = require('path');
module.exports = fs.readFileSync(
path.join(__dirname, 'typeDefs.graphql'),
'utf8'
);
@@ -0,0 +1,11 @@
let values = {};
const getScores = () => values.getScores;
const isToxic = () => values.isToxic;
const setValues = newValues => {
values = newValues;
};
module.exports = { getScores, isToxic, setValues };
@@ -1,11 +1,6 @@
const { getScores, isToxic } = require('./perspective');
const { ErrToxic } = require('./errors');
// We don't add the hooks during _test_ as the perspective API is not available.
if (process.env.NODE_ENV === 'test') {
return null;
}
module.exports = {
RootMutation: {
createComment: {
@@ -16,7 +11,7 @@ module.exports = {
scores = await getScores(input.body);
} catch (err) {
// Warn and let mutation pass.
console.trace(err);
console.trace(err); // TODO: log/handle this differently?
return;
}
@@ -0,0 +1,31 @@
const hooks = require('./hooks');
const { ErrToxic } = require('./errors');
// Mock out the perspective api call.
jest.mock('./perspective');
describe('talk-plugin-toxic-comments', () => {
describe('hooks', () => {
beforeEach(() => {
require('./perspective').setValues({ isToxic: true });
});
it('sets the correct values for a toxic comment', async () => {
let input = { body: 'This is a body.', checkToxicity: false };
await hooks.RootMutation.createComment.pre(null, { input }, null, null);
expect(input).toHaveProperty('status', 'SYSTEM_WITHHELD');
});
it('throws an error when a toxic comment is sent', async () => {
expect.assertions(1);
await expect(
hooks.RootMutation.createComment.pre(
null,
{ input: { checkToxicity: true } },
null,
null
)
).rejects.toBeInstanceOf(ErrToxic);
});
});
});
+3 -4
View File
@@ -7,11 +7,11 @@ const { LOGGING_LEVEL, REVISION_HASH } = require('../config');
// but will send JSON logs in production that's parsable by a system like ELK.
const streams = (() => {
// In development, use the debug stream printer.
if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV !== 'production') {
const debug = require('bunyan-debug-stream');
return [
{
level: 'debug',
level: LOGGING_LEVEL,
type: 'raw',
stream: debug({
basepath: path.resolve(__dirname, '..'),
@@ -22,7 +22,7 @@ const streams = (() => {
}
// In production, emit JSON.
return [{ stream: process.stdout, level: 'info' }];
return [{ stream: process.stdout, level: LOGGING_LEVEL }];
})();
// logger is the base logger used by all logging systems in Talk.
@@ -31,7 +31,6 @@ const logger = createBunyanLogger({
name: 'talk',
version,
revision: REVISION_HASH,
level: LOGGING_LEVEL,
streams,
serializers: stdSerializers,
});
+9 -3
View File
@@ -37,6 +37,15 @@ if (WEBPACK) {
// here just ensures that the application can quit correctly.
mongoose.disconnect();
} else {
mongoose.connection.on('connected', () => logger.debug('mongodb connected'));
mongoose.connection.on('disconnected', () =>
logger.debug('mongodb disconnected')
);
setTimeout(() => {
mongoose.disconnect();
}, 5000);
// Connect to the Mongo instance.
mongoose
.connect(MONGO_URL, {
@@ -45,9 +54,6 @@ if (WEBPACK) {
autoIndex: CREATE_MONGO_INDEXES,
},
})
.then(() => {
logger.debug('mongodb connection established');
})
.catch(err => {
console.error(err);
process.exit(1);
+21
View File
@@ -0,0 +1,21 @@
const mongoose = require('../services/mongoose');
beforeEach(async () => {
await Promise.all(
Object.keys(mongoose.connection.collections).map(collection => {
return new Promise((resolve, reject) => {
mongoose.connection.collections[collection].remove(function(err) {
if (err) {
return reject(err);
}
return resolve();
});
});
})
);
});
afterAll(async function() {
await mongoose.disconnect();
});
+444 -576
View File
File diff suppressed because it is too large Load Diff