diff --git a/.gitignore b/.gitignore index 3b59ff2f5..5a42acb67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ node_modules -npm-debug.log +npm-debug.log* dist !dist/coral-admin dist/coral-admin/bundle.js +tests/e2e/reports .DS_Store *.iml +dump.rdb .env gaba.cfg .idea/ diff --git a/bin/cli-serve b/bin/cli-serve index 14c1261d7..dd682f5ad 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -17,7 +17,8 @@ const util = require('../util'); /** * Get port from environment and store in Express. */ -const port = normalizePort(process.env.TALK_PORT || '3000'); + +const port = normalizePort(process.env.TALK_PORT || (process.env.NODE_ENV === 'test' ? '3011' : '3000')); app.set('port', port); @@ -95,6 +96,10 @@ function startApp() { server.listen(port); server.on('error', onError); server.on('listening', onListening); + }) + .catch((err) => { + console.error(err); + util.shutdown(1); }); } diff --git a/cache.js b/cache.js index 9345f8cde..1d090e46d 100644 --- a/cache.js +++ b/cache.js @@ -76,6 +76,21 @@ cache.get = (key) => new Promise((resolve, reject) => { }); }); +/** + * This invalidates a cached entry in the cache. + * @param {Mixed} key Either an array of items composing a key or a string + * @return {Promise} + */ +cache.invalidate = (key) => new Promise((resolve, reject) => { + cache.client.del(keyfunc(key), (err) => { + if (err) { + return reject(err); + } + + resolve(); + }); +}); + /** * This sets a value on the key with the expiry and then resolves once it is * done. diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js index 40ad72a41..0fda9444d 100644 --- a/client/coral-embed-stream/src/CommentStream.js +++ b/client/coral-embed-stream/src/CommentStream.js @@ -7,6 +7,7 @@ import { Notification, notificationActions, authActions, + configActions } from '../../coral-framework'; import CommentBox from '../../coral-plugin-commentbox/CommentBox'; @@ -21,14 +22,17 @@ import LikeButton from '../../coral-plugin-likes/LikeButton'; import PermalinkButton from '../../coral-plugin-permalinks/PermalinkButton'; import SignInContainer from '../../coral-sign-in/containers/SignInContainer'; import UserBox from '../../coral-sign-in/components/UserBox'; + import {TabBar, Tab, TabContent, Spinner} from '../../coral-ui'; import SettingsContainer from '../../coral-settings/containers/SettingsContainer'; import RestrictedContent from '../../coral-framework/components/RestrictedContent'; import SuspendedAccount from '../../coral-framework/components/SuspendedAccount'; +import CloseCommentsInfo from '../../coral-framework/components/CloseCommentsInfo'; const {addItem, updateItem, postItem, getStream, postAction, deleteAction, appendItemArray} = itemActions; const {addNotification, clearNotification} = notificationActions; const {logout, showSignInDialog} = authActions; +const {updateOpenStatus} = configActions; class CommentStream extends Component { @@ -40,6 +44,7 @@ class CommentStream extends Component { }; this.changeTab = this.changeTab.bind(this); + this.toggleStatus = this.toggleStatus.bind(this); } changeTab (tab) { @@ -48,6 +53,10 @@ class CommentStream extends Component { }); } + toggleStatus () { + this.props.updateStatus(this.props.config.status === 'open' ? 'closed' : 'open'); + } + static propTypes = { items: PropTypes.object.isRequired, addItem: PropTypes.func.isRequired, @@ -56,31 +65,22 @@ class CommentStream extends Component { componentDidMount () { // Set up messaging between embedded Iframe an parent component - // Using recommended Pym init code which violates .eslint standards - const pym = new Pym.Child({polling: 100}); + this.pym = new Pym.Child({polling: 100}); - if (/https?\:\/\/([^?]+)/.test(pym.parentUrl)) { - this.props.getStream(pym.parentUrl); - } else { - this.props.getStream(window.location); - } + const path = /https?\:\/\/([^?#]+)/.exec(this.pym.parentUrl); + + this.props.getStream(path[1] || window.location); + this.path = path; + + this.pym.sendMessage('childReady'); } render () { - if (Object.keys(this.props.items).length === 0) { - // Loading mock asset - this.props.postItem({ - comments: [], - url: 'http://coralproject.net' - }, 'asset', 'assetTest'); - } - - // TODO: Replace teststream id with id from params - const rootItemId = this.props.items.assets && Object.keys(this.props.items.assets)[0]; const rootItem = this.props.items.assets && this.props.items.assets[rootItemId]; const {actions, users, comments} = this.props.items; const {loggedIn, user, showSignInDialog} = this.props.auth; + const {status} = this.props.config; const {activeTab} = this.state; return
@@ -90,135 +90,145 @@ class CommentStream extends Component { Settings + Configure Stream - {loggedIn && } + {loggedIn && } {/* Add to the restricted param a boolean if the user is suspended*/} }> -
- - + { + status === 'open' + ?
+ + +
+ :

Comments are closed for this thread.

+ } {!loggedIn && } -
- { - rootItem.comments && rootItem.comments.map((commentId) => { - const comment = comments[commentId]; - return
-
- - - -
- { + const comment = comments[commentId]; + return
+
+ + + +
+ + +
+
+ + +
+ - -
-
- - -
- - { - comment.children && - comment.children.map((replyId) => { - let reply = this.props.items.comments[replyId]; - return
-
- - - -
- { + let reply = this.props.items.comments[replyId]; + return
+
+ + + +
+ + +
+
+ + +
+ - -
-
- - -
- -
; - }) +
; + }) }
; - }) - } + }) + } + - {!loggedIn && } + + +

{status === 'open' ? 'Close' : 'Open'} Comment Stream

