diff --git a/app/init/push_notifications.test.ts b/app/init/push_notifications.test.ts new file mode 100644 index 000000000..28a1818ac --- /dev/null +++ b/app/init/push_notifications.test.ts @@ -0,0 +1,515 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AppState, DeviceEventEmitter, Platform} from 'react-native'; + +import {storeDeviceToken} from '@actions/app/global'; +import {markChannelAsViewed} from '@actions/local/channel'; +import {updateThread} from '@actions/local/thread'; +import {openNotification} from '@actions/remote/notifications'; +import {Device, Events, PushNotification} from '@constants'; +import DatabaseManager from '@database/manager'; +import {getCurrentChannelId} from '@queries/servers/system'; +import {getIsCRTEnabled, getThreadById} from '@queries/servers/thread'; +import EphemeralStore from '@store/ephemeral_store'; +import NavigationStore from '@store/navigation_store'; +import {isMainActivity} from '@utils/helpers'; + +import PushNotifications from './push_notifications'; + +import type ThreadModel from '@typings/database/models/servers/thread'; + +const subscription = {remove: jest.fn()}; +const events = { + registerNotificationOpened: jest.fn(() => subscription), + registerRemoteNotificationsRegistered: jest.fn(() => subscription), + registerNotificationReceivedBackground: jest.fn(() => subscription), + registerNotificationReceivedForeground: jest.fn(() => subscription), + registerRemoteNotificationsRegistrationFailed: jest.fn(() => subscription), + registerRemoteNotificationsRegistrationDenied: jest.fn(() => subscription), +} as any; + +const mockNotifications = { + events: () => events, + isRegisteredForRemoteNotifications: jest.fn(), + registerRemoteNotifications: jest.fn(), + setCategories: jest.fn(), + postLocalNotification: jest.fn(), + cancelLocalNotification: jest.fn(), +}; + +const NotificationBackgroundFetchResult = { + NEW_DATA: 'new-data', + NO_DATA: 'no-data', + FAILED: 'failed', +}; + +class mockNotificationAction { +} + +class mockNotificationCategory { +} + +jest.mock('react-native-notifications', () => ({ + Notifications: mockNotifications, + NotificationAction: mockNotificationAction, + NotificationCategory: mockNotificationCategory, + NotificationBackgroundFetchResult, +})); + +jest.mock('@actions/app/global'); +jest.mock('@actions/local/channel'); +jest.mock('@actions/local/thread'); +jest.mock('@actions/remote/notifications'); +jest.mock('@utils/general', () => ({ + ...jest.requireActual('@utils/general'), + isBetaApp: jest.fn(), +})); +jest.mock('@utils/helpers', () => ({ + ...jest.requireActual('@utils/helpers'), + isMainActivity: jest.fn(), +})); +jest.mock('@store/navigation_store', () => ({ + getVisibleScreen: jest.fn(() => 'Channel'), +})); +jest.mock('@queries/servers/system', () => ({ + getCurrentChannelId: jest.fn(), +})); +jest.mock('@queries/servers/thread', () => ({ + getIsCRTEnabled: jest.fn(), + getThreadById: jest.fn(), +})); + +describe('PushNotifications', () => { + let pushNotifications: typeof PushNotifications; + + beforeEach(() => { + jest.clearAllMocks(); + pushNotifications = require('./push_notifications').default; + }); + + describe('init', () => { + it('should register event listeners', () => { + pushNotifications.init(false); + + expect(events.registerNotificationOpened).toHaveBeenCalled(); + expect(events.registerRemoteNotificationsRegistered).toHaveBeenCalled(); + expect(events.registerNotificationReceivedBackground).toHaveBeenCalled(); + expect(events.registerNotificationReceivedForeground).toHaveBeenCalled(); + expect(events.registerRemoteNotificationsRegistrationFailed).toHaveBeenCalled(); + expect(events.registerRemoteNotificationsRegistrationDenied).toHaveBeenCalled(); + }); + + it('should register for notifications when register is true', (done) => { + pushNotifications.init(true); + + setTimeout(() => { + expect(mockNotifications.registerRemoteNotifications).toHaveBeenCalled(); + done(); + }, 0); + }); + }); + + describe('onRemoteNotificationsRegistered', () => { + it('should store device token with correct prefix for iOS', async () => { + Platform.OS = 'ios'; + const deviceToken = 'test-token'; + + await pushNotifications.onRemoteNotificationsRegistered({deviceToken}); + + expect(storeDeviceToken).toHaveBeenCalledWith( + `apple_rnbeta-v2:${deviceToken}`, + ); + }); + + it('should store device token with correct prefix for Android', async () => { + pushNotifications.configured = false; + Platform.OS = 'android'; + const deviceToken = 'test-token'; + + await pushNotifications.onRemoteNotificationsRegistered({deviceToken}); + + expect(storeDeviceToken).toHaveBeenCalledWith( + `${Device.PUSH_NOTIFY_ANDROID_REACT_NATIVE}-v2:${deviceToken}`, + ); + }); + }); + + describe('handleMessageNotification', () => { + it('should handle foreground notification', async () => { + const notification = { + payload: { + channel_id: 'channel1', + server_url: 'http://test.com', + }, + foreground: true, + }; + const handleInAppSpy = jest.spyOn(pushNotifications, 'handleInAppNotification'); + + await pushNotifications.handleMessageNotification(notification as any); + + expect(handleInAppSpy).toHaveBeenCalledWith('http://test.com', notification); + }); + + it('should handle background notification with user interaction', async () => { + const notification = { + payload: { + channel_id: 'channel1', + server_url: 'http://test.com', + userInfo: {}, + }, + foreground: false, + userInteraction: true, + }; + + await pushNotifications.handleMessageNotification(notification as any); + + expect(openNotification).toHaveBeenCalledWith('http://test.com', notification); + }); + }); + + describe('handleInAppNotification', () => { + const mockGetCurrentChannelId = jest.mocked(getCurrentChannelId); + const mockGetIsCRTEnabled = jest.mocked(getIsCRTEnabled); + const mockDatabase = {}; + const mockServerDatabases = { + 'http://test.com': { + database: mockDatabase, + }, + }; + + beforeEach(() => { + DatabaseManager.serverDatabases = mockServerDatabases as any; + (isMainActivity as jest.Mock).mockReturnValue(true); + (NavigationStore.getVisibleScreen as jest.Mock).mockReturnValue('Channel'); + AppState.currentState = 'active'; + jest.spyOn(DeviceEventEmitter, 'emit').mockImplementation(); + mockGetCurrentChannelId.mockResolvedValue('channel1'); + mockGetIsCRTEnabled.mockResolvedValue(false); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not show notification for same channel when in channel screen', async () => { + const serverUrl = 'http://test.com'; + const notification = { + payload: { + channel_id: 'channel1', + }, + }; + + await pushNotifications.handleInAppNotification(serverUrl, notification as any); + + expect(DeviceEventEmitter.emit).not.toHaveBeenCalled(); + }); + + it('should show notification for different channel when in channel screen', async () => { + const serverUrl = 'http://test.com'; + const notification = { + payload: { + channel_id: 'channel2', + }, + }; + + await pushNotifications.handleInAppNotification(serverUrl, notification as any); + + expect(DeviceEventEmitter.emit).toHaveBeenCalled(); + }); + + it('should show notification when not in channel screen', async () => { + const serverUrl = 'http://test.com'; + const notification = { + payload: { + channel_id: 'channel1', + }, + }; + jest.mocked(NavigationStore.getVisibleScreen).mockReturnValue('Settings'); + + await pushNotifications.handleInAppNotification(serverUrl, notification as any); + + expect(DeviceEventEmitter.emit).toHaveBeenCalled(); + }); + + it('should show notification for thread in CRT mode', async () => { + const serverUrl = 'http://test.com'; + const notification = { + payload: { + channel_id: 'channel1', + root_id: 'thread1', + }, + }; + mockGetIsCRTEnabled.mockResolvedValue(true); + + await pushNotifications.handleInAppNotification(serverUrl, notification as any); + + expect(DeviceEventEmitter.emit).toHaveBeenCalled(); + }); + + it('should not show notification for same thread in thread screen', async () => { + const serverUrl = 'http://test.com'; + const notification = { + payload: { + channel_id: 'channel1', + root_id: 'thread1', + }, + }; + jest.mocked(NavigationStore.getVisibleScreen).mockReturnValue('Thread'); + mockGetIsCRTEnabled.mockResolvedValue(true); + jest.spyOn(EphemeralStore, 'getCurrentThreadId').mockReturnValue('thread1'); + + await pushNotifications.handleInAppNotification(serverUrl, notification as any); + + expect(DeviceEventEmitter.emit).not.toHaveBeenCalled(); + }); + }); + + describe('handleClearNotification', () => { + const mockDatabase = {}; + const mockServerDatabases = { + 'http://test.com': { + database: mockDatabase, + }, + }; + const mockGetIsCRTEnabled = jest.mocked(getIsCRTEnabled); + const mockGetThreadById = jest.mocked(getThreadById); + + beforeEach(() => { + DatabaseManager.serverDatabases = mockServerDatabases as any; + mockGetIsCRTEnabled.mockResolvedValue(false); + }); + + it('should mark channel as viewed for non-CRT notification', async () => { + const notification = { + payload: { + channel_id: 'channel1', + server_url: 'http://test.com', + }, + }; + + await pushNotifications.handleClearNotification(notification as any); + + expect(markChannelAsViewed).toHaveBeenCalledWith('http://test.com', 'channel1'); + }); + + it('should update thread for CRT notification', async () => { + const notification = { + payload: { + channel_id: 'channel1', + root_id: 'thread1', + server_url: 'http://test.com', + }, + }; + mockGetIsCRTEnabled.mockResolvedValue(true); + mockGetThreadById.mockResolvedValue({isFollowing: true} as ThreadModel); + + await pushNotifications.handleClearNotification(notification as any); + + expect(updateThread).toHaveBeenCalledWith('http://test.com', 'thread1', expect.any(Object)); + }); + }); + + describe('onNotificationOpened', () => { + it('should process notification with user interaction', () => { + const notification = { + payload: { + channel_id: 'channel1', + }, + }; + const completion = jest.fn(); + const processSpy = jest.spyOn(pushNotifications, 'processNotification'); + + pushNotifications.onNotificationOpened(notification as any, completion); + + expect(processSpy).toHaveBeenCalledWith(expect.objectContaining({ + userInteraction: true, + })); + expect(completion).toHaveBeenCalled(); + }); + }); + + describe('onNotificationReceivedBackground', () => { + it('should not process unverified notification', async () => { + const notification = { + payload: { + verified: 'false', + ackId: '123', + }, + }; + const completion = jest.fn(); + const processSpy = jest.spyOn(pushNotifications, 'processNotification'); + + await pushNotifications.onNotificationReceivedBackground(notification as any, completion); + + expect(processSpy).not.toHaveBeenCalled(); + }); + + it('should process verified notification', async () => { + const notification = { + payload: { + verified: 'true', + channel_id: 'channel1', + }, + }; + const completion = jest.fn(); + const processSpy = jest.spyOn(pushNotifications, 'processNotification'); + + await pushNotifications.onNotificationReceivedBackground(notification as any, completion); + + expect(processSpy).toHaveBeenCalled(); + expect(completion).toHaveBeenCalledWith('new-data'); + }); + }); + + describe('onNotificationReceivedForeground', () => { + beforeEach(() => { + (isMainActivity as jest.Mock).mockReturnValue(true); + AppState.currentState = 'active'; + }); + + it('should not process unverified notification', () => { + const notification = { + payload: { + verified: 'false', + ackId: '123', + }, + }; + const completion = jest.fn(); + const processSpy = jest.spyOn(pushNotifications, 'processNotification'); + + pushNotifications.onNotificationReceivedForeground(notification as any, completion); + + expect(processSpy).not.toHaveBeenCalled(); + }); + + it('should process verified notification with sound', () => { + const notification = { + payload: { + verified: 'true', + channel_id: 'channel1', + }, + }; + const completion = jest.fn(); + const processSpy = jest.spyOn(pushNotifications, 'processNotification'); + + pushNotifications.onNotificationReceivedForeground(notification as any, completion); + + expect(processSpy).toHaveBeenCalled(); + expect(completion).toHaveBeenCalledWith({ + alert: false, + sound: true, + badge: true, + }); + }); + }); + + describe('handleSessionNotification', () => { + beforeEach(() => { + jest.spyOn(DeviceEventEmitter, 'emit'); + }); + + it('should emit session expired event on user interaction', async () => { + const notification = { + payload: { + server_url: 'http://test.com', + }, + userInteraction: true, + }; + + await pushNotifications.handleSessionNotification(notification as any); + + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.SESSION_EXPIRED, 'http://test.com'); + }); + + it('should emit server logout event without user interaction', async () => { + const notification = { + payload: { + server_url: 'http://test.com', + }, + userInteraction: false, + }; + + await pushNotifications.handleSessionNotification(notification as any); + + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.SERVER_LOGOUT, {serverUrl: 'http://test.com'}); + }); + }); + + describe('processNotification', () => { + beforeEach(() => { + jest.spyOn(pushNotifications, 'handleClearNotification'); + jest.spyOn(pushNotifications, 'handleMessageNotification'); + jest.spyOn(pushNotifications, 'handleSessionNotification'); + }); + + it('should handle clear notification', async () => { + const notification = { + payload: { + type: PushNotification.NOTIFICATION_TYPE.CLEAR, + }, + }; + + await pushNotifications.processNotification(notification as any); + + expect(pushNotifications.handleClearNotification).toHaveBeenCalledWith(notification); + }); + + it('should handle message notification', async () => { + const notification = { + payload: { + type: PushNotification.NOTIFICATION_TYPE.MESSAGE, + }, + }; + + await pushNotifications.processNotification(notification as any); + + expect(pushNotifications.handleMessageNotification).toHaveBeenCalledWith(notification); + }); + + it('should handle session notification', async () => { + const notification = { + payload: { + type: PushNotification.NOTIFICATION_TYPE.SESSION, + }, + }; + + await pushNotifications.processNotification(notification as any); + + expect(pushNotifications.handleSessionNotification).toHaveBeenCalledWith(notification); + }); + + it('should not process notification without payload', async () => { + const notification = {} as any; + + await pushNotifications.processNotification(notification); + + expect(pushNotifications.handleClearNotification).not.toHaveBeenCalled(); + expect(pushNotifications.handleMessageNotification).not.toHaveBeenCalled(); + expect(pushNotifications.handleSessionNotification).not.toHaveBeenCalled(); + }); + }); + + describe('notification management', () => { + it('should schedule notification with correct date format', () => { + const notification = { + fireDate: new Date('2025-01-01').getTime(), + }; + Platform.OS = 'ios'; + + pushNotifications.scheduleNotification(notification as any); + + expect(mockNotifications.postLocalNotification).toHaveBeenCalledWith( + expect.objectContaining({ + fireDate: new Date('2025-01-01').toISOString(), + }), + ); + }); + + it('should cancel scheduled notification', () => { + pushNotifications.cancelScheduleNotification(123); + + expect(mockNotifications.cancelLocalNotification).toHaveBeenCalledWith(123); + }); + }); +});