From 4532e3ac185a74643bac9df7731bc98eb64ee9fa Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Sun, 17 Dec 2017 21:28:03 +0200 Subject: [PATCH 01/13] Add validator for comments --- src/api/controllers/comments.js | 24 +++-------- src/api/validators.js | 4 ++ test/integration/api/comment.js | 68 ++++++++++++++++++++++++++++++ test/integration/triggers/index.js | 4 +- 4 files changed, 80 insertions(+), 20 deletions(-) diff --git a/src/api/controllers/comments.js b/src/api/controllers/comments.js index ecd80f8c..619f89ad 100644 --- a/src/api/controllers/comments.js +++ b/src/api/controllers/comments.js @@ -16,8 +16,10 @@ along with this program. If not, see . */ import uuid from 'uuid'; +import Joi from 'joi'; import { addToSearchIndex } from '../utils/search'; +import { CommentValidator } from '../validators'; export async function getPostComments(ctx) { const Comment = ctx.bookshelf.model('Comment'); @@ -39,20 +41,13 @@ export async function postComment(ctx) { const post = await Post.where({ id: ctx.params.id }).fetch({ require: true }); - // TODO: Replace with joi validation. - if (!('text' in ctx.request.body) || !ctx.request.body.text) { - ctx.status = 400; - ctx.body = { error: 'Comment text cannot be empty' }; - return; - } - - const text = ctx.request.body.text.trim(); + const attributes = Joi.attempt(ctx.request.body, CommentValidator); const comment = new Comment({ id: uuid.v4(), post_id: ctx.params.id, user_id: ctx.state.user, - text + text: attributes.text }); post.attributes.updated_at = new Date().toJSON(); @@ -82,16 +77,9 @@ export async function editComment(ctx) { ctx.status = 403; } - // TODO: Replace with joi validation. - if (!('text' in ctx.request.body) || ctx.request.body.text.trim().length === 0) { - ctx.status = 400; - ctx.body = { error: 'Comment text cannot be empty' }; - return; - } - - const text = ctx.request.body.text.trim(); + const attributes = Joi.attempt(ctx.request.body, CommentValidator); - comment.set('text', text); + comment.set('text', attributes.text); comment.set('updated_at', new Date().toJSON()); post.attributes.updated_at = new Date().toJSON(); diff --git a/src/api/validators.js b/src/api/validators.js index 121fc31c..f495d193 100644 --- a/src/api/validators.js +++ b/src/api/validators.js @@ -160,3 +160,7 @@ export const PostValidator = Joi.object({ schools: Joi.array().items(Joi.string()), geotags: Joi.array().items(Joi.string()) }).options({ abortEarly: false, stripUnknown: true }); + +export const CommentValidator = Joi.object({ + text: Joi.string().trim().min(1), +}); diff --git a/test/integration/api/comment.js b/test/integration/api/comment.js index 4067b22e..dc017ca3 100644 --- a/test/integration/api/comment.js +++ b/test/integration/api/comment.js @@ -79,6 +79,40 @@ describe('Comment', () => { [{ id: comment.id }, { text: 'some text' }] ); }); + + context('when text is not provided', () => { + it('responds with validation error', async () => { + await expect( + { + session: sessionId, + url: `/api/v1/post/${post.id}/comments`, + method: 'POST', + body: { + text: '' + } + }, + 'body to satisfy', + { error: 'api.errors.validation', fields: [{ path: 'text', type: 'any.empty' }] } + ); + }); + }); + + context('when text is not a string', () => { + it('responds with validation error', async () => { + await expect( + { + session: sessionId, + url: `/api/v1/post/${post.id}/comments`, + method: 'POST', + body: { + text: 123 + } + }, + 'body to satisfy', + { error: 'api.errors.validation', fields: [{ path: 'text', type: 'string.base' }] } + ); + }); + }); }); describe('POST /api/v1/post/:id/comment/:comment_id', () => { @@ -148,6 +182,40 @@ describe('Comment', () => { [{ id: comment.id }, { id: myComment.id, text: 'new text' }] ); }); + + context('when text is not provided', () => { + it('responds with validation error', async () => { + await expect( + { + session: sessionId, + url: `/api/v1/post/${post.id}/comment/${comment.id}`, + method: 'POST', + body: { + text: '' + } + }, + 'body to satisfy', + { error: 'api.errors.validation', fields: [{ path: 'text', type: 'any.empty' }] } + ); + }); + }); + + context('when text is not a string', () => { + it('responds with validation error', async () => { + await expect( + { + session: sessionId, + url: `/api/v1/post/${post.id}/comment/${comment.id}`, + method: 'POST', + body: { + text: 123 + } + }, + 'body to satisfy', + { error: 'api.errors.validation', fields: [{ path: 'text', type: 'string.base' }] } + ); + }); + }); }); describe('DELETE /api/v1/post/:id/comment/:comment_id', () => { diff --git a/test/integration/triggers/index.js b/test/integration/triggers/index.js index 94c78f3f..3d2e5ae8 100644 --- a/test/integration/triggers/index.js +++ b/test/integration/triggers/index.js @@ -189,7 +189,7 @@ describe('ActionsTrigger', () => { store = initState(); triggers = new ActionsTrigger(client, store.dispatch); await triggers.createComment(post.get('id'), ''); - expect(store.getState().getIn(['ui', 'comments', 'new', 'error']), 'to equal', 'Comment text cannot be empty'); + expect(store.getState().getIn(['ui', 'comments', 'new', 'error']), 'to equal', 'api.errors.validation'); }); it('#deleteComment should work', async () => { @@ -223,7 +223,7 @@ describe('ActionsTrigger', () => { await comment.save(null, { method: 'insert' }); await triggers.saveComment(post.get('id'), comment.get('id'), ''); - expect(store.getState().getIn(['ui', 'comments', comment.get('id'), 'error']), 'to equal', 'Comment text cannot be empty'); + expect(store.getState().getIn(['ui', 'comments', comment.get('id'), 'error']), 'to equal', 'api.errors.validation'); }); }); }); From 3f298bcba185ad262291cc62fb08b39be2637f5f Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Sun, 17 Dec 2017 22:01:01 +0200 Subject: [PATCH 02/13] Replace `mute_all_posts` user setting with `comment_notifications` `comment_notifications` can be one of: 'on', 'off', 'weekly', 'daily'. --- src/api/validators.js | 11 +++++------ src/definitions/users.js | 2 +- src/pages/settings/settings-email.js | 9 ++++++++- tasks.js | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/api/validators.js b/src/api/validators.js index f495d193..32764a80 100644 --- a/src/api/validators.js +++ b/src/api/validators.js @@ -46,15 +46,14 @@ export const UserRegistrationValidator = Joi.object({ export const UserSettingsValidator = Joi.object({ more: Joi.object({ - summary: Joi.string(), - bio: Joi.string(), + summary: Joi.string().trim().empty(''), + bio: Joi.string().trim().empty(''), roles: Joi.array(), // TODO: validate role variants - //first_login: Joi.bool(), // private avatar: PictureAttachment, head_pic: PictureAttachment, - mute_all_posts: Joi.bool(), - firstName: Joi.string(), - lastName: Joi.string(), + comment_notifications: Joi.string().only(['on', 'off', 'weekly', 'daily']), + firstName: Joi.string().trim().empty(''), + lastName: Joi.string().trim().empty(''), lang: Joi.string().only(SUPPORTED_LOCALES), sidebar: Joi.object({ collapsed: Joi.bool() diff --git a/src/definitions/users.js b/src/definitions/users.js index c41e2eff..72704545 100644 --- a/src/definitions/users.js +++ b/src/definitions/users.js @@ -84,7 +84,7 @@ export type UserMore = { firstName?: string, head_pic?: Attachment, lastName?: string, - mute_all_posts?: boolean, + comment_notifications?: string, roles?: Array, social?: UserSocial, summary?: string diff --git a/src/pages/settings/settings-email.js b/src/pages/settings/settings-email.js index e2a4846a..bd390db5 100644 --- a/src/pages/settings/settings-email.js +++ b/src/pages/settings/settings-email.js @@ -38,9 +38,16 @@ class SettingsEmailPage extends React.Component { const client = new ApiClient(API_HOST); const triggers = new ActionsTrigger(client, this.props.dispatch); + let comment_notifications; + if (this.form.mute_all_posts.checked) { + comment_notifications = 'off'; + } else { + comment_notifications = 'on'; + } + await triggers.updateUserInfo({ more: { - mute_all_posts: this.form.mute_all_posts.checked, + comment_notifications } }); }; diff --git a/tasks.js b/tasks.js index 1a5e95bd..a8f1da72 100644 --- a/tasks.js +++ b/tasks.js @@ -120,7 +120,7 @@ export default function startServer(/*params*/) { .map(subscriber => subscriber.attributes); for (const subscriber of subscribers) { - if (!subscriber.more.mute_all_posts && commentAuthor.id !== subscriber.id) { + if (subscriber.more.comment_notifications !== 'off' && commentAuthor.id !== subscriber.id) { queue.create('new-comment-email', { comment: comment.attributes, commentAuthor: commentAuthor.attributes, From acbeeb0d9639f191e5f314c9e6dd62af33ab9b68 Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Sun, 17 Dec 2017 23:20:08 +0200 Subject: [PATCH 03/13] Remove dev comment --- src/api/controllers/misc.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/api/controllers/misc.js b/src/api/controllers/misc.js index 668bc615..c3776382 100644 --- a/src/api/controllers/misc.js +++ b/src/api/controllers/misc.js @@ -99,8 +99,6 @@ export async function getRecentlyUsedTags(ctx) { const yesterday = new Date(); yesterday.setHours(yesterday.getHours() - 24); - // Count fails prob because of the tag models that are used for couting posts (or not) - return { hashtags: { entries: await Hashtag.getRecentlyUsed({ limit: 5 }).fetch(), From 6ca160c6be7f2436fcaf77e3bed976d8bb30042c Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Mon, 25 Dec 2017 00:57:17 +0200 Subject: [PATCH 04/13] Refactor part of email templates to use includes --- src/email-templates/index.js | 97 +++++-- src/email-templates/new-comment.ejs | 33 +++ src/email-templates/new-comments.ejs | 35 +++ src/email-templates/new_comment.ejs | 262 ------------------ src/email-templates/partials/comment.ejs | 18 ++ .../partials/common-styles.ejs | 201 ++++++++++++++ src/email-templates/partials/footer.ejs | 20 ++ src/email-templates/partials/post.ejs | 34 +++ 8 files changed, 409 insertions(+), 291 deletions(-) create mode 100644 src/email-templates/new-comment.ejs create mode 100644 src/email-templates/new-comments.ejs delete mode 100644 src/email-templates/new_comment.ejs create mode 100644 src/email-templates/partials/comment.ejs create mode 100644 src/email-templates/partials/common-styles.ejs create mode 100644 src/email-templates/partials/footer.ejs create mode 100644 src/email-templates/partials/post.ejs diff --git a/src/email-templates/index.js b/src/email-templates/index.js index b43c68f8..2a394128 100644 --- a/src/email-templates/index.js +++ b/src/email-templates/index.js @@ -18,6 +18,7 @@ import { renderFile } from 'ejs'; import { promisify } from 'bluebird'; import moment from 'moment'; +import { get } from 'lodash'; import { getUrl } from '../utils/urlGenerator'; import { API_HOST, URL_NAMES } from '../config'; @@ -53,39 +54,77 @@ export async function renderWelcomeTemplate(dateObject, username, email) { } export async function renderNewCommentTemplate(comment, commentAuthor, post, postAuthor) { - let authorAvatarUrl; - if (commentAuthor.more && commentAuthor.more.avatar && commentAuthor.more.avatar.url) { - authorAvatarUrl = commentAuthor.more.avatar.url; - } else { - authorAvatarUrl = `http://www.gravatar.com/avatar/${commentAuthor.gravatarHash}?s=17&r=g&d=retro`; - } - - let userAvatarUrl; - if (postAuthor.more && postAuthor.more.avatar && postAuthor.more.avatar.url) { - userAvatarUrl = postAuthor.more.avatar.url; - } else { - userAvatarUrl = `http://www.gravatar.com/avatar/${postAuthor.gravatarHash}?s=36&r=g&d=retro`; - } - const context = { host: API_HOST, - comment: { - text: comment.text, - date: moment(comment.created_at).format('Do [of] MMMM YYYY') - }, - commentAuthor: { - name: `${commentAuthor.more.firstName} ${commentAuthor.more.lastName}`, - url: API_HOST + getUrl(URL_NAMES.USER, { username: commentAuthor.username }), - avatarUrl: authorAvatarUrl - }, post: { - url: API_HOST + getUrl(URL_NAMES.POST, { uuid: comment.post_id }), - title: post.more.pageTitle + url: getPostUrl(post), + title: post.more.pageTitle, + author: { + avatarUrl: getUserAvatarUrl(postAuthor, { size: 36 }) + }, + comments: [{ + text: comment.text, + date: moment(comment.created_at).format('Do [of] MMMM YYYY'), + author: { + name: getUserName(commentAuthor), + url: getUserUrl(commentAuthor), + avatarUrl: getUserAvatarUrl(commentAuthor, { size: 17 }) + } + }], }, - postAuthor: { - avatarUrl: userAvatarUrl - } }; - return await renderFileAsync(`${__dirname}/new_comment.ejs`, context); + return await renderFileAsync(`${__dirname}/new-comment.ejs`, context); +} + +export async function renderNewCommentsTemplate({ posts, since }) { + posts = posts.map(post => ({ + id: post.id, + title: post.more.pageTitle, + url: getPostUrl(post), + author: { + avatarUrl: getUserAvatarUrl(post.user, { size: 36 }), + }, + comments: post.comments.map(comment => ({ + text: comment.text, + date: moment(comment.created_at).format('Do [of] MMMM YYYY'), + author: { + name: getUserName(comment.user), + url: getUserUrl(comment.user), + avatarUrl: getUserAvatarUrl(comment.user, { size: 17 }), + } + })) + })); + + return await renderFileAsync(`${__dirname}/new-comments.ejs`, { + host: API_HOST, + posts, + since, + }); +} + +function getUserAvatarUrl(user, { size }) { + if (get(user, 'more.avatar.url')) { + return user.more.avatar.url; + } + + return `http://www.gravatar.com/avatar/${user.gravatarHash}?s=${size}&r=g&d=retro`; +} + +function getUserUrl(user) { + return `${API_HOST}${getUrl(URL_NAMES.USER, { username: user.username })}`; +} + +function getPostUrl(post) { + return `${API_HOST}${getUrl(URL_NAMES.POST, { uuid: post.id })}`; +} + +function getUserName(user) { + const more = user.more; + + if (more && 'firstName' in more && 'lastName' in more) { + return `${more.firstName} ${more.lastName}`; + } + + return user.username; } diff --git a/src/email-templates/new-comment.ejs b/src/email-templates/new-comment.ejs new file mode 100644 index 00000000..df7bbd0c --- /dev/null +++ b/src/email-templates/new-comment.ejs @@ -0,0 +1,33 @@ + + + + + New Comment on LibertySoil.org + + <%- include('partials/common-styles') %> + + + + + + + + + + + <%- include('partials/footer', { host }) %> +
+ + + + + +
+
+ <%- include('partials/post', { post, host }) %> +
+ + + diff --git a/src/email-templates/new-comments.ejs b/src/email-templates/new-comments.ejs new file mode 100644 index 00000000..b1eb17ae --- /dev/null +++ b/src/email-templates/new-comments.ejs @@ -0,0 +1,35 @@ + + + + + New Comments on LibertySoil.org + + <%- include('partials/common-styles') %> + + + + + + + + + + + <%- include('partials/footer', { host }) %> +
+ + + + + +
+
+ <% posts.forEach(function (post) { %> +
+ <%- include('partials/post', { post, host }) %> + <% }); %> +
+ + diff --git a/src/email-templates/new_comment.ejs b/src/email-templates/new_comment.ejs deleted file mode 100644 index 35efd874..00000000 --- a/src/email-templates/new_comment.ejs +++ /dev/null @@ -1,262 +0,0 @@ - - - - - New Comment on LibertySoil.org - - - - - - - - - - - - - - -
- - - - - - -
-
- -
-
-
-

<%= comment.text %>

- - - - - -
-
- -
-
- -
<%= comment.date %>
-
-
 
-

Commented your post: <%= post.title %>

-
 
-
 
-

Click here to mute this thread (stop receiving email notifications for this post only).

- -
- - - - - - - - - -
-

We never spam.
If this email was sent to you in error — please let us know, forward it to info@libertysoil.org

-
-
- - diff --git a/src/email-templates/partials/comment.ejs b/src/email-templates/partials/comment.ejs new file mode 100644 index 00000000..d1be5748 --- /dev/null +++ b/src/email-templates/partials/comment.ejs @@ -0,0 +1,18 @@ + + +

<%= comment.text %>

+ + + + + +
+
+ +
+
+ +
<%= comment.date %>
+
+ + diff --git a/src/email-templates/partials/common-styles.ejs b/src/email-templates/partials/common-styles.ejs new file mode 100644 index 00000000..c33f018e --- /dev/null +++ b/src/email-templates/partials/common-styles.ejs @@ -0,0 +1,201 @@ + diff --git a/src/email-templates/partials/footer.ejs b/src/email-templates/partials/footer.ejs new file mode 100644 index 00000000..c1ea037b --- /dev/null +++ b/src/email-templates/partials/footer.ejs @@ -0,0 +1,20 @@ + + + + + + + + + + + +
+

We never spam.
If this email was sent to you in error — please let us know, forward it to info@libertysoil.org

+
+ + diff --git a/src/email-templates/partials/post.ejs b/src/email-templates/partials/post.ejs new file mode 100644 index 00000000..a059d3f2 --- /dev/null +++ b/src/email-templates/partials/post.ejs @@ -0,0 +1,34 @@ + + + + + + + +
+ + + + + +
+
+ + + +
+
+ +
+ Click here to mute this thread (stop receiving email notifications for this post only). +
+
+
+ + <% post.comments.forEach(function (comment) { %> + <%- include('comment', { comment })%> + <% }); %> +
+
From 9dd1219f63c7993798e67fbcb2620ad45356124c Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Mon, 25 Dec 2017 01:10:38 +0200 Subject: [PATCH 05/13] Add email templates preview for dev env --- src/api/controllers/dev/emailTemplates.js | 66 +++++++++++++++++++++++ src/api/routing.js | 4 ++ 2 files changed, 70 insertions(+) create mode 100644 src/api/controllers/dev/emailTemplates.js diff --git a/src/api/controllers/dev/emailTemplates.js b/src/api/controllers/dev/emailTemplates.js new file mode 100644 index 00000000..2ae5a25c --- /dev/null +++ b/src/api/controllers/dev/emailTemplates.js @@ -0,0 +1,66 @@ +import path from 'path'; +import faker from 'faker'; +import moment from 'moment'; +import md5 from 'md5'; +import { renderFile } from 'ejs'; +import { promisify } from 'bluebird'; +import { API_HOST } from '../../../config'; + +const renderFileAsync = promisify(renderFile); + +const commonTemplateParams = { + host: API_HOST +}; + +function post({ numComments } = { numComments: 1 }) { + return { + id: faker.random.uuid(), + title: faker.name.title(), + url: `https://www.libertysoil.com/post/${faker.random.uuid()}`, + author: user(), + comments: Array.apply(null, Array(numComments)).map(() => comment()), + }; +} + +function user({ avatarSize } = { avatarSize: 36 }) { + return { + name: faker.name.findName(), + url: `https://www.libertysoil.com/u/${faker.random.uuid()}`, + avatarUrl: `http://www.gravatar.com/avatar/${md5(faker.internet.email().toLowerCase())}?s=${avatarSize}&r=g&d=retro`, + }; +} + +function comment() { + return { + text: faker.lorem.paragraph(), + date: moment(faker.date.recent()).format('Do [of] MMMM YYYY'), + author: user({ avatarSize: 17 }), + }; +} + +const templateParams = { + 'new-comments.ejs': { + posts: [ + post({ numComments: 3 }), + post({ numComments: 1 }), + post({ numComments: 4 }), + ] + }, + 'new-comment.ejs': { + post: post(), + } +}; + +export async function renderEmailTemplate(ctx) { + const filePath = path.join('src/email-templates', ctx.params.name); + + try { + ctx.body = await renderFileAsync(filePath, { + ...commonTemplateParams, + ...templateParams[ctx.params.name], + }); + } catch (e) { + console.error(e); // eslint-disable-line no-console + ctx.body = e.stack; + } +} diff --git a/src/api/routing.js b/src/api/routing.js index 05e64e35..2c251af4 100644 --- a/src/api/routing.js +++ b/src/api/routing.js @@ -189,5 +189,9 @@ export function initApi(bookshelf) { api.get('/locale/:lang_code', misc.getLocale); + if (process.env.NODE_ENV !== 'production') { + api.get('/email-template/:name', require('./controllers/dev/emailTemplates').renderEmailTemplate); + } + return api.routes(); } From 977b8f3560a930db4559cd6807d9a9663b4ec2ef Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Mon, 25 Dec 2017 01:22:24 +0200 Subject: [PATCH 06/13] Fix incorrect gravatar hash for some cases Gravatar requires email to be lower-cased before hashing. --- src/api/db/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/db/index.js b/src/api/db/index.js index 955237ff..fcf3c09e 100644 --- a/src/api/db/index.js +++ b/src/api/db/index.js @@ -110,7 +110,7 @@ export function initBookshelfFromKnex(knex) { if (!email) { return ''; } - return md5(email); + return md5(email.toLowerCase()); }, fullName() { const more = this.get('more'); From a8a645175555cd71cf6408f189890806d8ca0163 Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Sun, 7 Jan 2018 22:01:56 +0200 Subject: [PATCH 07/13] Add migration for comment notifications settings - Replaced `users.more.mute_all_posts` with `users.more.comment_notification`. - Initialized `users.more.last_comment_notification_at` for each user. --- migrations/20180107210900_users.js | 53 ++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 migrations/20180107210900_users.js diff --git a/migrations/20180107210900_users.js b/migrations/20180107210900_users.js new file mode 100644 index 00000000..225d5968 --- /dev/null +++ b/migrations/20180107210900_users.js @@ -0,0 +1,53 @@ +export async function up(knex) { + // Replace `mute_all_posts` with `comment_notifications` + // Initialize`more.last_comment_notification_at` for each user. + const users = await knex('users') + .select('id', 'more'); + + for (const user of users) { + if (!user.more) { + user.more = {}; + } + + let comment_notifications = 'on'; + if (user.more.mute_all_posts) { + comment_notifications = 'off'; + } + + const more = { + ...users.more, + comment_notifications, + last_comment_notification_at: new Date().toJSON() + }; + + delete more.mute_all_posts; + + await knex('users').where('id', user.id).update({ more }); + } +} + +export async function down(knex) { + const users = await knex('users') + .select('id', 'more'); + + for (const user of users) { + if (!user.more) { + user.more = {}; + } + + let mute_all_posts = false; + if (user.more.comment_notifications === 'off') { + mute_all_posts = false; + } + + const more = { + ...users.more, + mute_all_posts, + }; + + delete more.last_comment_notification_at; + delete more.comment_notifications; + + await knex('users').where('id', user.id).update({ more }); + } +} From 134687c3b91ef1a77fd4e3bb5810529f122cd166 Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Sun, 7 Jan 2018 22:06:06 +0200 Subject: [PATCH 08/13] Add node-schedule tasks for comment notifications --- tasks.js | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 2 deletions(-) diff --git a/tasks.js b/tasks.js index a8f1da72..a4e5c1c0 100644 --- a/tasks.js +++ b/tasks.js @@ -17,9 +17,16 @@ */ import kueLib from 'kue'; import schedule from 'node-schedule'; +import { values, isEmpty } from 'lodash'; import config from './config'; -import { renderVerificationTemplate, renderResetTemplate, renderWelcomeTemplate, renderNewCommentTemplate } from './src/email-templates/index'; +import { + renderVerificationTemplate, + renderResetTemplate, + renderWelcomeTemplate, + renderNewCommentTemplate, + renderNewCommentsTemplate +} from './src/email-templates/index'; import { sendEmail } from './src/utils/email'; import { API_HOST, API_URL_PREFIX } from './src/config'; import dbConfig from './knexfile'; // eslint-disable-line import/default @@ -30,6 +37,81 @@ const dbEnv = process.env.DB_ENV || 'development'; const knexConfig = dbConfig[dbEnv]; const bookshelf = initBookshelf(knexConfig); const knex = bookshelf.knex; +const User = bookshelf.model('User'); + + +export async function sendCommentNotifications(queue) { + function processQueryResult(rows) { + const postsObj = rows.reduce((acc, cur) => { + const postId = cur.post.id; + + if (!acc[postId]) { + acc[postId] = cur.post; + acc[postId].comments = []; + acc[postId].user = cur.post_author; + } + + const comment = cur.comment; + comment.user = new User(cur.comment_author).toJSON(); + acc[postId].comments.push(comment); + + return acc; + }, {}); + + return values(postsObj); + } + + try { + // TODO: Optimize for large number of users. + const users = (await User.collection().query(qb => { + qb.whereRaw(`more->>'comment_notifications' in ('weekly', 'daily')`); + }).fetch()).toArray(); + + for (const user of users) { + const query = knex('post_subscriptions') + .select(knex.raw(` + to_json(posts.*) as post, + to_json(comments.*) as comment, + to_json(post_authors.*) as post_author, + to_json(comment_authors.*) as comment_author + `)) + .join('comments', 'comments.post_id', 'post_subscriptions.post_id') + .join('posts', 'posts.id', 'post_subscriptions.post_id') + .joinRaw('inner join users as post_authors on post_authors.id = posts.user_id') + .joinRaw('inner join users as comment_authors on comment_authors.id = comments.user_id') + .whereNot('comment_authors.id', user.id) // ignore user's own comments + .where('post_subscriptions.user_id', user.id) + .orderBy('comments.created_at', 'asc'); + + let since; + if (user.get('more').last_comment_notification_at) { + since = user.get('more').last_comment_notification_at; + } else { + // A default `since` for an exceptional ocasion. + since = new Date(); + since.setHours(-24); + } + + query.where('comments.created_at', '>', since); + + const posts = processQueryResult(await query); + + if (!isEmpty(posts)) { + queue.createQueue('new-comments-email', { posts, subscriber: { email: user.get('email') }, since }); + + // update the date of the latest delivered notification + user.save({ + more: { + ...user.get('more'), + last_comment_notification_at: new Date().toJSON() + } + }, { patch: true }); + } + } + } catch (e) { + console.error('Failed sending comment notifications: ', e); // eslint-disable-line no-console + } +} export default function startServer(/*params*/) { const queue = kueLib.createQueue(config.kue); @@ -67,6 +149,12 @@ export default function startServer(/*params*/) { } }); + // Daily e-mail notification delivery + schedule.scheduleJob('0 0 * * *', sendCommentNotifications.bind(null, queue)); + + // Weekly e-mail notification delivery + schedule.scheduleJob('0 0 * * 0', sendCommentNotifications.bind(null, queue)); + queue.on('error', (err) => { process.stderr.write(`${err.message}\n`); }); @@ -120,7 +208,8 @@ export default function startServer(/*params*/) { .map(subscriber => subscriber.attributes); for (const subscriber of subscribers) { - if (subscriber.more.comment_notifications !== 'off' && commentAuthor.id !== subscriber.id) { + // Only for users with enabled per-comment notifications. + if (subscriber.more.comment_notifications === 'on' && commentAuthor.id !== subscriber.id) { queue.create('new-comment-email', { comment: comment.attributes, commentAuthor: commentAuthor.attributes, @@ -154,5 +243,22 @@ export default function startServer(/*params*/) { } }); + queue.process('new-comments-email', async function (job, done) { + try { + const { + posts, + since, + subscriber + } = job.data; + + const html = await renderNewCommentsTemplate({ posts, since }); + await sendEmail('New Comments on LibertySoil.org', html, subscriber.email); + + done(); + } catch (e) { + done(e); + } + }); + process.stdout.write(`Job service started\n`); } From 7ddfc5c936ef525048a23202f98470810b975b25 Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Sun, 7 Jan 2018 23:22:09 +0200 Subject: [PATCH 09/13] Refactor email template functions to use pre-compiled templates --- src/email-templates/index.js | 165 +++++++++++++++++++---------------- tasks.js | 34 ++++---- 2 files changed, 107 insertions(+), 92 deletions(-) diff --git a/src/email-templates/index.js b/src/email-templates/index.js index 2a394128..b9a287a3 100644 --- a/src/email-templates/index.js +++ b/src/email-templates/index.js @@ -15,92 +15,119 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ -import { renderFile } from 'ejs'; +import { readFile } from 'fs'; +import path from 'path'; +import { compile } from 'ejs'; import { promisify } from 'bluebird'; import moment from 'moment'; import { get } from 'lodash'; - import { getUrl } from '../utils/urlGenerator'; import { API_HOST, URL_NAMES } from '../config'; -const renderFileAsync = promisify(renderFile); +const readFileAsync = promisify(readFile); -export async function renderResetTemplate(dateObject, username, email, confirmationLink) { - const date = moment(dateObject).format('Do [of] MMMM YYYY'); +export class EmailTemplates { + templateCache = {}; - return await renderFileAsync( - `${__dirname}/reset.ejs`, - { confirmationLink, date, email, host: API_HOST, username } - ); -} + async getTemplate(fileName) { + let template = this.templateCache[fileName]; + if (!template) { + const filePath = path.join(__dirname, fileName); + const text = await readFileAsync(filePath, 'utf8'); + template = this.templateCache[fileName] = compile(text, { + cache: true, + filename: filePath, + }); + } -export async function renderVerificationTemplate(dateObject, username, email, confirmationLink) { - const date = moment(dateObject).format('Do [of] MMMM YYYY'); + return template; + } - return await renderFileAsync( - `${__dirname}/verification.ejs`, - { confirmationLink, date, email, host: API_HOST, username } - ); -} + async renderTemplate(filename, data) { + return (await this.getTemplate(filename))(data); + } -export async function renderWelcomeTemplate(dateObject, username, email) { - const date = moment(dateObject).format('Do [of] MMMM YYYY'); + async renderResetTemplate(dateObject, username, email, confirmationLink) { + const date = moment(dateObject).format('Do [of] MMMM YYYY'); - return await renderFileAsync( - `${__dirname}/welcome.ejs`, - { date, email, host: API_HOST, username } - ); -} + return await this.renderTemplate( + `reset.ejs`, + { confirmationLink, date, email, host: API_HOST, username } + ); + } -export async function renderNewCommentTemplate(comment, commentAuthor, post, postAuthor) { - const context = { - host: API_HOST, - post: { - url: getPostUrl(post), + async renderVerificationTemplate(dateObject, username, email, confirmationLink) { + const date = moment(dateObject).format('Do [of] MMMM YYYY'); + + return await this.renderTemplate( + `verification.ejs`, + { confirmationLink, date, email, host: API_HOST, username } + ); + } + + async renderWelcomeTemplate(dateObject, username, email) { + const date = moment(dateObject).format('Do [of] MMMM YYYY'); + + return await this.renderTemplate( + `welcome.ejs`, + { date, email, host: API_HOST, username } + ); + } + + async renderNewCommentTemplate({ post }) { + const commenent = post.comments[0]; + const context = { + host: API_HOST, + post: { + url: getPostUrl(post), + title: post.more.pageTitle, + author: { + avatarUrl: getUserAvatarUrl(post.user, { size: 36 }) + }, + comments: [{ + text: commenent.text, + date: moment(commenent.created_at).format('Do [of] MMMM YYYY'), + author: { + name: commenent.user.fullName, + url: getUserUrl(commenent.user), + avatarUrl: getUserAvatarUrl(commenent.user, { size: 17 }) + } + }], + }, + }; + + return await this.renderTemplate(`new-comment.ejs`, context); + } + + async renderNewCommentsTemplate({ posts, since }) { + posts = posts.map(post => ({ + id: post.id, title: post.more.pageTitle, + url: getPostUrl(post), author: { - avatarUrl: getUserAvatarUrl(postAuthor, { size: 36 }) + avatarUrl: getUserAvatarUrl(post.user, { size: 36 }), }, - comments: [{ + comments: post.comments.map(comment => ({ text: comment.text, date: moment(comment.created_at).format('Do [of] MMMM YYYY'), author: { - name: getUserName(commentAuthor), - url: getUserUrl(commentAuthor), - avatarUrl: getUserAvatarUrl(commentAuthor, { size: 17 }) + name: comment.user.fullName, + url: getUserUrl(comment.user), + avatarUrl: getUserAvatarUrl(comment.user, { size: 17 }), } - }], - }, - }; - - return await renderFileAsync(`${__dirname}/new-comment.ejs`, context); -} - -export async function renderNewCommentsTemplate({ posts, since }) { - posts = posts.map(post => ({ - id: post.id, - title: post.more.pageTitle, - url: getPostUrl(post), - author: { - avatarUrl: getUserAvatarUrl(post.user, { size: 36 }), - }, - comments: post.comments.map(comment => ({ - text: comment.text, - date: moment(comment.created_at).format('Do [of] MMMM YYYY'), - author: { - name: getUserName(comment.user), - url: getUserUrl(comment.user), - avatarUrl: getUserAvatarUrl(comment.user, { size: 17 }), + })) + })); + + return await this.renderTemplate( + `new-comments.ejs`, + { + host: API_HOST, + since: moment(since).format('Do [of] MMMM YYYY'), + posts, } - })) - })); - - return await renderFileAsync(`${__dirname}/new-comments.ejs`, { - host: API_HOST, - posts, - since, - }); + ); + } } function getUserAvatarUrl(user, { size }) { @@ -118,13 +145,3 @@ function getUserUrl(user) { function getPostUrl(post) { return `${API_HOST}${getUrl(URL_NAMES.POST, { uuid: post.id })}`; } - -function getUserName(user) { - const more = user.more; - - if (more && 'firstName' in more && 'lastName' in more) { - return `${more.firstName} ${more.lastName}`; - } - - return user.username; -} diff --git a/tasks.js b/tasks.js index a4e5c1c0..0c4e4b78 100644 --- a/tasks.js +++ b/tasks.js @@ -20,13 +20,7 @@ import schedule from 'node-schedule'; import { values, isEmpty } from 'lodash'; import config from './config'; -import { - renderVerificationTemplate, - renderResetTemplate, - renderWelcomeTemplate, - renderNewCommentTemplate, - renderNewCommentsTemplate -} from './src/email-templates/index'; +import { EmailTemplates } from './src/email-templates/index'; import { sendEmail } from './src/utils/email'; import { API_HOST, API_URL_PREFIX } from './src/config'; import dbConfig from './knexfile'; // eslint-disable-line import/default @@ -48,7 +42,7 @@ export async function sendCommentNotifications(queue) { if (!acc[postId]) { acc[postId] = cur.post; acc[postId].comments = []; - acc[postId].user = cur.post_author; + acc[postId].user = new User(cur.post_author).toJSON(); } const comment = cur.comment; @@ -115,6 +109,7 @@ export async function sendCommentNotifications(queue) { export default function startServer(/*params*/) { const queue = kueLib.createQueue(config.kue); + const emailTemplates = new EmailTemplates(); // Every 10 minutes, update post statistics. schedule.scheduleJob('*/10 * * * *', async function () { @@ -167,7 +162,7 @@ export default function startServer(/*params*/) { } = job.data; try { - const html = await renderVerificationTemplate(new Date(), username, email, `${API_URL_PREFIX}/user/verify/${hash}`); + const html = await emailTemplates.renderVerificationTemplate(new Date(), username, email, `${API_URL_PREFIX}/user/verify/${hash}`); await sendEmail('Please confirm email Libertysoil.org', html, job.data.email); done(); } catch (e) { @@ -177,7 +172,7 @@ export default function startServer(/*params*/) { queue.process('reset-password-email', async function (job, done) { try { - const html = await renderResetTemplate(new Date(), job.data.username, job.data.email, `${API_HOST}/newpassword/${job.data.hash}`); + const html = await emailTemplates.renderResetTemplate(new Date(), job.data.username, job.data.email, `${API_HOST}/newpassword/${job.data.hash}`); await sendEmail('Reset Libertysoil.org Password', html, job.data.email); done(); } catch (e) { @@ -187,7 +182,7 @@ export default function startServer(/*params*/) { queue.process('verify-email', async function (job, done) { try { - const html = await renderWelcomeTemplate(new Date(), job.data.username, job.data.email); + const html = await emailTemplates.renderWelcomeTemplate(new Date(), job.data.username, job.data.email); await sendEmail('Welcome to Libertysoil.org', html, job.data.email); done(); } catch (e) { @@ -206,14 +201,19 @@ export default function startServer(/*params*/) { const post = comment.related('post'); const subscribers = (await post.related('subscribers').fetch()) .map(subscriber => subscriber.attributes); + const serializedComment = comment.toJSON(); + delete serializedComment.post; for (const subscriber of subscribers) { // Only for users with enabled per-comment notifications. if (subscriber.more.comment_notifications === 'on' && commentAuthor.id !== subscriber.id) { queue.create('new-comment-email', { - comment: comment.attributes, - commentAuthor: commentAuthor.attributes, - post: post.attributes, + post: { + ...post.toJSON(), + comments: [ + serializedComment + ] + }, subscriber }).priority('medium').save(); } @@ -228,13 +228,11 @@ export default function startServer(/*params*/) { queue.process('new-comment-email', async function (job, done) { try { const { - comment, - commentAuthor, post, subscriber } = job.data; - const html = await renderNewCommentTemplate(comment, commentAuthor, post, subscriber); + const html = await emailTemplates.renderNewCommentTemplate({ post }); await sendEmail('New Comment on LibertySoil.org', html, subscriber.email); done(); @@ -251,7 +249,7 @@ export default function startServer(/*params*/) { subscriber } = job.data; - const html = await renderNewCommentsTemplate({ posts, since }); + const html = await emailTemplates.renderNewCommentsTemplate({ posts, since }); await sendEmail('New Comments on LibertySoil.org', html, subscriber.email); done(); From b68229e149a397e32fed441612a9e5d8ebf3b68d Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Mon, 8 Jan 2018 01:14:15 +0200 Subject: [PATCH 10/13] Refactor email template tests --- test/unit/email-templates/email-templates.js | 181 +++++++++++++------ 1 file changed, 122 insertions(+), 59 deletions(-) diff --git a/test/unit/email-templates/email-templates.js b/test/unit/email-templates/email-templates.js index 752e3eb3..3499831a 100644 --- a/test/unit/email-templates/email-templates.js +++ b/test/unit/email-templates/email-templates.js @@ -1,97 +1,160 @@ /*eslint-env node, mocha */ +import faker from 'faker'; +import { JSDOM } from 'jsdom'; import { API_HOST } from '../../../src/config'; import { expect } from '../../../test-helpers/expect-unit'; -import { renderVerificationTemplate, renderWelcomeTemplate, renderNewCommentTemplate } from '../../../src/email-templates/index'; +import { EmailTemplates } from '../../../src/email-templates'; describe('Email templates:', function () { - it('Verification template exist', async function () { - const template = await renderVerificationTemplate(new Date()); - - expect(template, 'to be a string'); - }); - - it('Welcome template exist', async function () { - const template = await renderWelcomeTemplate(new Date()); - - expect(template, 'to be a string'); - }); - - describe('#renderNewCommentTemplate', function () { - const comment = { - text: 'Test comment text', - post_id: 1 - }; - - const commentAuthor = { - username: 'JohnDoe', - more: { - firstName: 'John', - lastName: 'Doe', - avatar: { - url: 'http://avatars.test/avatar.png' - } - } - }; + const templates = new EmailTemplates(); + describe('new-comment.ejs', function () { const post = { - id: 1, + id: '99e79372-2958-4ecb-8314-aa56a3dad326', more: { - pageTitle: 'Hello world!' - } + pageTitle: 'Post Title' + }, + user: { + username: 'post_author', + gravatarHash: '123' + }, + comments: [ + { + text: 'Comment 1 text', + created_at: new Date(2018, 0, 0).toJSON(), + user: { + fullName: 'Comment Author', + username: 'comment_author', + gravatarHash: '123' + }, + }, + ] }; - const postAuthor = { - more: { - avatar: { - url: 'http://avatars.test/avatar2.png' - } - } - }; - - let template; + let document; before(async function () { - template = await renderNewCommentTemplate(comment, commentAuthor, post, postAuthor); + const template = await templates.renderNewCommentTemplate({ post }); + document = (new JSDOM(template)).window.document; }); - it('renders link to the author', function () { + it('renders link to author', function () { expect( - template, - 'when parsed as HTML', + document.body, 'queried for first', '#comment-author-link', - 'to have attributes', { href: `${API_HOST}/user/${commentAuthor.username}` } + 'to have attributes', { href: `${API_HOST}/user/${post.comments[0].user.username}` } ); expect( - template, - 'when parsed as HTML', + document.body, 'queried for first', '#comment-author-link', - 'to have text', 'John Doe' + 'to have text', 'Comment Author' ); }); - it('renders link to the post', function () { + it('renders link to post', function () { expect( - template, - 'when parsed as HTML', + document.body, 'queried for first', '#post-link', 'to have attributes', { href: `${API_HOST}/post/${post.id}` } ); expect( - template, - 'when parsed as HTML', + document.body, 'queried for first', '#post-link', 'to have text', post.more.pageTitle ); }); - it('renders the comment', function () { + it('renders comment', () => { + expect( + document.body, + 'queried for first', '.post_comment', + 'to satisfy', + expect.it('to have text', expect.it('to contain', 'Comment 1 text')) + ); + }); + }); + + describe('new-comments.ejs', function () { + const posts = [ + { + id: '99e79372-2958-4ecb-8314-aa56a3dad326', + more: { + pageTitle: 'Post 1' + }, + user: { + fullName: faker.name.findName(), + gravatarHash: '123' + }, + comments: [ + { + text: 'Post 1 Comment 1 text', + created_at: new Date(2018, 0, 0).toJSON(), + user: { + fullName: faker.name.findName(), + gravatarHash: '123' + }, + }, + { + text: 'Post 1 Comment 2 text', + created_at: new Date(2018, 0, 0).toJSON(), + user: { + fullName: faker.name.findName(), + gravatarHash: '123' + }, + } + ] + }, + { + id: '99e79372-2958-4ecb-8314-aa56a3dad326', + more: { + pageTitle: 'Post 2' + }, + user: { + name: 'John Doe', + gravatarHash: '123' + }, + comments: [ + { + text: 'Post 2 Comment 1 text', + created_at: new Date(2018, 0, 0).toJSON(), + user: { + name: 'Commenter', + gravatarHash: '123' + }, + } + ] + }, + ]; + + let document; + + before(async function () { + const template = await templates.renderNewCommentsTemplate({ posts, since: faker.date.past() }); + document = (new JSDOM(template)).window.document; + }); + + it('renders all comments', () => { + expect( + document.body, + 'queried for', '.post_comment', + 'to satisfy', [ + expect.it('to have text', expect.it('to contain', 'Post 1 Comment 1 text')), + expect.it('to have text', expect.it('to contain', 'Post 1 Comment 2 text')), + expect.it('to have text', expect.it('to contain', 'Post 2 Comment 1 text')), + ] + ); + }); + + it('renders multiple posts', () => { expect( - template, - 'when parsed as HTML', - 'queried for first', '.page__content_text-comment', - 'to have text', comment.text + document.body, + 'queried for', '.post', + 'to satisfy', [ + expect.it('to have text', expect.it('to contain', 'Post 1')), + expect.it('to have text', expect.it('to contain', 'Post 2')), + ] ); }); }); From 03621c6e8657d58e0b30992b8f9f643fd9264de0 Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Sun, 14 Jan 2018 16:37:55 +0200 Subject: [PATCH 11/13] Set up bunyan logger for tasks --- package.json | 4 ++-- tasks.js | 57 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 31154121..d51faf86 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,9 @@ "start:_all:dev": "run-p start:server:dev start:tasks:dev", "start:_all:prod": "run-p start:server:prod start:tasks:prod", "start:server:prod": "DEV=0 NODE_ENV=production node bin/start-server.js 2>&1 | bunyan", - "start:tasks:prod": "DEV=0 NODE_ENV=production node bin/start-tasks.js", + "start:tasks:prod": "DEV=0 NODE_ENV=production node bin/start-tasks.js 2>&1 | bunyan", "start:server:dev": "DEV=1 NODE_ENV=development nodemon ./bin/start-server.js --watch ./public/server 2>&1 | bunyan", - "start:tasks:dev": "DEV=1 NODE_ENV=development nodemon ./bin/start-tasks.js --watch ./public/server", + "start:tasks:dev": "DEV=1 NODE_ENV=development nodemon ./bin/start-tasks.js --watch ./public/server 2>&1 | bunyan", "build:_all:prod": "run-p build:client:prod build:server:prod build:tasks:prod", "build:_all:dev:watch": "run-p build:client:dev:watch build:server:dev:watch build:tasks:dev:watch", "build:client:dev": "DEV=1 NODE_ENV=development webpack --config './webpack.config.client.babel.js' --colors --hide-modules --display-error-details", diff --git a/tasks.js b/tasks.js index 0c4e4b78..0e3e82f7 100644 --- a/tasks.js +++ b/tasks.js @@ -15,9 +15,12 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ +import fs, { accessSync } from 'fs'; + import kueLib from 'kue'; import schedule from 'node-schedule'; import { values, isEmpty } from 'lodash'; +import Logger, { createLogger } from 'bunyan'; import config from './config'; import { EmailTemplates } from './src/email-templates/index'; @@ -27,12 +30,47 @@ import dbConfig from './knexfile'; // eslint-disable-line import/default import initBookshelf from './src/api/db'; +const execEnv = process.env.NODE_ENV || 'development'; const dbEnv = process.env.DB_ENV || 'development'; const knexConfig = dbConfig[dbEnv]; const bookshelf = initBookshelf(knexConfig); const knex = bookshelf.knex; const User = bookshelf.model('User'); +// TODO: Logger initialization is copy-pasted from server.js. Extract into a common function. +const streams = []; + +if (execEnv !== 'test') { + streams.push({ + stream: process.stderr, + level: 'info' + }); +} + +try { + accessSync('/var/log', fs.W_OK); + + streams.push({ + type: 'rotating-file', + path: '/var/log/libertysoil-tasks.log', + level: 'warn', + period: '1d', // daily rotation + count: 3 // keep 3 back copies + }); +} catch (e) { + // do nothing +} + +const logger = createLogger({ + name: 'libertysoil-tasks', + serializers: Logger.stdSerializers, + src: true, + streams +}); + +if (execEnv === 'development') { + logger.level('debug'); +} export async function sendCommentNotifications(queue) { function processQueryResult(rows) { @@ -103,7 +141,7 @@ export async function sendCommentNotifications(queue) { } } } catch (e) { - console.error('Failed sending comment notifications: ', e); // eslint-disable-line no-console + logger.error(e, 'Failed to send comment notifications'); } } @@ -137,10 +175,9 @@ export default function startServer(/*params*/) { score = new_like_count + new_fav_count + new_comment_count; `); - // TODO: Use proper logger - console.log('Post stats updated'); // eslint-disable-line no-console + logger.info('Post stats updated'); } catch (e) { - console.error('Failed to update post stats: ', e); // eslint-disable-line no-console + logger.error(e, 'Failed to update post stats'); } }); @@ -151,7 +188,15 @@ export default function startServer(/*params*/) { schedule.scheduleJob('0 0 * * 0', sendCommentNotifications.bind(null, queue)); queue.on('error', (err) => { - process.stderr.write(`${err.message}\n`); + logger.error(err); + }); + + queue.on('job enqueue', function (id, type) { + logger.info('Job %s (id %s) queued', type, id); + }); + + queue.on('job complete', function (id, type) { + logger.info('Job %s (id %s) completed', type, id); }); queue.process('register-user-email', async function (job, done) { @@ -258,5 +303,5 @@ export default function startServer(/*params*/) { } }); - process.stdout.write(`Job service started\n`); + logger.info(`Job service started`); } From 1f3e25053645391050d5e91d4fbe43d3b6277765 Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Mon, 15 Jan 2018 01:28:09 +0200 Subject: [PATCH 12/13] Add unexpected-date --- package.json | 1 + test-helpers/expect.js | 1 + 2 files changed, 2 insertions(+) diff --git a/package.json b/package.json index d51faf86..babb8255 100644 --- a/package.json +++ b/package.json @@ -235,6 +235,7 @@ "trace": "~3.0.0", "transform-loader": "^0.2.3", "unexpected": "~10.35.0", + "unexpected-date": "^1.1.1", "unexpected-dom": "~4.0.0", "unexpected-http": "~6.0.0", "unexpected-immutable": "~0.2.6", diff --git a/test-helpers/expect.js b/test-helpers/expect.js index 59be1916..90615e68 100644 --- a/test-helpers/expect.js +++ b/test-helpers/expect.js @@ -27,6 +27,7 @@ expect.installPlugin(require('unexpected-http')); expect.installPlugin(require('unexpected-dom')); expect.installPlugin(require('unexpected-immutable')); expect.installPlugin(require('unexpected-sinon')); +expect.installPlugin(require('unexpected-date')); const subjectToRequest = (subject) => { if (isString(subject)) { From d676b5070668a8ae17b165be552462e74b7ff6ec Mon Sep 17 00:00:00 2001 From: Dmitry Vdovin Date: Mon, 15 Jan 2018 01:36:58 +0200 Subject: [PATCH 13/13] Add tests for sendCommentNotifications node-schedule task --- test/integration/tasks.js | 113 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 test/integration/tasks.js diff --git a/test/integration/tasks.js b/test/integration/tasks.js new file mode 100644 index 00000000..80df842d --- /dev/null +++ b/test/integration/tasks.js @@ -0,0 +1,113 @@ +/* + This file is a part of libertysoil.org website + Copyright (C) 2015 Loki Education (Social Enterprise) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + */ +/* eslint-env node, mocha */ +import sinon from 'sinon'; +import { fill } from 'lodash'; + +import expect from '../../test-helpers/expect'; +import { createUsers } from '../../test-helpers/factories/user'; +import { createPosts } from '../../test-helpers/factories/post'; +import { createComments } from '../../test-helpers/factories/comment'; +import { knex } from '../../test-helpers/db'; +import { sendCommentNotifications } from '../../tasks'; + + +describe('node-schedule tasks', () => { + describe('sendCommentNotifications', () => { + let posters, commenters, posts, comments; + + beforeEach(async () => { + posters = await createUsers( + fill( + Array(2), + { + more: { + comment_notifications: 'daily', + last_comment_notification_at: new Date(2000, 0, 0).toJSON(), + } + } + ) + ); + commenters = await createUsers(2); + posts = await createPosts([{ user_id: posters[0].id }, { user_id: posters[1].id }]); + comments = await createComments([ + { user_id: commenters[0].id, post_id: posts[0].id, created_at: new Date(2018, 0, 0, 1) }, + { user_id: commenters[0].id, post_id: posts[1].id, created_at: new Date(2018, 0, 0, 3) }, + { user_id: commenters[1].id, post_id: posts[0].id, created_at: new Date(2018, 0, 0, 2) }, + { user_id: commenters[1].id, post_id: posts[1].id, created_at: new Date(2018, 0, 0, 4) }, + ]); + + await posters[0].post_subscriptions().attach([posts[0].id, posts[1].id]); + await posters[1].post_subscriptions().attach([posts[0].id, posts[1].id]); + }); + + afterEach(async () => { + await knex('users').whereIn( + 'id', + [posters[0].id, posters[1].id, commenters[0].id, commenters[1].id] + ).del(); + }); + + it('adds new-comments-email task to kue queue', async () => { + const queue = { + createQueue: sinon.stub() + }; + + await sendCommentNotifications(queue); + + expect(queue.createQueue, 'to have calls satisfying', () => { + const subscribedPosts = [ + { + id: posts[0].id, + comments: [{ id: comments[0].id }, { id: comments[2].id }] + }, + { + id: posts[1].id, + comments: [{ id: comments[1].id }, { id: comments[3].id }] + } + ]; + + queue.createQueue('new-comments-email', expect.it('to satisfy', { + subscriber: { email: posters[0].get('email') }, + posts: subscribedPosts, + })); + queue.createQueue('new-comments-email', expect.it('to satisfy', { + subscriber: { email: posters[1].get('email') }, + posts: subscribedPosts, + })); + }); + }); + + + it('updates users.more.last_comment_notification_at', async () => { + const queue = { + createQueue: () => {} + }; + + const dateJustBefore = new Date(); + dateJustBefore.setHours(-1); + + await sendCommentNotifications(queue); + await posters[0].refresh(); + await posters[1].refresh(); + + expect(new Date(posters[0].get('more').last_comment_notification_at), 'to be same or after', dateJustBefore); + expect(new Date(posters[1].get('more').last_comment_notification_at), 'to be same or after', dateJustBefore); + }); + }); +});