+ + +
: - + }
; } @@ -263,6 +278,7 @@ const mapDispatchToProps = (dispatch) => ({ appendItemArray: (item, property, value, addToFront, itemType) => dispatch(appendItemArray(item, property, value, addToFront, itemType)), handleSignInDialog: () => dispatch(authActions.showSignInDialog()), logout: () => dispatch(logout()), + updateStatus: status => dispatch(updateOpenStatus(status)) }); export default connect(mapStateToProps, mapDispatchToProps)(CommentStream); diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 21e40ce98..59efc3dc8 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -197,3 +197,42 @@ hr { float: right; margin: 8px; } + +/* Close comments */ + +.close-comments-intro-wrapper { + display: flex; + justify-content: space-between; + align-items: center; +} + +.close-comments-intro-wrapper button { + width: 300px; + margin-left: 20px; +} + +.close-comments-intro-wrapper button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.close-comments-message { + box-sizing: border-box; + width: 100%; + height: 100px; +} + +.close-comments-confirm-wrapper { + float: right; +} + +.close-comments-alert { + background-color: #d65344; + color: white; + font-size: 16px; + padding: 5px; +} + +.close-comments-alert i.material-icons { + font-size: 16px !important; +} diff --git a/client/coral-framework/actions/config.js b/client/coral-framework/actions/config.js new file mode 100644 index 000000000..6dc880434 --- /dev/null +++ b/client/coral-framework/actions/config.js @@ -0,0 +1,18 @@ +import coralApi from '../helpers/response'; +/* Config Actions */ + +/** + * Action name constants + */ + +export const OPEN_COMMENTS = 'OPEN_COMMENTS'; +export const CLOSE_COMMENTS = 'CLOSE_COMMENTS'; +export const ADD_ITEM = 'ADD_ITEM'; + +export const updateOpenStatus = status => (dispatch, getState) => { + const assetId = getState().items.get('assets') + .keySeq() + .toArray()[0]; + return coralApi(`/asset/${assetId}/status?status=${status}`, {method: 'PUT'}) + .then(() => dispatch({type: status === 'open' ? OPEN_COMMENTS : CLOSE_COMMENTS})); +}; diff --git a/client/coral-framework/components/CloseCommentsInfo.js b/client/coral-framework/components/CloseCommentsInfo.js new file mode 100644 index 000000000..627328f9b --- /dev/null +++ b/client/coral-framework/components/CloseCommentsInfo.js @@ -0,0 +1,23 @@ +import React from 'react'; +import {Button} from 'coral-ui'; + +export default ({status, onClick}) => ( + status === 'open' ? ( +
+

+ This comment stream is currently open. By closing this comment stream, + no new comments may be submitted and all previous comments will still + be displayed. +

+ +
+ ) : ( +
+

+ This comment stream is currently closed. By opening this comment stream, + new comments may be submitted and displayed +

+ +
+ ) +); diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js index 7b721badc..9a679d969 100644 --- a/client/coral-framework/index.js +++ b/client/coral-framework/index.js @@ -4,6 +4,7 @@ import * as itemActions from './actions/items'; import I18n from './modules/i18n/i18n'; import * as notificationActions from './actions/notification'; import * as authActions from './actions/auth'; +import * as configActions from './actions/config'; export { Notification, @@ -12,4 +13,5 @@ export { I18n, notificationActions, authActions, + configActions }; diff --git a/client/coral-framework/reducers/config.js b/client/coral-framework/reducers/config.js index 6521d92a3..9620f5b97 100644 --- a/client/coral-framework/reducers/config.js +++ b/client/coral-framework/reducers/config.js @@ -1,19 +1,30 @@ /* @flow */ import {Map} from 'immutable'; -import * as actions from '../actions/items'; +import * as actions from '../actions/config'; const initialState = Map({ - features: Map({}) + features: Map({}), + status: 'open' }); export default (state = initialState, action) => { switch(action.type) { - // Override config if worked case actions.UPDATE_SETTINGS: return action.config; + case actions.OPEN_COMMENTS: + return state.set('status', 'open'); + + case actions.CLOSE_COMMENTS: + return state.set('status', 'closed'); + + case actions.ADD_ITEM: + return action.item_type === 'assets' ? + state.set('status', action.item.status) + : state; + default: return state; } diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index 9ae524652..5f9592cd5 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -78,6 +78,7 @@ class CommentBox extends Component { { author && ( diff --git a/client/coral-sign-in/components/SignInContent.js b/client/coral-sign-in/components/SignInContent.js index 395800e6c..de5e77a49 100644 --- a/client/coral-sign-in/components/SignInContent.js +++ b/client/coral-sign-in/components/SignInContent.js @@ -44,7 +44,7 @@ const SignInContent = ({handleChange, formData, ...props}) => (
{ !props.auth.isLoading ? - : @@ -56,7 +56,7 @@ const SignInContent = ({handleChange, formData, ...props}) => ( props.changeView('FORGOT')}>{lang.t('signIn.forgotYourPass')} {lang.t('signIn.needAnAccount')} - props.changeView('SIGNUP')}> + props.changeView('SIGNUP')} id='coralRegister'> {lang.t('signIn.register')} diff --git a/client/coral-sign-in/components/SignUpContent.js b/client/coral-sign-in/components/SignUpContent.js index 38fa554fe..2f9e9afe1 100644 --- a/client/coral-sign-in/components/SignUpContent.js +++ b/client/coral-sign-in/components/SignUpContent.js @@ -69,7 +69,7 @@ const SignUpContent = ({handleChange, formData, ...props}) => ( />
{ !props.auth.isLoading && !props.auth.successSignUp && ( - )} diff --git a/client/coral-sign-in/containers/SignInContainer.js b/client/coral-sign-in/containers/SignInContainer.js index c201a3863..28dc021d2 100644 --- a/client/coral-sign-in/containers/SignInContainer.js +++ b/client/coral-sign-in/containers/SignInContainer.js @@ -132,7 +132,9 @@ class SignInContainer extends Component { const {auth, showSignInDialog, noButton} = this.props; return (
- {!noButton && } + {!noButton && } { + + // Get the user id's from the author id's as a unique array that gets + // sorted. + let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort(); + + // Fetch the actions for pretty much everything at this point. + return Action.getActionSummaries(_.uniq([ + + // Actions can be on assets... + asset_id, + + // Comments... + ...comments.map((comment) => comment.id), + + // Or Authors... + ...userIDs + ].filter((e) => e)), current_user_id); +}; + /** * Returns summaries of actions for an array of ids * @param {String} ids array of user identifiers (uuid) diff --git a/models/asset.js b/models/asset.js index 00251985a..a0619576f 100644 --- a/models/asset.js +++ b/models/asset.js @@ -1,6 +1,8 @@ const mongoose = require('../mongoose'); const Schema = mongoose.Schema; +const Setting = require('./setting'); + const uuid = require('uuid'); const AssetSchema = new Schema({ @@ -34,7 +36,11 @@ const AssetSchema = new Schema({ subsection: String, author: String, publication_date: Date, - modified_date: Date + modified_date: Date, + status: { + type: String, + default: 'open' + } }, { versionKey: false, timestamps: { @@ -54,11 +60,6 @@ AssetSchema.index({ background: true }); -/** - * Search for assets. Currently only returns all. - */ -AssetSchema.statics.search = (query) => Asset.find(query); - /** * Finds an asset by its id. * @param {String} id identifier of the asset (uuid). @@ -71,6 +72,25 @@ AssetSchema.statics.findById = (id) => Asset.findOne({id}); */ AssetSchema.statics.findByUrl = (url) => Asset.findOne({url}); +/** + * Retrieves the settings given an asset query and rectifies it against the + * global settings. + * @param {Promise} assetQuery an asset query that returns a single asset. + * @return {Promise} + */ +AssetSchema.statics.rectifySettings = (assetQuery) => Promise.all([ + Setting.retrieve(), + assetQuery +]).then(([settings, asset]) => { + + // If the asset exists and has settings then return the merged object. + if (asset && asset.settings) { + return Object.assign({}, settings, asset.settings); + } + + return settings; +}); + /** * Finds a asset by its url. * diff --git a/models/comment.js b/models/comment.js index c1a32cf1b..af9aa4cb1 100644 --- a/models/comment.js +++ b/models/comment.js @@ -1,9 +1,37 @@ const mongoose = require('../mongoose'); +const Schema = mongoose.Schema; const uuid = require('uuid'); const Action = require('./action'); -const Schema = mongoose.Schema; +/** + * The Mongo schema for a Comment Status. + * @type {Schema} + */ +const StatusSchema = new Schema({ + type: { + type: String, + enum: [ + 'accepted', + 'rejected', + 'premod', + ], + }, + // The User ID of the user that assigned the status. + assigned_by: { + type: String, + default: null + }, + + created_at: Date +}, { + _id: false +}); + +/** + * The Mongo schema for a Comment. + * @type {Schema} + */ const CommentSchema = new Schema({ id: { type: String, @@ -17,11 +45,7 @@ const CommentSchema = new Schema({ }, asset_id: String, author_id: String, - status: { - type: String, - enum: ['accepted', 'rejected', ''], - default: '' - }, + status: [StatusSchema], parent_id: String }, { timestamps: { @@ -30,90 +54,168 @@ const CommentSchema = new Schema({ } }); -//============================================================================== -// Find Statics -//============================================================================== +/** + * toJSON overrides to remove fields from the json + * output. + */ +CommentSchema.options.toJSON = {}; +CommentSchema.options.toJSON.hide = '_id status'; +CommentSchema.options.toJSON.transform = (doc, ret, options) => { + if (options.hide) { + options.hide.split(' ').forEach((prop) => { + delete ret[prop]; + }); + } + + return ret; +}; + +/** + * toJSON overrides to remove fields from the json + * output. + */ +CommentSchema.options.toJSON = {}; +CommentSchema.options.toJSON.hide = '_id'; +CommentSchema.options.toJSON.transform = (doc, ret, options) => { + if (options.hide) { + options.hide.split(' ').forEach((prop) => { + delete ret[prop]; + }); + } + + return ret; +}; + +/** + * Sets up a virtual getter function on a comment such that when you try and + * access the `comment.last_status` it returns the last status in the array + * of status's on the comment, or `null` if there was no status. + */ +CommentSchema.virtual('last_status').get(function() { + if (this.status && this.status.length > 0) { + return this.status[this.status.length - 1].type; + } + + return null; +}); + +/** + * Creates a new Comment that came from a public source. + * @param {Mixed} comment either a single comment or an array of comments. + * @return {Promise} + */ +CommentSchema.statics.publicCreate = (comment) => { + + // Check to see if this is an array of comments, if so map it out. + if (Array.isArray(comment)) { + return Promise.all(comment.map(Comment.publicCreate)); + } + + const { + body, + asset_id, + parent_id, + status = false, + author_id + } = comment; + + comment = new Comment({ + body, + asset_id, + parent_id, + status: status ? [{ + type: status, + created_at: new Date() + }] : [], + author_id + }); + + return comment.save(); +}; /** * Finds a comment by the id. * @param {String} id identifier of comment (uuid) * @return {Promise} */ -CommentSchema.statics.findById = function(id) { - return Comment.findOne({'id': id}); -}; +CommentSchema.statics.findById = (id) => Comment.findOne({id}); /** * Finds ALL the comments by the asset_id. * @param {String} asset_id identifier of the asset which owns this comment (uuid) * @return {Promise} */ -CommentSchema.statics.findByAssetId = function(asset_id) { - return Comment.find({asset_id}); -}; +CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({ + asset_id +}); /** - * Finds the accepted comments by the asset_id. - * get the comments that are accepted. + * Finds the accepted comments by the asset_id get the comments that are + * accepted. * @param {String} asset_id identifier of the asset which owns the comments (uuid) * @return {Promise} */ -CommentSchema.statics.findAcceptedByAssetId = function(asset_id) { - return Comment.find({asset_id: asset_id, status:'accepted'}); -}; +CommentSchema.statics.findAcceptedByAssetId = (asset_id) => Comment.find({ + asset_id, + 'status.type': 'accepted' +}); /** * Finds the new and accepted comments by the asset_id. * @param {String} asset_id identifier of the asset which owns the comments (uuid) * @return {Promise} */ -CommentSchema.statics.findAcceptedAndNewByAssetId = function(asset_id) { - return Comment.find({asset_id: asset_id, status: {'$in': ['accepted', '']}}); -}; +CommentSchema.statics.findAcceptedAndNewByAssetId = (asset_id) => Comment.find({ + asset_id, + $or: [ + { + 'status.type': 'accepted' + }, + { + status: { + $size: 0 + } + } + ] +}); /** * Find comments by an action that was performed on them. * @param {String} action_type the type of action that was performed on the comment * @return {Promise} */ -CommentSchema.statics.findByActionType = function(action_type) { - return Action - .findCommentsIdByActionType(action_type, 'comment') - .then((actions) => { - return Comment.find({'id': {'$in': actions.map(function(a){ - return a.item_id;})} - }); - }); -}; +CommentSchema.statics.findByActionType = (action_type) => Action + .findCommentsIdByActionType(action_type, 'comment') + .then((actions) => Comment.find({ + id: { + $in: actions.map((a) => a.item_id) + } + })); /** - * Find not moderated comments by an action that was performed on them. + * Find comment id's where the action type matches the argument. * @param {String} action_type the type of action that was performed on the comment - * @param {String} status the status of the comment to search for * @return {Promise} */ -CommentSchema.statics.findByStatusByActionType = function(status, action_type) { - return Action - .findCommentsIdByActionType(action_type, 'comment') - .then((actions) => { - return Comment.find({ - status: status, - id: { - $in: actions.map(a => a.item_id) - } - }); - }); -}; +CommentSchema.statics.findIdsByActionType = (action_type) => Action + .findCommentsIdByActionType(action_type, 'comment') + .then((actions) => actions.map(a => a.item_id)); /** * Find comments by their status. * @param {String} status the status of the comment to search for * @return {Promise} */ -CommentSchema.statics.findByStatus = function(status) { - return Comment.find({ - status: status === 'new' ? '' : status - }); +CommentSchema.statics.findByStatus = (status = false) => { + let q = {}; + + if (status) { + q['status.type'] = status; + } else { + q.status = {$size: 0}; + } + + return Comment.find(q); }; /** @@ -121,39 +223,59 @@ CommentSchema.statics.findByStatus = function(status) { * @param {String} moderationValue pre or post moderation setting. If it is undefined then look at the settings. * @return {Promise} */ -CommentSchema.statics.moderationQueue = function(moderation) { - switch(moderation){ +CommentSchema.statics.moderationQueue = (moderation, asset_id = false) => { - // Pre-moderation: New comments are shown in the moderator queues immediately. - case 'pre': - return Comment.findByStatus('').then((comments) => { - return comments; - }); + /** + * This adds the asset_id requirement to the query if the asset_id is defined. + */ + const assetIDWrap = (query) => { + if (asset_id) { + query = query.where('asset_id', asset_id); + } - // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. - case 'post': - return Comment.findByStatusByActionType('', 'flag').then((comments) => { - return comments; - }); + return query; + }; - default: - return Promise.reject(Error('Moderation setting not found.')); + // Decide on whether or not we need to load extended options for the + // moderation based on the moderation options. + let comments; + + if (moderation === 'pre') { + + // Pre-moderation: New comments are shown in the moderator queues immediately. + comments = assetIDWrap(CommentSchema.statics.findByStatus('premod')); + + } else { + + // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. + comments = CommentSchema.statics.findIdsByActionType('flag') + .then((ids) => assetIDWrap(Comment.find({ + id: { + $in: ids + } + }))); } -}; -//============================================================================== -// Update Statics -//============================================================================== + return comments; +}; /** - * Change the status of a comment. - * @param {String} id identifier of the comment (uuid) - * @param {String} status the new status of the comment + * Pushes a new status in for the user. + * @param {String} id identifier of the comment (uuid) + * @param {String} status the new status of the comment + * @param {String} assigned_by the user id for the user who performed the + * moderation action * @return {Promise} */ -CommentSchema.statics.changeStatus = function(id, status) { - return Comment.findOneAndUpdate({'id': id}, {$set: {'status': status}}); -}; +CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.update({id}, { + $push: { + status: { + type: status, + created_at: new Date(), + assigned_by + } + } +}); /** * Add an action to the comment. @@ -169,19 +291,13 @@ CommentSchema.statics.addAction = (item_id, user_id, action_type) => Action.inse action_type }); -//============================================================================== -// Remove Statics -//============================================================================== - /** * Change the status of a comment. * @param {String} id identifier of the comment (uuid) * @param {String} status the new status of the comment * @return {Promise} */ -CommentSchema.statics.removeById = function(id) { - return Comment.remove({'id': id}); -}; +CommentSchema.statics.removeById = (id) => Comment.remove({id}); /** * Remove an action from the comment. @@ -190,22 +306,18 @@ CommentSchema.statics.removeById = function(id) { * @param {String} user_id the id of the user performing the action * @return {Promise} */ -CommentSchema.statics.removeAction = function(item_id, user_id, action_type) { - return Action.remove({ - action_type, - item_type: 'comment', - item_id, - user_id - }); -}; +CommentSchema.statics.removeAction = (item_id, user_id, action_type) => Action.remove({ + action_type, + item_type: 'comment', + item_id, + user_id +}); /** * Returns all the comments in the collection. * @return {Promise} */ -CommentSchema.statics.all = () => { - return Comment.find(); -}; +CommentSchema.statics.all = () => Comment.find(); // Comment model. const Comment = mongoose.model('Comment', CommentSchema); diff --git a/models/setting.js b/models/setting.js index bf5ac290a..22b2b103f 100644 --- a/models/setting.js +++ b/models/setting.js @@ -1,18 +1,33 @@ const mongoose = require('../mongoose'); const Schema = mongoose.Schema; +const _ = require('lodash'); +const cache = require('../cache'); /** - * this Schema manages application settings that get used on front and backend - * NOTE: when you set a setting here, it will not automatically be exposed to - * the front end. You must add it to the whitelist in the settings route - * in /routes/api/settings/index.js + * SettingSchema manages application settings that get used on front and backend. * @type {Schema} */ const SettingSchema = new Schema({ - id: {type: String, default: '1'}, - moderation: {type: String, enum: ['pre', 'post'], default: 'pre'}, - infoBoxEnable: {type: Boolean, default: false}, - infoBoxContent: {type: String, default: ''}, + id: { + type: String, + default: '1' + }, + moderation: { + type: String, + enum: [ + 'pre', + 'post' + ], + default: 'pre' + }, + infoBoxEnable: { + type: Boolean, + default: false + }, + infoBoxContent: { + type: String, + default: '' + }, wordlist: [String] }, { timestamps: { @@ -22,48 +37,70 @@ const SettingSchema = new Schema({ }); /** - * this is run once when the app starts to ensure settings are populated - * @return {Promise} null initialize the global settings object + * The Mongo Mongoose object. */ -SettingSchema.statics.init = function (defaults) { - return this.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}); -}; +const Setting = mongoose.model('Setting', SettingSchema); + +/** + * The Setting Service object exposing the Setting model. + * @type {Object} + */ +const SettingService = module.exports = {}; + +/** + * The selector used to uniquely identify the settings document. + */ +const selector = {id: '1'}; + +/** + * Cache expiry time in seconds for when the cached entry of the settings object + * expires. 2 minutes. + */ +const EXPIRY_TIME = 60 * 2; /** * Gets the entire settings record and sends it back * @return {Promise} settings the whole settings record */ -SettingSchema.statics.getSettings = function () { - return this.findOne({id: '1'}); -}; - -/** - * Gets the settings visible to the public - * @return {Promise} moderation the settings for how to moderate comments - */ -SettingSchema.statics.getPublicSettings = function () { - return this.findOne({id: '1'}).select('moderation infoBoxEnable infoBoxContent'); -}; - -/** - * Gets the info box settings and sends it back - * @return {Promise} content the content of the info Box - */ -SettingSchema.statics.getInfoBoxSetting = function () { - return this.findOne({id: '1'}).select('infoBoxEnable infoBoxContent'); -}; +SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => Setting.findOne(selector)); /** * This will update the settings object with whatever you pass in * @param {object} setting a hash of whatever settings you want to update * @return {Promise} settings Promise that resolves to the entire (updated) settings object. */ -SettingSchema.statics.updateSettings = function (setting) { - // There should only ever be one record unless something has gone wrong. - // In the future we may have multiple records for custom settings for objects/users. - return this.findOneAndUpdate({id: '1'}, {$set: setting}, {new: true}); +SettingService.update = (settings) => Setting.findOneAndUpdate(selector, { + $set: settings +}, { + upsert: true, + new: true, + setDefaultsOnInsert: true +}).then((settings) => { + + // Invalidate the settings cache. + return cache + .set('settings', settings, EXPIRY_TIME) + .then(() => settings); +}); + +/** + * Filters the document to ensure that the resulting document is indeed ready + * for non authenticated users. + * @param {Object} settings the source settings object + * @return {Object} the filtered settings object + */ +SettingService.public = (settings) => _.pick(settings, ['moderation', 'infoBoxEnable', 'infoBoxContent']); + +/** + * This is run once when the app starts to ensure settings are populated. + * @return {Promise} null initialize the global settings object + */ +SettingService.init = (defaults) => { + + // Inject the defaults on top of the passed in defaults to ensure that the new + // settings conform to the required selector. + defaults = Object.assign({}, defaults, selector); + + // Actually update the settings collection. + return SettingService.update(defaults); }; - -const Setting = mongoose.model('Setting', SettingSchema); - -module.exports = Setting; diff --git a/models/user.js b/models/user.js index 86908ab42..336486a54 100644 --- a/models/user.js +++ b/models/user.js @@ -9,7 +9,6 @@ const SALT_ROUNDS = 10; // USER_ROLES is the array of roles that is permissible as a user role. const USER_ROLES = [ - '', 'admin', 'moderator' ]; @@ -106,7 +105,7 @@ UserSchema.index({ * output. */ UserSchema.options.toJSON = {}; -UserSchema.options.toJSON.hide = 'password profiles roles disabled'; +UserSchema.options.toJSON.hide = '_id password profiles roles disabled'; UserSchema.options.toJSON.transform = (doc, ret, options) => { if (options.hide) { options.hide.split(' ').forEach((prop) => { diff --git a/mongoose.js b/mongoose.js index 25d1cf65a..0f728ef16 100644 --- a/mongoose.js +++ b/mongoose.js @@ -1,7 +1,13 @@ const mongoose = require('mongoose'); const debug = require('debug')('talk:db'); const enabled = require('debug').enabled; -const url = process.env.TALK_MONGO_URL || (process.env.NODE_ENV === 'test' ? 'mongodb://localhost/test' : 'mongodb://localhost/talk'); + +//Append '-test' to the db if node_env === 'test' +let url = process.env.TALK_MONGO_URL || 'mongodb://localhost/coral-talk'; + +if (process.env.NODE_ENV === 'test') { + url += '-test'; +} // Use native promises mongoose.Promise = global.Promise; diff --git a/nightwatch.conf.js b/nightwatch.conf.js new file mode 100644 index 000000000..a39ab128f --- /dev/null +++ b/nightwatch.conf.js @@ -0,0 +1,44 @@ +require('babel-core/register'); + +module.exports = { + 'src_folders': './tests/e2e/tests', + 'output_folder': './tests/e2e/reports', + 'page_objects_path': './tests/e2e/pages', + 'globals_path': './tests/e2e/globals', + 'custom_commands_path' : '', + 'custom_assertions_path' : '', + 'selenium': { + 'start_process': true, + 'server_path': 'node_modules/selenium-standalone/.selenium/selenium-server/2.53.1-server.jar', + 'log_path': './tests/e2e/reports', + 'host': '127.0.0.1', + 'port': 6666, + 'cli_args': { + 'webdriver.chrome.driver': 'node_modules/selenium-standalone/.selenium/chromedriver/2.25-x64-chromedriver' + } + }, + 'test_settings': { + 'default': { + 'selenium_port': 6666, + 'selenium_host': 'localhost', + 'silent': true, + 'desiredCapabilities': { + 'browserName': 'chrome', + 'javascriptEnabled': true, + 'acceptSslCerts': true, + 'webStorageEnabled' : true, + 'databaseEnabled' : true, + 'applicationCacheEnabled' : false, + 'nativeEvents' : true + }, + 'screenshots' : { + 'enabled' : true, + 'on_failure' : true, + 'on_error' : true, + 'path' : './tests/e2e/reports' + }, + 'exclude': [ + ] + } + } +}; diff --git a/package.json b/package.json index 8d0256af2..c54bd6723 100644 --- a/package.json +++ b/package.json @@ -11,18 +11,15 @@ "lint-fix": "eslint . --fix", "test": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive tests", "test-watch": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests", + "pree2e": "NODE_ENV=test ./pree2e.sh", + "e2e": "NODE_ENV=test node_modules/.bin/nightwatch", "embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs" }, "config": { "pre-git": { "commit-msg": [], - "pre-commit": [ - "npm run lint", - "npm test" - ], - "pre-push": [ - "npm test" - ], + "pre-commit": ["npm run lint", "npm test"], + "pre-push": ["npm test"], "post-commit": [], "post-merge": [] } @@ -31,12 +28,7 @@ "type": "git", "url": "git+https://github.com/coralproject/talk.git" }, - "keywords": [ - "talk", - "coral", - "coralproject", - "ask" - ], + "keywords": ["talk", "coral", "coralproject", "ask"], "author": "", "license": "Apache-2.0", "bugs": { @@ -97,6 +89,7 @@ "eslint-config-standard": "^6.2.1", "eslint-plugin-flowtype": "^2.25.0", "eslint-plugin-import": "^2.2.0", + "eslint-plugin-mocha": "^4.7.0", "eslint-plugin-promise": "^3.3.1", "eslint-plugin-react": "^6.6.0", "eslint-plugin-standard": "^2.0.1", @@ -110,6 +103,8 @@ "material-design-lite": "^1.2.1", "mocha": "^3.1.2", "mocha-junit-reporter": "^1.12.1", + "nightwatch": "^0.9.9", + "node-fetch": "^1.6.3", "postcss-loader": "^1.1.0", "postcss-modules": "^0.5.2", "postcss-smart-import": "^0.5.1", @@ -127,11 +122,11 @@ "redux-mock-store": "^1.2.1", "redux-thunk": "^2.1.0", "regenerator": "^0.8.46", + "selenium-standalone": "^5.8.0", "style-loader": "^0.13.1", "supertest": "^2.0.1", "timeago.js": "^2.0.3", - "webpack": "^1.13.3", - "whatwg-fetch": "^1.0.0" + "webpack": "^1.13.3" }, "engines": { "node": ">=7.0.0" diff --git a/pree2e.sh b/pree2e.sh new file mode 100755 index 000000000..8857e5745 --- /dev/null +++ b/pree2e.sh @@ -0,0 +1,2 @@ +node_modules/selenium-standalone/bin/selenium-standalone install +npm start & diff --git a/routes/api/asset/index.js b/routes/api/asset/index.js index 96b83a969..05926bc15 100644 --- a/routes/api/asset/index.js +++ b/routes/api/asset/index.js @@ -96,4 +96,12 @@ router.put('/:asset_id/settings', (req, res, next) => { }); +router.put('/:asset_id/status', (req, res, next) => { + // Update the asset status + Asset + .update({id: req.params.asset_id}, {status: req.query.status}) + .then(() => res.status(204).end()) + .catch((err) => next(err)); +}); + module.exports = router; diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index 3bdf4afc6..b82c1faf3 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -1,5 +1,6 @@ const express = require('express'); const Comment = require('../../../models/comment'); +const Asset = require('../../../models/asset'); const User = require('../../../models/user'); const Action = require('../../../models/action'); const wordlist = require('../../../services/wordlist'); @@ -9,24 +10,45 @@ const _ = require('lodash'); const router = express.Router(); router.get('/', authorization.needed('admin'), (req, res, next) => { + + const { + status = null, + action_type = null, + asset_id = null + } = req.query; + + /** + * This adds the asset_id requirement to the query if the asset_id is defined. + */ + const assetIDWrap = (query) => { + if (asset_id) { + query = query.where('asset_id', asset_id); + } + + return query; + }; + let query; - if (req.query.status) { - query = Comment.findByStatus(req.query.status); - } else if (req.query.action_type) { - query = Comment.findByActionType(req.query.action_type); + if (status) { + query = assetIDWrap(Comment.findByStatus(status === 'new' ? null : status)); + } else if (action_type) { + query = Comment + .findIdsByActionType(action_type) + .then((ids) => assetIDWrap(Comment.find({ + id: { + $in: ids + }, + }))); } else { - query = Comment.all(); + query = assetIDWrap(Comment.all()); } query.then((comments) => { return Promise.all([ comments, User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), - Action.getActionSummaries(_.uniq([ - ...comments.map((comment) => comment.id), - ...comments.map((comment) => comment.author_id) - ])) + Action.getActionSummariesFromComments(asset_id, comments, req.user ? req.user.id : false) ]); }) .then(([comments, users, actions])=> @@ -48,21 +70,38 @@ router.post('/', wordlist.filter('body'), (req, res, next) => { parent_id } = req.body; - Comment - .create({ - body, - asset_id, - parent_id, - status: req.wordlist.matched ? 'rejected' : '', - author_id: req.user.id - }) - .then((comment) => { + // Decide the status based on whether or not the current asset/settings + // has pre-mod enabled or not. If the comment was rejected based on the + // wordlist, then reject it, otherwise if the moderation setting is + // premod, set it to `premod`. + let status; - res.status(201).send(comment); - }) - .catch((err) => { - next(err); - }); + if (req.wordlist.matched) { + status = Promise.resolve('rejected'); + } else { + status = Asset + .rectifySettings(Asset.findById(asset_id)) + + // Return `premod` if pre-moderation is enabled and an empty "new" status + // in the event that it is not in pre-moderation mode. + .then(({moderation}) => moderation === 'pre' ? 'premod' : ''); + } + + status.then((status) => Comment.publicCreate({ + body, + asset_id, + parent_id, + status, + author_id: req.user.id + })) + .then((comment) => { + + // The comment was created! Send back the created comment. + res.status(201).send(comment); + }) + .catch((err) => { + next(err); + }); }); router.get('/:comment_id', authorization.needed('admin'), (req, res, next) => { @@ -99,7 +138,7 @@ router.put('/:comment_id/status', authorization.needed('admin'), (req, res, next } = req.body; Comment - .changeStatus(req.params.comment_id, status) + .pushStatus(req.params.comment_id, status, req.user.id) .then(() => { res.status(204).end(); }) diff --git a/routes/api/queue/index.js b/routes/api/queue/index.js index b38d1b763..9c85fbe94 100644 --- a/routes/api/queue/index.js +++ b/routes/api/queue/index.js @@ -3,6 +3,7 @@ const Comment = require('../../../models/comment'); const User = require('../../../models/user'); const Action = require('../../../models/action'); const Setting = require('../../../models/setting'); +const Asset = require('../../../models/asset'); const _ = require('lodash'); const router = express.Router(); @@ -16,27 +17,52 @@ const router = express.Router(); // Pre-moderation: New comments are shown in the moderator queues immediately. // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. router.get('/comments/pending', (req, res, next) => { - Setting.getPublicSettings().then(({moderation}) => - Comment.moderationQueue(moderation)) - .then((comments) => { - return Promise.all([ - comments, - User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), - Action.getActionSummaries(_.uniq([ - ...comments.map((comment) => comment.id), - ...comments.map((comment) => comment.author_id) - ])) - ]); - }) - .then(([comments, users, actions])=> - res.status(200).json({ - comments, - users, - actions - })) - .catch(error => { - next(error); - }); + + const { + asset_id + } = req.query; + + let settings = Setting.retrieve(); + + if (asset_id) { + + // In the event that we have an asset_id, we should fetch the asset settings + // in order to actually determine if there is additional comments to parse. + settings = Promise.all([ + settings, + Asset.findById(asset_id).select('settings') + ]).then(([{moderation}, asset]) => { + if (asset.settings && asset.settings.moderation) { + return {moderation: asset.settings.moderation}; + } + + return {moderation}; + }); + } + + settings + .then(({moderation}) => { + return Comment.moderationQueue(moderation); + }).then((comments) => { + return Promise.all([ + comments, + User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), + Action.getActionSummaries(_.uniq([ + ...comments.map((comment) => comment.id), + ...comments.map((comment) => comment.author_id) + ])) + ]); + }) + .then(([comments, users, actions]) => { + res.json({ + comments, + users, + actions + }); + }) + .catch(error => { + next(error); + }); }); module.exports = router; diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js index 910524fb2..76d16c7ec 100644 --- a/routes/api/settings/index.js +++ b/routes/api/settings/index.js @@ -4,19 +4,21 @@ const Setting = require('../../../models/setting'); const router = express.Router(); router.get('/', (req, res, next) => { - Setting - .getSettings() - .then(settings => { - res.json(settings); - }) - .catch(next); + Setting.retrieve().then((settings) => { + res.json(settings); + }) + .catch((err) => { + next(err); + }); }); router.put('/', (req, res, next) => { - Setting - .updateSettings(req.body) - .then(() => res.status(204).end()) - .catch(next); + Setting.update(req.body).then(() => { + res.status(204).end(); + }) + .catch((err) => { + next(err); + }); }); module.exports = router; diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js index 4e8373999..26947be09 100644 --- a/routes/api/stream/index.js +++ b/routes/api/stream/index.js @@ -26,14 +26,14 @@ router.get('/', (req, res, next) => { return asset; }), - // Get the public settings. - Setting.getPublicSettings() + // Get the moderation setting from the settings. + Setting.retrieve() ]) .then(([asset, settings]) => { // Merge the asset specific settings with the returned settings object in // the event that the asset that was returned also had settings. - if (asset.settings) { + if (asset && asset.settings) { settings = Object.assign({}, settings, asset.settings); } @@ -70,17 +70,7 @@ router.get('/', (req, res, next) => { let users = userIDs.length > 0 ? User.findByIdArray(userIDs) : []; // Fetch the actions for pretty much everything at this point. - let actions = Action.getActionSummaries(_.uniq([ - - // Actions can be on assets... - asset.id, - - // Comments... - ...comments.map((comment) => comment.id), - - // Or Authors... - ...userIDs - ]), req.user ? req.user.id : false); + let actions = Action.getActionSummariesFromComments(asset.id, comments, req.user ? req.user.id : false); return Promise.all([ @@ -108,7 +98,7 @@ router.get('/', (req, res, next) => { comments, users, actions, - settings + settings: Setting.public(settings) }); }) .catch(error => { diff --git a/services/wordlist.js b/services/wordlist.js index e6f2ad668..59a842817 100644 --- a/services/wordlist.js +++ b/services/wordlist.js @@ -20,7 +20,7 @@ const wordlist = { */ wordlist.init = () => { return Setting - .getSettings() + .retrieve() .then((settings) => { // Insert the settings wordlist. diff --git a/tests/.eslintrc.json b/tests/.eslintrc.json index 363ef5a28..b188e92cf 100644 --- a/tests/.eslintrc.json +++ b/tests/.eslintrc.json @@ -4,8 +4,12 @@ "node": true, "mocha": true }, + "plugins": [ + "mocha" + ], "extends": "../.eslintrc.json", "rules": { - "no-undef": [0] + "no-undef": [0], + "mocha/no-exclusive-tests": "warn" } } diff --git a/tests/e2e/globals.js b/tests/e2e/globals.js new file mode 100644 index 000000000..147af2663 --- /dev/null +++ b/tests/e2e/globals.js @@ -0,0 +1,4 @@ +module.exports = { + waitForConditionTimeout: 20000, + baseUrl: 'localhost:3011/' +}; diff --git a/tests/e2e/mocks.js b/tests/e2e/mocks.js new file mode 100644 index 000000000..a4f430cd5 --- /dev/null +++ b/tests/e2e/mocks.js @@ -0,0 +1,25 @@ +const Comments = require('../../models/comment'); +const Users = require('../../models/user'); +const Actions = require('../../models/action'); +const Assets = require('../../models/asset'); +const Settings = require('../../models/setting'); +const globals = require('./globals'); + +/* Create an array of comments */ +module.exports.comments = (comments) => Assets.findOrCreateByUrl(globals.baseUrl) + .then((asset) => { + comments = comments.map((comment) => { + comment.asset_id = asset.id; + return comment; + }); + return Comments.create(comments); + }); + +/* Create an array of users */ +module.exports.users = (users) => Users.createLocalUsers(users); + +/* Create an array of actions */ +module.exports.actions = (actions) => Actions.create(actions); + +/* Update a setting */ +module.exports.settings = (setting) => Settings.init().then(() => Settings.updateSettings(setting)); diff --git a/tests/e2e/pages/embedStream.js b/tests/e2e/pages/embedStream.js new file mode 100644 index 000000000..cc584a656 --- /dev/null +++ b/tests/e2e/pages/embedStream.js @@ -0,0 +1,45 @@ +const fetch = require('node-fetch'); + +const embedCommands = { + ready() { + return this.resizeWindow(1200, 800) + .url(client.globals.baseUrl) + .waitForElementVisible('body', 2000) + .frame('coralStreamIframe'); + }, + setConfig(config, baseUrl) { + return fetch(`${baseUrl}/api/v1/settings`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(config) + }); + }, + enterComment() { + const comment = 'This is a test comment'; + return this + .waitForElementVisible('@commentBox') + .setValue('@commentBox', comment) + .click('@postButton') + .waitForElementVisible('.comment', 1000); + }, + validateComment(comment) { + return this + .assert.equal(comment, '.comment'); + } +}; + +module.exports = { + commands: [embedCommands], + elements: { + + commentBox: { + selector: '#commentBox' + }, + postButton: { + selector: '#commentBox .coral-plugin-commentbox-button' + } + } +}; diff --git a/tests/e2e/tests/AppTest.js b/tests/e2e/tests/AppTest.js new file mode 100644 index 000000000..36281ed3f --- /dev/null +++ b/tests/e2e/tests/AppTest.js @@ -0,0 +1,13 @@ +module.exports = { + '@tags': ['app'], + 'Base url and Hostname': browser => { + const {baseUrl} = browser.globals; + browser + .url(baseUrl) + .assert.title('Coral Talk') + .waitForElementPresent('body', 1000); + }, + after: client => { + client.end(); + } +}; diff --git a/tests/e2e/tests/EmbedStreamTests.js b/tests/e2e/tests/EmbedStreamTests.js new file mode 100644 index 000000000..65ea9fa76 --- /dev/null +++ b/tests/e2e/tests/EmbedStreamTests.js @@ -0,0 +1,173 @@ +const utils = require('../../utils/e2e-mongoose'); +const mocks = require('../mocks'); + +const mockComment = 'This is a test comment.'; +const mockReply = 'This is a test reply'; +const mockUser = { + email: `${new Date().getTime()}@test.com`, + name: 'Test User', + pw: 'testtesttest' +}; + +module.exports = { + '@tags': ['embed-stream', 'comment', 'premodoff', 'premodon'], + before: () => { + utils.before(); + }, + 'User registers and posts a comment with premod off': client => { + client.perform((client, done) => { + mocks.settings({moderation: 'post'}) + .then(() => { + //Load Page + client.resizeWindow(1200, 800) + .url(client.globals.baseUrl) + .frame('coralStreamIframe') + + //Register and Log In + .waitForElementVisible('#commentBox', 1000) + .waitForElementVisible('#coralSignInButton', 2000) + .click('#coralSignInButton') + .waitForElementVisible('#coralRegister', 1000) + .click('#coralRegister') + .waitForElementVisible('#email', 1000) + .setValue('#email', mockUser.email) + .setValue('#displayName', mockUser.name) + .setValue('#password', mockUser.pw) + .setValue('#confirmPassword', mockUser.pw) + .click('#coralSignUpButton') + .waitForElementVisible('#coralLogInButton', 10000) + .click('#coralLogInButton') + .waitForElementVisible('.coral-plugin-commentbox-button', 4000) + + // Post a comment + .setValue('.coral-plugin-commentbox-textarea', mockComment) + .click('.coral-plugin-commentbox-button') + .waitForElementVisible('.comment', 1000) + + //Verify that it appears + .assert.containsText('.comment', mockComment); + done(); + }) + .catch((err) => { + console.log(err); + done(); + }); + }); + }, + 'User posts a comment with premod on': client => { + client.perform((client, done) => { + mocks.settings({moderation: 'pre'}) + .then(() => { + //Load Page + client.url(client.globals.baseUrl) + .frame('coralStreamIframe'); + + // Post a comment + client.waitForElementVisible('.coral-plugin-commentbox-button', 2000) + .setValue('.coral-plugin-commentbox-textarea', mockComment) + .click('.coral-plugin-commentbox-button') + .waitForElementVisible('#coral-notif', 1000) + + //Verify that it appears + .assert.containsText('#coral-notif', 'moderation team'); + done(); + }) + .catch((err) => { + console.log(err); + done(); + }); + }); + }, + 'User replies to a comment with premod off': client => { + client.perform((client, done) => { + mocks.settings({moderation: 'post'}) + .then(() => { + //Load Page + client.resizeWindow(1200, 800) + .url(client.globals.baseUrl) + .frame('coralStreamIframe'); + + // Post a comment + client.waitForElementVisible('.coral-plugin-commentbox-button', 2000) + .setValue('.coral-plugin-commentbox-textarea', mockComment) + .click('.coral-plugin-commentbox-button') + + // Post a reply + .waitForElementVisible('.coral-plugin-replies-reply-button', 5000) + .click('.coral-plugin-replies-reply-button') + .waitForElementVisible('#replyText') + .setValue('#replyText', mockReply) + .click('.coral-plugin-replies-textarea button') + .waitForElementVisible('.reply', 2000) + + //Verify that it appears + .assert.containsText('.reply', mockReply); + done(); + }) + .catch((err) => { + console.log(err); + done(); + }); + }); + }, + 'User replies to a comment with premod on': client => { + client.perform((client, done) => { + mocks.settings({moderation: 'pre'}) + + // Add a mock user + .then(() => { + return mocks.users([{ + displayName: 'Baby Blue', + email: 'whale@tale.sea', + password: 'krill' + }]); + }) + + // Add a mock preapproved comment by that user + .then((user) => { + return mocks.comments([{ + body: 'Whales are not fish.', + status: 'accepted', + author_id: user.id + }]); + }) + .then(() => { + //Load Page + client.resizeWindow(1200, 800) + .url(client.globals.baseUrl) + .frame('coralStreamIframe'); + + // Post a reply + client.waitForElementVisible('.coral-plugin-replies-reply-button', 5000) + .click('.coral-plugin-replies-reply-button') + .waitForElementVisible('#replyText') + .setValue('#replyText', mockReply) + .click('.coral-plugin-replies-textarea button') + .waitForElementVisible('#coral-notif', 1000) + + //Verify that it appears + .assert.containsText('#coral-notif', 'moderation team'); + done(); + }) + .catch((err) => { + console.log(err); + done(); + }); + }); + }, + 'Total comment count premod on': client => { + client.perform((client, done) => { + client.url(client.globals.baseUrl) + .frame('coralStreamIframe'); + + // Verify that comment count is correct + client.waitForElementVisible('.coral-plugin-comment-count-text', 2000) + .assert.containsText('.coral-plugin-comment-count-text', '1 Comment'); + done(); + }); + }, + after: client => { + utils.after(); + client.end(); + } +}; diff --git a/tests/models/action.js b/tests/models/action.js index c354b8f83..5f05dea71 100644 --- a/tests/models/action.js +++ b/tests/models/action.js @@ -1,35 +1,34 @@ const Action = require('../../models/action'); const expect = require('chai').expect; -describe('Action: models', () => { - let mockActions; +describe('models.Action', () => { + let mockActions = []; - beforeEach(() => { - return Action.create([{ - action_type: 'flag', - item_id: '123', - item_type: 'comment', - user_id: 'flagginguserid' - }, { - action_type: 'flag', - item_id: '456', - item_type: 'comment' - }, { - action_type: 'flag', - item_id: '123', - item_type: 'comment' - }, { - action_type: 'like', - item_id: '123', - item_type: 'comment' - }]).then((actions) => { - mockActions = actions; - }); - }); + beforeEach(() => Action.create([{ + action_type: 'flag', + item_id: '123', + item_type: 'comment', + user_id: 'flagginguserid' + }, { + action_type: 'flag', + item_id: '456', + item_type: 'comment' + }, { + action_type: 'flag', + item_id: '123', + item_type: 'comment' + }, { + action_type: 'like', + item_id: '123', + item_type: 'comment' + }]).then((actions) => { + mockActions = actions; + })); describe('#findById()', () => { it('should find an action by id', () => { return Action.findById(mockActions[0].id).then((result) => { + expect(result).to.not.be.null; expect(result).to.have.property('action_type', 'flag'); }); }); diff --git a/tests/models/asset.js b/tests/models/asset.js index 69bf99f9d..c9eff819e 100644 --- a/tests/models/asset.js +++ b/tests/models/asset.js @@ -6,7 +6,7 @@ const expect = chai.expect; // Use the chai should. chai.should(); -describe('Asset: model', () => { +describe('models.Asset', () => { beforeEach(() => { const defaults = {url:'http://test.com'}; diff --git a/tests/models/comment.js b/tests/models/comment.js index 07834a186..a9c2f4787 100644 --- a/tests/models/comment.js +++ b/tests/models/comment.js @@ -7,35 +7,57 @@ const settings = {id: '1', moderation: 'pre'}; const expect = require('chai').expect; -describe('Comment: models', () => { +describe('models.Comment', () => { const comments = [{ body: 'comment 10', asset_id: '123', - status: '', + status: [], parent_id: '', author_id: '123', id: '1' }, { body: 'comment 20', asset_id: '123', - status: 'accepted', + status: [{ + type: 'accepted' + }], parent_id: '', author_id: '123', id: '2' }, { body: 'comment 30', asset_id: '456', - status: '', + status: [], parent_id: '', author_id: '456', id: '3' }, { body: 'comment 40', asset_id: '123', - status: 'rejected', + status: [{ + type: 'rejected' + }], parent_id: '', author_id: '456', id: '4' + }, { + body: 'comment 50', + asset_id: '1234', + status: [{ + type: 'premod' + }], + parent_id: '', + author_id: '456', + id: '5' + }, { + body: 'comment 60', + asset_id: '1234', + status: [{ + type: 'premod' + }], + parent_id: '', + author_id: '456', + id: '6' }]; const users = [{ @@ -60,25 +82,69 @@ describe('Comment: models', () => { user_id: '456' }]; - beforeEach(() => { - return Promise.all([ - Setting.create(settings), - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); + beforeEach(() => Promise.all([ + Setting.init(settings), + Comment.create(comments), + User.createLocalUsers(users), + Action.create(actions) + ])); + + describe('#publicCreate()', () => { + + it('creates a new comment', () => { + return Comment.publicCreate({ + body: 'This is a comment!', + status: 'accepted' + }).then((c) => { + expect(c).to.not.be.null; + expect(c.id).to.not.be.null; + expect(c.id).to.be.uuid; + expect(c.status).to.have.length(1); + expect(c.status[0]).to.have.property('type', 'accepted'); + }); + }); + + it('creates many new comments', () => { + return Comment.publicCreate([{ + body: 'This is a comment!', + status: 'accepted' + }, { + body: 'This is another comment!' + }, { + body: 'This is a rejected comment!', + status: 'rejected' + }]).then(([c1, c2, c3]) => { + expect(c1).to.not.be.null; + expect(c1.id).to.be.uuid; + expect(c1.status).to.have.length(1); + expect(c1.status[0]).to.have.property('type', 'accepted'); + + expect(c2).to.not.be.null; + expect(c2.id).to.be.uuid; + expect(c2.status).to.have.length(0); + + expect(c3).to.not.be.null; + expect(c3.id).to.be.uuid; + expect(c3.status).to.have.length(1); + expect(c3.status[0]).to.have.property('type', 'rejected'); + }); + }); + }); describe('#findById()', () => { + it('should find a comment by id', () => { return Comment.findById('1').then((result) => { expect(result).to.not.be.null; expect(result).to.have.property('body', 'comment 10'); }); }); + }); describe('#findByAssetId()', () => { + it('should find an array of all comments by asset id', () => { return Comment.findByAssetId('123').then((result) => { expect(result).to.have.length(3); @@ -91,6 +157,7 @@ describe('Comment: models', () => { expect(result[2]).to.have.property('body', 'comment 40'); }); }); + it('should find an array of accepted comments by asset id', () => { return Comment.findAcceptedByAssetId('123').then((result) => { expect(result).to.have.length(1); @@ -101,6 +168,7 @@ describe('Comment: models', () => { expect(result[0]).to.have.property('body', 'comment 20'); }); }); + it('should find an array of new and accepted comments by asset id', () => { return Comment.findAcceptedAndNewByAssetId('123').then((result) => { expect(result).to.have.length(2); @@ -112,13 +180,16 @@ describe('Comment: models', () => { }); }); }); + describe('#moderationQueue()', () => { + it('should find an array of new comments to moderate when pre-moderation', () => { return Comment.moderationQueue('pre').then((result) => { expect(result).to.not.be.null; expect(result).to.have.lengthOf(2); }); }); + it('should find an array of new comments to moderate when post-moderation', () => { return Comment.moderationQueue('post').then((result) => { expect(result).to.not.be.null; @@ -126,21 +197,56 @@ describe('Comment: models', () => { expect(result[0]).to.have.property('body', 'comment 30'); }); }); - // it('should fail when the moderation is not pre or post', () => { - // return Comment.moderationQueue('any').catch(function(error) { - // expect(error).to.not.be.null; - // }); - // }); + }); describe('#removeAction', () => { + it('should remove an action', () => { - return Comment.removeAction('3', '123', 'flag').then(() => { - return Action.findByItemIdArray(['123']); - }) - .then((actions) => { - expect(actions.length).to.equal(0); - }); + return Comment.removeAction('3', '123', 'flag') + .then(() => { + return Action.findByItemIdArray(['123']); + }) + .then((actions) => { + expect(actions.length).to.equal(0); + }); }); }); + + describe('#changeStatus', () => { + + it('should change the status of a comment from no status', () => { + let comment_id = comments[0].id; + + return Comment.findById(comment_id) + .then((c) => { + expect(c).to.have.property('status'); + expect(c.status).to.have.length(0); + + return Comment.pushStatus(comment_id, 'rejected', '123'); + }) + .then(() => Comment.findById(comment_id)) + .then((c) => { + expect(c).to.have.property('status'); + expect(c.status).to.have.length(1); + expect(c.status[0]).to.have.property('type', 'rejected'); + expect(c.status[0]).to.have.property('assigned_by', '123'); + }); + }); + + it('should change the status of a comment from accepted', () => { + return Comment.pushStatus(comments[1].id, 'rejected', '123') + .then(() => Comment.findById(comments[1].id)) + .then((c) => { + expect(c).to.have.property('status'); + expect(c.status).to.have.length(2); + expect(c.status[0]).to.have.property('type', 'accepted'); + expect(c.status[0]).to.have.property('assigned_by', null); + + expect(c.status[1]).to.have.property('type', 'rejected'); + expect(c.status[1]).to.have.property('assigned_by', '123'); + }); + }); + + }); }); diff --git a/tests/models/setting.js b/tests/models/setting.js index 186a65245..3e77297fc 100644 --- a/tests/models/setting.js +++ b/tests/models/setting.js @@ -1,34 +1,29 @@ const Setting = require('../../models/setting'); const expect = require('chai').expect; -describe('Setting: model', () => { +describe('models.Setting', () => { - beforeEach(() => { - const defaults = { - id: 1 - }; - return Setting.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}); - }); + beforeEach(() => Setting.init({moderation: 'pre'})); - describe('#getSettings()', () => { + describe('#retrieve()', () => { it('should have a moderation field defined', () => { - return Setting.getSettings().then(settings => { + return Setting.retrieve().then(settings => { expect(settings).to.have.property('moderation').and.to.equal('pre'); }); }); it('should have two infoBox fields defined', () => { - return Setting.getSettings().then(settings => { + return Setting.retrieve().then(settings => { expect(settings).to.have.property('infoBoxEnable').and.to.equal(false); expect(settings).to.have.property('infoBoxContent').and.to.equal(''); }); }); }); - describe('#updateSettings()', () => { + describe('#update()', () => { it('should update the settings with a passed object', () => { const mockSettings = {moderation: 'post', infoBoxEnable: true, infoBoxContent: 'yeah'}; - return Setting.updateSettings(mockSettings).then(updatedSettings => { + return Setting.update(mockSettings).then(updatedSettings => { expect(updatedSettings).to.be.an('object'); expect(updatedSettings).to.have.property('moderation').and.to.equal('post'); expect(updatedSettings).to.have.property('infoBoxEnable', true); @@ -37,13 +32,10 @@ describe('Setting: model', () => { }); }); - describe('#getPublicSettings', () => { + describe('#get', () => { it('should return the moderation settings', () => { - return Setting.getPublicSettings().then(({moderation, infoBoxEnable, infoBoxContent, wordlist}) => { + return Setting.retrieve().then(({moderation}) => { expect(moderation).not.to.be.null; - expect(infoBoxEnable).not.to.be.null; - expect(infoBoxContent).not.to.be.null; - expect(wordlist).to.be.undefined; }); }); }); diff --git a/tests/models/user.js b/tests/models/user.js index c8af92054..5e1677016 100644 --- a/tests/models/user.js +++ b/tests/models/user.js @@ -1,7 +1,7 @@ const User = require('../../models/user'); const expect = require('chai').expect; -describe('User: models', () => { +describe('models.User', () => { let mockUsers; beforeEach(() => { return User.createLocalUsers([{ diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js index bc8b67bc1..bf7a06ff6 100644 --- a/tests/routes/api/comments/index.js +++ b/tests/routes/api/comments/index.js @@ -10,6 +10,7 @@ chai.use(require('chai-http')); const wordlist = require('../../../../services/wordlist'); const Comment = require('../../../../models/comment'); +const Asset = require('../../../../models/asset'); const Action = require('../../../../models/action'); const User = require('../../../../models/user'); @@ -17,63 +18,71 @@ const Setting = require('../../../../models/setting'); const settings = {id: '1', moderation: 'pre'}; describe('/api/v1/comments', () => { - const comments = [{ - id: 'abc', - body: 'comment 10', - asset_id: 'asset', - author_id: '123' - }, { - id: 'def', - body: 'comment 20', - asset_id: 'asset', - author_id: '456' - }, { - id: 'def-rejected', - body: 'comment 20', - asset_id: 'asset', - author_id: '456', - status: 'rejected' - }, { - id: 'hij', - body: 'comment 30', - asset_id: '456', - author_id: '456', - status: 'accepted' - }]; - - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc', - item_type: 'comment' - }, { - action_type: 'like', - item_id: 'hij', - item_type: 'comment' - }]; - - beforeEach(() => { - return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions), - wordlist.insert([ - 'bad words' - ]), - Setting.create(settings) - ]); - }); describe('#get', () => { + const comments = [{ + body: 'comment 10', + asset_id: 'asset', + author_id: '123' + }, { + body: 'comment 20', + asset_id: 'asset', + author_id: '456' + }, { + body: 'comment 20', + asset_id: 'asset', + author_id: '456', + status: [{ + type: 'rejected' + }] + }, { + body: 'comment 30', + asset_id: '456', + status: [{ + type: 'accepted' + }] + }]; + + const users = [{ + displayName: 'Ana', + email: 'ana@gmail.com', + password: '123' + }, { + displayName: 'Maria', + email: 'maria@gmail.com', + password: '123' + }]; + + const actions = [{ + action_type: 'flag', + item_id: 'abc', + item_type: 'comment' + }, { + action_type: 'like', + item_id: 'hij', + item_type: 'comment' + }]; + + beforeEach(() => { + return Promise.all([ + Comment.create(comments).then((newComments) => { + newComments.forEach((comment, i) => { + comments[i].id = comment.id; + }); + + actions[0].item_id = comments[0].id; + actions[1].item_id = comments[1].id; + + return Action.create(actions); + }), + User.createLocalUsers(users), + wordlist.insert([ + 'bad words' + ]), + Setting.init(settings) + ]); + }); + it('should return all the comments', () => { return chai.request(app) .get('/api/v1/comments') @@ -91,7 +100,9 @@ describe('/api/v1/comments', () => { .set(passport.inject({roles: ['admin']})) .then((res) => { expect(res).to.have.status(200); - expect(res.body.comments[0]).to.have.property('id', 'def-rejected'); + expect(res.body).to.have.property('comments'); + expect(res.body.comments).to.have.length(1); + expect(res.body.comments[0]).to.have.property('id', comments[2].id); }); }); @@ -102,7 +113,7 @@ describe('/api/v1/comments', () => { .then((res) => { expect(res).to.have.status(200); expect(res.body.comments).to.have.length(1); - expect(res.body.comments[0]).to.have.property('id', 'hij'); + expect(res.body.comments[0]).to.have.property('id', comments[3].id); }); }); @@ -124,8 +135,7 @@ describe('/api/v1/comments', () => { expect(res).to.have.status(200); expect(res.body.comments).to.have.length(1); - expect(res.body.comments[0]).to.have.property('id', 'abc'); - + expect(res.body.comments[0]).to.have.property('id', comments[0].id); }); }); }); @@ -151,7 +161,31 @@ describe('/api/v1/comments', () => { .then((res) => { expect(res).to.have.status(201); expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status', 'rejected'); + expect(res.body).to.have.property('status').and.to.have.length(1); + expect(res.body.status[0]).to.have.property('type', 'rejected'); + }); + }); + + it('should create a comment with a premod status if it\'s asset is has pre-moderation enabled', () => { + return Asset + .findOrCreateByUrl('https://coralproject.net/article1') + .then((asset) => { + return Asset + .overrideSettings(asset.id, {moderation: 'pre'}) + .then(() => asset); + }) + .then((asset) => { + return chai.request(app) + .post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); + }) + .then((res) => { + expect(res).to.have.status(201); + expect(res.body).to.have.property('id'); + expect(res.body).to.have.property('asset_id'); + expect(res.body).to.have.property('status').and.to.have.length(1); + expect(res.body.status[0]).to.have.property('type', 'premod'); }); }); }); @@ -267,18 +301,22 @@ describe('/api/v1/comments/:comment_id/actions', () => { body: 'comment 10', asset_id: 'asset', author_id: '123', - status: '' + status: [] }, { id: 'def', body: 'comment 20', asset_id: 'asset', author_id: '456', - status: 'rejected' + status: [{ + type: 'rejected' + }] }, { id: 'hij', body: 'comment 30', asset_id: '456', - status: 'accepted' + status: [{ + type: 'accepted' + }] }]; const users = [{ @@ -316,10 +354,8 @@ describe('/api/v1/comments/:comment_id/actions', () => { .then((res) => { expect(res).to.have.status(201); expect(res).to.have.body; - expect(res.body).to.have.property('item_type', 'comment'); expect(res.body).to.have.property('action_type', 'flag'); expect(res.body).to.have.property('item_id', 'abc'); - expect(res.body).to.have.property('user_id', '456'); }); }); }); diff --git a/tests/routes/api/queue/index.js b/tests/routes/api/queue/index.js index 1e99ae61e..d64423013 100644 --- a/tests/routes/api/queue/index.js +++ b/tests/routes/api/queue/index.js @@ -15,56 +15,54 @@ const User = require('../../../../models/user'); const Setting = require('../../../../models/setting'); const settings = {id: '1', moderation: 'pre'}; -beforeEach(() => { - return Setting.create(settings); -}); +describe('/api/v1/queue', () => { + const comments = [{ + id: 'abc', + body: 'comment 10', + asset_id: 'asset', + author_id: '123', + status: [{ + type: 'rejected' + }] + }, { + id: 'def', + body: 'comment 20', + asset_id: 'asset', + author_id: '456', + status: [{ + type: 'premod' + }] + }, { + id: 'hij', + body: 'comment 30', + asset_id: '456', + status: [{ + type: 'accepted' + }] + }]; -describe('Get moderation queues rejected, pending, flags', () => { + const users = [{ + displayName: 'Ana', + email: 'ana@gmail.com', + password: '123' + }, { + displayName: 'Maria', + email: 'maria@gmail.com', + password: '123' + }]; - describe('/api/v1/queue', () => { - let comments; + const actions = [{ + action_type: 'flag', + item_id: 'abc', + item_type: 'comment' + }, { + action_type: 'like', + item_id: 'hij', + item_type: 'comment' + }]; - const users = [{ - id: '456', - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123' - }, { - id: '123', - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123' - }]; - - let actions; - - beforeEach(() => { - - comments = [{ - id: 'abc', - body: 'comment 10', - asset_id: 'asset', - status: 'rejected' - }, { - id: 'def', - body: 'comment 20', - asset_id: 'asset' - }, { - id: 'hij', - body: 'comment 30', - asset_id: '456', - status: 'accepted' - }]; - - actions = [{ - action_type: 'flag', - item_type: 'comment' - }, { - action_type: 'like', - item_type: 'comment' - }]; - - return User.createLocalUsers(users) + beforeEach(() => { + return User.createLocalUsers(users) .then((u) => { comments[0].author_id = u[0].id; comments[1].author_id = u[1].id; @@ -76,22 +74,24 @@ describe('Get moderation queues rejected, pending, flags', () => { actions[0].item_id = c[0].id; actions[1].item_id = c[1].id; - return Action.create(actions); + return Promise.all([ + Action.create(actions), + Setting.init(settings) + ]); }); - }); + }); - it('should return all the pending comments, users and actions', function(done){ - chai.request(app) - .get('/api/v1/queue/comments/pending') - .set(passport.inject({roles: ['admin']})) - .end(function(err, res){ - expect(err).to.be.null; - expect(res).to.have.status(200); - expect(res.body.comments[0]).to.have.property('body'); - expect(res.body.users[0]).to.have.property('displayName'); - expect(res.body.actions[0]).to.have.property('action_type'); - done(); - }); - }); + it('should return all the pending comments, users and actions', function(done){ + chai.request(app) + .get('/api/v1/queue/comments/pending') + .set(passport.inject({roles: ['admin']})) + .end(function(err, res){ + expect(err).to.be.null; + expect(res).to.have.status(200); + expect(res.body.comments[0]).to.have.property('body'); + expect(res.body.users[0]).to.have.property('displayName'); + expect(res.body.actions[0]).to.have.property('action_type'); + done(); + }); }); }); diff --git a/tests/routes/api/settings/index.js b/tests/routes/api/settings/index.js index d1a7ba81b..318af81f4 100644 --- a/tests/routes/api/settings/index.js +++ b/tests/routes/api/settings/index.js @@ -12,7 +12,7 @@ const defaults = {id: '1', moderation: 'pre'}; describe('/api/v1/settings', () => { - beforeEach(() => Setting.create(defaults)); + beforeEach(() => Setting.init(defaults)); describe('#get', () => { @@ -40,7 +40,7 @@ describe('/api/v1/settings', () => { .then((res) => { expect(res).to.have.status(204); - return Setting.getSettings(); + return Setting.retrieve(); }) .then((settings) => { expect(settings).to.have.property('moderation', 'post'); diff --git a/tests/routes/api/stream/index.js b/tests/routes/api/stream/index.js index 6484fdc12..a128938ba 100644 --- a/tests/routes/api/stream/index.js +++ b/tests/routes/api/stream/index.js @@ -20,7 +20,37 @@ describe('/api/v1/stream', () => { moderation: 'post' }; - let comments; + const comments = [{ + id: 'abc', + body: 'comment 10', + author_id: '', + parent_id: '', + status: [{ + type: 'accepted' + }] + }, { + id: 'def', + body: 'comment 20', + author_id: '', + parent_id: '', + status: [] + }, { + id: 'uio', + body: 'comment 30', + asset_id: 'asset', + author_id: '456', + parent_id: '', + status: [{ + type: 'accepted' + }] + }, { + id: 'hij', + body: 'comment 40', + asset_id: '456', + status: [{ + type: 'rejected' + }] + }]; const users = [{ displayName: 'Ana', @@ -41,33 +71,6 @@ describe('/api/v1/stream', () => { }]; beforeEach(() => { - - comments = [{ - id: 'abc', - body: 'comment 10', - author_id: '', - parent_id: '', - status: 'accepted' - }, { - id: 'def', - body: 'comment 20', - author_id: '', - parent_id: '', - status: '' - }, { - id: 'uio', - body: 'comment 30', - asset_id: 'asset', - author_id: '456', - parent_id: '', - status: 'accepted' - }, { - id: 'hij', - body: 'comment 40', - asset_id: '456', - status: 'rejected' - }]; - return Promise.all([ User.createLocalUsers(users), Asset.findOrCreateByUrl('http://test.com'), @@ -94,10 +97,11 @@ describe('/api/v1/stream', () => { return Promise.all([ Comment.create(comments), Action.create(actions), - Setting.init().then(() => Setting.updateSettings(settings)) + Setting.init(settings) ]); }); }); + it('should return a stream with comments, users and actions for an existing asset', () => { return chai.request(app) .get('/api/v1/stream') diff --git a/tests/utils/e2e-mongoose.js b/tests/utils/e2e-mongoose.js new file mode 100644 index 000000000..1bb7e695b --- /dev/null +++ b/tests/utils/e2e-mongoose.js @@ -0,0 +1,34 @@ +const mongoose = require('../../mongoose'); + +// Ensure the NODE_ENV is set to 'test', +// this is helpful when you would like to change behavior when testing. +function clearDB() { + // console.log('Clearing DB', mongoose.connection); + for (let i in mongoose.connection.collections) { + // console.log('Clearing', i); + mongoose.connection.collections[i].remove(function() {}); + } +} + +module.exports = { + before: () => { + clearDB(); + }, + beforeEach: () => { + if (mongoose.connection.readyState === 0) { + mongoose.on('open', function() { + if (err) { + throw err; + } + + return clearDB(); + }); + } else { + return clearDB(); + } + }, + after: () => { + clearDB(); + mongoose.disconnect(); + } +}; diff --git a/views/article.ejs b/views/article.ejs index 6fd420f82..f4d7ef910 100644 --- a/views/article.ejs +++ b/views/article.ejs @@ -15,6 +15,7 @@ width:500px; } + <%= title %>
@@ -35,7 +36,7 @@