From 92ee02fe02b6500a5d0178933d436fe541a53b67 Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 6 Jan 2022 10:47:03 +0100 Subject: [PATCH] unit test Notifications.tsx (#7468) Signed-off-by: Kerry Archibald --- .../views/settings/Notifications.tsx | 29 +- src/i18n/strings/en_EN.json | 6 +- .../views/settings/Notifications-test.tsx | 283 ++++++++++++++++++ .../__snapshots__/Notifications-test.tsx.snap | 114 +++++++ test/test-utils.js | 6 + 5 files changed, 428 insertions(+), 10 deletions(-) create mode 100644 test/components/views/settings/Notifications-test.tsx create mode 100644 test/components/views/settings/__snapshots__/Notifications-test.tsx.snap diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx index 7340c1f637..658d02a3f7 100644 --- a/src/components/views/settings/Notifications.tsx +++ b/src/components/views/settings/Notifications.tsx @@ -148,7 +148,6 @@ export default class Notifications extends React.PureComponent { private async refreshRules(): Promise> { const ruleSets = await MatrixClientPeg.get().getPushRules(); - const categories = { [RuleId.Master]: RuleClass.Master, @@ -182,6 +181,7 @@ export default class Notifications extends React.PureComponent { for (const k in ruleSets.global) { // noinspection JSUnfilteredForInLoop const kind = k as PushRuleKind; + for (const r of ruleSets.global[kind]) { const rule: IAnnotatedPushRule = Object.assign(r, { kind }); const category = categories[rule.rule_id] ?? RuleClass.Other; @@ -471,6 +471,7 @@ export default class Notifications extends React.PureComponent { private renderTopSection() { const masterSwitch = { const emailSwitches = (this.state.threepids || []).filter(t => t.medium === ThreepidMedium.Email) .map(e => p.kind === "email" && p.pushkey === e.address)} label={_t("Enable email notifications for %(email)s", { email: e.address })} @@ -495,6 +497,7 @@ export default class Notifications extends React.PureComponent { { masterSwitch } { /> { /> { />; } + const VectorStateToLabel = { + [VectorState.On]: _t('On'), + [VectorState.Off]: _t('Off'), + [VectorState.Loud]: _t('Noisy'), + }; + const makeRadio = (r: IVectorPushRule, s: VectorState) => ( { checked={r.vectorState === s} onChange={this.onRadioChecked.bind(this, r, s)} disabled={this.state.phase === Phase.Persisting} + aria-label={VectorStateToLabel[s]} /> ); - const rows = this.state.vectorPushRules[category].map(r => + const rows = this.state.vectorPushRules[category].map(r => { r.description } { makeRadio(r, VectorState.Off) } { makeRadio(r, VectorState.On) } @@ -592,13 +607,13 @@ export default class Notifications extends React.PureComponent { } return <> - +
- - - + + + @@ -635,7 +650,7 @@ export default class Notifications extends React.PureComponent { // Ends up default centered return ; } else if (this.state.phase === Phase.Error) { - return

{ _t("There was an error loading your notification settings.") }

; + return

{ _t("There was an error loading your notification settings.") }

; } return
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ba50922187..4fec00823e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1276,11 +1276,11 @@ "Clear notifications": "Clear notifications", "Keyword": "Keyword", "New keyword": "New keyword", + "On": "On", + "Off": "Off", + "Noisy": "Noisy", "Global": "Global", "Mentions & keywords": "Mentions & keywords", - "Off": "Off", - "On": "On", - "Noisy": "Noisy", "Notification targets": "Notification targets", "There was an error loading your notification settings.": "There was an error loading your notification settings.", "Failed to save your profile": "Failed to save your profile", diff --git a/test/components/views/settings/Notifications-test.tsx b/test/components/views/settings/Notifications-test.tsx new file mode 100644 index 0000000000..eda2167221 --- /dev/null +++ b/test/components/views/settings/Notifications-test.tsx @@ -0,0 +1,283 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { mount } from 'enzyme'; +import '../../../skinned-sdk'; +import { IPushRule, IPushRules, RuleId } from 'matrix-js-sdk'; +import { ThreepidMedium } from 'matrix-js-sdk/src/@types/threepids'; +import { act } from 'react-dom/test-utils'; + +import { createTestClient } from '../../../test-utils'; +import Notifications from '../../../../src/components/views/settings/Notifications'; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; +import { StandardActions } from '../../../../src/notifications/StandardActions'; + +jest.mock('../../../../src/settings/SettingsStore', () => ({ + monitorSetting: jest.fn(), + getValue: jest.fn(), + setValue: jest.fn(), +})); + +// don't pollute test output with error logs from mock rejections +jest.mock("matrix-js-sdk/src/logger"); + +jest.useFakeTimers(); + +const masterRule = { + actions: ["dont_notify"], + conditions: [], + default: true, + enabled: false, + rule_id: RuleId.Master, +}; +// eslint-disable-next-line max-len +const oneToOneRule = { "conditions": [{ "kind": "room_member_count", "is": "2" }, { "kind": "event_match", "key": "type", "pattern": "m.room.message" }], "actions": ["notify", { "set_tweak": "highlight", "value": false }], "rule_id": ".m.rule.room_one_to_one", "default": true, "enabled": true } as IPushRule; +// eslint-disable-next-line max-len +const encryptedOneToOneRule = { "conditions": [{ "kind": "room_member_count", "is": "2" }, { "kind": "event_match", "key": "type", "pattern": "m.room.encrypted" }], "actions": ["notify", { "set_tweak": "sound", "value": "default" }, { "set_tweak": "highlight", "value": false }], "rule_id": ".m.rule.encrypted_room_one_to_one", "default": true, "enabled": true } as IPushRule; +// eslint-disable-next-line max-len +const encryptedGroupRule = { "conditions": [{ "kind": "event_match", "key": "type", "pattern": "m.room.encrypted" }], "actions": ["dont_notify"], "rule_id": ".m.rule.encrypted", "default": true, "enabled": true } as IPushRule; +// eslint-disable-next-line max-len +const pushRules: IPushRules = { "global": { "underride": [{ "conditions": [{ "kind": "event_match", "key": "type", "pattern": "m.call.invite" }], "actions": ["notify", { "set_tweak": "sound", "value": "ring" }, { "set_tweak": "highlight", "value": false }], "rule_id": ".m.rule.call", "default": true, "enabled": true }, oneToOneRule, encryptedOneToOneRule, { "conditions": [{ "kind": "event_match", "key": "type", "pattern": "m.room.message" }], "actions": ["notify", { "set_tweak": "sound", "value": "default" }, { "set_tweak": "highlight", "value": false }], "rule_id": ".m.rule.message", "default": true, "enabled": true }, encryptedGroupRule, { "conditions": [{ "kind": "event_match", "key": "type", "pattern": "im.vector.modular.widgets" }, { "kind": "event_match", "key": "content.type", "pattern": "jitsi" }, { "kind": "event_match", "key": "state_key", "pattern": "*" }], "actions": ["notify", { "set_tweak": "highlight", "value": false }], "rule_id": ".im.vector.jitsi", "default": true, "enabled": true }], "sender": [], "room": [{ "actions": ["dont_notify"], "rule_id": "!zJPyWqpMorfCcWObge:matrix.org", "default": false, "enabled": true }], "content": [{ "actions": ["notify", { "set_tweak": "highlight", "value": false }], "pattern": "banana", "rule_id": "banana", "default": false, "enabled": true }, { "actions": ["notify", { "set_tweak": "sound", "value": "default" }, { "set_tweak": "highlight" }], "pattern": "kadev1", "rule_id": ".m.rule.contains_user_name", "default": true, "enabled": true }], "override": [{ "conditions": [], "actions": ["dont_notify"], "rule_id": ".m.rule.master", "default": true, "enabled": false }, { "conditions": [{ "kind": "event_match", "key": "content.msgtype", "pattern": "m.notice" }], "actions": ["dont_notify"], "rule_id": ".m.rule.suppress_notices", "default": true, "enabled": true }, { "conditions": [{ "kind": "event_match", "key": "type", "pattern": "m.room.member" }, { "kind": "event_match", "key": "content.membership", "pattern": "invite" }, { "kind": "event_match", "key": "state_key", "pattern": "@kadev1:matrix.org" }], "actions": ["notify", { "set_tweak": "sound", "value": "default" }, { "set_tweak": "highlight", "value": false }], "rule_id": ".m.rule.invite_for_me", "default": true, "enabled": true }, { "conditions": [{ "kind": "event_match", "key": "type", "pattern": "m.room.member" }], "actions": ["dont_notify"], "rule_id": ".m.rule.member_event", "default": true, "enabled": true }, { "conditions": [{ "kind": "contains_display_name" }], "actions": ["notify", { "set_tweak": "sound", "value": "default" }, { "set_tweak": "highlight" }], "rule_id": ".m.rule.contains_display_name", "default": true, "enabled": true }, { "conditions": [{ "kind": "event_match", "key": "content.body", "pattern": "@room" }, { "kind": "sender_notification_permission", "key": "room" }], "actions": ["notify", { "set_tweak": "highlight", "value": true }], "rule_id": ".m.rule.roomnotif", "default": true, "enabled": true }, { "conditions": [{ "kind": "event_match", "key": "type", "pattern": "m.room.tombstone" }, { "kind": "event_match", "key": "state_key", "pattern": "" }], "actions": ["notify", { "set_tweak": "highlight", "value": true }], "rule_id": ".m.rule.tombstone", "default": true, "enabled": true }, { "conditions": [{ "kind": "event_match", "key": "type", "pattern": "m.reaction" }], "actions": ["dont_notify"], "rule_id": ".m.rule.reaction", "default": true, "enabled": true }] }, "device": {} } as IPushRules; + +const flushPromises = async () => await new Promise(process.nextTick); + +describe('', () => { + const getComponent = () => mount(); + + // get component, wait for async data and force a render + const getComponentAndWait = async () => { + const component = getComponent(); + await flushPromises(); + component.setProps({}); + return component; + }; + + const mockClient = createTestClient(); + mockClient.getPushRules.mockResolvedValue(pushRules); + + const findByTestId = (component, id) => component.find(`[data-test-id="${id}"]`); + + beforeAll(() => { + MatrixClientPeg.get = () => mockClient; + }); + + beforeEach(() => { + mockClient.getPushRules.mockClear().mockResolvedValue(pushRules); + mockClient.getPushers.mockClear().mockResolvedValue({ pushers: [] }); + mockClient.getThreePids.mockClear().mockResolvedValue({ threepids: [] }); + mockClient.setPusher.mockClear().mockResolvedValue({}); + + (SettingsStore.getValue as jest.Mock).mockClear().mockReturnValue(true); + (SettingsStore.setValue as jest.Mock).mockClear().mockResolvedValue(true); + }); + + it('renders spinner while loading', () => { + const component = getComponent(); + expect(component.find('.mx_Spinner').length).toBeTruthy(); + }); + + it('renders error message when fetching push rules fails', async () => { + mockClient.getPushRules.mockRejectedValue(); + const component = await getComponentAndWait(); + expect(findByTestId(component, 'error-message').length).toBeTruthy(); + }); + it('renders error message when fetching push rules fails', async () => { + mockClient.getPushRules.mockRejectedValue(); + const component = await getComponentAndWait(); + expect(findByTestId(component, 'error-message').length).toBeTruthy(); + }); + it('renders error message when fetching pushers fails', async () => { + mockClient.getPushers.mockRejectedValue(); + const component = await getComponentAndWait(); + expect(findByTestId(component, 'error-message').length).toBeTruthy(); + }); + it('renders error message when fetching threepids fails', async () => { + mockClient.getThreePids.mockRejectedValue(); + const component = await getComponentAndWait(); + expect(findByTestId(component, 'error-message').length).toBeTruthy(); + }); + + describe('main notification switches', () => { + it('renders only enable notifications switch when notifications are disabled', async () => { + const disableNotificationsPushRules = { + global: { + ...pushRules.global, + override: [{ ...masterRule, enabled: true }], + }, + }; + mockClient.getPushRules.mockClear().mockResolvedValue(disableNotificationsPushRules); + const component = await getComponentAndWait(); + + expect(component).toMatchSnapshot(); + }); + it('renders switches correctly', async () => { + const component = await getComponentAndWait(); + + expect(findByTestId(component, 'notif-master-switch').length).toBeTruthy(); + expect(findByTestId(component, 'notif-setting-notificationsEnabled').length).toBeTruthy(); + expect(findByTestId(component, 'notif-setting-notificationBodyEnabled').length).toBeTruthy(); + expect(findByTestId(component, 'notif-setting-audioNotificationsEnabled').length).toBeTruthy(); + }); + + describe('email switches', () => { + const testEmail = 'tester@test.com'; + beforeEach(() => { + mockClient.getThreePids.mockResolvedValue({ + threepids: [ + // should render switch bc pushKey and address match + { + medium: ThreepidMedium.Email, + address: testEmail, + }, + ], + }); + }); + + it('renders email switches correctly when email 3pids exist', async () => { + const component = await getComponentAndWait(); + + expect(findByTestId(component, 'notif-email-switch')).toMatchSnapshot(); + }); + + it('renders email switches correctly when notifications are on for email', async () => { + mockClient.getPushers.mockResolvedValue({ pushers: [{ kind: 'email', pushkey: testEmail }] }); + const component = await getComponentAndWait(); + + expect(findByTestId(component, 'notif-email-switch').props().value).toEqual(true); + }); + + it('enables email notification when toggling on', async () => { + const component = await getComponentAndWait(); + + const emailToggle = findByTestId(component, 'notif-email-switch') + .find('div[role="switch"]'); + + await act(async () => { + emailToggle.simulate('click'); + }); + + expect(mockClient.setPusher).toHaveBeenCalledWith(expect.objectContaining({ + kind: "email", + app_id: "m.email", + pushkey: testEmail, + app_display_name: "Email Notifications", + device_display_name: testEmail, + append: true, + })); + }); + + it('displays error when pusher update fails', async () => { + mockClient.setPusher.mockRejectedValue(); + const component = await getComponentAndWait(); + + const emailToggle = findByTestId(component, 'notif-email-switch') + .find('div[role="switch"]'); + + await act(async () => { + emailToggle.simulate('click'); + }); + + // force render + await flushPromises(); + await component.setProps({}); + + expect(findByTestId(component, 'error-message').length).toBeTruthy(); + }); + + it('enables email notification when toggling off', async () => { + const testPusher = { kind: 'email', pushkey: 'tester@test.com' }; + mockClient.getPushers.mockResolvedValue({ pushers: [testPusher] }); + const component = await getComponentAndWait(); + + const emailToggle = findByTestId(component, 'notif-email-switch') + .find('div[role="switch"]'); + + await act(async () => { + emailToggle.simulate('click'); + }); + + expect(mockClient.setPusher).toHaveBeenCalledWith({ + ...testPusher, kind: null, + }); + }); + }); + + it('sets settings value on toggle click', async () => { + const component = await getComponentAndWait(); + + const audioNotifsToggle = findByTestId(component, 'notif-setting-audioNotificationsEnabled') + .find('div[role="switch"]'); + + await act(async () => { + audioNotifsToggle.simulate('click'); + }); + + expect(SettingsStore.setValue).toHaveBeenCalledWith('audioNotificationsEnabled', null, "device", false); + }); + }); + + describe('individual notification level settings', () => { + const getCheckedRadioForRule = (ruleEl) => + ruleEl.find('input[type="radio"][checked=true]').props()['aria-label']; + it('renders categories correctly', async () => { + const component = await getComponentAndWait(); + + expect(findByTestId(component, 'notif-section-vector_global').length).toBeTruthy(); + expect(findByTestId(component, 'notif-section-vector_mentions').length).toBeTruthy(); + expect(findByTestId(component, 'notif-section-vector_other').length).toBeTruthy(); + }); + + it('renders radios correctly', async () => { + const component = await getComponentAndWait(); + const section = 'vector_global'; + + const globalSection = findByTestId(component, `notif-section-${section}`); + // 16 notification rules with class 'global' + expect(globalSection.find('td').length).toEqual(16); + // oneToOneRule is set to 'on' + const oneToOneRuleElement = findByTestId(component, section + oneToOneRule.rule_id); + expect(getCheckedRadioForRule(oneToOneRuleElement)).toEqual('On'); + // encryptedOneToOneRule is set to 'loud' + const encryptedOneToOneElement = findByTestId(component, section + encryptedOneToOneRule.rule_id); + expect(getCheckedRadioForRule(encryptedOneToOneElement)).toEqual('Noisy'); + // encryptedGroupRule is set to 'off' + const encryptedGroupElement = findByTestId(component, section + encryptedGroupRule.rule_id); + expect(getCheckedRadioForRule(encryptedGroupElement)).toEqual('Off'); + }); + + it('updates notification level when changed', async () => { + const component = await getComponentAndWait(); + const section = 'vector_global'; + + // oneToOneRule is set to 'on' + // and is kind: 'underride' + const oneToOneRuleElement = findByTestId(component, section + oneToOneRule.rule_id); + + await act(async () => { + // toggle at 0 is 'off' + const offToggle = oneToOneRuleElement.find('input[type="radio"]').at(0); + offToggle.simulate('change'); + }); + + expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith( + 'global', 'underride', oneToOneRule.rule_id, true); + + // actions for '.m.rule.room_one_to_one' state is ACTION_DONT_NOTIFY + expect(mockClient.setPushRuleActions).toHaveBeenCalledWith( + 'global', 'underride', oneToOneRule.rule_id, StandardActions.ACTION_DONT_NOTIFY); + }); + }); +}); diff --git a/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap b/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap new file mode 100644 index 0000000000..1e8be2b6a9 --- /dev/null +++ b/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap @@ -0,0 +1,114 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` main notification switches email switches renders email switches correctly when email 3pids exist 1`] = ` + +
+ + Enable email notifications for tester@test.com + + <_default + aria-label="Enable email notifications for tester@test.com" + checked={false} + disabled={false} + onChange={[Function]} + > + +
+
+
+ + +
+ +`; + +exports[` main notification switches renders only enable notifications switch when notifications are disabled 1`] = ` + +
+ +
+ + Enable for this account + + <_default + aria-label="Enable for this account" + checked={false} + disabled={false} + onChange={[Function]} + > + +
+
+
+ + +
+ +
+ +`; diff --git a/test/test-utils.js b/test/test-utils.js index 0b9bbd642c..1f6cb85d16 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -113,6 +113,12 @@ export function createTestClient() { registerWithIdentityServer: jest.fn().mockResolvedValue({}), getIdentityAccount: jest.fn().mockResolvedValue({}), getTerms: jest.fn().mockResolvedValueOnce(), + getPushRules: jest.fn().mockResolvedValue(), + getPushers: jest.fn().mockResolvedValue({ pushers: [] }), + getThreePids: jest.fn().mockResolvedValue({ threepids: [] }), + setPusher: jest.fn().mockResolvedValue(), + setPushRuleEnabled: jest.fn().mockResolvedValue(), + setPushRuleActions: jest.fn().mockResolvedValue(), }; }
{ sectionName }{ _t("Off") }{ _t("On") }{ _t("Noisy") }{ VectorStateToLabel[VectorState.Off] }{ VectorStateToLabel[VectorState.On] }{ VectorStateToLabel[VectorState.Loud] }