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
}