Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 52 additions & 31 deletions tests/actions/SessionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,21 @@ import getPlatform from '@libs/getPlatform';
import HttpUtils from '@libs/HttpUtils';
import {setHasRadio} from '@libs/NetworkState';
import PushNotification from '@libs/Notification/PushNotification';
import {isRecord} from '@libs/ObjectUtils';
import reauthenticate from '@libs/Reauthentication';

import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import * as SessionUtil from '@src/libs/actions/Session';
// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection
import '@libs/Notification/PushNotification/subscribeToPushNotifications';

import * as SessionUtil from '@src/libs/actions/Session';
import {KEYS_TO_PRESERVE_SUPPORTAL, signOutAndRedirectToSignIn} from '@src/libs/actions/Session';
import * as API from '@src/libs/API';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Credentials, Session} from '@src/types/onyx';
import type {Account, Credentials, Session} from '@src/types/onyx';

import type {OnyxEntry} from 'react-native-onyx';
import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx';

import {openAuthSessionAsync} from 'expo-web-browser';
import {clearTokenRefresh, removeAllFromAutoprefetch} from 'react-native-nitro-fetch';
Expand Down Expand Up @@ -61,6 +62,23 @@ jest.mock('@libs/getPlatform', () => jest.fn());

const mockedGetPlatform = jest.mocked(getPlatform);

type AccountMergeUpdate = Extract<OnyxUpdate<typeof ONYXKEYS.ACCOUNT>, {onyxMethod: typeof Onyx.METHOD.MERGE}>;
type AccountMergeValue = Pick<NonNullable<AccountMergeUpdate['value']>, 'isLoading' | 'errors' | 'twoFactorAuthSecretKey'>;
type AccountMergeObjectUpdate = Omit<AccountMergeUpdate, 'value'> & {value: AccountMergeValue};

function isAccountMergeUpdate(value: unknown): value is AccountMergeObjectUpdate {
if (!isRecord(value) || value.key !== ONYXKEYS.ACCOUNT || value.onyxMethod !== Onyx.METHOD.MERGE || !isRecord(value.value)) {
return false;
}

const {isLoading, errors, twoFactorAuthSecretKey} = value.value;
return (
(isLoading === undefined || isLoading === null || typeof isLoading === 'boolean') &&
(errors === undefined || errors === null || (isRecord(errors) && Object.values(errors).every((error) => error === null || typeof error === 'string'))) &&
(twoFactorAuthSecretKey === undefined || twoFactorAuthSecretKey === null || typeof twoFactorAuthSecretKey === 'string')
);
}

