diff --git a/backend/src/db/factories.ts b/backend/src/db/factories.ts index 86f51a259..e09a4f921 100644 --- a/backend/src/db/factories.ts +++ b/backend/src/db/factories.ts @@ -72,7 +72,6 @@ Factory.define('basicUser') termsAndConditionsAgreedAt: '2019-08-01T10:47:19.212Z', allowEmbedIframes: false, showShoutsPublicly: false, - sendNotificationEmails: true, locale: 'en', }) .attr('slug', ['slug', 'name'], (slug, name) => { diff --git a/backend/src/db/migrate/store.ts b/backend/src/db/migrate/store.ts index 1e32b49f5..e373c41c0 100644 --- a/backend/src/db/migrate/store.ts +++ b/backend/src/db/migrate/store.ts @@ -56,19 +56,18 @@ const createDefaultAdminUser = async (session) => { `MERGE (e:EmailAddress { email: "${defaultAdmin.email}", createdAt: toString(datetime()) - })-[:BELONGS_TO]->(u:User { - name: "${defaultAdmin.name}", - encryptedPassword: "${defaultAdmin.password}", - role: "admin", - id: "${defaultAdmin.id}", - slug: "${defaultAdmin.slug}", - createdAt: toString(datetime()), - allowEmbedIframes: false, - showShoutsPublicly: false, - sendNotificationEmails: true, - deleted: false, - disabled: false - })-[:PRIMARY_EMAIL]->(e)`, + })-[:BELONGS_TO]->(u:User { + name: "${defaultAdmin.name}", + encryptedPassword: "${defaultAdmin.password}", + role: "admin", + id: "${defaultAdmin.id}", + slug: "${defaultAdmin.slug}", + createdAt: toString(datetime()), + allowEmbedIframes: false, + showShoutsPublicly: false, + deleted: false, + disabled: false + })-[:PRIMARY_EMAIL]->(e)`, ) }) try { diff --git a/backend/src/db/migrations/20250405030454-email-notification-settings.ts b/backend/src/db/migrations/20250405030454-email-notification-settings.ts new file mode 100644 index 000000000..07ce9ab79 --- /dev/null +++ b/backend/src/db/migrations/20250405030454-email-notification-settings.ts @@ -0,0 +1,68 @@ +import { getDriver } from '@db/neo4j' + +export const description = + 'Transforms the `sendNotificationEmails` property on User to a multi value system' + +export async function up(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + MATCH (user:User) + SET user.emailNotificationsCommentOnObservedPost = user.sendNotificationEmails + SET user.emailNotificationsMention = user.sendNotificationEmails + SET user.emailNotificationsChatMessage = user.sendNotificationEmails + SET user.emailNotificationsGroupMemberJoined = user.sendNotificationEmails + SET user.emailNotificationsGroupMemberLeft = user.sendNotificationEmails + SET user.emailNotificationsGroupMemberRemoved = user.sendNotificationEmails + SET user.emailNotificationsGroupMemberRoleChanged = user.sendNotificationEmails + REMOVE user.sendNotificationEmails + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} + +export async function down(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + MATCH (user:User) + SET user.sendNotificationEmails = true + REMOVE user.emailNotificationsCommentOnObservedPost + REMOVE user.emailNotificationsMention + REMOVE user.emailNotificationsChatMessage + REMOVE user.emailNotificationsGroupMemberJoined + REMOVE user.emailNotificationsGroupMemberLeft + REMOVE user.emailNotificationsGroupMemberRemoved + REMOVE user.emailNotificationsGroupMemberRoleChanged + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.ts b/backend/src/middleware/notifications/notificationsMiddleware.spec.ts index 75b84f720..c636a7c87 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.spec.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.ts @@ -20,8 +20,10 @@ jest.mock('../helpers/email/sendMail', () => ({ })) const chatMessageTemplateMock = jest.fn() +const notificationTemplateMock = jest.fn() jest.mock('../helpers/email/templateBuilder', () => ({ chatMessageTemplate: () => chatMessageTemplateMock(), + notificationTemplate: () => notificationTemplateMock(), })) let isUserOnlineMock = jest.fn() @@ -86,8 +88,8 @@ afterAll(async () => { beforeEach(async () => { publishSpy.mockClear() - notifiedUser = await neode.create( - 'User', + notifiedUser = await Factory.build( + 'user', { id: 'you', name: 'Al Capone', @@ -187,6 +189,7 @@ describe('notifications', () => { describe('commenter is not me', () => { beforeEach(async () => { + jest.clearAllMocks() commentContent = 'Commenters comment.' commentAuthor = await neode.create( 'User', @@ -202,25 +205,8 @@ describe('notifications', () => { ) }) - it('sends me a notification', async () => { + it('sends me a notification and email', async () => { await createCommentOnPostAction() - const expected = expect.objectContaining({ - data: { - notifications: [ - { - read: false, - createdAt: expect.any(String), - reason: 'commented_on_post', - from: { - __typename: 'Comment', - id: 'c47', - content: commentContent, - }, - relatedUser: null, - }, - ], - }, - }) await expect( query({ query: notificationQuery, @@ -228,24 +214,85 @@ describe('notifications', () => { read: false, }, }), - ).resolves.toEqual(expected) + ).resolves.toMatchObject( + expect.objectContaining({ + data: { + notifications: [ + { + read: false, + createdAt: expect.any(String), + reason: 'commented_on_post', + from: { + __typename: 'Comment', + id: 'c47', + content: commentContent, + }, + relatedUser: null, + }, + ], + }, + }), + ) + + // Mail + expect(sendMailMock).toHaveBeenCalledTimes(1) + expect(notificationTemplateMock).toHaveBeenCalledTimes(1) }) - it('sends me no notification if I have blocked the comment author', async () => { - await notifiedUser.relateTo(commentAuthor, 'blocked') - await createCommentOnPostAction() - const expected = expect.objectContaining({ - data: { notifications: [] }, - }) + describe('if I have disabled `emailNotificationsCommentOnObservedPost`', () => { + it('sends me a notification but no email', async () => { + await notifiedUser.update({ emailNotificationsCommentOnObservedPost: false }) + await createCommentOnPostAction() + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toMatchObject( + expect.objectContaining({ + data: { + notifications: [ + { + read: false, + createdAt: expect.any(String), + reason: 'commented_on_post', + from: { + __typename: 'Comment', + id: 'c47', + content: commentContent, + }, + relatedUser: null, + }, + ], + }, + }), + ) - await expect( - query({ - query: notificationQuery, - variables: { - read: false, - }, - }), - ).resolves.toEqual(expected) + // No Mail + expect(sendMailMock).not.toHaveBeenCalled() + expect(notificationTemplateMock).not.toHaveBeenCalled() + }) + }) + + describe('if I have blocked the comment author', () => { + it('sends me no notification', async () => { + await notifiedUser.relateTo(commentAuthor, 'blocked') + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { notifications: [] }, + }) + + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) }) }) @@ -274,6 +321,7 @@ describe('notifications', () => { }) beforeEach(async () => { + jest.clearAllMocks() postAuthor = await neode.create( 'User', { @@ -296,7 +344,7 @@ describe('notifications', () => { 'Hey @al-capone how do you do?' }) - it('sends me a notification', async () => { + it('sends me a notification and email', async () => { await createPostAction() const expectedContent = 'Hey @al-capone how do you do?' @@ -324,6 +372,47 @@ describe('notifications', () => { ], }, }) + + // Mail + expect(sendMailMock).toHaveBeenCalledTimes(1) + expect(notificationTemplateMock).toHaveBeenCalledTimes(1) + }) + + describe('if I have disabled `emailNotificationsMention`', () => { + it('sends me a notification but no email', async () => { + await notifiedUser.update({ emailNotificationsMention: false }) + await createPostAction() + const expectedContent = + 'Hey @al-capone how do you do?' + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + notifications: [ + { + read: false, + createdAt: expect.any(String), + reason: 'mentioned_in_post', + from: { + __typename: 'Post', + id: 'p47', + content: expectedContent, + }, + }, + ], + }, + }) + + // Mail + expect(sendMailMock).not.toHaveBeenCalled() + expect(notificationTemplateMock).not.toHaveBeenCalled() + }) }) it('publishes `NOTIFICATION_ADDED` to me', async () => { @@ -689,7 +778,7 @@ describe('notifications', () => { roomId = room.data.CreateRoom.id }) - describe('chatReceiver is online', () => { + describe('if the chatReceiver is online', () => { it('sends no email', async () => { isUserOnlineMock = jest.fn().mockReturnValue(true) @@ -706,7 +795,7 @@ describe('notifications', () => { }) }) - describe('chatReceiver is offline', () => { + describe('if the chatReceiver is offline', () => { it('sends an email', async () => { isUserOnlineMock = jest.fn().mockReturnValue(false) @@ -723,7 +812,7 @@ describe('notifications', () => { }) }) - describe('chatReceiver has blocked chatSender', () => { + describe('if the chatReceiver has blocked chatSender', () => { it('sends no email', async () => { isUserOnlineMock = jest.fn().mockReturnValue(false) await chatReceiver.relateTo(chatSender, 'blocked') @@ -741,10 +830,10 @@ describe('notifications', () => { }) }) - describe('chatReceiver has disabled email notifications', () => { + describe('if the chatReceiver has disabled `emailNotificationsChatMessage`', () => { it('sends no email', async () => { isUserOnlineMock = jest.fn().mockReturnValue(false) - await chatReceiver.update({ sendNotificationEmails: false }) + await chatReceiver.update({ emailNotificationsChatMessage: false }) await mutate({ mutation: createMessageMutation(), @@ -764,8 +853,8 @@ describe('notifications', () => { let groupOwner beforeEach(async () => { - groupOwner = await neode.create( - 'User', + groupOwner = await Factory.build( + 'user', { id: 'group-owner', name: 'Group Owner', @@ -792,7 +881,7 @@ describe('notifications', () => { }) describe('user joins group', () => { - beforeEach(async () => { + const joinGroupAction = async () => { authenticatedUser = await notifiedUser.toJson() await mutate({ mutation: joinGroupMutation(), @@ -802,9 +891,14 @@ describe('notifications', () => { }, }) authenticatedUser = await groupOwner.toJson() + } + + beforeEach(async () => { + jest.clearAllMocks() }) - it('has the notification in database', async () => { + it('sends the group owner a notification and email', async () => { + await joinGroupAction() await expect( query({ query: notificationQuery, @@ -828,19 +922,50 @@ describe('notifications', () => { }, errors: undefined, }) + + // Mail + expect(sendMailMock).toHaveBeenCalledTimes(1) + expect(notificationTemplateMock).toHaveBeenCalledTimes(1) + }) + + describe('if the group owner has disabled `emailNotificationsGroupMemberJoined`', () => { + it('sends the group owner a notification but no email', async () => { + await groupOwner.update({ emailNotificationsGroupMemberJoined: false }) + await joinGroupAction() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + read: false, + reason: 'user_joined_group', + createdAt: expect.any(String), + from: { + __typename: 'Group', + id: 'closed-group', + }, + relatedUser: { + id: 'you', + }, + }, + ], + }, + errors: undefined, + }) + + // Mail + expect(sendMailMock).not.toHaveBeenCalled() + expect(notificationTemplateMock).not.toHaveBeenCalled() + }) }) }) - describe('user leaves group', () => { - beforeEach(async () => { + describe('user joins and leaves group', () => { + const leaveGroupAction = async () => { authenticatedUser = await notifiedUser.toJson() - await mutate({ - mutation: joinGroupMutation(), - variables: { - groupId: 'closed-group', - userId: authenticatedUser.id, - }, - }) await mutate({ mutation: leaveGroupMutation(), variables: { @@ -849,9 +974,22 @@ describe('notifications', () => { }, }) authenticatedUser = await groupOwner.toJson() + } + + beforeEach(async () => { + jest.clearAllMocks() + authenticatedUser = await notifiedUser.toJson() + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'closed-group', + userId: authenticatedUser.id, + }, + }) }) - it('has two the notification in database', async () => { + it('sends the group owner two notifications and emails', async () => { + await leaveGroupAction() await expect( query({ query: notificationQuery, @@ -887,19 +1025,61 @@ describe('notifications', () => { }, errors: undefined, }) + + // Mail + expect(sendMailMock).toHaveBeenCalledTimes(2) + expect(notificationTemplateMock).toHaveBeenCalledTimes(2) + }) + + describe('if the group owner has disabled `emailNotificationsGroupMemberLeft`', () => { + it('sends the group owner two notification but only only one email', async () => { + await groupOwner.update({ emailNotificationsGroupMemberLeft: false }) + await leaveGroupAction() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + read: false, + reason: 'user_left_group', + createdAt: expect.any(String), + from: { + __typename: 'Group', + id: 'closed-group', + }, + relatedUser: { + id: 'you', + }, + }, + { + read: false, + reason: 'user_joined_group', + createdAt: expect.any(String), + from: { + __typename: 'Group', + id: 'closed-group', + }, + relatedUser: { + id: 'you', + }, + }, + ], + }, + errors: undefined, + }) + + // Mail + expect(sendMailMock).toHaveBeenCalledTimes(1) + expect(notificationTemplateMock).toHaveBeenCalledTimes(1) + }) }) }) describe('user role in group changes', () => { - beforeEach(async () => { - authenticatedUser = await notifiedUser.toJson() - await mutate({ - mutation: joinGroupMutation(), - variables: { - groupId: 'closed-group', - userId: authenticatedUser.id, - }, - }) + const changeGroupMemberRoleAction = async () => { authenticatedUser = await groupOwner.toJson() await mutate({ mutation: changeGroupMemberRoleMutation(), @@ -910,9 +1090,23 @@ describe('notifications', () => { }, }) authenticatedUser = await notifiedUser.toJson() + } + + beforeEach(async () => { + authenticatedUser = await notifiedUser.toJson() + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'closed-group', + userId: authenticatedUser.id, + }, + }) + // Clear after because the above generates a notification not related + jest.clearAllMocks() }) - it('has notification in database', async () => { + it('sends the group member a notification and email', async () => { + await changeGroupMemberRoleAction() await expect( query({ query: notificationQuery, @@ -936,19 +1130,49 @@ describe('notifications', () => { }, errors: undefined, }) + + // Mail + expect(sendMailMock).toHaveBeenCalledTimes(1) + expect(notificationTemplateMock).toHaveBeenCalledTimes(1) + }) + + describe('if the group member has disabled `emailNotificationsGroupMemberRoleChanged`', () => { + it('sends the group member a notification but no email', async () => { + notifiedUser.update({ emailNotificationsGroupMemberRoleChanged: false }) + await changeGroupMemberRoleAction() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + read: false, + reason: 'changed_group_member_role', + createdAt: expect.any(String), + from: { + __typename: 'Group', + id: 'closed-group', + }, + relatedUser: { + id: 'group-owner', + }, + }, + ], + }, + errors: undefined, + }) + + // Mail + expect(sendMailMock).not.toHaveBeenCalled() + expect(notificationTemplateMock).not.toHaveBeenCalled() + }) }) }) describe('user is removed from group', () => { - beforeEach(async () => { - authenticatedUser = await notifiedUser.toJson() - await mutate({ - mutation: joinGroupMutation(), - variables: { - groupId: 'closed-group', - userId: authenticatedUser.id, - }, - }) + const removeUserFromGroupAction = async () => { authenticatedUser = await groupOwner.toJson() await mutate({ mutation: removeUserFromGroupMutation(), @@ -958,9 +1182,23 @@ describe('notifications', () => { }, }) authenticatedUser = await notifiedUser.toJson() + } + + beforeEach(async () => { + authenticatedUser = await notifiedUser.toJson() + await mutate({ + mutation: joinGroupMutation(), + variables: { + groupId: 'closed-group', + userId: authenticatedUser.id, + }, + }) + // Clear after because the above generates a notification not related + jest.clearAllMocks() }) - it('has notification in database', async () => { + it('sends the previous group member a notification and email', async () => { + await removeUserFromGroupAction() await expect( query({ query: notificationQuery, @@ -984,6 +1222,44 @@ describe('notifications', () => { }, errors: undefined, }) + + // Mail + expect(sendMailMock).toHaveBeenCalledTimes(1) + expect(notificationTemplateMock).toHaveBeenCalledTimes(1) + }) + + describe('if the previous group member has disabled `emailNotificationsGroupMemberRemoved`', () => { + it('sends the previous group member a notification but no email', async () => { + notifiedUser.update({ emailNotificationsGroupMemberRemoved: false }) + await removeUserFromGroupAction() + await expect( + query({ + query: notificationQuery, + }), + ).resolves.toMatchObject({ + data: { + notifications: [ + { + read: false, + reason: 'removed_user_from_group', + createdAt: expect.any(String), + from: { + __typename: 'Group', + id: 'closed-group', + }, + relatedUser: { + id: 'group-owner', + }, + }, + ], + }, + errors: undefined, + }) + + // Mail + expect(sendMailMock).not.toHaveBeenCalled() + expect(notificationTemplateMock).not.toHaveBeenCalled() + }) }) }) }) diff --git a/backend/src/middleware/notifications/notificationsMiddleware.ts b/backend/src/middleware/notifications/notificationsMiddleware.ts index 237e294b4..faf4fd994 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.ts @@ -38,7 +38,7 @@ const queryNotificationEmails = async (context, notificationUserIds) => { } } -const publishNotifications = async (context, promises) => { +const publishNotifications = async (context, promises, emailNotificationSetting: string) => { let notifications = await Promise.all(promises) notifications = notifications.flat() const notificationsEmailAddresses = await queryNotificationEmails( @@ -47,7 +47,7 @@ const publishNotifications = async (context, promises) => { ) notifications.forEach((notificationAdded, index) => { pubsub.publish(NOTIFICATION_ADDED, { notificationAdded }) - if (notificationAdded.to.sendNotificationEmails) { + if (notificationAdded.to[emailNotificationSetting] ?? true) { sendMail( notificationTemplate({ email: notificationsEmailAddresses[index].email, @@ -62,9 +62,11 @@ const handleJoinGroup = async (resolve, root, args, context, resolveInfo) => { const { groupId, userId } = args const user = await resolve(root, args, context, resolveInfo) if (user) { - await publishNotifications(context, [ - notifyOwnersOfGroup(groupId, userId, 'user_joined_group', context), - ]) + await publishNotifications( + context, + [notifyOwnersOfGroup(groupId, userId, 'user_joined_group', context)], + 'emailNotificationsGroupMemberJoined', + ) } return user } @@ -73,9 +75,11 @@ const handleLeaveGroup = async (resolve, root, args, context, resolveInfo) => { const { groupId, userId } = args const user = await resolve(root, args, context, resolveInfo) if (user) { - await publishNotifications(context, [ - notifyOwnersOfGroup(groupId, userId, 'user_left_group', context), - ]) + await publishNotifications( + context, + [notifyOwnersOfGroup(groupId, userId, 'user_left_group', context)], + 'emailNotificationsGroupMemberLeft', + ) } return user } @@ -84,9 +88,11 @@ const handleChangeGroupMemberRole = async (resolve, root, args, context, resolve const { groupId, userId } = args const user = await resolve(root, args, context, resolveInfo) if (user) { - await publishNotifications(context, [ - notifyMemberOfGroup(groupId, userId, 'changed_group_member_role', context), - ]) + await publishNotifications( + context, + [notifyMemberOfGroup(groupId, userId, 'changed_group_member_role', context)], + 'emailNotificationsGroupMemberRoleChanged', + ) } return user } @@ -95,9 +101,11 @@ const handleRemoveUserFromGroup = async (resolve, root, args, context, resolveIn const { groupId, userId } = args const user = await resolve(root, args, context, resolveInfo) if (user) { - await publishNotifications(context, [ - notifyMemberOfGroup(groupId, userId, 'removed_user_from_group', context), - ]) + await publishNotifications( + context, + [notifyMemberOfGroup(groupId, userId, 'removed_user_from_group', context)], + 'emailNotificationsGroupMemberRemoved', + ) } return user } @@ -106,9 +114,11 @@ 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(context, [ - notifyUsersOfMention('Post', post.id, idsOfUsers, 'mentioned_in_post', context), - ]) + await publishNotifications( + context, + [notifyUsersOfMention('Post', post.id, idsOfUsers, 'mentioned_in_post', context)], + 'emailNotificationsMention', + ) } return post } @@ -119,16 +129,26 @@ 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(context, [ - notifyUsersOfMention( - 'Comment', - comment.id, - idsOfMentionedUsers, - 'mentioned_in_comment', - context, - ), - notifyUsersOfComment('Comment', comment.id, 'commented_on_post', context), - ]) + await publishNotifications( + context, + [ + notifyUsersOfMention( + 'Comment', + comment.id, + idsOfMentionedUsers, + 'mentioned_in_comment', + context, + ), + ], + 'emailNotificationsMention', + ) + + await publishNotifications( + context, + [notifyUsersOfComment('Comment', comment.id, 'commented_on_post', context)], + 'emailNotificationsCommentOnObservedPost', + ) + return comment } @@ -339,7 +359,7 @@ const handleCreateMessage = async (resolve, root, args, context, resolveInfo) => MATCH (room)<-[:CHATS_IN]-(recipientUser:User)-[:PRIMARY_EMAIL]->(emailAddress:EmailAddress) WHERE NOT recipientUser.id = $currentUserId AND NOT (recipientUser)-[:BLOCKED]-(currentUser) - AND recipientUser.sendNotificationEmails = true + AND NOT recipientUser.emailNotificationsChatMessage = false RETURN recipientUser, emailAddress {.email} ` const txResponse = await transaction.run(messageRecipientCypher, { diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index c1eaf4b75..fcda6d218 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -471,6 +471,7 @@ export default shield( }, User: { email: or(isMyOwn, isAdmin), + emailNotificationSettings: isMyOwn, }, Report: isModerator, }, diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts index fa357775a..e9fbfb6ce 100644 --- a/backend/src/models/User.ts +++ b/backend/src/models/User.ts @@ -155,10 +155,37 @@ export default { type: 'boolean', default: false, }, - sendNotificationEmails: { + + // emailNotifications + emailNotificationsCommentOnObservedPost: { type: 'boolean', default: true, }, + emailNotificationsMention: { + type: 'boolean', + default: true, + }, + emailNotificationsChatMessage: { + type: 'boolean', + default: true, + }, + emailNotificationsGroupMemberJoined: { + type: 'boolean', + default: true, + }, + emailNotificationsGroupMemberLeft: { + type: 'boolean', + default: true, + }, + emailNotificationsGroupMemberRemoved: { + type: 'boolean', + default: true, + }, + emailNotificationsGroupMemberRoleChanged: { + type: 'boolean', + default: true, + }, + locale: { type: 'string', allow: [null], diff --git a/backend/src/schema/index.ts b/backend/src/schema/index.ts index 9f83bc43b..e043bc243 100644 --- a/backend/src/schema/index.ts +++ b/backend/src/schema/index.ts @@ -11,6 +11,8 @@ export default makeAugmentedSchema({ exclude: [ 'Badge', 'Embed', + 'EmailNotificationSettings', + 'EmailNotificationSettingsOption', 'EmailAddress', 'Notification', 'Statistics', diff --git a/backend/src/schema/resolvers/registration.ts b/backend/src/schema/resolvers/registration.ts index 3d5dfd6b3..fc3fc37bb 100644 --- a/backend/src/schema/resolvers/registration.ts +++ b/backend/src/schema/resolvers/registration.ts @@ -100,7 +100,6 @@ const signupCypher = (inviteCode) => { SET user.updatedAt = toString(datetime()) SET user.allowEmbedIframes = false SET user.showShoutsPublicly = false - SET user.sendNotificationEmails = true SET email.verifiedAt = toString(datetime()) WITH user OPTIONAL MATCH (post:Post)-[:IN]->(group:Group) diff --git a/backend/src/schema/resolvers/users.spec.ts b/backend/src/schema/resolvers/users.spec.ts index 88dfa653d..df5a7f785 100644 --- a/backend/src/schema/resolvers/users.spec.ts +++ b/backend/src/schema/resolvers/users.spec.ts @@ -593,6 +593,220 @@ describe('switch user role', () => { }) }) +let anotherUser +const emailNotificationSettingsQuery = gql` + query ($id: ID!) { + User(id: $id) { + emailNotificationSettings { + type + settings { + name + value + } + } + } + } +` + +const emailNotificationSettingsMutation = gql` + mutation ($id: ID!, $emailNotificationSettings: [EmailNotificationSettingsInput]!) { + UpdateUser(id: $id, emailNotificationSettings: $emailNotificationSettings) { + emailNotificationSettings { + type + settings { + name + value + } + } + } + } +` + +describe('emailNotificationSettings', () => { + beforeEach(async () => { + user = await Factory.build('user', { + id: 'user', + role: 'user', + }) + anotherUser = await Factory.build('user', { + id: 'anotherUser', + role: 'anotherUser', + }) + }) + + describe('query the field', () => { + describe('as another user', () => { + it('throws an error', async () => { + authenticatedUser = await anotherUser.toJson() + const targetUser = await user.toJson() + await expect( + query({ query: emailNotificationSettingsQuery, variables: { id: targetUser.id } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + expect.objectContaining({ + message: 'Not Authorized!', + }), + ], + }), + ) + }) + }) + + describe('as self', () => { + it('returns the emailNotificationSettings', async () => { + authenticatedUser = await user.toJson() + await expect( + query({ query: emailNotificationSettingsQuery, variables: { id: authenticatedUser.id } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + User: [ + { + emailNotificationSettings: [ + { + type: 'post', + settings: [ + { + name: 'commentOnObservedPost', + value: true, + }, + { + name: 'mention', + value: true, + }, + ], + }, + { + type: 'chat', + settings: [ + { + name: 'chatMessage', + value: true, + }, + ], + }, + { + type: 'group', + settings: [ + { + name: 'groupMemberJoined', + value: true, + }, + { + name: 'groupMemberLeft', + value: true, + }, + { + name: 'groupMemberRemoved', + value: true, + }, + { + name: 'groupMemberRoleChanged', + value: true, + }, + ], + }, + ], + }, + ], + }, + }), + ) + }) + }) + }) + + describe('mutate the field', () => { + const emailNotificationSettings = [{ name: 'mention', value: false }] + + describe('as another user', () => { + it('throws an error', async () => { + authenticatedUser = await anotherUser.toJson() + const targetUser = await user.toJson() + await expect( + mutate({ + mutation: emailNotificationSettingsMutation, + variables: { id: targetUser.id, emailNotificationSettings }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + expect.objectContaining({ + message: 'Not Authorized!', + }), + ], + }), + ) + }) + }) + + describe('as self', () => { + it('updates the emailNotificationSettings', async () => { + authenticatedUser = await user.toJson() + await expect( + mutate({ + mutation: emailNotificationSettingsMutation, + variables: { id: authenticatedUser.id, emailNotificationSettings }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + UpdateUser: { + emailNotificationSettings: [ + { + type: 'post', + settings: [ + { + name: 'commentOnObservedPost', + value: true, + }, + { + name: 'mention', + value: false, + }, + ], + }, + { + type: 'chat', + settings: [ + { + name: 'chatMessage', + value: true, + }, + ], + }, + { + type: 'group', + settings: [ + { + name: 'groupMemberJoined', + value: true, + }, + { + name: 'groupMemberLeft', + value: true, + }, + { + name: 'groupMemberRemoved', + value: true, + }, + { + name: 'groupMemberRoleChanged', + value: true, + }, + ], + }, + ], + }, + }, + }), + ) + }) + }) + }) +}) + describe('save category settings', () => { beforeEach(async () => { await Promise.all( diff --git a/backend/src/schema/resolvers/users.ts b/backend/src/schema/resolvers/users.ts index fe5e3d2de..cca8e1278 100644 --- a/backend/src/schema/resolvers/users.ts +++ b/backend/src/schema/resolvers/users.ts @@ -152,6 +152,19 @@ export default { } params.termsAndConditionsAgreedAt = new Date().toISOString() } + + const { + emailNotificationSettings, + }: { emailNotificationSettings: { name: string; value: boolean }[] | undefined } = params + delete params.emailNotificationSettings + if (emailNotificationSettings) { + emailNotificationSettings.forEach((setting) => { + params[ + 'emailNotifications' + setting.name.charAt(0).toUpperCase() + setting.name.slice(1) + ] = setting.value + }) + } + const session = context.driver.session() const writeTxResultPromise = session.writeTransaction(async (transaction) => { @@ -357,6 +370,53 @@ export default { const [{ email }] = result.records.map((r) => r.get('e').properties) return email }, + emailNotificationSettings: async (parent, params, context, resolveInfo) => { + return [ + { + type: 'post', + settings: [ + { + name: 'commentOnObservedPost', + value: parent.emailNotificationsCommentOnObservedPost ?? true, + }, + { + name: 'mention', + value: parent.emailNotificationsMention ?? true, + }, + ], + }, + { + type: 'chat', + settings: [ + { + name: 'chatMessage', + value: parent.emailNotificationsChatMessage ?? true, + }, + ], + }, + { + type: 'group', + settings: [ + { + name: 'groupMemberJoined', + value: parent.emailNotificationsGroupMemberJoined ?? true, + }, + { + name: 'groupMemberLeft', + value: parent.emailNotificationsGroupMemberLeft ?? true, + }, + { + name: 'groupMemberRemoved', + value: parent.emailNotificationsGroupMemberRemoved ?? true, + }, + { + name: 'groupMemberRoleChanged', + value: parent.emailNotificationsGroupMemberRoleChanged ?? true, + }, + ], + }, + ] + }, ...Resolver('User', { undefinedToNull: [ 'actorId', @@ -368,7 +428,6 @@ export default { 'termsAndConditionsAgreedAt', 'allowEmbedIframes', 'showShoutsPublicly', - 'sendNotificationEmails', 'locale', ], boolean: { diff --git a/backend/src/schema/types/enum/EmailNotificationSettingsName.gql b/backend/src/schema/types/enum/EmailNotificationSettingsName.gql new file mode 100644 index 000000000..fa1d5846e --- /dev/null +++ b/backend/src/schema/types/enum/EmailNotificationSettingsName.gql @@ -0,0 +1,9 @@ +enum EmailNotificationSettingsName { + commentOnObservedPost + mention + chatMessage + groupMemberJoined + groupMemberLeft + groupMemberRemoved + groupMemberRoleChanged +} \ No newline at end of file diff --git a/backend/src/schema/types/enum/EmailNotificationSettingsType.gql b/backend/src/schema/types/enum/EmailNotificationSettingsType.gql new file mode 100644 index 000000000..70128a6b2 --- /dev/null +++ b/backend/src/schema/types/enum/EmailNotificationSettingsType.gql @@ -0,0 +1,5 @@ +enum EmailNotificationSettingsType { + post + chat + group +} \ No newline at end of file diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 70b10aa42..37281d6bb 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -19,6 +19,21 @@ enum _UserOrdering { locale_desc } +input EmailNotificationSettingsInput { + name: EmailNotificationSettingsName + value: Boolean +} + +type EmailNotificationSettings { + type: EmailNotificationSettingsType + settings: [EmailNotificationSettingsOption] @neo4j_ignore +} + +type EmailNotificationSettingsOption { + name: EmailNotificationSettingsName + value: Boolean +} + type User { id: ID! actorId: String @@ -46,7 +61,7 @@ type User { allowEmbedIframes: Boolean showShoutsPublicly: Boolean - sendNotificationEmails: Boolean + emailNotificationSettings: [EmailNotificationSettings]! @neo4j_ignore locale: String friends: [User]! @relation(name: "FRIENDS", direction: "BOTH") friendsCount: Int! @cypher(statement: "MATCH (this)<-[:FRIENDS]->(r:User) RETURN COUNT(DISTINCT r)") @@ -206,7 +221,7 @@ type Mutation { termsAndConditionsAgreedAt: String allowEmbedIframes: Boolean showShoutsPublicly: Boolean - sendNotificationEmails: Boolean + emailNotificationSettings: [EmailNotificationSettingsInput] locale: String ): User diff --git a/cypress/e2e/User.SettingNotifications.feature b/cypress/e2e/User.SettingNotifications.feature.broken similarity index 100% rename from cypress/e2e/User.SettingNotifications.feature rename to cypress/e2e/User.SettingNotifications.feature.broken diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js index 4b743a0e3..8ad247ad1 100644 --- a/webapp/graphql/User.js +++ b/webapp/graphql/User.js @@ -46,7 +46,6 @@ export const profileUserQuery = (i18n) => { url } showShoutsPublicly - sendNotificationEmails } } ` @@ -335,7 +334,7 @@ export const updateUserMutation = () => { $about: String $allowEmbedIframes: Boolean $showShoutsPublicly: Boolean - $sendNotificationEmails: Boolean + $emailNotificationSettings: [EmailNotificationSettingsInput] $termsAndConditionsAgreedVersion: String $avatar: ImageInput $locationName: String # empty string '' sets it to null @@ -347,7 +346,7 @@ export const updateUserMutation = () => { about: $about allowEmbedIframes: $allowEmbedIframes showShoutsPublicly: $showShoutsPublicly - sendNotificationEmails: $sendNotificationEmails + emailNotificationSettings: $emailNotificationSettings termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion avatar: $avatar locationName: $locationName @@ -359,7 +358,13 @@ export const updateUserMutation = () => { about allowEmbedIframes showShoutsPublicly - sendNotificationEmails + emailNotificationSettings { + type + settings { + name + value + } + } locale termsAndConditionsAgreedVersion avatar { @@ -390,7 +395,13 @@ export const currentUserQuery = gql` locale allowEmbedIframes showShoutsPublicly - sendNotificationEmails + emailNotificationSettings { + type + settings { + name + value + } + } termsAndConditionsAgreedVersion socialMedia { id diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 518ba99a9..42f6ab74f 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -1039,9 +1039,23 @@ }, "name": "Einstellungen", "notifications": { - "name": "Benachrichtigungen", + "chat": "Chat", + "chatMessage": "Nachricht erhalten während Abwesenheit", + "checkAll": "Alle auswählen", + "commentOnObservedPost": "Kommentare zu beobachteten Beiträgen", + "group": "Gruppen", + "groupMemberJoined": "Ein Mitglied ist deiner Gruppe beigetreten", + "groupMemberLeft": "Ein Mitglied hat deine Gruppe verlassen", + "groupMemberRemoved": "Du wurdest aus einer Gruppe entfernt", + "groupMemberRoleChanged": "Deine Rolle in einer Gruppe wurde geändert", + "mention": "Ich wurde erwähnt", + "name": "Benachrichtigungen per Email", + "post": "Beiträge und Kommentare", + "postByFollowedUser": "Beitrag von einem Nutzer, dem ich folge", + "postInGroup": "Beitrag in einer Gruppe, die ich beobachte", "send-email-notifications": "Sende E-Mail-Benachrichtigungen", - "success-update": "Benachrichtigungs-Einstellungen gespeichert!" + "success-update": "Benachrichtigungs-Einstellungen gespeichert!", + "uncheckAll": "Alle abwählen" }, "organizations": { "name": "Meine Organisationen" diff --git a/webapp/locales/en.json b/webapp/locales/en.json index f78728c4f..714c3f3c0 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -1039,9 +1039,23 @@ }, "name": "Settings", "notifications": { - "name": "Notifications", + "chat": "Chat", + "chatMessage": "Message received while absent", + "checkAll": "Check all", + "commentOnObservedPost": "Comments on observed posts", + "group": "Groups", + "groupMemberJoined": "Member joined a group I own", + "groupMemberLeft": "Member left a group I own", + "groupMemberRemoved": "I was removed from a group", + "groupMemberRoleChanged": "My role in a group was changed", + "mention": "I was mentioned", + "name": "Email Notifications", + "post": "Posts and comments", + "postByFollowedUser": "Posts by users I follow", + "postInGroup": "Post in a group I am a member of", "send-email-notifications": "Send e-mail notifications", - "success-update": "Notifications settings saved!" + "success-update": "Notifications settings saved!", + "uncheckAll": "Uncheck all" }, "organizations": { "name": "My Organizations" diff --git a/webapp/locales/es.json b/webapp/locales/es.json index a085a53e0..f0a1a866b 100644 --- a/webapp/locales/es.json +++ b/webapp/locales/es.json @@ -1039,9 +1039,23 @@ }, "name": "Configuración", "notifications": { - "name": null, - "send-email-notifications": null, - "success-update": null + "chat": "Chat", + "chatMessage": "Mensaje recibido mientras estaba ausente", + "checkAll": "Seleccionar todo", + "commentOnObservedPost": "Comentario en una contribución que estoy observando", + "group": "Grupos", + "groupMemberJoined": "Un nuevo miembro se unió a un grupo mio", + "groupMemberLeft": "Un miembro dejó un grupo mio", + "groupMemberRemoved": "Fui eliminado de un grupo", + "groupMemberRoleChanged": "Mi rol en un grupo ha cambiado", + "mention": "Mencionado en una contribución", + "name": "Notificaciones por correo electrónico", + "post": "Entradas y comentarios", + "postByFollowedUser": "Posts by users I follow", + "postInGroup": "Post en un grupo del que soy miembro", + "send-email-notifications": "Enviar notificaciones por correo electrónico", + "success-update": "¡Configuración de notificaciones guardada!", + "uncheckAll": "Deseleccionar todo" }, "organizations": { "name": "Mis organizaciones" diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json index f1b5642de..a31e197a1 100644 --- a/webapp/locales/fr.json +++ b/webapp/locales/fr.json @@ -1039,9 +1039,23 @@ }, "name": "Paramètres", "notifications": { - "name": null, - "send-email-notifications": null, - "success-update": null + "chat": "Chat", + "chatMessage": "Message reçu pendant l'absence", + "checkAll": "Tout cocher", + "commentOnObservedPost": "Commentez une contribution que je suis", + "group": "Groups", + "groupMemberJoined": "Un nouveau membre a rejoint un de mes groupes", + "groupMemberLeft": "Un membre a quitté un de mes groupes", + "groupMemberRemoved": "J'ai été retiré d'un groupe", + "groupMemberRoleChanged": "Mon rôle au sein d'un groupe a changé", + "mention": "Mentionné dans une contribution", + "name": "Notifications par mail", + "post": "Messages et commentaires", + "postByFollowedUser": "Messages des utilisateurs que je suis", + "postInGroup": "Message dans un groupe dont je suis membre", + "send-email-notifications": "Envoyer des notifications par courrier électronique", + "success-update": "Paramètres de notification sauvegardés ! ", + "uncheckAll": "Tout décocher" }, "organizations": { "name": "Mes organisations" diff --git a/webapp/locales/it.json b/webapp/locales/it.json index 54248e6ee..8f14dfda2 100644 --- a/webapp/locales/it.json +++ b/webapp/locales/it.json @@ -1039,9 +1039,23 @@ }, "name": "Impostazioni", "notifications": { - "name": null, - "send-email-notifications": null, - "success-update": null + "chat": "Chat", + "chatMessage": "Messaggio ricevuto durante l'assenza", + "checkAll": "Seleziona tutto", + "commentOnObservedPost": "Commenta un contributo che sto guardando", + "group": "Gruppi", + "groupMemberJoined": "Un nuovo membro si è unito a un mio gruppo", + "groupMemberLeft": "Un membro ha lasciato un mio gruppo", + "groupMemberRemoved": "Sono stato rimosso da un gruppo", + "groupMemberRoleChanged": "Il mio ruolo in un gruppo è cambiato", + "mention": "Menzionato in un contributo", + "name": "Notifiche via e-mail", + "post": "Messaggi e commenti", + "postByFollowedUser": "Messaggi di utenti che seguo", + "postInGroup": "Post in un gruppo di cui sono membro", + "send-email-notifications": "Invia notifiche via e-mail", + "success-update": "Impostazioni di notifica salvate! ", + "uncheckAll": "Deseleziona tutto" }, "organizations": { "name": "Mie organizzazioni" diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json index 7907ce052..e332d38dc 100644 --- a/webapp/locales/nl.json +++ b/webapp/locales/nl.json @@ -1039,9 +1039,23 @@ }, "name": "Instellingen", "notifications": { - "name": null, - "send-email-notifications": null, - "success-update": null + "chat": "Chat", + "chatMessage": "Bericht ontvangen tijdens afwezigheid", + "checkAll": "Vink alles aan", + "commentOnObservedPost": "Geef commentaar op een bijdrage die ik volg", + "group": "Groepen", + "groupMemberJoined": "Een nieuw lid is lid geworden van een groep van mij", + "groupMemberLeft": "Een lid heeft een groep van mij verlaten", + "groupMemberRemoved": "Ik ben verwijderd uit een groep", + "groupMemberRoleChanged": "Mijn rol in een groep is veranderd", + "mention": "Genoemd in een bijdrage", + "name": "Email Meldingen", + "post": "Berichten en reacties", + "postByFollowedUser": "Berichten van gebruikers die ik volg", + "postInGroup": "Bericht in een groep waar ik lid van ben", + "send-email-notifications": "E-mailmeldingen verzenden", + "success-update": "Meldingsinstellingen opgeslagen! ", + "uncheckAll": "Vink alles uit" }, "organizations": { "name": "Mijn Organisaties" diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json index 7a800b3d0..5c636dfab 100644 --- a/webapp/locales/pl.json +++ b/webapp/locales/pl.json @@ -1039,9 +1039,23 @@ }, "name": "Ustawienia", "notifications": { - "name": null, - "send-email-notifications": null, - "success-update": null + "chat": "Chat", + "chatMessage": "Wiadomość otrzymana podczas nieobecności", + "checkAll": "Wybierz wszystko", + "commentOnObservedPost": "Skomentuj wpis, który obserwuję", + "group": "Grupy", + "groupMemberJoined": "Nowy członek dołączył do mojej grupy", + "groupMemberLeft": "Członek opuścił moją grupę", + "groupMemberRemoved": "Zostałem usunięty z grupy", + "groupMemberRoleChanged": "Moja rola w grupie uległa zmianie", + "mention": "Mentioned in a contribution", + "name": "Powiadomienia e-mail", + "post": "Posty", + "postByFollowedUser": "Posty użytkowników, których obserwuję", + "postInGroup": "Posty w grupie, której jestem członkiem", + "send-email-notifications": "Wyślij powiadomienia e-mail", + "success-update": "Ustawienia powiadomień zapisane! ", + "uncheckAll": "Odznacz wszystko" }, "organizations": { "name": "My Organizations" diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index c0bb8a500..c00acbf0a 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -1039,9 +1039,23 @@ }, "name": "Configurações", "notifications": { - "name": null, - "send-email-notifications": null, - "success-update": null + "chat": "Chat", + "chatMessage": "Mensagem recebida durante a ausência", + "checkAll": "Marcar tudo", + "commentOnObservedPost": "Comentários sobre as mensagens observadas", + "group": "Grupos", + "groupMemberJoined": "Member joined a group I own", + "groupMemberLeft": "Membro saiu de um grupo de que sou proprietário", + "groupMemberRemoved": "Fui removido de um grupo", + "groupMemberRoleChanged": "O meu papel num grupo foi alterado", + "mention": "Fui mencionado", + "name": "Notificações por correio eletrónico", + "post": "Posts e comentários", + "postByFollowedUser": "Publicações de utilizadores que sigo", + "postInGroup": "Postar num grupo de que sou membro", + "send-email-notifications": "Enviar notificações por correio eletrónico", + "success-update": "Definições de notificações guardadas!", + "uncheckAll": "Desmarcar tudo" }, "organizations": { "name": "Minhas Organizações" diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json index bebae1012..5775264fa 100644 --- a/webapp/locales/ru.json +++ b/webapp/locales/ru.json @@ -1039,9 +1039,23 @@ }, "name": "Настройки", "notifications": { - "name": null, - "send-email-notifications": null, - "success-update": null + "chat": "Чат", + "chatMessage": "Сообщение, полученное во время отсутствия", + "checkAll": "Отметить все", + "commentOnObservedPost": "Комментарии по поводу замеченных сообщений", + "group": "Группы", + "groupMemberJoined": "Участник присоединился к группе, которой я владею", + "groupMemberLeft": "Участник вышел из группы, которой владею", + "groupMemberRemoved": "Был удален из группы", + "groupMemberRoleChanged": "Моя роль в группе была изменена", + "mention": "Упоминание в вкладе", + "name": "Уведомления", + "post": "Сообщения и комментарии", + "postByFollowedUser": "Сообщения пользователей, за которыми я слежу", + "postInGroup": "Сообщение в группе, членом которой я являюсь", + "send-email-notifications": "Отправлять уведомления по электронной почте", + "success-update": "Настройки уведомлений сохранены! ", + "uncheckAll": "Снимите все флажки" }, "organizations": { "name": "Мои организации" diff --git a/webapp/package.json b/webapp/package.json index 8ae97ec3e..f1c3778d0 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -18,6 +18,7 @@ "locales:normalize": "../scripts/translations/normalize.sh", "precommit": "yarn lint", "test": "cross-env NODE_ENV=test jest --coverage --forceExit --detectOpenHandles", + "test:unit:update": "yarn test -- --updateSnapshot", "test:unit:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --no-cache --runInBand" }, "dependencies": { @@ -78,6 +79,7 @@ "@storybook/addon-actions": "^5.3.21", "@storybook/addon-notes": "^5.3.18", "@storybook/vue": "~7.4.0", + "@testing-library/vue": "5", "@vue/cli-shared-utils": "~4.3.1", "@vue/eslint-config-prettier": "~6.0.0", "@vue/server-test-utils": "~1.0.0-beta.31", diff --git a/webapp/pages/settings/__snapshots__/notifications.spec.js.snap b/webapp/pages/settings/__snapshots__/notifications.spec.js.snap new file mode 100644 index 000000000..0b70393ee --- /dev/null +++ b/webapp/pages/settings/__snapshots__/notifications.spec.js.snap @@ -0,0 +1,197 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`notifications.vue mount renders 1`] = ` +
+

+ settings.notifications.name +

+ +
+
+

+ settings.notifications.post +

+
+ +
+
+ + + +
+
+ + + +
+
+
+
+
+

+ settings.notifications.group +

+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + + + + + + + +
+`; diff --git a/webapp/pages/settings/notifications.spec.js b/webapp/pages/settings/notifications.spec.js index 855505fe2..a16e99ed4 100644 --- a/webapp/pages/settings/notifications.spec.js +++ b/webapp/pages/settings/notifications.spec.js @@ -1,5 +1,6 @@ import Vuex from 'vuex' import { mount } from '@vue/test-utils' +import { render, fireEvent, screen } from '@testing-library/vue' import Notifications from './notifications.vue' const localVue = global.localVue @@ -11,7 +12,7 @@ describe('notifications.vue', () => { beforeEach(() => { mocks = { - $t: jest.fn(), + $t: jest.fn((v) => v), $apollo: { mutate: jest.fn(), }, @@ -26,7 +27,42 @@ describe('notifications.vue', () => { return { id: 'u343', name: 'MyAccount', - sendNotificationEmails: true, + emailNotificationSettings: [ + { + type: 'post', + settings: [ + { + name: 'commentOnObservedPost', + value: true, + }, + { + name: 'mention', + value: false, + }, + ], + }, + { + type: 'group', + settings: [ + { + name: 'groupMemberJoined', + value: true, + }, + { + name: 'groupMemberLeft', + value: true, + }, + { + name: 'groupMemberRemoved', + value: false, + }, + { + name: 'groupMemberRoleChanged', + value: true, + }, + ], + }, + ], } }, }, @@ -47,21 +83,116 @@ describe('notifications.vue', () => { }) it('renders', () => { - expect(wrapper.classes('base-card')).toBe(true) + expect(wrapper.element).toMatchSnapshot() + }) + }) + + describe('Notifications', () => { + beforeEach(() => { + render(Notifications, { + store, + mocks, + localVue, + }) }) - it('clicking on submit changes notifyByEmail to false', async () => { - await wrapper.find('#send-email').setChecked(false) - await wrapper.find('.base-button').trigger('click') - expect(wrapper.vm.notifyByEmail).toBe(false) + it('check all button works', async () => { + const button = screen.getByText('settings.notifications.checkAll') + await fireEvent.click(button) + + const checkboxes = screen.getAllByRole('checkbox') + for (const checkbox of checkboxes) { + expect(checkbox.checked).toEqual(true) + } + + // Check that the button is disabled + expect(button.disabled).toBe(true) }) - it('clicking on submit with a server error shows a toast and notifyByEmail is still true', async () => { + it('uncheck all button works', async () => { + const button = screen.getByText('settings.notifications.uncheckAll') + await fireEvent.click(button) + + const checkboxes = screen.getAllByRole('checkbox') + for (const checkbox of checkboxes) { + expect(checkbox.checked).toEqual(false) + } + + // Check that the button is disabled + expect(button.disabled).toBe(true) + }) + + it('clicking on submit keeps set values and shows success message', async () => { + mocks.$apollo.mutate = jest.fn().mockResolvedValue({ + data: { + UpdateUser: { + emailNotificationSettings: [ + { + type: 'post', + settings: [ + { + name: 'commentOnObservedPost', + value: false, + }, + { + name: 'mention', + value: false, + }, + ], + }, + { + type: 'group', + settings: [ + { + name: 'groupMemberJoined', + value: true, + }, + { + name: 'groupMemberLeft', + value: true, + }, + { + name: 'groupMemberRemoved', + value: false, + }, + { + name: 'groupMemberRoleChanged', + value: true, + }, + ], + }, + ], + }, + }, + }) + + // Change some value to enable save button + const checkbox = screen.getAllByRole('checkbox')[0] + await fireEvent.click(checkbox) + + const newValue = checkbox.checked + + // Click save button + const button = screen.getByText('actions.save') + await fireEvent.click(button) + + expect(checkbox.checked).toEqual(newValue) + + expect(mocks.$toast.success).toHaveBeenCalledWith('settings.notifications.success-update') + }) + + it('clicking on submit with a server error shows a toast', async () => { mocks.$apollo.mutate = jest.fn().mockRejectedValue({ message: 'Ouch!' }) - await wrapper.find('#send-email').setChecked(false) - await wrapper.find('.base-button').trigger('click') + + // Change some value to enable save button + const checkbox = screen.getAllByRole('checkbox')[0] + await fireEvent.click(checkbox) + + // Click save button + const button = screen.getByText('actions.save') + await fireEvent.click(button) + expect(mocks.$toast.error).toHaveBeenCalledWith('Ouch!') - expect(wrapper.vm.notifyByEmail).toBe(true) }) }) }) diff --git a/webapp/pages/settings/notifications.vue b/webapp/pages/settings/notifications.vue index a2828a1a9..35249a37d 100644 --- a/webapp/pages/settings/notifications.vue +++ b/webapp/pages/settings/notifications.vue @@ -1,11 +1,26 @@