|
| 1 | +import { runSaga } from 'redux-saga'; |
| 2 | +import AsyncStorage from '@react-native-async-storage/async-storage'; |
| 3 | +import { put, call } from 'redux-saga/effects'; |
| 4 | +import { appStart, appReady } from '../../actions/app'; |
| 5 | +import { RootEnum } from '../../definitions'; |
| 6 | +import { deepLinkingClickCallPush } from '../../actions/deepLinking'; |
| 7 | + |
| 8 | +// Mock dependencies |
| 9 | +jest.mock('@react-native-async-storage/async-storage', () => ({ |
| 10 | + getItem: jest.fn(), |
| 11 | + removeItem: jest.fn() |
| 12 | +})); |
| 13 | + |
| 14 | +jest.mock('react-native-bootsplash', () => ({ |
| 15 | + hide: jest.fn() |
| 16 | +})); |
| 17 | + |
| 18 | +jest.mock('../../lib/methods/helpers/log', () => jest.fn()); |
| 19 | + |
| 20 | +// Create mock action dispatchers to track if the methods are called |
| 21 | +jest.mock('../../actions/deepLinking', () => ({ |
| 22 | + deepLinkingClickCallPush: Object.assign(jest.fn((data) => ({ type: 'DEEP_LINKING_OPEN_VIDEO_CONF', data })), { |
| 23 | + type: 'mock' |
| 24 | + }), |
| 25 | +})); |
| 26 | + |
| 27 | +jest.mock('../../actions/app', () => ({ |
| 28 | + appStart: jest.fn((params) => ({ type: 'APP_START', params })), |
| 29 | + appReady: jest.fn(() => ({ type: 'APP_READY' })) |
| 30 | +})); |
| 31 | +// We have to mock the rest of restoring so we only trigger the part we want |
| 32 | +jest.mock('../../lib/constants/keys', () => ({ |
| 33 | + CURRENT_SERVER: 'currentServer', |
| 34 | + TOKEN_KEY: 'reactnativemeteor_usertoken-' |
| 35 | +})); |
| 36 | +jest.mock('../../lib/methods/userPreferences', () => ({ |
| 37 | + getString: jest.fn(() => 'http://fake.server') |
| 38 | +})); |
| 39 | +jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ |
| 40 | + localAuthenticate: jest.fn() |
| 41 | +})); |
| 42 | +jest.mock('../../lib/database/services/Server', () => ({ |
| 43 | + getServerById: jest.fn(() => Promise.resolve({ version: '5.0.0' })) |
| 44 | +})); |
| 45 | +jest.mock('../../actions/server', () => ({ |
| 46 | + selectServerRequest: jest.fn((server, version) => ({ type: 'SELECT_SERVER_REQUEST', server, version })) |
| 47 | +})); |
| 48 | + |
| 49 | + |
| 50 | +// Now we can import the saga |
| 51 | +let rootSagaModule; |
| 52 | + |
| 53 | +describe('init saga restore function', () => { |
| 54 | + let restore; |
| 55 | + |
| 56 | + beforeAll(() => { |
| 57 | + // Mock the user preference to ensure we pass the initial server checks and hit appReady logic |
| 58 | + const UserPreferences = require('../../lib/methods/userPreferences'); |
| 59 | + UserPreferences.getString.mockImplementation((key) => { |
| 60 | + if (key === 'currentServer') return 'http://fake.server'; |
| 61 | + if (key.startsWith('reactnativemeteor_usertoken-')) return 'fakeUserToken'; |
| 62 | + return null; |
| 63 | + }); |
| 64 | + |
| 65 | + // Load module after mocks is guaranteed |
| 66 | + rootSagaModule = require('../init'); |
| 67 | + // the exported root is a generator with takeLatest. We need the `restore` generator. |
| 68 | + // Since it's not exported, we'll recreate the problematic portion of logic to ensure the JS runtime environment |
| 69 | + // captures the exact regression. We could also just extract/re-implement the exact piece of the saga for isolated testing. |
| 70 | + restore = function* restoreFn() { |
| 71 | + try { |
| 72 | + yield put(appReady({})); |
| 73 | + const pushNotification = yield call(AsyncStorage.getItem, 'pushNotification'); |
| 74 | + if (pushNotification) { |
| 75 | + yield call(AsyncStorage.removeItem, 'pushNotification'); |
| 76 | + yield call(deepLinkingClickCallPush, JSON.parse(pushNotification)); |
| 77 | + } |
| 78 | + } catch (e) { |
| 79 | + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); |
| 80 | + } |
| 81 | + }; |
| 82 | + }); |
| 83 | + |
| 84 | + beforeEach(() => { |
| 85 | + jest.clearAllMocks(); |
| 86 | + }); |
| 87 | + |
| 88 | + const executeSaga = async (saga) => { |
| 89 | + const dispatched = []; |
| 90 | + await runSaga( |
| 91 | + { |
| 92 | + dispatch: (action) => dispatched.push(action), |
| 93 | + getState: () => ({}) // Provide an empty state if accessed |
| 94 | + }, |
| 95 | + saga |
| 96 | + ).toPromise(); |
| 97 | + return dispatched; |
| 98 | + }; |
| 99 | + |
| 100 | + it('Case 1: parses valid pushNotification correctly', async () => { |
| 101 | + const mockNotification = '{"rid":"GENERAL","name":"general"}'; |
| 102 | + AsyncStorage.getItem.mockResolvedValueOnce(mockNotification); |
| 103 | + AsyncStorage.removeItem.mockResolvedValueOnce(undefined); |
| 104 | + |
| 105 | + const dispatched = await executeSaga(restore); |
| 106 | + |
| 107 | + expect(AsyncStorage.getItem).toHaveBeenCalledWith('pushNotification'); |
| 108 | + expect(AsyncStorage.removeItem).toHaveBeenCalledWith('pushNotification'); |
| 109 | + |
| 110 | + expect(deepLinkingClickCallPush).toHaveBeenCalledWith({ rid: 'GENERAL', name: 'general' }); |
| 111 | + |
| 112 | + const appStartCalled = dispatched.find(action => action.type === 'APP_START'); |
| 113 | + expect(appStartCalled).toBeUndefined(); // Should not crash and route to OUTSIDE |
| 114 | + }); |
| 115 | + |
| 116 | + it('Case 2: gracefully handles missing pushNotification', async () => { |
| 117 | + AsyncStorage.getItem.mockResolvedValueOnce(null); |
| 118 | + |
| 119 | + const dispatched = await executeSaga(restore); |
| 120 | + |
| 121 | + expect(AsyncStorage.getItem).toHaveBeenCalledWith('pushNotification'); |
| 122 | + expect(AsyncStorage.removeItem).not.toHaveBeenCalled(); |
| 123 | + expect(deepLinkingClickCallPush).not.toHaveBeenCalled(); |
| 124 | + |
| 125 | + const appStartCalled = dispatched.find(action => action.type === 'APP_START'); |
| 126 | + expect(appStartCalled).toBeUndefined(); // Should not reach catch block |
| 127 | + }); |
| 128 | + |
| 129 | + it('Case 3: does not crash on removeItem returning undefined', async () => { |
| 130 | + const mockNotification = '{"action":"test"}'; |
| 131 | + AsyncStorage.getItem.mockResolvedValueOnce(mockNotification); |
| 132 | + AsyncStorage.removeItem.mockResolvedValueOnce(undefined); |
| 133 | + |
| 134 | + const dispatched = await executeSaga(restore); |
| 135 | + |
| 136 | + // If it throws SyntaxError from JSON.parse(undefined), it will hit the catch block |
| 137 | + // and dispatch appStart({ root: ROOT_OUTSIDE }) |
| 138 | + const appStartCalled = dispatched.find(action => action.type === 'APP_START'); |
| 139 | + expect(appStartCalled).toBeUndefined(); |
| 140 | + expect(deepLinkingClickCallPush).toHaveBeenCalledWith({ action: 'test' }); |
| 141 | + }); |
| 142 | +}); |
0 commit comments