diff --git a/.eslintignore b/.eslintignore index c558f1256..f55a7b393 100644 --- a/.eslintignore +++ b/.eslintignore @@ -22,6 +22,7 @@ plugins/* !plugins/talk-plugin-author-menu !plugins/talk-plugin-member-since !plugins/talk-plugin-ignore-user +!plugins/talk-plugin-toxic-comments !plugins/talk-plugin-remember-sort node_modules diff --git a/.eslintrc.json b/.eslintrc.json index 237650932..8ca153cbc 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,7 +3,9 @@ "es6": true, "node": true }, - "extends": "eslint:recommended", + "extends": [ + "eslint:recommended" + ], "parserOptions": { "ecmaVersion": 2017 }, @@ -12,9 +14,7 @@ "json" ], "rules": { - "indent": ["error", - 2 - ], + "indent": ["error", 2], "no-console": "off", "linebreak-style": ["error", "unix"], "quotes": ["error", "single"], @@ -29,7 +29,7 @@ "no-global-assign": "error", "no-implied-eval": "error", "lines-around-comment": ["warn", {"beforeLineComment": true}], - "spaced-comment": ["warn", "always", { "line": { "exceptions": ["-", "="] } }], + "spaced-comment": ["warn", "always", {"line": {"exceptions": ["-", "="]}}], "no-script-url": "error", "no-throw-literal": "error", "yoda": "warn", @@ -41,32 +41,20 @@ "object-curly-spacing": "warn", "space-infix-ops": ["error"], "space-in-parens": ["error", "never"], - "space-unary-ops": ["error", { - "words": true, - "nonwords": false - }], + "space-unary-ops": ["error", {"words": true, "nonwords": false}], "no-const-assign": "error", "no-duplicate-imports": "error", "prefer-template": "warn", - "comma-spacing": ["error", { - "after": true - }], + "comma-spacing": ["error", {"after": true}], "no-var": "error", "no-lonely-if": "error", "curly": "error", - "no-unused-vars": ["error", { - "argsIgnorePattern": "^_|next", - "varsIgnorePattern": "^_" - }], - "no-multiple-empty-lines": ["error", { - "max": 1 - }], - "newline-per-chained-call": ["error", { - "ignoreChainWithDepth": 2 - }], + "no-unused-vars": ["error", {"argsIgnorePattern": "^_|next", "varsIgnorePattern": "^_"}], + "no-multiple-empty-lines": ["error", {"max": 1}], + "newline-per-chained-call": ["error", {"ignoreChainWithDepth": 2}], "promise/no-return-wrap": "error", "promise/param-names": "error", - "promise/catch-or-return": "error", + "promise/catch-or-return": "warn", "promise/no-native": "off", "promise/no-nesting": "warn", "promise/no-promise-in-callback": "warn", diff --git a/.gitignore b/.gitignore index 05e41eac3..1e6ce197d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ plugins/* !plugins/talk-plugin-comment-content !plugins/talk-plugin-permalink !plugins/talk-plugin-featured-comments +!plugins/talk-plugin-toxic-comments !plugins/talk-plugin-sort-newest !plugins/talk-plugin-sort-oldest !plugins/talk-plugin-sort-most-replied @@ -38,6 +39,7 @@ plugins/* !plugins/talk-plugin-author-menu !plugins/talk-plugin-member-since !plugins/talk-plugin-ignore-user +!plugins/talk-plugin-toxic-comments !plugins/talk-plugin-remember-sort **/node_modules/* diff --git a/client/coral-admin/src/actions/install.js b/client/coral-admin/src/actions/install.js index d6b27115a..02d562d65 100644 --- a/client/coral-admin/src/actions/install.js +++ b/client/coral-admin/src/actions/install.js @@ -128,18 +128,18 @@ const checkInstallRequest = () => ({type: actions.CHECK_INSTALL_REQUEST}); const checkInstallSuccess = (installed) => ({type: actions.CHECK_INSTALL_SUCCESS, installed}); const checkInstallFailure = (error) => ({type: actions.CHECK_INSTALL_FAILURE, error}); -export const checkInstall = (next) => (dispatch, _, {rest}) => { +export const checkInstall = (next) => async (dispatch, _, {rest}) => { dispatch(checkInstallRequest()); - rest('/setup') - .then(({installed}) => { - dispatch(checkInstallSuccess(installed)); - if (installed) { - next(); - } - }) - .catch((error) => { - console.error(error); - const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); - dispatch(checkInstallFailure(errorMessage)); - }); + + try { + const {installed} = await rest('/setup'); + dispatch(checkInstallSuccess(installed)); + if (installed) { + next(); + } + } catch (error) { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(checkInstallFailure(errorMessage)); + } }; diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index a7eadbac3..fd44d5f17 100644 --- a/client/coral-admin/src/components/UserDetail.js +++ b/client/coral-admin/src/components/UserDetail.js @@ -28,16 +28,26 @@ export default class UserDetail extends React.Component { bulkReject: PropTypes.func.isRequired, } - rejectThenReload = (info) => { - this.props.rejectComment(info).then(() => { + rejectThenReload = async (info) => { + try { + await this.props.rejectComment(info); this.props.data.refetch(); - }); + } catch (err) { + + // TODO: handle error. + console.error(err); + } } - acceptThenReload = (info) => { - this.props.acceptComment(info).then(() => { + acceptThenReload = async (info) => { + try { + await this.props.acceptComment(info); this.props.data.refetch(); - }); + } catch (err) { + + // TODO: handle error. + console.error(err); + } } showAll = () => { @@ -133,7 +143,7 @@ export default class UserDetail extends React.Component {
diff --git a/client/coral-admin/src/containers/UserDetail.js b/client/coral-admin/src/containers/UserDetail.js index 0eb81a9a1..5effb2b84 100644 --- a/client/coral-admin/src/containers/UserDetail.js +++ b/client/coral-admin/src/containers/UserDetail.js @@ -36,14 +36,19 @@ class UserDetailContainer extends React.Component { isLoadingMore = false; // status can be 'ACCEPTED' or 'REJECTED' - bulkSetCommentStatus = (status) => { + bulkSetCommentStatus = async (status) => { const changes = this.props.selectedCommentIds.map((commentId) => { return this.props.setCommentStatus({commentId, status}); }); - Promise.all(changes).then(() => { + try { + await Promise.all(changes); this.props.clearUserDetailSelections(); // un-select everything - }); + } catch (err) { + + // TODO: handle error. + console.error(err); + } } bulkReject = () => { diff --git a/client/coral-admin/src/routes/Community/components/FlaggedUser.js b/client/coral-admin/src/routes/Community/components/FlaggedUser.js index 5b2238592..81d7790fe 100644 --- a/client/coral-admin/src/routes/Community/components/FlaggedUser.js +++ b/client/coral-admin/src/routes/Community/components/FlaggedUser.js @@ -85,7 +85,7 @@ class User extends React.Component { {t('community.flags')}({ user.actions.length }) : - { user.action_summaries.map( + { user.action_summaries.map( (action, i) => { return {shortReasons[action.reason]} ({action.count}) diff --git a/client/coral-admin/src/routes/Community/components/RejectUsernameDialog.js b/client/coral-admin/src/routes/Community/components/RejectUsernameDialog.js index 128946b61..4932021bd 100644 --- a/client/coral-admin/src/routes/Community/components/RejectUsernameDialog.js +++ b/client/coral-admin/src/routes/Community/components/RejectUsernameDialog.js @@ -48,11 +48,15 @@ class RejectUsernameDialog extends Component { const cancel = this.props.handleClose; const next = () => this.setState({stage: stage + 1}); - const suspend = () => { - rejectUsername({id: user.user.id, message: this.state.email}) - .then(() => { - this.props.handleClose(); - }); + const suspend = async () => { + try { + await rejectUsername({id: user.user.id, message: this.state.email}); + this.props.handleClose(); + } catch (err) { + + // TODO: handle error. + console.error(err); + } }; const suspendModalActions = [ diff --git a/client/coral-admin/src/routes/Stories/components/Stories.js b/client/coral-admin/src/routes/Stories/components/Stories.js index 5b931e6fc..d57b8f779 100644 --- a/client/coral-admin/src/routes/Stories/components/Stories.js +++ b/client/coral-admin/src/routes/Stories/components/Stories.js @@ -50,17 +50,22 @@ export default class Stories extends Component { return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`; } - onStatusClick = (closeStream, id, statusMenuOpen) => () => { + onStatusClick = (closeStream, id, statusMenuOpen) => async () => { if (statusMenuOpen) { this.setState((prev) => { prev.statusMenus[id] = false; return prev; }); - this.props.updateAssetState(id, closeStream ? Date.now() : null) - .then(() => { - const {search, sort, filter, page} = this.state; - this.props.fetchAssets(page, limit, search, sort, filter); - }); + + try { + await this.props.updateAssetState(id, closeStream ? Date.now() : null); + const {search, sort, filter, page} = this.state; + this.props.fetchAssets(page, limit, search, sort, filter); + } catch (err) { + + // TODO: handle error. + console.error(err); + } } else { this.setState((prev) => { prev.statusMenus[id] = true; diff --git a/client/coral-embed-stream/src/components/StreamTabPanel.js b/client/coral-embed-stream/src/components/StreamTabPanel.js index a511ac555..50f54c2a0 100644 --- a/client/coral-embed-stream/src/components/StreamTabPanel.js +++ b/client/coral-embed-stream/src/components/StreamTabPanel.js @@ -15,8 +15,8 @@ class StreamTabPanel extends React.Component { {loading ?
: - {tabPanes} - + {tabPanes} + } ); diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js index 09470de14..ad281ce0c 100644 --- a/client/coral-embed-stream/src/graphql/index.js +++ b/client/coral-embed-stream/src/graphql/index.js @@ -109,7 +109,7 @@ export default { }, mutations: { PostComment: ({ - variables: {comment: {asset_id, body, parent_id, tags = []}}, + variables: {input: {asset_id, body, parent_id, tags = []}}, state: {auth}, }) => ({ optimisticResponse: { diff --git a/client/coral-framework/graphql/fragments.js b/client/coral-framework/graphql/fragments.js index 110f0796b..5b3fe5498 100644 --- a/client/coral-framework/graphql/fragments.js +++ b/client/coral-framework/graphql/fragments.js @@ -6,6 +6,7 @@ export default { 'SetCommentStatusResponse', 'SuspendUserResponse', 'RejectUsernameResponse', + 'CreateCommentResponse', 'SetUserStatusResponse', 'CreateFlagResponse', 'EditCommentResponse', diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js index 4254400bb..c09ce8751 100644 --- a/client/coral-framework/graphql/mutations.js +++ b/client/coral-framework/graphql/mutations.js @@ -192,17 +192,17 @@ export const withSetUserStatus = withMutation( export const withPostComment = withMutation( gql` - mutation PostComment($comment: CreateCommentInput!) { - createComment(comment: $comment) { + mutation PostComment($input: CreateCommentInput!) { + createComment(input: $input) { ...CreateCommentResponse } } `, { props: ({mutate}) => ({ - postComment: (comment) => { + postComment: (input) => { return mutate({ variables: { - comment + input }, }); } diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 4424e62c5..037c1b99f 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -109,7 +109,7 @@ export function mergeDocuments(documents) { export function getResponseErrors(mutationResult) { const result = []; Object.keys(mutationResult.data).forEach((response) => { - const errors = mutationResult.data[response].errors; + const errors = mutationResult.data[response] && mutationResult.data[response].errors; if (errors && errors.length) { result.push(...errors); } diff --git a/client/talk-plugin-commentbox/CommentBox.js b/client/talk-plugin-commentbox/CommentBox.js index d4cc44f22..2c671946a 100644 --- a/client/talk-plugin-commentbox/CommentBox.js +++ b/client/talk-plugin-commentbox/CommentBox.js @@ -54,7 +54,7 @@ class CommentBox extends React.Component { return; } - let comment = { + let input = { asset_id: assetId, parent_id: parentId, body: this.state.body, @@ -62,10 +62,10 @@ class CommentBox extends React.Component { }; // Execute preSubmit Hooks - this.state.hooks.preSubmit.forEach((hook) => hook()); + this.state.hooks.preSubmit.forEach((hook) => hook(input)); this.setState({loadingState: 'loading'}); - postComment(comment, 'comments') + postComment(input, 'comments') .then(({data}) => { this.setState({loadingState: 'success', body: ''}); const postedComment = data.createComment.comment; diff --git a/graph/errorHandler.js b/graph/errorHandler.js new file mode 100644 index 000000000..d7176f16a --- /dev/null +++ b/graph/errorHandler.js @@ -0,0 +1,54 @@ +const {forEachField} = require('./utils'); +const {maskErrors} = require('graphql-errors'); +const errors = require('../errors'); +const {Error: {ValidationError}} = require('mongoose'); + +// If an APIError happens in a mutation, then respond with `{errors: Array}` +// according to the schema. +const decorateWithMutationErrorHandler = (field) => { + const fieldResolver = field.resolve; + field.resolve = async (obj, args, ctx, info) => { + try { + return await fieldResolver(obj, args, ctx, info); + } + catch(err) { + if (err instanceof errors.APIError) { + return { + errors: [err] + }; + } else if (err instanceof ValidationError) { + + // TODO: wrap this with one of our internal errors. + throw err; + } + + throw err; + } + }; +}; + +/** + * Masks errors during production and handle mutation errors inside the schema. + * @param {GraphQLSchema} schema the schema to decorate + * @return {void} + */ +const decorateWithErrorHandler = (schema) => { + forEachField(schema, (field, typeName) => { + + // Handle mutation errors. + if (typeName === 'RootMutation') { + decorateWithMutationErrorHandler(field); + } + + // If we are in production mode, don't show server errors to the front end. + if (process.env.NODE_ENV === 'production') { + + // Mask errors that are thrown if we are in a production environment. + maskErrors(field); + } + }); +}; + +module.exports = { + decorateWithErrorHandler, +}; diff --git a/graph/helpers/response.js b/graph/helpers/response.js deleted file mode 100644 index 1db1e9b89..000000000 --- a/graph/helpers/response.js +++ /dev/null @@ -1,34 +0,0 @@ -const errors = require('../../errors'); -const {Error: {ValidationError}} = require('mongoose'); - -/** - * Wraps up a promise or value to return an object with the resolution of the promise - * keyed at `key` or an error caught at `errors`. - */ - -const wrapResponse = (key) => async (promise) => { - try { - let value = await promise; - - let res = {}; - if (key) { - res[key] = value; - } - - return res; - } catch (err) { - if (err instanceof errors.APIError) { - return { - errors: [err] - }; - } else if (err instanceof ValidationError) { - - // TODO: wrap this with one of our internal errors. - throw err; - } - - throw err; - } -}; - -module.exports = wrapResponse; diff --git a/graph/hooks.js b/graph/hooks.js index 7f6be7feb..3a34be0bb 100644 --- a/graph/hooks.js +++ b/graph/hooks.js @@ -1,7 +1,4 @@ -const { - GraphQLObjectType, - GraphQLInterfaceType -} = require('graphql'); +const {forEachField} = require('./utils'); const debug = require('debug')('talk:graph:schema'); const Joi = require('joi'); @@ -26,33 +23,6 @@ const defaultResolveFn = (source, args, context, {fieldName}) => { } }; -// This function is pretty much copied verbatim from the graphql-tools repo: -// https://github.com/apollographql/graphql-tools/blob/b12973c86e00be209d04af0184780998056051c4/src/schemaGenerator.ts#L180-L194 -// With the small alteration that we look for the `resolveType` function on the -// schema so we can wrap post hooks around it to provide additional resolve -// points. -const forEachField = (schema, fn) => { - const typeMap = schema.getTypeMap(); - Object.keys(typeMap).forEach((typeName) => { - const type = typeMap[typeName]; - - if (type instanceof GraphQLObjectType || type instanceof GraphQLInterfaceType) { - - // Here we capture the change to extract the resolve type. We pass this - // with the `isResolveType = true` to introduce the specific beheviour. - if ('resolveType' in type) { - fn(type, typeName, '__resolveType', true); - } - - const fields = type.getFields(); - Object.keys(fields).forEach((fieldName) => { - const field = fields[fieldName]; - fn(field, typeName, fieldName); - }); - } - }); -}; - /** * Decorates the field with the post resolvers (if available) and attaches a * default type in the form of `Default${typeName}`. @@ -239,6 +209,8 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa return result; }, result); }; +}, { + includeResolveType: true, }); module.exports = { diff --git a/graph/loaders/users.js b/graph/loaders/users.js index ac3493479..b943c60ed 100644 --- a/graph/loaders/users.js +++ b/graph/loaders/users.js @@ -3,6 +3,10 @@ const DataLoader = require('dataloader'); const util = require('./util'); const union = require('lodash/union'); +const { + SEARCH_OTHER_USERS, +} = require('../../perms/constants'); + const UsersService = require('../../services/users'); const UserModel = require('../../models/user'); @@ -28,12 +32,23 @@ const genUserByIDs = async (context, ids) => { * @param {Object} query query terms to apply to the users query */ const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor, statuses, action_type, sortOrder}) => { - let query = UserModel.find(); - if (action_type) { - const userIds = await Actions.getByTypes({action_type, item_type: 'USERS'}); - ids = ids ? union(ids, userIds) : userIds; + if (action_type || statuses) { + if (!user || !user.can(SEARCH_OTHER_USERS)) { + return null; + } + + if (statuses) { + query = query.where({ + status: { + $in: statuses + } + }); + } else { + const userIds = await Actions.getByTypes({action_type, item_type: 'USERS'}); + ids = ids ? union(ids, userIds) : userIds; + } } if (ids) { @@ -44,14 +59,6 @@ const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor, }); } - if (statuses) { - query = query.where({ - status: { - $in: statuses - } - }); - } - if (cursor) { if (sortOrder === 'DESC') { query = query.where({ diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 4ca044fcf..9142687f6 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -154,7 +154,7 @@ const adjustKarma = (Comments, id, status) => async () => { * @param {String} [status='NONE'] the status of the new comment * @return {Promise} resolves to the created comment */ -const createComment = async (context, {tags = [], body, asset_id, parent_id = null}, status = 'NONE') => { +const createComment = async (context, {tags = [], body, asset_id, parent_id = null, metadata = {}}, status = 'NONE') => { const {user, loaders: {Comments}, pubsub} = context; // Resolve the tags for the comment. @@ -166,7 +166,8 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu parent_id, status, tags, - author_id: user.id + author_id: user.id, + metadata, }); // If the loaders are present, clear the caches for these values because we @@ -214,7 +215,7 @@ const filterNewComment = (context, {body, asset_id}) => { * @param {Object} [wordlist={}] the results of the wordlist scan * @return {Promise} resolves to the comment's status */ -const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, settings = {}) => { +const resolveNewCommentStatus = async (context, {asset_id, body, status}, wordlist = {}, settings = {}) => { let {user} = context; // Check to see if the body is too short, if it is, then complain about it! @@ -269,7 +270,7 @@ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, } } - return moderation === 'PRE' ? 'PREMOD' : 'NONE'; + return (moderation === 'PRE' || status === 'PREMOD') ? 'PREMOD' : 'NONE'; }; /** diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index bfdc3991e..df67b8e8a 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -1,43 +1,41 @@ -const wrapResponse = require('../helpers/response'); - const RootMutation = { - createComment(_, {comment}, {mutators: {Comment}}) { - return wrapResponse('comment')(Comment.create(comment)); + createComment: async (_, {input}, {mutators: {Comment}}) => ({ + comment: await Comment.create(input), + }), + editComment: async (_, {id, asset_id, edit: {body}}, {mutators: {Comment}}) => ({ + comment: await Comment.edit({id, asset_id, edit: {body}}), + }), + createFlag: async (_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) => ({ + flag: Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}}), + }), + createDontAgree: async (_, {dontagree: {item_id, item_type, reason, message}}, {mutators: {Action}}) => ({ + dontagree: await Action.create({item_id, item_type, action_type: 'DONTAGREE', group_id: reason, metadata: {message}}), + }), + deleteAction: async (_, {id}, {mutators: {Action}}) => { + await Action.delete({id}); }, - editComment(_, {id, asset_id, edit: {body}}, {mutators: {Comment}}) { - return wrapResponse('comment')(Comment.edit({id, asset_id, edit: {body}})); + setUserStatus: async (_, {id, status}, {mutators: {User}}) => { + await User.setUserStatus({id, status}); }, - createFlag(_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) { - return wrapResponse('flag')(Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}})); + suspendUser: async (_, {input: {id, message, until}}, {mutators: {User}}) => { + await User.suspendUser({id, message, until}); }, - createDontAgree(_, {dontagree: {item_id, item_type, reason, message}}, {mutators: {Action}}) { - return wrapResponse('dontagree')(Action.create({item_id, item_type, action_type: 'DONTAGREE', group_id: reason, metadata: {message}})); + rejectUsername: async (_, {input: {id, message}}, {mutators: {User}}) => { + await User.rejectUsername({id, message}); }, - deleteAction(_, {id}, {mutators: {Action}}) { - return wrapResponse(null)(Action.delete({id})); + ignoreUser: async (_, {id}, {mutators: {User}}) => { + await User.ignoreUser({id}); }, - setUserStatus(_, {id, status}, {mutators: {User}}) { - return wrapResponse(null)(User.setUserStatus({id, status})); + stopIgnoringUser: async (_, {id}, {mutators: {User}}) => { + await User.stopIgnoringUser({id}); }, - suspendUser(_, {input: {id, message, until}}, {mutators: {User}}) { - return wrapResponse(null)(User.suspendUser({id, message, until})); + updateAssetSettings: async (_, {id, input: settings}, {mutators: {Asset}}) => { + await Asset.updateSettings(id, settings); }, - rejectUsername(_, {input: {id, message}}, {mutators: {User}}) { - return wrapResponse(null)(User.rejectUsername({id, message})); + updateAssetStatus: async (_, {id, input: status}, {mutators: {Asset}}) => { + await Asset.updateStatus(id, status); }, - updateAssetSettings(_, {id, input: settings}, {mutators: {Asset}}) { - return wrapResponse(null)(Asset.updateSettings(id, settings)); - }, - updateAssetStatus(_, {id, input: status}, {mutators: {Asset}}) { - return wrapResponse(null)(Asset.updateStatus(id, status)); - }, - ignoreUser(_, {id}, {mutators: {User}}) { - return wrapResponse(null)(User.ignoreUser({id})); - }, - stopIgnoringUser(_, {id}, {mutators: {User}}) { - return wrapResponse(null)(User.stopIgnoringUser({id})); - }, - async setCommentStatus(_, {id, status}, {mutators: {Comment}, pubsub}) { + setCommentStatus: async (_, {id, status}, {mutators: {Comment}, pubsub}) => { const comment = await Comment.setStatus({id, status}); if (status === 'ACCEPTED') { @@ -48,25 +46,24 @@ const RootMutation = { // Publish the comment status change via the subscription. pubsub.publish('commentRejected', comment); } - return wrapResponse(null)(comment); }, - addTag(_, {tag}, {mutators: {Tag}}) { - return wrapResponse(null)(Tag.add(tag)); + addTag: async (_, {tag}, {mutators: {Tag}}) => { + await Tag.add(tag); }, - removeTag(_, {tag}, {mutators: {Tag}}) { - return wrapResponse(null)(Tag.remove(tag)); + removeTag: async (_, {tag}, {mutators: {Tag}}) => { + await Tag.remove(tag); }, - updateSettings(_, {input: settings}, {mutators: {Settings}}) { - return wrapResponse(null)(Settings.update(settings)); + updateSettings: async (_, {input: settings}, {mutators: {Settings}}) => { + await Settings.update(settings); }, - updateWordlist(_, {input: wordlist}, {mutators: {Settings}}) { - return wrapResponse(null)(Settings.updateWordlist(wordlist)); + updateWordlist: async (_, {input: wordlist}, {mutators: {Settings}}) => { + await Settings.updateWordlist(wordlist); }, - createToken(_, {input}, {mutators: {Token}}) { - return wrapResponse('token')(Token.create(input)); - }, - revokeToken(_, {input}, {mutators: {Token}}) { - return wrapResponse(null)(Token.revoke(input)); + createToken: async (_, {input}, {mutators: {Token}}) => ({ + token: await Token.create(input), + }), + revokeToken: async (_, {input}, {mutators: {Token}}) => { + await Token.revoke(input); } }; diff --git a/graph/schema.js b/graph/schema.js index 53360e701..d4cd9f890 100644 --- a/graph/schema.js +++ b/graph/schema.js @@ -1,6 +1,6 @@ const {makeExecutableSchema} = require('graphql-tools'); -const {maskErrors} = require('graphql-errors'); const {decorateWithHooks} = require('./hooks'); +const {decorateWithErrorHandler} = require('./errorHandler'); const plugins = require('../services/plugins'); const resolvers = require('./resolvers'); @@ -11,11 +11,7 @@ const schema = makeExecutableSchema({typeDefs, resolvers}); // Plugin to the schema level resolvers to provide an before/after hook. decorateWithHooks(schema, plugins.get('server', 'hooks')); -// If we are in production mode, don't show server errors to the front end. -if (process.env.NODE_ENV === 'production') { - - // Mask errors that are thrown if we are in a production environment. - maskErrors(schema); -} +// Handle errors like masking in production and mutation errors. +decorateWithErrorHandler(schema); module.exports = schema; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 24d814311..916c0ade5 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -1254,7 +1254,7 @@ type RevokeTokenResponse implements Response { type RootMutation { # Creates a comment on the asset. - createComment(comment: CreateCommentInput!): CreateCommentResponse! + createComment(input: CreateCommentInput!): CreateCommentResponse! # Creates a flag on an entity. createFlag(flag: CreateFlagInput!): CreateFlagResponse! @@ -1270,42 +1270,42 @@ type RootMutation { # Sets User status. Requires the `ADMIN` role. # Mutation is restricted. - setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse! + setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse # Suspends a user. Requires the `ADMIN` role. # Mutation is restricted. - suspendUser(input: SuspendUserInput!): SuspendUserResponse! + suspendUser(input: SuspendUserInput!): SuspendUserResponse # Reject a username. Requires the `ADMIN` role. # Mutation is restricted. - rejectUsername(input: RejectUsernameInput!): RejectUsernameResponse! + rejectUsername(input: RejectUsernameInput!): RejectUsernameResponse # Sets Comment status. Requires the `ADMIN` role. # Mutation is restricted. - setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse! + setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse # Add a tag. - addTag(tag: ModifyTagInput!): ModifyTagResponse! + addTag(tag: ModifyTagInput!): ModifyTagResponse # Removes a tag. - removeTag(tag: ModifyTagInput!): ModifyTagResponse! + removeTag(tag: ModifyTagInput!): ModifyTagResponse # Updates settings on a given asset. # Mutation is restricted. - updateAssetSettings(id: ID!, input: AssetSettingsInput!): UpdateAssetSettingsResponse! + updateAssetSettings(id: ID!, input: AssetSettingsInput!): UpdateAssetSettingsResponse # Updates the status of an asset allowing you to close/reopen an asset for # commenting. # Mutation is restricted. - updateAssetStatus(id: ID!, input: UpdateAssetStatusInput!): UpdateAssetStatusResponse! + updateAssetStatus(id: ID!, input: UpdateAssetStatusInput!): UpdateAssetStatusResponse # updateSettings will update the global settings. # Mutation is restricted. - updateSettings(input: UpdateSettingsInput!): UpdateSettingsResponse! + updateSettings(input: UpdateSettingsInput!): UpdateSettingsResponse # updateWordlist will update the given Wordlist. # Mutation is restricted. - updateWordlist(input: UpdateWordlistInput!): UpdateWordlistResponse! + updateWordlist(input: UpdateWordlistInput!): UpdateWordlistResponse # Ignore comments by another user ignoreUser(id: ID!): IgnoreUserResponse @@ -1316,10 +1316,10 @@ type RootMutation { # RevokeToken will revoke an existing token. # Mutation is restricted. - revokeToken(input: RevokeTokenInput!): RevokeTokenResponse! + revokeToken(input: RevokeTokenInput!): RevokeTokenResponse # Stop Ignoring comments by another user. - stopIgnoringUser(id: ID!): StopIgnoringUserResponse! + stopIgnoringUser(id: ID!): StopIgnoringUserResponse } ################################################################################ diff --git a/graph/utils.js b/graph/utils.js new file mode 100644 index 000000000..d170a2d83 --- /dev/null +++ b/graph/utils.js @@ -0,0 +1,46 @@ +const { + GraphQLObjectType, + GraphQLInterfaceType +} = require('graphql'); + +/** + * Iterates over each field in a schema. + * This function is pretty much copied verbatim from the graphql-tools repo: + * https://github.com/apollographql/graphql-tools/blob/b12973c86e00be209d04af0184780998056051c4/src/schemaGenerator.ts#L180-L194 + * With the small alteration that we look for the `resolveType` function on the + * schema so we can wrap post hooks around it to provide additional resolve + * points. (Only when `options.includeResolveType` is set to true). + * + * @param {GraphQLSchema} schema the schema to iterate over + * @param {function} fn callback to call on each field + * @param {object} [options] options + * @param {boolean} [options.includeResolveType] include resolveType during iteration + * @return {void} + */ +const forEachField = (schema, fn, options = {}) => { + const {includeResolveType = false} = options; + + const typeMap = schema.getTypeMap(); + Object.keys(typeMap).forEach((typeName) => { + const type = typeMap[typeName]; + + if (type instanceof GraphQLObjectType || type instanceof GraphQLInterfaceType) { + + // Here we capture the change to extract the resolve type. We pass this + // with the `isResolveType = true` to introduce the specific beheviour. + if (includeResolveType && 'resolveType' in type) { + fn(type, typeName, '__resolveType', true); + } + + const fields = type.getFields(); + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + fn(field, typeName, fieldName); + }); + } + }); +}; + +module.exports = { + forEachField, +}; diff --git a/package.json b/package.json index 3d054e0d6..29ba80ae8 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "3.5.0", "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", + "private": true, "scripts": { "postinstall": "./bin/cli plugins reconcile --skip-remote", "start": "./bin/cli serve -j -w", @@ -11,8 +12,8 @@ "build": "WEBPACK=TRUE NODE_ENV=production webpack -p --config webpack.config.js --bail", "prebuild-watch": "yarn generate-introspection", "build-watch": "WEBPACK=TRUE NODE_ENV=development webpack --progress --config webpack.config.js --watch", - "lint": "eslint --ext .json bin/* .", - "lint-fix": "eslint bin/* . --fix", + "lint": "eslint --ext=.js --ext=.json bin/* .", + "lint-fix": "yarn lint --fix", "test": "TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}", "test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec", "heroku-postbuild": "./bin/cli plugins reconcile && yarn build", diff --git a/plugin-api/beta/server/getReactionConfig.js b/plugin-api/beta/server/getReactionConfig.js index 02878e010..481990c16 100644 --- a/plugin-api/beta/server/getReactionConfig.js +++ b/plugin-api/beta/server/getReactionConfig.js @@ -1,4 +1,3 @@ -const wrapResponse = require('../../../graph/helpers/response'); const {SEARCH_OTHER_USERS} = require('../../../perms/constants'); const errors = require('../../../errors'); const pluralize = require('pluralize'); @@ -90,10 +89,6 @@ function getReactionConfig(reaction) { } type Delete${Reaction}ActionResponse implements Response { - - # The ${reaction} that was created. - ${reaction}: ${Reaction}Action - # An array of errors relating to the mutation that occurred. errors: [UserError!] } @@ -101,7 +96,7 @@ function getReactionConfig(reaction) { type RootMutation { # Creates a ${reaction} on an entity. - create${Reaction}Action(input: Create${Reaction}ActionInput!): Create${Reaction}ActionResponse + create${Reaction}Action(input: Create${Reaction}ActionInput!): Create${Reaction}ActionResponse! delete${Reaction}Action(input: Delete${Reaction}ActionInput!): Delete${Reaction}ActionResponse } @@ -160,7 +155,7 @@ function getReactionConfig(reaction) { } }, RootMutation: { - [`create${Reaction}Action`]: (_, {input: {item_id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => wrapResponse(reaction)(async () => { + [`create${Reaction}Action`]: async (_, {input: {item_id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => { const comment = await Comments.get.load(item_id); let action; @@ -180,9 +175,11 @@ function getReactionConfig(reaction) { pubsub.publish(`${reaction}ActionCreated`, {action, comment}); } - return action; - }), - [`delete${Reaction}Action`]: (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => wrapResponse(reaction)(async () => { + return { + [reaction]: action, + }; + }, + [`delete${Reaction}Action`]: async (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => { const action = await Action.delete({id}); if (!action) { return null; @@ -194,9 +191,7 @@ function getReactionConfig(reaction) { // The comment is needed to allow better filtering e.g. by asset_id. pubsub.publish(`${reaction}ActionDeleted`, {action, comment}); } - - return action; - }) + }, }, }, hooks: { diff --git a/plugins/talk-plugin-toxic-comments/client/.babelrc b/plugins/talk-plugin-toxic-comments/client/.babelrc new file mode 100644 index 000000000..60be246eb --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/client/.babelrc @@ -0,0 +1,14 @@ +{ + "presets": [ + "es2015" + ], + "plugins": [ + "add-module-exports", + "transform-class-properties", + "transform-decorators-legacy", + "transform-object-assign", + "transform-object-rest-spread", + "transform-async-to-generator", + "transform-react-jsx" + ] +} \ No newline at end of file diff --git a/plugins/talk-plugin-toxic-comments/client/.eslintrc.json b/plugins/talk-plugin-toxic-comments/client/.eslintrc.json new file mode 100644 index 000000000..9fe56bd14 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/client/.eslintrc.json @@ -0,0 +1,23 @@ +{ + "env": { + "browser": true, + "es6": true, + "mocha": true + }, + "parserOptions": { + "sourceType": "module", + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "jsx": true + } + }, + "parser": "babel-eslint", + "plugins": [ + "react" + ], + "rules": { + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "no-console": ["warn", { "allow": ["warn", "error"] }] + } +} diff --git a/plugins/talk-plugin-toxic-comments/client/components/CheckToxicityHook.js b/plugins/talk-plugin-toxic-comments/client/components/CheckToxicityHook.js new file mode 100644 index 000000000..22c378435 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/client/components/CheckToxicityHook.js @@ -0,0 +1,38 @@ +import React from 'react'; + +/** + * CheckToxicityHook adds hooks to the `commentBox` + * that handles checking a comment for toxicity. + */ +export default class CheckToxicityHook extends React.Component { + + // checked signifies if we already sent a request with the `checkToxicity` set to true. + checked = false; + + componentDidMount() { + this.toxicityPreHook = this.props.registerHook('preSubmit', (input) => { + + // If we haven't check the toxicity yet, make sure to include `checkToxicity=true` in the mutation. + // Otherwise post comment without checking the toxicity. + if (!this.checked) { + input.checkToxicity = true; + this.checked = true; + } + }); + + this.toxicityPostHook = this.props.registerHook('postSubmit', () => { + + // Reset `checked` after comment was successfully posted. + this.checked = false; + }); + } + + componentWillUnmount() { + this.props.unregisterHook(this.toxicityPreHook); + this.props.unregisterHook(this.toxicityPostHook); + } + + render() { + return null; + } +} diff --git a/plugins/talk-plugin-toxic-comments/client/index.js b/plugins/talk-plugin-toxic-comments/client/index.js new file mode 100644 index 000000000..c364a2a00 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/client/index.js @@ -0,0 +1,9 @@ +import translations from './translations.yml'; +import CheckToxicityHook from './components/CheckToxicityHook'; + +export default { + translations, + slots: { + commentInputDetailArea: [CheckToxicityHook], + }, +}; diff --git a/plugins/talk-plugin-toxic-comments/client/translations.yml b/plugins/talk-plugin-toxic-comments/client/translations.yml new file mode 100644 index 000000000..ebcc31903 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/client/translations.yml @@ -0,0 +1,6 @@ +en: + error: + COMMENT_IS_TOXIC: | + Are you sure? The language in this comment might violate our community guidelines. + You can edit the comment or submit it for moderator review. +es: diff --git a/plugins/talk-plugin-toxic-comments/index.js b/plugins/talk-plugin-toxic-comments/index.js new file mode 100644 index 000000000..0802a6c11 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/index.js @@ -0,0 +1,8 @@ +const {readFileSync} = require('fs'); +const path = require('path'); +const hooks = require('./server/hooks'); + +module.exports = { + typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8'), + hooks, +}; diff --git a/plugins/talk-plugin-toxic-comments/package.json b/plugins/talk-plugin-toxic-comments/package.json new file mode 100644 index 000000000..848e6d948 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/package.json @@ -0,0 +1,9 @@ +{ + "name": "@coralproject/talk-plugin-toxicity", + "pluginName": "talk-plugin-toxicity", + "version": "0.0.1", + "description": "Provides support for measuring the toxicity of user comments using the Perspectives API", + "main": "index.js", + "author": "The Coral Project Team ", + "license": "Apache-2.0" +} diff --git a/plugins/talk-plugin-toxic-comments/server/config.js b/plugins/talk-plugin-toxic-comments/server/config.js new file mode 100644 index 000000000..bc7ddf138 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/server/config.js @@ -0,0 +1,12 @@ +const config = { + API_ENDPOINT: process.env.TALK_PERSPECTIVE_API_ENDPOINT || 'https://commentanalyzer.googleapis.com/v1alpha1', + API_KEY: process.env.TALK_PERSPECTIVE_API_KEY, + THRESHOLD: process.env.TALK_TOXICITY_THRESHOLD || 0.8, + API_TIMEOUT: process.env.TALK_PERSPECTIVE_TIMEOUT || 300, +}; + +if (process.env.NODE_ENV !== 'test' && !config.API_KEY) { + throw new Error('Please set the TALK_PERSPECTIVE_API_KEY environment variable to use the toxic-comments plugin. Visit https://www.perspectiveapi.com/ to request API access.'); +} + +module.exports = config; diff --git a/plugins/talk-plugin-toxic-comments/server/errors.js b/plugins/talk-plugin-toxic-comments/server/errors.js new file mode 100644 index 000000000..e5c77ff78 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/server/errors.js @@ -0,0 +1,13 @@ +const {APIError} = require('errors'); + +// ErrToxic is sent during a `CreateComment` mutation where +// `input.checkToxicity` is set to true and the comment contains +// toxic language as determined by the perspective service. +const ErrToxic = new APIError('Comment is toxic', { + status: 400, + translation_key: 'COMMENT_IS_TOXIC', +}); + +module.exports = { + ErrToxic, +}; diff --git a/plugins/talk-plugin-toxic-comments/server/hooks.js b/plugins/talk-plugin-toxic-comments/server/hooks.js new file mode 100644 index 000000000..7fd138641 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/server/hooks.js @@ -0,0 +1,67 @@ +const {getScores, isToxic} = require('./perspective'); +const {ErrToxic} = require('./errors'); +const ActionsService = require('../../../services/actions'); + +// 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: { + async pre(_, {input}, _context, _info) { + + let scores; + + // Try getting scores. + try { + scores = await getScores(input.body); + } + catch(err) { + + // Warn and let mutation pass. + console.trace(err); + return; + } + + const commentIsToxic = isToxic(scores); + + if (input.checkToxicity && commentIsToxic) { + throw ErrToxic; + } + + // attach scores to metadata. + input.metadata = Object.assign({}, input.metadata, { + perspective: scores, + }); + + if (commentIsToxic) { + + // TODO: this should have a different status than Premod. + input.status = 'PREMOD'; + } + }, + async post(_, _input, _context, _info, result) { + const metadata = result.comment.metadata; + if (metadata.perspective && isToxic(metadata.perspective)) { + + // TODO: this is kind of fragile, we should refactor this to resolve + // all these const's that we're using like 'COMMENTS', 'FLAG' to be + // defined in a checkable schema. + + // Add a flag to the comment. + await ActionsService.create({ + item_id: result.comment.id, + item_type: 'COMMENTS', + action_type: 'FLAG', + user_id: null, + group_id: 'Comment contains toxic language', + metadata: {} + }); + } + return result; + }, + }, + }, +}; diff --git a/plugins/talk-plugin-toxic-comments/server/perspective.js b/plugins/talk-plugin-toxic-comments/server/perspective.js new file mode 100644 index 000000000..fba81b3d2 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/server/perspective.js @@ -0,0 +1,85 @@ +const fetch = require('node-fetch'); +const {API_ENDPOINT, API_KEY, THRESHOLD, API_TIMEOUT} = require('./config'); + +/** + * Get scores from the perspective api + * @param {string} text text to be anaylized + * @return {object} object containing toxicity scores + */ +async function getScores(text) { + const response = await fetch(`${API_ENDPOINT}/comments:analyze?key=${API_KEY}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + timeout: API_TIMEOUT, + body: JSON.stringify({ + comment: { + text, + }, + + // TODO: support other languages. + languages: ['en'], + requestedAttributes: { + TOXICITY: {}, + SEVERE_TOXICITY: {}, + } + }), + }); + const data = await response.json(); + return { + TOXICITY: { + summaryScore: data.attributeScores.TOXICITY.summaryScore.value + }, + SEVERE_TOXICITY: { + summaryScore: data.attributeScores.SEVERE_TOXICITY.summaryScore.value + }, + }; +} + +/** + * Get toxicity probability + * @param {object} scores scores as returned by `getScores` + * @return {number} toxicity probability from 0 - 1.0 + */ +function getProbability(scores) { + return scores.SEVERE_TOXICITY.summaryScore; +} + +/** + * isToxic determines if given probabilty or scores meets the toxicity threshold. + * @param {object|number} scoresOrProbability scores or probability + * @return {boolean} + */ +function isToxic(scoresOrProbability) { + const probability = typeof scoresOrProbability === 'object' + ? getProbability(scoresOrProbability) + : scoresOrProbability; + return probability > THRESHOLD; +} + +/** + * maskKeyInError is a decorator that calls fn and masks the + * API_KEY in errors before throwing. + * @param {function} fn Function that returns a Promise + * @return {function} decorated function + */ +function maskKeyInError(fn) { + return async (...args) => { + try { + return await fn(...args); + } + catch(err) { + if (err.message) { + err.message = err.message.replace(API_KEY, '***'); + } + throw err; + } + }; +} + +module.exports = { + getScores: maskKeyInError(getScores), + getProbability, + isToxic, +}; diff --git a/plugins/talk-plugin-toxic-comments/server/typeDefs.graphql b/plugins/talk-plugin-toxic-comments/server/typeDefs.graphql new file mode 100644 index 000000000..b4dfc8345 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/server/typeDefs.graphql @@ -0,0 +1,7 @@ +input CreateCommentInput { + + # If true, the mutation will fail when the + # body contains toxic language. + checkToxicity: Boolean +} + diff --git a/plugins/talk-plugin-toxic-comments/yarn.lock b/plugins/talk-plugin-toxic-comments/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + diff --git a/test/server/graph/mutations/createComment.js b/test/server/graph/mutations/createComment.js index 8b99dd557..72c163d35 100644 --- a/test/server/graph/mutations/createComment.js +++ b/test/server/graph/mutations/createComment.js @@ -16,8 +16,8 @@ describe('graph.mutations.createComment', () => { beforeEach(() => SettingsService.init()); const query = ` - mutation CreateComment($comment: CreateCommentInput = {asset_id: 123, body: "Here's my comment!"}) { - createComment(comment: $comment) { + mutation CreateComment($input: CreateCommentInput = {asset_id: 123, body: "Here's my comment!"}) { + createComment(input: $input) { comment { id status @@ -176,7 +176,7 @@ describe('graph.mutations.createComment', () => { const context = new Context({user: new UserModel({status: 'ACTIVE'})}); return graphql(schema, query, {}, context, { - comment: { + input: { asset_id: '123', body } diff --git a/test/server/graph/mutations/removeTag.js b/test/server/graph/mutations/removeTag.js index 4cb4f1e6a..761cd44c9 100644 --- a/test/server/graph/mutations/removeTag.js +++ b/test/server/graph/mutations/removeTag.js @@ -45,7 +45,7 @@ describe('graph.mutations.removeTag', () => { console.error(response.errors); } expect(response.errors).to.be.empty; - expect(response.data.removeTag.errors).to.be.null; + expect(response.data.removeTag).to.be.null; let retrievedComment = await CommentsService.findById(comment.id); diff --git a/test/server/graph/mutations/updateAssetSettings.js b/test/server/graph/mutations/updateAssetSettings.js index 3fda0bd6c..306209c74 100644 --- a/test/server/graph/mutations/updateAssetSettings.js +++ b/test/server/graph/mutations/updateAssetSettings.js @@ -57,7 +57,10 @@ describe('graph.mutations.updateAssetSettings', () => { expect(res.data.updateAssetSettings.errors).to.not.be.empty; expect(res.data.updateAssetSettings.errors[0]).to.have.property('translation_key', error); } else { - expect(res.data.updateAssetSettings.errors).to.be.null; + if (res.data.updateAssetSettings && res.data.updateAssetSettings.errors) { + console.error(res.data.updateAssetSettings.errors); + } + expect(res.data.updateAssetSettings).to.be.null; const retrievedAsset = await AssetModel.findOne({id: asset.id}); Object.keys(settings).forEach((key) => { diff --git a/test/server/graph/mutations/updateAssetStatus.js b/test/server/graph/mutations/updateAssetStatus.js index b1eb8874c..77be55976 100644 --- a/test/server/graph/mutations/updateAssetStatus.js +++ b/test/server/graph/mutations/updateAssetStatus.js @@ -55,7 +55,10 @@ describe('graph.mutations.updateAssetStatus', () => { expect(res.data.updateAssetStatus.errors).to.not.be.empty; expect(res.data.updateAssetStatus.errors[0]).to.have.property('translation_key', error); } else { - expect(res.data.updateAssetStatus.errors).to.be.null; + if (res.data.updateAssetStatus && res.data.updateAssetStatus.errors) { + console.error(res.data.updateAssetStatus.errors); + } + expect(res.data.updateAssetStatus).to.be.null; const retrievedAsset = await AssetModel.findOne({id: asset.id}); expect(retrievedAsset.closedAt).to.not.be.null; diff --git a/test/server/graph/mutations/updateSettings.js b/test/server/graph/mutations/updateSettings.js index 27269b70a..c1ec678d6 100644 --- a/test/server/graph/mutations/updateSettings.js +++ b/test/server/graph/mutations/updateSettings.js @@ -58,10 +58,10 @@ describe('graph.mutations.updateSettings', () => { expect(res.data.updateSettings.errors).to.not.be.empty; expect(res.data.updateSettings.errors[0]).to.have.property('translation_key', error); } else { - if (res.data.updateSettings.errors) { + if (res.data.updateSettings && res.data.updateSettings.errors) { console.error(res.data.updateSettings.errors); } - expect(res.data.updateSettings.errors).to.be.null; + expect(res.data.updateSettings).to.be.null; const retrievedSettings = await SettingsService.retrieve(); Object.keys(newSettings).forEach((key) => { diff --git a/test/server/graph/mutations/updateWordlist.js b/test/server/graph/mutations/updateWordlist.js index b2f2e4abb..3a392a074 100644 --- a/test/server/graph/mutations/updateWordlist.js +++ b/test/server/graph/mutations/updateWordlist.js @@ -65,10 +65,10 @@ describe('graph.mutations.updateWordlist', () => { expect(retrievedWordlist).to.have.property('suspect'); expect(retrievedWordlist.suspect).to.have.members([]); } else { - if (res.data.updateWordlist.errors) { + if (res.data.updateWordlist && res.data.updateWordlist.errors) { console.error(res.data.updateWordlist.errors); } - expect(res.data.updateWordlist.errors).to.be.null; + expect(res.data.updateWordlist).to.be.null; const {wordlist: retrievedWordlist} = await SettingsService.retrieve(); expect(retrievedWordlist).to.have.property('banned');