diff --git a/backend/src/middleware/notifications/notificationsMiddleware.emails.spec.ts b/backend/src/middleware/notifications/notificationsMiddleware.emails.spec.ts new file mode 100644 index 000000000..497bf4c54 --- /dev/null +++ b/backend/src/middleware/notifications/notificationsMiddleware.emails.spec.ts @@ -0,0 +1,716 @@ +import { createTestClient } from 'apollo-server-testing' +import gql from 'graphql-tag' + +import Factory, { cleanDatabase } from '@db/factories' +import { getNeode, getDriver } from '@db/neo4j' +import { createGroupMutation, joinGroupMutation } from '@graphql/groups' +import CONFIG from '@src/config' +import createServer from '@src/server' + +CONFIG.CATEGORIES_ACTIVE = false + +const sendMailMock = jest.fn() +jest.mock('../helpers/email/sendMail', () => ({ + sendMail: () => sendMailMock(), +})) + +let server, query, mutate, authenticatedUser + +let postAuthor, groupMember + +const driver = getDriver() +const neode = getNeode() + +const mentionString = + '@group-member' + +const createPostMutation = gql` + mutation ($id: ID, $title: String!, $content: String!, $groupId: ID) { + CreatePost(id: $id, title: $title, content: $content, groupId: $groupId) { + id + title + content + } + } +` + +const createCommentMutation = gql` + mutation ($id: ID, $postId: ID!, $content: String!) { + CreateComment(id: $id, postId: $postId, content: $content) { + id + content + } + } +` + +const notificationQuery = gql` + query ($read: Boolean) { + notifications(read: $read, orderBy: updatedAt_desc) { + read + reason + createdAt + relatedUser { + id + } + from { + __typename + ... on Post { + id + content + } + ... on Comment { + id + content + } + ... on Group { + id + } + } + } + } +` + +const followUserMutation = gql` + mutation ($id: ID!) { + followUser(id: $id) { + id + } + } +` + +const markAllAsRead = async () => + mutate({ + mutation: gql` + mutation { + markAllAsRead { + id + } + } + `, + }) + +beforeAll(async () => { + await cleanDatabase() + + const createServerResult = createServer({ + context: () => { + return { + user: authenticatedUser, + neode, + driver, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, + } + }, + }) + server = createServerResult.server + const createTestClientResult = createTestClient(server) + query = createTestClientResult.query + mutate = createTestClientResult.mutate +}) + +afterAll(async () => { + await cleanDatabase() + driver.close() +}) + +describe('emails sent for notifications', () => { + beforeEach(async () => { + postAuthor = await Factory.build( + 'user', + { + id: 'post-author', + name: 'Post Author', + slug: 'post-author', + }, + { + email: 'test@example.org', + password: '1234', + }, + ) + groupMember = await Factory.build( + 'user', + { + id: 'group-member', + name: 'Group Member', + slug: 'group-member', + }, + { + email: 'group.member@example.org', + password: '1234', + }, + ) + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'public-group', + name: 'A public group', + description: 'A public group to test the notifications of mentions', + groupType: 'public', + actionRadius: 'national', + }, + }) + authenticatedUser = await groupMember.toJson() + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'public-group', + userId: 'group-member', + }, + }) + await mutate({ + mutation: followUserMutation, + variables: { id: 'post-author' }, + }) + }) + + afterEach(async () => { + await cleanDatabase() + }) + + describe('handleContentDataOfPost', () => { + describe('post-author posts into group and mentions following group-member', () => { + describe('all email notification settings are true', () => { + beforeEach(async () => { + jest.clearAllMocks() + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'post', + title: 'This is the post', + content: `Hello, ${mentionString}, my trusty follower.`, + groupId: 'public-group', + }, + }) + }) + + it('sends only one email', () => { + expect(sendMailMock).toHaveBeenCalledTimes(1) + }) + + it('sends 3 notifications', async () => { + authenticatedUser = await groupMember.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: expect.arrayContaining([ + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'post_in_group', + relatedUser: null, + }, + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'followed_user_posted', + relatedUser: null, + }, + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'mentioned_in_post', + relatedUser: null, + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('email notification for mention in post is false', () => { + beforeEach(async () => { + jest.clearAllMocks() + await groupMember.update({ emailNotificationsMention: false }) + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'post', + title: 'This is the post', + content: `Hello, ${mentionString}, my trusty follower.`, + groupId: 'public-group', + }, + }) + }) + + it('sends only one email', () => { + expect(sendMailMock).toHaveBeenCalledTimes(1) + }) + + it('sends 3 notifications', async () => { + authenticatedUser = await groupMember.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: expect.arrayContaining([ + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'post_in_group', + relatedUser: null, + }, + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'followed_user_posted', + relatedUser: null, + }, + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'mentioned_in_post', + relatedUser: null, + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('email notification for mention in post and followed users is false', () => { + beforeEach(async () => { + jest.clearAllMocks() + await groupMember.update({ emailNotificationsMention: false }) + await groupMember.update({ emailNotificationsFollowingUsers: false }) + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'post', + title: 'This is the post', + content: `Hello, ${mentionString}, my trusty follower.`, + groupId: 'public-group', + }, + }) + }) + + it('sends only one email', () => { + expect(sendMailMock).toHaveBeenCalledTimes(1) + }) + + it('sends 3 notifications', async () => { + authenticatedUser = await groupMember.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: expect.arrayContaining([ + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'post_in_group', + relatedUser: null, + }, + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'followed_user_posted', + relatedUser: null, + }, + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'mentioned_in_post', + relatedUser: null, + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('all relevant email notifications are false', () => { + beforeEach(async () => { + jest.clearAllMocks() + await groupMember.update({ emailNotificationsMention: false }) + await groupMember.update({ emailNotificationsFollowingUsers: false }) + await groupMember.update({ emailNotificationsPostInGroup: false }) + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'post', + title: 'This is the post', + content: `Hello, ${mentionString}, my trusty follower.`, + groupId: 'public-group', + }, + }) + }) + + it('sends NO email', () => { + expect(sendMailMock).not.toHaveBeenCalled() + }) + + it('sends 3 notifications', async () => { + authenticatedUser = await groupMember.toJson() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: expect.arrayContaining([ + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'post_in_group', + relatedUser: null, + }, + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'followed_user_posted', + relatedUser: null, + }, + { + createdAt: expect.any(String), + from: { + __typename: 'Post', + id: 'post', + content: + 'Hello, @group-member, my trusty follower.', + }, + read: false, + reason: 'mentioned_in_post', + relatedUser: null, + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + }) + + describe('handleContentDataOfComment', () => { + describe('user comments post and author responds with in a comment and mentions the user', () => { + describe('all email notification settings are true', () => { + beforeEach(async () => { + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'post', + title: 'This is the post', + content: `Hello, ${mentionString}, my trusty follower.`, + groupId: 'public-group', + }, + }) + authenticatedUser = await groupMember.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + id: 'comment', + content: `Hello, my beloved author.`, + postId: 'post', + }, + }) + await markAllAsRead() + jest.clearAllMocks() + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + id: 'comment-2', + content: `Hello, ${mentionString}, my beloved follower.`, + postId: 'post', + }, + }) + }) + + it('sends only one email', () => { + expect(sendMailMock).toHaveBeenCalledTimes(1) + }) + + it('sends 2 notifications', async () => { + authenticatedUser = await groupMember.toJson() + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toMatchObject({ + data: { + notifications: expect.arrayContaining([ + { + createdAt: expect.any(String), + from: { + __typename: 'Comment', + id: 'comment-2', + content: + 'Hello, @group-member, my beloved follower.', + }, + read: false, + reason: 'commented_on_post', + relatedUser: null, + }, + { + createdAt: expect.any(String), + from: { + __typename: 'Comment', + id: 'comment-2', + content: + 'Hello, @group-member, my beloved follower.', + }, + read: false, + reason: 'mentioned_in_comment', + relatedUser: null, + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('email notification commented on post is false', () => { + beforeEach(async () => { + await groupMember.update({ emailNotificationsCommentOnObservedPost: false }) + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'post', + title: 'This is the post', + content: `Hello, ${mentionString}, my trusty follower.`, + groupId: 'public-group', + }, + }) + authenticatedUser = await groupMember.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + id: 'comment', + content: `Hello, my beloved author.`, + postId: 'post', + }, + }) + await markAllAsRead() + jest.clearAllMocks() + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + id: 'comment-2', + content: `Hello, ${mentionString}, my beloved follower.`, + postId: 'post', + }, + }) + }) + + it('sends only one email', () => { + expect(sendMailMock).toHaveBeenCalledTimes(1) + }) + + it('sends 2 notifications', async () => { + authenticatedUser = await groupMember.toJson() + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toMatchObject({ + data: { + notifications: expect.arrayContaining([ + { + createdAt: expect.any(String), + from: { + __typename: 'Comment', + id: 'comment-2', + content: + 'Hello, @group-member, my beloved follower.', + }, + read: false, + reason: 'commented_on_post', + relatedUser: null, + }, + { + createdAt: expect.any(String), + from: { + __typename: 'Comment', + id: 'comment-2', + content: + 'Hello, @group-member, my beloved follower.', + }, + read: false, + reason: 'mentioned_in_comment', + relatedUser: null, + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('all relevant email notifications are false', () => { + beforeEach(async () => { + await groupMember.update({ emailNotificationsCommentOnObservedPost: false }) + await groupMember.update({ emailNotificationsMention: false }) + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'post', + title: 'This is the post', + content: `Hello, ${mentionString}, my trusty follower.`, + groupId: 'public-group', + }, + }) + authenticatedUser = await groupMember.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + id: 'comment', + content: `Hello, my beloved author.`, + postId: 'post', + }, + }) + await markAllAsRead() + jest.clearAllMocks() + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + id: 'comment-2', + content: `Hello, ${mentionString}, my beloved follower.`, + postId: 'post', + }, + }) + }) + + it('sends NO email', () => { + expect(sendMailMock).not.toHaveBeenCalled() + }) + + it('sends 2 notifications', async () => { + authenticatedUser = await groupMember.toJson() + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toMatchObject({ + data: { + notifications: expect.arrayContaining([ + { + createdAt: expect.any(String), + from: { + __typename: 'Comment', + id: 'comment-2', + content: + 'Hello, @group-member, my beloved follower.', + }, + read: false, + reason: 'commented_on_post', + relatedUser: null, + }, + { + createdAt: expect.any(String), + from: { + __typename: 'Comment', + id: 'comment-2', + content: + 'Hello, @group-member, my beloved follower.', + }, + read: false, + reason: 'mentioned_in_comment', + relatedUser: null, + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/notifications/notificationsMiddleware.mentions-in-groups.spec.ts b/backend/src/middleware/notifications/notificationsMiddleware.mentions-in-groups.spec.ts index 8bddff733..49763b4c3 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.mentions-in-groups.spec.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.mentions-in-groups.spec.ts @@ -124,7 +124,7 @@ describe('mentions in groups', () => { slug: 'post-author', }, { - email: 'test@example.org', + email: 'post.author@example.org', password: '1234', }, ) @@ -136,7 +136,7 @@ describe('mentions in groups', () => { slug: 'group-member', }, { - email: 'test2@example.org', + email: 'group.member@example.org', password: '1234', }, ) @@ -148,7 +148,7 @@ describe('mentions in groups', () => { slug: 'pending-member', }, { - email: 'test3@example.org', + email: 'pending.member@example.org', password: '1234', }, ) @@ -160,7 +160,7 @@ describe('mentions in groups', () => { slug: 'no-member', }, { - email: 'test4@example.org', + email: 'no.member@example.org', password: '1234', }, ) @@ -347,8 +347,8 @@ describe('mentions in groups', () => { }) }) - it('sends 3 emails, 2 mentions and 1 post in group', () => { - expect(sendMailMock).toHaveBeenCalledTimes(5) + it('sends 3 emails, one for each user', () => { + expect(sendMailMock).toHaveBeenCalledTimes(3) }) }) @@ -443,8 +443,8 @@ describe('mentions in groups', () => { }) }) - it('sends 2 emails, one mention and one post in group', () => { - expect(sendMailMock).toHaveBeenCalledTimes(2) + it('sends only 1 email', () => { + expect(sendMailMock).toHaveBeenCalledTimes(1) }) }) @@ -539,8 +539,8 @@ describe('mentions in groups', () => { }) }) - it('sends 2 emails, one mention and one post in group', () => { - expect(sendMailMock).toHaveBeenCalledTimes(2) + it('sends only 1 email', () => { + expect(sendMailMock).toHaveBeenCalledTimes(1) }) }) diff --git a/backend/src/middleware/notifications/notificationsMiddleware.ts b/backend/src/middleware/notifications/notificationsMiddleware.ts index 27216988f..4fb8cba93 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.ts @@ -40,7 +40,12 @@ const queryNotificationEmails = async (context, notificationUserIds) => { } } -const publishNotifications = async (context, promises, emailNotificationSetting: string) => { +const publishNotifications = async ( + context, + promises, + emailNotificationSetting: string, + emailsSent: string[] = [], +): Promise => { let notifications = await Promise.all(promises) notifications = notifications.flat() const notificationsEmailAddresses = await queryNotificationEmails( @@ -51,7 +56,8 @@ const publishNotifications = async (context, promises, emailNotificationSetting: pubsub.publish(NOTIFICATION_ADDED, { notificationAdded }) if ( (notificationAdded.to[emailNotificationSetting] ?? true) && - !isUserOnline(notificationAdded.to) + !isUserOnline(notificationAdded.to) && + !emailsSent.includes(notificationsEmailAddresses[index].email) ) { sendMail( notificationTemplate({ @@ -59,8 +65,10 @@ const publishNotifications = async (context, promises, emailNotificationSetting: variables: { notification: notificationAdded }, }), ) + emailsSent.push(notificationsEmailAddresses[index].email) } }) + return emailsSent } const handleJoinGroup = async (resolve, root, args, context, resolveInfo) => { @@ -120,20 +128,24 @@ const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo const idsOfUsers = extractMentionedUsers(args.content) const post = await resolve(root, args, context, resolveInfo) if (post) { - await publishNotifications( + const sentEmails: string[] = await publishNotifications( context, [notifyUsersOfMention('Post', post.id, idsOfUsers, 'mentioned_in_post', context)], 'emailNotificationsMention', ) - await publishNotifications( - context, - [notifyFollowingUsers(post.id, groupId, context)], - 'emailNotificationsFollowingUsers', + sentEmails.concat( + await publishNotifications( + context, + [notifyFollowingUsers(post.id, groupId, context)], + 'emailNotificationsFollowingUsers', + sentEmails, + ), ) await publishNotifications( context, [notifyGroupMembersOfNewPost(post.id, groupId, context)], 'emailNotificationsPostInGroup', + sentEmails, ) } return post @@ -145,7 +157,7 @@ const handleContentDataOfComment = async (resolve, root, args, context, resolveI const comment = await resolve(root, args, context, resolveInfo) const [postAuthor] = await postAuthorOfComment(comment.id, { context }) idsOfMentionedUsers = idsOfMentionedUsers.filter((id) => id !== postAuthor.id) - await publishNotifications( + const sentEmails: string[] = await publishNotifications( context, [ notifyUsersOfMention( @@ -158,13 +170,12 @@ const handleContentDataOfComment = async (resolve, root, args, context, resolveI ], 'emailNotificationsMention', ) - await publishNotifications( context, [notifyUsersOfComment('Comment', comment.id, 'commented_on_post', context)], 'emailNotificationsCommentOnObservedPost', + sentEmails, ) - return comment }