diff --git a/__mocks__/@react-native-community/push-notification-ios.js b/__mocks__/@react-native-community/push-notification-ios.js new file mode 100644 index 00000000000..0fe8354b9e0 --- /dev/null +++ b/__mocks__/@react-native-community/push-notification-ios.js @@ -0,0 +1,5 @@ +export default { + addEventListener: jest.fn(), + requestPermissions: jest.fn(() => Promise.resolve()), + getInitialNotification: jest.fn(() => Promise.resolve()), +}; diff --git a/__mocks__/pusher-js/react-native.js b/__mocks__/pusher-js/react-native.js new file mode 100644 index 00000000000..0cb6afb4bb1 --- /dev/null +++ b/__mocks__/pusher-js/react-native.js @@ -0,0 +1,3 @@ +import {PusherMock} from 'pusher-js-mock'; + +export default PusherMock; diff --git a/__mocks__/urbanairship-react-native.js b/__mocks__/urbanairship-react-native.js index fe99d594f48..481cec4e40c 100644 --- a/__mocks__/urbanairship-react-native.js +++ b/__mocks__/urbanairship-react-native.js @@ -1,3 +1,12 @@ export default { setUserNotificationsEnabled: jest.fn(), }; + +const EventType = { + NotificationResponse: 'notificationResponse', + PushReceived: 'pushReceived', +}; + +export { + EventType, +}; diff --git a/package-lock.json b/package-lock.json index 9d8163176eb..853eb7123d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20725,6 +20725,12 @@ "tweetnacl": "^1.0.3" } }, + "pusher-js-mock": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/pusher-js-mock/-/pusher-js-mock-0.3.3.tgz", + "integrity": "sha512-Qn8u167Qm+pU+ZhE/vTLL7msh3Zk6C4im4fXC/Vxsjv2anXRYhhBwt+eqFVTfGvhb0ZcwPtL8w5dkwKAFSiMGQ==", + "dev": true + }, "q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -21032,8 +21038,8 @@ } }, "react-native-onyx": { - "version": "git+https://github.com/Expensify/react-native-onyx.git#783806e7a3d82d78025bb9025767f42c3964eff9", - "from": "git+https://github.com/Expensify/react-native-onyx.git#783806e7a3d82d78025bb9025767f42c3964eff9", + "version": "git+https://github.com/Expensify/react-native-onyx.git#bbed584f0e9f9ce128361c7138220738d8525a6a", + "from": "git+https://github.com/Expensify/react-native-onyx.git#bbed584f0e9f9ce128361c7138220738d8525a6a", "requires": { "@react-native-community/async-storage": "^1.12.1", "expensify-common": "git+https://github.com/Expensify/expensify-common.git#cd9f195ed1fd340e7e890c41672a97af4f2956ca", diff --git a/package.json b/package.json index a098aeb9a86..b426b969e49 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "react-native-image-picker": "^2.3.3", "react-native-keyboard-spacer": "^0.4.1", "react-native-modal": "^11.5.6", - "react-native-onyx": "git+https://github.com/Expensify/react-native-onyx.git#783806e7a3d82d78025bb9025767f42c3964eff9", + "react-native-onyx": "git+https://github.com/Expensify/react-native-onyx.git#bbed584f0e9f9ce128361c7138220738d8525a6a", "react-native-pdf": "^6.2.2", "react-native-render-html": "^6.0.0-alpha.10", "react-native-safe-area-context": "^3.1.4", @@ -111,6 +111,7 @@ "jest-circus": "^26.5.2", "jest-cli": "^26.5.2", "metro-react-native-babel-preset": "^0.61.0", + "pusher-js-mock": "^0.3.3", "react-hot-loader": "^4.12.21", "react-native-svg-transformer": "^0.14.3", "react-native-version": "^4.0.0", diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 6f576e8ef87..3baf0a21092 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -529,6 +529,7 @@ function addAction(reportID, text, file) { [newSequenceNumber]: { actionName: 'ADDCOMMENT', actorEmail: currentUserEmail, + actorAccountID: currentUserAccountID, person: [ { style: 'strong', @@ -553,6 +554,7 @@ function addAction(reportID, text, file) { isFirstItem: false, isAttachment, loading: true, + shouldShow: true, }, }); diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js new file mode 100644 index 00000000000..fbf7efe341e --- /dev/null +++ b/tests/actions/ReportTest.js @@ -0,0 +1,100 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../src/ONYXKEYS'; +import * as Pusher from '../../src/libs/Pusher/pusher'; +import PusherConnectionManager from '../../src/libs/PusherConnectionManager'; +import CONFIG from '../../src/CONFIG'; +import {addAction, subscribeToReportCommentEvents} from '../../src/libs/actions/Report'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import PushNotification from '../../src/libs/Notification/PushNotification'; +import {signInWithTestUser, fetchPersonalDetailsForTestUser} from '../utils/TestHelper'; + +PushNotification.register = () => {}; +PushNotification.deregister = () => {}; + +describe('actions/Report', () => { + it('should store a new report action in Onyx when one is handled via Pusher', () => { + const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@test.com'; + const REPORT_ID = 1; + const ACTION_ID = 1; + const REPORT_ACTION = { + actionName: 'ADDCOMMENT', + actorAccountID: TEST_USER_ACCOUNT_ID, + actorEmail: TEST_USER_LOGIN, + automatic: false, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_3.png', + message: [{type: 'COMMENT', html: 'Testing a comment', text: 'Testing a comment'}], + person: [{type: 'TEXT', style: 'strong', text: 'Test User'}], + sequenceNumber: ACTION_ID, + shouldShow: true, + }; + + // When using the Pusher mock the act of calling Pusher.isSubscribed will create a + // channel already in a subscribed state. These methods are normally used to prevent + // duplicated subscriptions, but we don't need them for this test so forcing them to + // return false will make the testing less complex. + Pusher.isSubscribed = jest.fn().mockReturnValue(false); + Pusher.isAlreadySubscribing = jest.fn().mockReturnValue(false); + + // Connect to Pusher + PusherConnectionManager.init(); + Pusher.init({ + appKey: CONFIG.PUSHER.APP_KEY, + cluster: CONFIG.PUSHER.CLUSTER, + authEndpoint: `${CONFIG.EXPENSIFY.URL_API_ROOT}api?command=Push_Authenticate`, + }); + + let reportActions; + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: val => reportActions = val, + }); + + // Set up Onyx with some test user data + return signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN) + .then(() => { + subscribeToReportCommentEvents(); + return waitForPromisesToResolve(); + }) + .then(() => fetchPersonalDetailsForTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, { + [TEST_USER_LOGIN]: { + accountID: TEST_USER_ACCOUNT_ID, + email: TEST_USER_LOGIN, + firstName: 'Test', + lastName: 'User', + }, + })) + .then(() => { + // This is a fire and forget response, but once it completes we should be able to verify that we + // have an "optimistic" report action in Onyx. + addAction(REPORT_ID, 'Testing a comment'); + return waitForPromisesToResolve(); + }) + .then(() => { + const resultAction = reportActions[ACTION_ID]; + expect(resultAction.message).toEqual(REPORT_ACTION.message); + expect(resultAction.person).toEqual(REPORT_ACTION.person); + expect(resultAction.loading).toEqual(true); + }) + .then(() => { + // We subscribed to the Pusher channel above and now we need to simulate a reportComment action + // Pusher event so we can verify that action was handled correctly and merged into the reportActions. + const channel = Pusher.getChannel('private-user-accountID-1'); + channel.emit('reportComment', { + reportID: REPORT_ID, + reportAction: REPORT_ACTION, + }); + + // Once a reportComment event is emitted to the Pusher channel we should see the comment get processed + // by the Pusher callback and added to the storage so we must wait for promises to resolve again and + // then verify the data is in Onyx. + return waitForPromisesToResolve(); + }) + .then(() => { + const resultAction = reportActions[ACTION_ID]; + + // Verify that our action is no longer in the loading state + expect(resultAction.loading).toEqual(false); + }); + }); +}); diff --git a/tests/actions/SessionTest.js b/tests/actions/SessionTest.js index 279731bf2b8..9f6d6ee5ad3 100644 --- a/tests/actions/SessionTest.js +++ b/tests/actions/SessionTest.js @@ -1,9 +1,9 @@ import Onyx from 'react-native-onyx'; -import {fetchAccountDetails, signIn} from '../../src/libs/actions/Session'; import * as API from '../../src/libs/API'; import HttpUtils from '../../src/libs/HttpUtils'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; import ONYXKEYS from '../../src/ONYXKEYS'; +import {signInWithTestUser} from '../utils/TestHelper'; // Set up manual mocks for methods used in the actions so our test does not fail. jest.mock('../../src/libs/Notification/PushNotification', () => ({ @@ -23,15 +23,6 @@ test('Authenticate is called with saved credentials when a session expires', () const TEST_INITIAL_AUTH_TOKEN = 'initialAuthToken'; const TEST_REFRESHED_AUTH_TOKEN = 'refreshedAuthToken'; - // Set up mock responses for all APIs that will be called. The next time this command is called it will return - // jsonCode: 200 and the response here. - HttpUtils.xhr.mockImplementation(() => Promise.resolve({ - jsonCode: 200, - accountExists: true, - canAccessExpensifyCash: true, - requiresTwoFactorAuth: false, - })); - let credentials; Onyx.connect({ key: ONYXKEYS.CREDENTIALS, @@ -44,39 +35,8 @@ test('Authenticate is called with saved credentials when a session expires', () callback: val => session = val, }); - // When the user enters their login and calls GetAccountStatus - fetchAccountDetails(TEST_USER_LOGIN); - - // Note: In order for this test to work we must return a promise! It will pass even with - // failing assertions if we remove the return keyword. - return waitForPromisesToResolve() - .then(() => { - // Then the login should exist in credentials - expect(credentials.login).toBe(TEST_USER_LOGIN); - - // Note: Every time we add a mockImplementationOnce() we are altering the API response. - HttpUtils.xhr - - // First call to Authenticate - .mockImplementationOnce(() => Promise.resolve({ - jsonCode: 200, - accountID: TEST_USER_ACCOUNT_ID, - authToken: TEST_INITIAL_AUTH_TOKEN, - email: TEST_USER_LOGIN, - })) - - // Next call to CreateLogin - .mockImplementationOnce(() => Promise.resolve({ - jsonCode: 200, - accountID: TEST_USER_ACCOUNT_ID, - authToken: TEST_INITIAL_AUTH_TOKEN, - email: TEST_USER_LOGIN, - })); - - // When we sign in - signIn('Password1'); - return waitForPromisesToResolve(); - }) + // When we sign in with the test user + return signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, 'Password1', TEST_INITIAL_AUTH_TOKEN) .then(() => { // Then our re-authentication credentials should be generated and our session data // have the correct information + initial authToken. diff --git a/tests/utils/TestHelper.js b/tests/utils/TestHelper.js new file mode 100644 index 00000000000..03fcf675716 --- /dev/null +++ b/tests/utils/TestHelper.js @@ -0,0 +1,82 @@ +import {signIn, fetchAccountDetails} from '../../src/libs/actions/Session'; +import {fetch as fetchPersonalDetails} from '../../src/libs/actions/PersonalDetails'; +import HttpUtils from '../../src/libs/HttpUtils'; +import waitForPromisesToResolve from './waitForPromisesToResolve'; + +/** + * Simulate signing in and make sure all API calls in this flow succeed. Every time we add + * a mockImplementationOnce() we are altering what Network.post() will return. + * + * @param {Number} accountID + * @param {String} login + * @param {String} password + * @param {String} authToken + * @return {Promise} + */ +function signInWithTestUser(accountID, login, password = 'Password1', authToken = 'asdfqwerty') { + HttpUtils.xhr = jest.fn(); + HttpUtils.xhr.mockImplementation(() => Promise.resolve({ + jsonCode: 200, + accountExists: true, + canAccessExpensifyCash: true, + requiresTwoFactorAuth: false, + })); + + // Simulate user entering their login and populating the credentials.login + fetchAccountDetails(login); + return waitForPromisesToResolve() + .then(() => { + // First call to Authenticate + HttpUtils.xhr + .mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + accountID, + authToken, + email: login, + })) + + // Next call to CreateLogin + .mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + accountID, + authToken, + email: login, + })); + signIn(password); + return waitForPromisesToResolve(); + }); +} + +/** + * Fetch and set personal details with provided personalDetailsList + * + * @param {Number} accountID + * @param {String} email + * @param {Object} personalDetailsList + * @returns {Promise} + */ +function fetchPersonalDetailsForTestUser(accountID, email, personalDetailsList) { + // Mock xhr() + HttpUtils.xhr = jest.fn(); + + // Get the personalDetails + HttpUtils.xhr + + // fetchPersonalDetails + .mockImplementationOnce(() => Promise.resolve({ + accountID, + email, + personalDetailsList, + })) + + // fetchTimezone + .mockImplementationOnce(() => Promise.resolve({})); + + fetchPersonalDetails(); + return waitForPromisesToResolve(); +} + +export { + signInWithTestUser, + fetchPersonalDetailsForTestUser, +};