diff --git a/test/integration/data/integration-init-state.json b/test/integration/data/integration-init-state.json index 2d9e50002a18..7949e19cfa51 100644 --- a/test/integration/data/integration-init-state.json +++ b/test/integration/data/integration-init-state.json @@ -783,7 +783,8 @@ "showTestNetworks": true, "smartTransactionsOptInStatus": false, "petnamesEnabled": false, - "showConfirmationAdvancedDetails": false + "showConfirmationAdvancedDetails": false, + "showMultiRpcModal": false }, "preventPollingOnNetworkRestart": true, "previousAppVersion": "11.14.4", diff --git a/test/integration/notifications&auth/data/notification-state.ts b/test/integration/notifications&auth/data/notification-state.ts new file mode 100644 index 000000000000..c58bf707f521 --- /dev/null +++ b/test/integration/notifications&auth/data/notification-state.ts @@ -0,0 +1,54 @@ +import { + INotification, + TRIGGER_TYPES, + processNotification, +} from '@metamask/notification-services-controller/notification-services'; +import { + createMockNotificationEthSent, + createMockFeatureAnnouncementRaw, +} from '@metamask/notification-services-controller/notification-services/mocks'; +import mockMetaMaskState from '../../data/integration-init-state.json'; + +const notificationsAccountAddress = + mockMetaMaskState.internalAccounts.accounts[ + mockMetaMaskState.internalAccounts + .selectedAccount as keyof typeof mockMetaMaskState.internalAccounts.accounts + ].address; + +export const ethSentNotification = processNotification( + createMockNotificationEthSent(), +) as Extract; + +if (ethSentNotification.type === TRIGGER_TYPES.ETH_SENT) { + ethSentNotification.address = notificationsAccountAddress; + ethSentNotification.data.from = notificationsAccountAddress; + ethSentNotification.isRead = true; +} + +export const featureNotification = processNotification( + createMockFeatureAnnouncementRaw(), +) as Extract; + +if (featureNotification.type === TRIGGER_TYPES.FEATURES_ANNOUNCEMENT) { + featureNotification.isRead = true; +} + +export const getMockedNotificationsState = () => { + return { + ...mockMetaMaskState, + isProfileSyncingEnabled: true, + isProfileSyncingUpdateLoading: false, + isMetamaskNotificationsFeatureSeen: true, + isNotificationServicesEnabled: true, + isFeatureAnnouncementsEnabled: true, + notifications: {}, + metamaskNotificationsReadList: [featureNotification.id], + metamaskNotificationsList: [featureNotification, ethSentNotification], + isUpdatingMetamaskNotifications: false, + isFetchingMetamaskNotifications: false, + isUpdatingMetamaskNotificationsAccount: [], + useExternalServices: true, + pendingApprovalCount: 0, + pendingApprovals: {}, + }; +}; diff --git a/test/integration/notifications&auth/notifications-activation.test.tsx b/test/integration/notifications&auth/notifications-activation.test.tsx new file mode 100644 index 000000000000..e11e58dad320 --- /dev/null +++ b/test/integration/notifications&auth/notifications-activation.test.tsx @@ -0,0 +1,196 @@ +import { + act, + fireEvent, + waitFor, + screen, + within, +} from '@testing-library/react'; +import { integrationTestRender } from '../../lib/render-helpers'; +import * as backgroundConnection from '../../../ui/store/background-connection'; +import { createMockImplementation } from '../helpers'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../shared/constants/metametrics'; +import { getMockedNotificationsState } from './data/notification-state'; + +jest.mock('../../../ui/store/background-connection', () => ({ + ...jest.requireActual('../../../ui/store/background-connection'), + submitRequestToBackground: jest.fn(), + callBackgroundMethod: jest.fn(), +})); + +const backgroundConnectionMocked = { + onNotification: jest.fn(), +}; + +const mockedBackgroundConnection = jest.mocked(backgroundConnection); + +const setupSubmitRequestToBackgroundMocks = ( + mockRequests?: Record, +) => { + mockedBackgroundConnection.submitRequestToBackground.mockImplementation( + createMockImplementation({ + ...(mockRequests ?? {}), + }), + ); +}; + +const trackNotificationsActivatedMetaMetricsEvent = async ( + actionType: string, + profileSyncEnabled: boolean, +) => { + const expectedCall = [ + 'trackMetaMetricsEvent', + [ + expect.objectContaining({ + event: MetaMetricsEventName.NotificationsActivated, + category: MetaMetricsEventCategory.NotificationsActivationFlow, + properties: { + action_type: actionType, + is_profile_syncing_enabled: profileSyncEnabled, + }, + }), + ], + ]; + + expect( + mockedBackgroundConnection.submitRequestToBackground.mock.calls, + ).toStrictEqual(expect.arrayContaining([expectedCall])); +}; +describe('Notifications Activation', () => { + beforeEach(() => { + jest.resetAllMocks(); + setupSubmitRequestToBackgroundMocks(); + }); + + afterEach(() => { + window.history.pushState({}, '', '/'); // return to homescreen + }); + + const clickElement = async (testId: string) => { + await act(async () => { + fireEvent.click(screen.getByTestId(testId)); + }); + }; + + const waitForElement = async (testId: string) => { + await waitFor(() => { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + }; + + it('should successfully activate notification for the first time', async () => { + const mockedState = getMockedNotificationsState(); + await act(async () => { + await integrationTestRender({ + preloadedState: { + ...mockedState, + isProfileSyncingEnabled: false, + isNotificationServicesEnabled: false, + isFeatureAnnouncementsEnabled: false, + isMetamaskNotificationsFeatureSeen: false, + }, + backgroundConnection: backgroundConnectionMocked, + }); + + await clickElement('account-options-menu-button'); + await waitForElement('notifications-menu-item'); + await clickElement('notifications-menu-item'); + + await waitFor(() => { + expect( + within(screen.getByRole('dialog')).getByText('Turn on'), + ).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByText('Turn on')); + }); + + await waitFor(() => { + const createOnChainTriggersCall = + mockedBackgroundConnection.submitRequestToBackground.mock.calls?.find( + (call) => call[0] === 'createOnChainTriggers', + ); + + expect(createOnChainTriggersCall?.[0]).toBe('createOnChainTriggers'); + }); + + await trackNotificationsActivatedMetaMetricsEvent('started', false); + await trackNotificationsActivatedMetaMetricsEvent('activated', true); + }); + }); + + it('should successfully send correct metrics when notifications modal is dismissed', async () => { + const mockedState = getMockedNotificationsState(); + await act(async () => { + await integrationTestRender({ + preloadedState: { + ...mockedState, + isProfileSyncingEnabled: false, + isNotificationServicesEnabled: false, + isFeatureAnnouncementsEnabled: false, + isMetamaskNotificationsFeatureSeen: false, + }, + backgroundConnection: backgroundConnectionMocked, + }); + + await clickElement('account-options-menu-button'); + await waitForElement('notifications-menu-item'); + await clickElement('notifications-menu-item'); + + await waitFor(() => { + expect( + within(screen.getByRole('dialog')).getByText('Turn on'), + ).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { + name: 'Close', + }), + ); + }); + + await trackNotificationsActivatedMetaMetricsEvent('dismissed', false); + }); + }); + + it('should successfully send correct metrics when notifications modal is dismissed', async () => { + const mockedState = getMockedNotificationsState(); + await act(async () => { + await integrationTestRender({ + preloadedState: { + ...mockedState, + isProfileSyncingEnabled: false, + isNotificationServicesEnabled: false, + isFeatureAnnouncementsEnabled: false, + isMetamaskNotificationsFeatureSeen: false, + }, + backgroundConnection: backgroundConnectionMocked, + }); + + await clickElement('account-options-menu-button'); + await waitForElement('notifications-menu-item'); + await clickElement('notifications-menu-item'); + + await waitFor(() => { + expect( + within(screen.getByRole('dialog')).getByText('Turn on'), + ).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { + name: 'Close', + }), + ); + }); + + await trackNotificationsActivatedMetaMetricsEvent('dismissed', false); + }); + }); +}); diff --git a/test/integration/notifications&auth/notifications-list.test.tsx b/test/integration/notifications&auth/notifications-list.test.tsx new file mode 100644 index 000000000000..4e17a53db107 --- /dev/null +++ b/test/integration/notifications&auth/notifications-list.test.tsx @@ -0,0 +1,246 @@ +import { + act, + fireEvent, + waitFor, + within, + screen, +} from '@testing-library/react'; +import { integrationTestRender } from '../../lib/render-helpers'; +import * as backgroundConnection from '../../../ui/store/background-connection'; +import { createMockImplementation } from '../helpers'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../shared/constants/metametrics'; +import { + ethSentNotification, + featureNotification, + getMockedNotificationsState, +} from './data/notification-state'; + +jest.mock('../../../ui/store/background-connection', () => ({ + ...jest.requireActual('../../../ui/store/background-connection'), + submitRequestToBackground: jest.fn(), + callBackgroundMethod: jest.fn(), +})); + +const backgroundConnectionMocked = { + onNotification: jest.fn(), +}; + +const mockedBackgroundConnection = jest.mocked(backgroundConnection); + +const setupSubmitRequestToBackgroundMocks = ( + mockRequests?: Record, +) => { + mockedBackgroundConnection.submitRequestToBackground.mockImplementation( + createMockImplementation({ + ...(mockRequests ?? {}), + }), + ); +}; + +const getStateWithTwoUnreadNotifications = () => { + const state = getMockedNotificationsState(); + return { + ...state, + metamaskNotificationsList: [ + { + ...state.metamaskNotificationsList[0], + isRead: false, + }, + { + ...state.metamaskNotificationsList[1], + isRead: false, + }, + ], + }; +}; + +describe('Notifications List', () => { + beforeEach(() => { + jest.resetAllMocks(); + setupSubmitRequestToBackgroundMocks(); + }); + + afterEach(() => { + window.history.pushState({}, '', '/'); // return to homescreen + }); + + it('should show the correct number of unread notifications on the badge', async () => { + const mockedState = getStateWithTwoUnreadNotifications(); + + await act(async () => { + await integrationTestRender({ + preloadedState: mockedState, + backgroundConnection: backgroundConnectionMocked, + }); + }); + + await waitFor(() => { + const unreadCount = screen.getByTestId( + 'notifications-tag-counter__unread-dot', + ); + expect(unreadCount).toBeInTheDocument(); + expect(unreadCount).toHaveTextContent('2'); + }); + }); + + it('should render notifications list and show correct details', async () => { + const mockedState = getStateWithTwoUnreadNotifications(); + + await act(async () => { + await integrationTestRender({ + preloadedState: mockedState, + backgroundConnection: backgroundConnectionMocked, + }); + }); + + fireEvent.click(screen.getByTestId('account-options-menu-button')); + + await waitFor(() => { + expect(screen.getByTestId('notifications-menu-item')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('notifications-menu-item')); + }); + + await waitFor(() => { + const notificationsList = screen.getByTestId('notifications-list'); + expect(notificationsList).toBeInTheDocument(); + expect(notificationsList.childElementCount).toBe(3); + + // Feature notification details + expect( + within(notificationsList).getByText(featureNotification.data.title), + ).toBeInTheDocument(); + expect( + within(notificationsList).getByText( + featureNotification.data.shortDescription, + ), + ).toBeInTheDocument(); + + // Eth sent notification details + const sentToElement = within(notificationsList).getByText('Sent to'); + expect(sentToElement).toBeInTheDocument(); + + const addressElement = sentToElement.nextElementSibling; + expect(addressElement).toHaveTextContent('0x881D4...D300D'); + + // Read all button + expect( + within(notificationsList).getByTestId( + 'notifications-list-read-all-button', + ), + ).toBeInTheDocument(); + + const unreadDot = screen.getAllByTestId('unread-dot'); + expect(unreadDot).toHaveLength(2); + }); + + await waitFor(() => { + const notificationsInteractionsEvent = + mockedBackgroundConnection.submitRequestToBackground.mock.calls?.find( + (call) => + call[0] === 'trackMetaMetricsEvent' && + call[1]?.[0].category === + MetaMetricsEventCategory.NotificationInteraction, + ); + + expect(notificationsInteractionsEvent?.[0]).toBe('trackMetaMetricsEvent'); + const [metricsEvent] = notificationsInteractionsEvent?.[1] as unknown as [ + { + event: string; + category: string; + properties: Record; + }, + ]; + + expect(metricsEvent?.event).toBe( + MetaMetricsEventName.NotificationsMenuOpened, + ); + + expect(metricsEvent?.category).toBe( + MetaMetricsEventCategory.NotificationInteraction, + ); + + expect(metricsEvent.properties).toMatchObject({ + unread_count: 2, + read_count: 0, + }); + }); + }); + + it('should not see mark all as read button if there are no unread notifications', async () => { + const mockedState = getMockedNotificationsState(); // all notifications are read by default + + await act(async () => { + await integrationTestRender({ + preloadedState: mockedState, + backgroundConnection: backgroundConnectionMocked, + }); + + fireEvent.click(screen.getByTestId('account-options-menu-button')); + + await waitFor(() => { + expect( + screen.getByTestId('notifications-menu-item'), + ).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('notifications-menu-item')); + }); + + await waitFor(() => { + const notificationsList = screen.getByTestId('notifications-list'); + expect(notificationsList).toBeInTheDocument(); + + expect(notificationsList.childElementCount).toBe(2); + + expect( + screen.queryByTestId('notifications-list-read-all-button'), + ).not.toBeInTheDocument(); + + expect(screen.queryAllByTestId('unread-dot')).toHaveLength(0); + }); + }); + }); + + it('should send request for marking notifications as read to the background with the correct params', async () => { + const mockedState = getStateWithTwoUnreadNotifications(); + await act(async () => { + await integrationTestRender({ + preloadedState: mockedState, + backgroundConnection: backgroundConnectionMocked, + }); + }); + + fireEvent.click(screen.getByTestId('account-options-menu-button')); + + await waitFor(() => { + expect(screen.getByTestId('notifications-menu-item')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('notifications-menu-item')); + }); + + fireEvent.click(screen.getByTestId('notifications-list-read-all-button')); + + await waitFor(() => { + const markAllAsReadEvent = + mockedBackgroundConnection.submitRequestToBackground.mock.calls?.find( + (call) => call[0] === 'markMetamaskNotificationsAsRead', + ); + + expect(markAllAsReadEvent?.[0]).toBe('markMetamaskNotificationsAsRead'); + expect(markAllAsReadEvent?.[1]).toStrictEqual([ + [ + { + id: featureNotification.id, + type: featureNotification.type, + isRead: false, + }, + { + id: ethSentNotification.id, + type: ethSentNotification.type, + isRead: false, + }, + ], + ]); + }); + }); +}); diff --git a/test/integration/notifications&auth/notifications-toggle.test.tsx b/test/integration/notifications&auth/notifications-toggle.test.tsx new file mode 100644 index 000000000000..8133e4c4bc3d --- /dev/null +++ b/test/integration/notifications&auth/notifications-toggle.test.tsx @@ -0,0 +1,224 @@ +import { + act, + fireEvent, + waitFor, + within, + screen, +} from '@testing-library/react'; +import { integrationTestRender } from '../../lib/render-helpers'; +import * as backgroundConnection from '../../../ui/store/background-connection'; +import { createMockImplementation } from '../helpers'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../shared/constants/metametrics'; +import { getMockedNotificationsState } from './data/notification-state'; + +jest.mock('../../../ui/store/background-connection', () => ({ + ...jest.requireActual('../../../ui/store/background-connection'), + submitRequestToBackground: jest.fn(), + callBackgroundMethod: jest.fn(), +})); + +const backgroundConnectionMocked = { + onNotification: jest.fn(), +}; + +const mockedBackgroundConnection = jest.mocked(backgroundConnection); + +const setupSubmitRequestToBackgroundMocks = ( + mockRequests?: Record, +) => { + mockedBackgroundConnection.submitRequestToBackground.mockImplementation( + createMockImplementation({ + ...(mockRequests ?? {}), + }), + ); +}; + +describe('Notifications Toggle', () => { + beforeEach(() => { + jest.resetAllMocks(); + setupSubmitRequestToBackgroundMocks(); + }); + + afterEach(() => { + window.history.pushState({}, '', '/'); // return to homescreen + }); + + const clickElement = async (testId: string) => { + await act(async () => { + fireEvent.click(screen.getByTestId(testId)); + }); + }; + + const waitForElement = async (testId: string) => { + await waitFor(() => { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + }; + + it('disabling notifications from settings', async () => { + const mockedState = getMockedNotificationsState(); + await act(async () => { + await integrationTestRender({ + preloadedState: { ...mockedState }, + backgroundConnection: backgroundConnectionMocked, + }); + + await clickElement('account-options-menu-button'); + await waitForElement('notifications-menu-item'); + await clickElement('notifications-menu-item'); + await waitForElement('notifications-settings-button'); + await clickElement('notifications-settings-button'); + await waitForElement('notifications-settings-allow-notifications'); + + const toggleSection = screen.getByTestId( + 'notifications-settings-allow-notifications', + ); + + await act(async () => { + fireEvent.click(within(toggleSection).getByRole('checkbox')); + }); + + await waitFor(() => { + const disableNotificationsCall = + mockedBackgroundConnection.submitRequestToBackground.mock.calls?.find( + (call) => call[0] === 'disableMetamaskNotifications', + ); + + const fetchAndUpdateMetamaskNotificationsCall = + mockedBackgroundConnection.submitRequestToBackground.mock.calls?.find( + (call) => call[0] === 'fetchAndUpdateMetamaskNotifications', + ); + + expect(disableNotificationsCall?.[0]).toBe( + 'disableMetamaskNotifications', + ); + + expect(fetchAndUpdateMetamaskNotificationsCall?.[0]).toBe( + 'fetchAndUpdateMetamaskNotifications', + ); + }); + + await waitFor(() => { + const metametrics = + mockedBackgroundConnection.submitRequestToBackground.mock.calls?.find( + (call) => + call[0] === 'trackMetaMetricsEvent' && + call[1]?.[0].category === + MetaMetricsEventCategory.NotificationSettings, + ); + + expect(metametrics?.[0]).toBe('trackMetaMetricsEvent'); + + const [metricsEvent] = metametrics?.[1] as unknown as [ + { + event: string; + category: string; + properties: Record; + }, + ]; + + expect(metricsEvent?.event).toBe( + MetaMetricsEventName.NotificationsSettingsUpdated, + ); + + expect(metricsEvent?.category).toBe( + MetaMetricsEventCategory.NotificationSettings, + ); + + expect(metricsEvent?.properties).toMatchObject({ + settings_type: 'notifications', + was_profile_syncing_on: true, + old_value: true, + new_value: false, + }); + }); + }); + }); + + it('enabling product announcments from settings', async () => { + const mockedState = getMockedNotificationsState(); + await act(async () => { + await integrationTestRender({ + preloadedState: { + ...mockedState, + isProfileSyncingEnabled: false, + isNotificationServicesEnabled: true, + isFeatureAnnouncementsEnabled: false, + isMetamaskNotificationsFeatureSeen: true, + }, + backgroundConnection: backgroundConnectionMocked, + }); + + await clickElement('account-options-menu-button'); + await waitForElement('notifications-menu-item'); + await clickElement('notifications-menu-item'); + await waitForElement('notifications-settings-button'); + await clickElement('notifications-settings-button'); + await waitForElement('notifications-settings-allow-notifications'); + + const allToggles = screen.getAllByTestId('test-toggle'); + + await act(async () => { + fireEvent.click(allToggles[1]); + }); + + await waitFor(() => { + const enableFeatureNotifications = + mockedBackgroundConnection.submitRequestToBackground.mock.calls?.find( + (call) => call[0] === 'setFeatureAnnouncementsEnabled', + ); + + const fetchAndUpdateMetamaskNotificationsCall = + mockedBackgroundConnection.submitRequestToBackground.mock.calls?.find( + (call) => call[0] === 'fetchAndUpdateMetamaskNotifications', + ); + + expect(enableFeatureNotifications?.[0]).toBe( + 'setFeatureAnnouncementsEnabled', + ); + expect(enableFeatureNotifications?.[1]).toEqual([true]); + + expect(fetchAndUpdateMetamaskNotificationsCall?.[0]).toBe( + 'fetchAndUpdateMetamaskNotifications', + ); + }); + + await waitFor(() => { + const metametrics = + mockedBackgroundConnection.submitRequestToBackground.mock.calls?.find( + (call) => + call[0] === 'trackMetaMetricsEvent' && + call[1]?.[0].category === + MetaMetricsEventCategory.NotificationSettings, + ); + + expect(metametrics?.[0]).toBe('trackMetaMetricsEvent'); + + const [metricsEvent] = metametrics?.[1] as unknown as [ + { + event: string; + category: string; + properties: Record; + }, + ]; + + expect(metricsEvent?.event).toBe( + MetaMetricsEventName.NotificationsSettingsUpdated, + ); + + expect(metricsEvent?.category).toBe( + MetaMetricsEventCategory.NotificationSettings, + ); + + expect(metricsEvent?.properties).toMatchObject({ + settings_type: 'product_announcements', + old_value: false, + new_value: true, + }); + }); + }); + }); +}); diff --git a/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap b/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap index d22597edd89f..247f7aeb5c78 100644 --- a/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap +++ b/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap @@ -616,6 +616,7 @@ exports[`App Header unlocked state matches snapshot: unlocked 1`] = ` >

1

diff --git a/ui/components/multichain/global-menu/global-menu.js b/ui/components/multichain/global-menu/global-menu.js index 08f64c65b0d3..84906fdd3e11 100644 --- a/ui/components/multichain/global-menu/global-menu.js +++ b/ui/components/multichain/global-menu/global-menu.js @@ -196,6 +196,7 @@ export const GlobalMenu = ({ closeMenu, anchorElement, isOpen }) => { handleNotificationsClick()} + data-testid="notifications-menu-item" > {notificationsUnreadCount > 10 ? '9+' : notificationsUnreadCount} diff --git a/ui/pages/notifications-settings/notifications-settings-types.tsx b/ui/pages/notifications-settings/notifications-settings-types.tsx index ba2516644e3b..5232b43121f8 100644 --- a/ui/pages/notifications-settings/notifications-settings-types.tsx +++ b/ui/pages/notifications-settings/notifications-settings-types.tsx @@ -101,6 +101,7 @@ export function NotificationsSettingsTypes({ onToggle={onToggleFeatureAnnouncements} error={errorFeatureAnnouncements} disabled={disabled} + data-testid="product-announcements-toggle" >