Onyx.init({
keys: ONYXKEYS,
});
Expand Down Expand Up @@ -167,8 +185,9 @@ describe('Session', () => {

test('reauthenticate proceeds even when a legacy session.isAuthenticatingWithShortLivedToken=true is persisted (recovers stuck users)', async () => {
// Given a session in Onyx that still carries the legacy stuck flag from before the RAM-only migration.
// The Session type no longer declares the field, so cast to write the legacy shape.
await Onyx.merge(ONYXKEYS.SESSION, {isAuthenticatingWithShortLivedToken: true} as unknown as Session);
// The Session type no longer declares the field, so write the legacy shape to exercise persisted-data compatibility.
// @ts-expect-error -- legacy persisted sessions can contain this field even though current Session does not.
await Onyx.merge(ONYXKEYS.SESSION, {isAuthenticatingWithShortLivedToken: true});
await waitForBatchedUpdates();

const redirectToSignInSpy = jest.spyOn(SignInRedirect, 'default').mockImplementation(() => Promise.resolve());
Expand Down Expand Up @@ -281,7 +300,7 @@ describe('Session', () => {
// to Re-Authenticate with the stored credentials. Our next call will be to Authenticate
// so we will mock that response with a new authToken and then verify that Onyx has our
// data.
(HttpUtils.xhr as jest.MockedFunction<typeof HttpUtils.xhr>)
jest.mocked(HttpUtils.xhr)

// This will make the call to OpenApp below return with an expired session code
.mockImplementationOnce(() =>
Expand Down Expand Up @@ -431,7 +450,7 @@ describe('Session', () => {
setHasRadio(false);
await waitForBatchedUpdates();

(HttpUtils.xhr as jest.MockedFunction<typeof HttpUtils.xhr>)
jest.mocked(HttpUtils.xhr)
// This will make the call to OpenApp below return with an expired session code
.mockImplementationOnce(() =>
Promise.resolve({
Expand Down Expand Up @@ -491,7 +510,7 @@ describe('Session', () => {
setHasRadio(false);
await waitForBatchedUpdates();

(HttpUtils.xhr as jest.MockedFunction<typeof HttpUtils.xhr>)
jest.mocked(HttpUtils.xhr)
// This will make the call to OpenApp below return with an expired session code
.mockImplementationOnce(() =>
Promise.resolve({
Expand All @@ -516,7 +535,7 @@ describe('Session', () => {
setHasRadio(false);
await waitForBatchedUpdates();

(HttpUtils.xhr as jest.MockedFunction<typeof HttpUtils.xhr>)
jest.mocked(HttpUtils.xhr)
// This will make the call to OpenApp below return with an expired session code
.mockImplementationOnce(() =>
Promise.resolve({
Expand All @@ -539,8 +558,8 @@ describe('Session', () => {
jest.spyOn(SessionUtil, 'isSupportAuthToken').mockReturnValue(true);
jest.spyOn(SessionUtil, 'hasStashedSession').mockReturnValue(true);
jest.spyOn(SessionUtil, 'signOut').mockResolvedValue(undefined);
jest.spyOn(Onyx, 'clear').mockResolvedValue(undefined);
jest.spyOn(Onyx, 'multiSet').mockResolvedValue(undefined);
const onyxClearSpy = jest.spyOn(Onyx, 'clear').mockResolvedValue(undefined);
const onyxMultiSetSpy = jest.spyOn(Onyx, 'multiSet').mockResolvedValue(undefined);

const testStashedCredentials = {login: 'stashed@expensify.com', autoGeneratedLogin: 'stashedAutoLogin', autoGeneratedPassword: 'stashedAutoPassword'};
const testStashedSession = {authToken: 'stashedAuthToken', email: 'stashed@expensify.com', accountID: 123, creationDate: new Date().getTime()};
Expand All @@ -551,9 +570,6 @@ describe('Session', () => {

await waitForBatchedUpdates();

const onyxClearSpy = Onyx.clear as jest.Mock;
const onyxMultiSetSpy = Onyx.multiSet as jest.Mock;

signOutAndRedirectToSignIn(false, false, true, true);

await waitForBatchedUpdates();
Expand All @@ -575,8 +591,8 @@ describe('Session', () => {
jest.spyOn(SessionUtil, 'isSupportAuthToken').mockReturnValue(false);
jest.spyOn(SessionUtil, 'hasStashedSession').mockReturnValue(true);
jest.spyOn(SessionUtil, 'signOut').mockResolvedValue(undefined);
jest.spyOn(Onyx, 'clear').mockResolvedValue(undefined);
jest.spyOn(Onyx, 'multiSet').mockResolvedValue(undefined);
const onyxClearSpy = jest.spyOn(Onyx, 'clear').mockResolvedValue(undefined);
const onyxMultiSetSpy = jest.spyOn(Onyx, 'multiSet').mockResolvedValue(undefined);

const testStashedCredentials = {login: 'delegate@expensify.com', autoGeneratedLogin: 'delegateAutoLogin', autoGeneratedPassword: 'delegateAutoPassword'};
const testStashedSession = {authToken: 'delegateAuthToken', email: 'delegate@expensify.com', accountID: 456, creationDate: new Date().getTime()};
Expand All @@ -586,8 +602,6 @@ describe('Session', () => {

await waitForBatchedUpdates();

const onyxClearSpy = Onyx.clear as jest.Mock;
const onyxMultiSetSpy = Onyx.multiSet as jest.Mock;
const redirectToSignInSpy = jest.spyOn(SignInRedirect, 'default').mockImplementation(() => Promise.resolve());

signOutAndRedirectToSignIn(true, false, true, true);
Expand Down Expand Up @@ -742,9 +756,12 @@ describe('Session', () => {

SessionUtil.replaceTwoFactorDevice('verify_old', '123456');

const onyxData = writeSpy.mock.calls.at(0)?.at(2) as {optimisticData: Array<{key: string; value: unknown}>};
const accountOptimistic = onyxData.optimisticData.find((d) => d.key === ONYXKEYS.ACCOUNT);
expect(accountOptimistic?.value).toStrictEqual({isLoading: true, errors: null});
const [, , onyxData] = TestHelper.getRequiredWriteCall(writeSpy.mock.calls, 0);
const accountOptimistic = TestHelper.getRequiredOnyxUpdate(onyxData, 'optimisticData', ONYXKEYS.ACCOUNT, Onyx.METHOD.MERGE, true);
if (!isAccountMergeUpdate(accountOptimistic)) {
throw new Error('Expected a typed account optimistic update');
}
expect(accountOptimistic.value).toStrictEqual({isLoading: true, errors: null});

writeSpy.mockRestore();
});
Expand All @@ -754,9 +771,12 @@ describe('Session', () => {

SessionUtil.replaceTwoFactorDevice('verify_old', '123456');

const onyxData = writeSpy.mock.calls.at(0)?.at(2) as {successData: Array<{key: string; value: Record<string, unknown>}>};
const accountSuccess = onyxData.successData.find((d) => d.key === ONYXKEYS.ACCOUNT);
expect(accountSuccess?.value).not.toHaveProperty('twoFactorAuthSecretKey');
const [, , onyxData] = TestHelper.getRequiredWriteCall(writeSpy.mock.calls, 0);
const accountSuccess = TestHelper.getRequiredOnyxUpdate(onyxData, 'successData', ONYXKEYS.ACCOUNT, Onyx.METHOD.MERGE, true);
if (!isAccountMergeUpdate(accountSuccess)) {
throw new Error('Expected a typed account success update');
}
expect(accountSuccess.value).not.toHaveProperty('twoFactorAuthSecretKey');

writeSpy.mockRestore();
});
Expand All @@ -766,9 +786,12 @@ describe('Session', () => {

SessionUtil.replaceTwoFactorDevice('verify_new', '654321');

const onyxData = writeSpy.mock.calls.at(0)?.at(2) as {successData: Array<{key: string; value: Record<string, unknown>}>};
const accountSuccess = onyxData.successData.find((d) => d.key === ONYXKEYS.ACCOUNT);
expect(accountSuccess?.value.twoFactorAuthSecretKey).toBeNull();
const [, , onyxData] = TestHelper.getRequiredWriteCall(writeSpy.mock.calls, 0);
const accountSuccess = TestHelper.getRequiredOnyxUpdate(onyxData, 'successData', ONYXKEYS.ACCOUNT, Onyx.METHOD.MERGE, true);
if (!isAccountMergeUpdate(accountSuccess)) {
throw new Error('Expected a typed account success update');
}
expect(accountSuccess.value.twoFactorAuthSecretKey).toBeNull();

writeSpy.mockRestore();
});
Expand All @@ -779,12 +802,10 @@ describe('Session', () => {
await Onyx.merge(ONYXKEYS.ACCOUNT, {twoFactorAuthSecretKey: 'SOMESECRETKEY123'});
await waitForBatchedUpdates();

let account: Record<string, unknown> | null | undefined;
let account: OnyxEntry<Account>;
Onyx.connect({
key: ONYXKEYS.ACCOUNT,
callback: (val) => {
account = val as Record<string, unknown> | null | undefined;
},
callback: (val) => (account = val),
});
await waitForBatchedUpdates();
expect(account?.twoFactorAuthSecretKey).toBe('SOMESECRETKEY123');
Expand Down
32 changes: 21 additions & 11 deletions tests/ui/NetSuiteCustomListSelectorPageTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ import type * as ReactNavigation from '@react-navigation/native';

import React from 'react';

import createMock from '../utils/createMock';

const mockUseState = React.useState;

type NetSuiteCustomListSelectorPageProps = Parameters<typeof NetSuiteCustomListSelectorPage>[0];

const mockCustomLists = [
{id: '123', name: 'Department'},
{id: '456', name: 'Project'},
Expand Down Expand Up @@ -75,7 +79,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({
}));

describe('NetSuiteCustomListSelectorPage', () => {
const mockedSelectionList = jest.mocked(SelectionList);
const mockedSelectionList = jest.mocked(SelectionList<CustomListSelectorType>);
const mockedSetDraftValues = jest.mocked(setDraftValues);
const mockedNavigationGoBack = jest.mocked(Navigation.goBack);

Expand All @@ -91,8 +95,8 @@ describe('NetSuiteCustomListSelectorPage', () => {

render(
<NetSuiteCustomListSelectorPage
route={{params: {policyID: 'P1'}} as never}
navigation={jest.fn() as never}
route={createMock<NetSuiteCustomListSelectorPageProps['route']>({params: {policyID: 'P1'}})}
navigation={createMock<NetSuiteCustomListSelectorPageProps['navigation']>({})}
/>,
);

Expand All @@ -107,13 +111,16 @@ describe('NetSuiteCustomListSelectorPage', () => {
it('writes both listName and internalID to the form draft on row select then returns to the custom list name sub-page', () => {
render(
<NetSuiteCustomListSelectorPage
route={{params: {policyID: 'P1'}} as never}
navigation={jest.fn() as never}
route={createMock<NetSuiteCustomListSelectorPageProps['route']>({params: {policyID: 'P1'}})}
navigation={createMock<NetSuiteCustomListSelectorPageProps['navigation']>({})}
/>,
);

const selectionListProps = mockedSelectionList.mock.lastCall?.[0];
const selectedRow = selectionListProps?.data.find((item) => (item as CustomListSelectorType).value === 'Department') as CustomListSelectorType;
const selectedRow = selectionListProps?.data.find((item) => item.value === 'Department');
if (!selectedRow) {
throw new Error('Expected the Department row to be rendered');
}
selectionListProps?.onSelectRow?.(selectedRow);

expect(mockedSetDraftValues).toHaveBeenCalledWith(ONYXKEYS.FORMS.NETSUITE_CUSTOM_LIST_ADD_FORM, {
Expand All @@ -129,13 +136,16 @@ describe('NetSuiteCustomListSelectorPage', () => {
it('returns to the name sub-page in edit mode on row select when the selector was opened while editing from the confirm step', () => {
render(
<NetSuiteCustomListSelectorPage
route={{params: {policyID: 'P1', action: 'edit'}} as never}
navigation={jest.fn() as never}
route={createMock<NetSuiteCustomListSelectorPageProps['route']>({params: {policyID: 'P1', action: 'edit'}})}
navigation={createMock<NetSuiteCustomListSelectorPageProps['navigation']>({})}
/>,
);

const selectionListProps = mockedSelectionList.mock.lastCall?.[0];
const selectedRow = selectionListProps?.data.find((item) => (item as CustomListSelectorType).value === 'Department') as CustomListSelectorType;
const selectedRow = selectionListProps?.data.find((item) => item.value === 'Department');
if (!selectedRow) {
throw new Error('Expected the Department row to be rendered');
}
selectionListProps?.onSelectRow?.(selectedRow);

expect(mockedNavigationGoBack).toHaveBeenCalledTimes(1);
Expand All @@ -147,8 +157,8 @@ describe('NetSuiteCustomListSelectorPage', () => {
it('renders an empty option set with a no-results header message when search filters everything out', () => {
render(
<NetSuiteCustomListSelectorPage
route={{params: {policyID: 'P1'}} as never}
navigation={jest.fn() as never}
route={createMock<NetSuiteCustomListSelectorPageProps['route']>({params: {policyID: 'P1'}})}
navigation={createMock<NetSuiteCustomListSelectorPageProps['navigation']>({})}
/>,
);

Expand Down
Loading
Loading