From cbc1f6d83d55b3536355a37578f82fa03f675be7 Mon Sep 17 00:00:00 2001 From: Yifan Goh Date: Sat, 14 Mar 2026 10:05:42 +0800 Subject: [PATCH 1/3] test: add unit tests for NetSuite credential commands and auth error handling Follow-up to PR #85200. Adds tests covering: - connectPolicyToNetSuite sends the correct write command and parameters - updateNetSuiteTokens sends the correct write command and parameters - Both commands set optimistic sync progress data - Credential command selection logic: verified connections with auth errors use updateNetSuiteTokens, while unverified connections use connectPolicyToNetSuite (the regression fix from #85196) Co-Authored-By: Claude Opus 4.6 --- tests/actions/connections/NetSuite.ts | 189 ++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/actions/connections/NetSuite.ts diff --git a/tests/actions/connections/NetSuite.ts b/tests/actions/connections/NetSuite.ts new file mode 100644 index 0000000000000..b44029e7ea4a7 --- /dev/null +++ b/tests/actions/connections/NetSuite.ts @@ -0,0 +1,189 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import {isAuthenticationError, isConnectionUnverified} from '@libs/actions/connections'; +import {connectPolicyToNetSuite, updateNetSuiteTokens} from '@libs/actions/connections/NetSuiteCommands'; +// eslint-disable-next-line no-restricted-syntax -- this is required to allow mocking +import * as API from '@libs/API'; +import type {WriteCommand} from '@libs/API/types'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; +import type {AnyOnyxData} from '@src/types/onyx/Request'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +jest.mock('@libs/API'); + +const writeSpy = jest.spyOn(API, 'write'); + +const MOCK_POLICY_ID = 'MOCK_POLICY_ID'; +const MOCK_CREDENTIALS = { + netSuiteAccountID: 'account-123', + netSuiteTokenID: 'token-123', + netSuiteTokenSecret: 'secret-123', +}; + +function getFirstWriteCall(): {command: WriteCommand; onyxData?: AnyOnyxData} { + const call = writeSpy.mock.calls.at(0); + if (!call) { + throw new Error('API.write was not called'); + } + const [command, , onyxData] = call; + return {command, onyxData}; +} + +describe('actions/connections/NetSuite', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + return Onyx.clear().then(waitForBatchedUpdates); + }); + + describe('connectPolicyToNetSuite', () => { + it('writes the ConnectPolicyToNetSuite command', () => { + connectPolicyToNetSuite(MOCK_POLICY_ID, MOCK_CREDENTIALS); + + const {command} = getFirstWriteCall(); + expect(command).toBe(WRITE_COMMANDS.CONNECT_POLICY_TO_NETSUITE); + }); + + it('passes the policyID and credentials as parameters', () => { + connectPolicyToNetSuite(MOCK_POLICY_ID, MOCK_CREDENTIALS); + + const call = writeSpy.mock.calls.at(0); + expect(call).toBeDefined(); + const params = call?.[1]; + expect(params).toEqual({ + policyID: MOCK_POLICY_ID, + ...MOCK_CREDENTIALS, + }); + }); + + it('sets optimistic sync progress data', () => { + connectPolicyToNetSuite(MOCK_POLICY_ID, MOCK_CREDENTIALS); + + const {onyxData} = getFirstWriteCall(); + const optimisticUpdate = onyxData?.optimisticData?.at(0); + + expect(optimisticUpdate?.onyxMethod).toBe(Onyx.METHOD.MERGE); + expect(optimisticUpdate?.key).toBe(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${MOCK_POLICY_ID}`); + expect(optimisticUpdate?.value).toEqual( + expect.objectContaining({ + stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.NETSUITE_SYNC_CONNECTION, + connectionName: CONST.POLICY.CONNECTIONS.NAME.NETSUITE, + }), + ); + }); + }); + + describe('updateNetSuiteTokens', () => { + it('writes the UpdateNetSuiteTokens command', () => { + updateNetSuiteTokens(MOCK_POLICY_ID, MOCK_CREDENTIALS); + + const {command} = getFirstWriteCall(); + expect(command).toBe(WRITE_COMMANDS.UPDATE_NETSUITE_TOKENS); + }); + + it('passes the policyID and credentials as parameters', () => { + updateNetSuiteTokens(MOCK_POLICY_ID, MOCK_CREDENTIALS); + + const call = writeSpy.mock.calls.at(0); + expect(call).toBeDefined(); + const params = call?.[1]; + expect(params).toEqual({ + policyID: MOCK_POLICY_ID, + ...MOCK_CREDENTIALS, + }); + }); + + it('sets optimistic sync progress data', () => { + updateNetSuiteTokens(MOCK_POLICY_ID, MOCK_CREDENTIALS); + + const {onyxData} = getFirstWriteCall(); + const optimisticUpdate = onyxData?.optimisticData?.at(0); + + expect(optimisticUpdate?.onyxMethod).toBe(Onyx.METHOD.MERGE); + expect(optimisticUpdate?.key).toBe(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${MOCK_POLICY_ID}`); + expect(optimisticUpdate?.value).toEqual( + expect.objectContaining({ + stageInProgress: CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.NETSUITE_SYNC_CONNECTION, + connectionName: CONST.POLICY.CONNECTIONS.NAME.NETSUITE, + }), + ); + }); + }); + + describe('credential command selection logic', () => { + // These tests verify the branching logic used in NetSuiteTokenInputForm: + // - Auth error + verified connection → updateNetSuiteTokens (preserves config) + // - Auth error + unverified connection → connectPolicyToNetSuite (full init) + // - No auth error → connectPolicyToNetSuite + + function createPolicy(options: {isAuthError?: boolean; verified?: boolean}): OnyxEntry { + return { + id: MOCK_POLICY_ID, + connections: { + netsuite: { + verified: options.verified ?? false, + lastSync: { + isAuthenticationError: options.isAuthError ?? false, + }, + }, + }, + } as unknown as Policy; + } + + it('returns true for isAuthenticationError when lastSync has authentication error', () => { + const policy = createPolicy({isAuthError: true}); + expect(isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE)).toBe(true); + }); + + it('returns false for isAuthenticationError when lastSync has no authentication error', () => { + const policy = createPolicy({isAuthError: false}); + expect(isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE)).toBe(false); + }); + + it('returns false for isAuthenticationError when policy is null', () => { + expect(isAuthenticationError(null, CONST.POLICY.CONNECTIONS.NAME.NETSUITE)).toBe(false); + }); + + it('returns true for isConnectionUnverified when NetSuite connection is not verified', () => { + const policy = createPolicy({verified: false}); + expect(isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE)).toBe(true); + }); + + it('returns false for isConnectionUnverified when NetSuite connection is verified', () => { + const policy = createPolicy({verified: true}); + expect(isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE)).toBe(false); + }); + + it('should use connectPolicyToNetSuite for unverified connection with auth error (regression case)', () => { + const policy = createPolicy({isAuthError: true, verified: false}); + + // This is the regression scenario: first-time connection failed with bad tokens + // isAuthenticationError is true but connection is unverified, so we must NOT use updateNetSuiteTokens + const shouldUseUpdate = isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE) && !isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); + expect(shouldUseUpdate).toBe(false); + }); + + it('should use updateNetSuiteTokens for verified connection with auth error', () => { + const policy = createPolicy({isAuthError: true, verified: true}); + + // Existing verified connection lost auth — use updateNetSuiteTokens to preserve config + const shouldUseUpdate = isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE) && !isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); + expect(shouldUseUpdate).toBe(true); + }); + + it('should use connectPolicyToNetSuite when there is no auth error', () => { + const policy = createPolicy({isAuthError: false, verified: true}); + + const shouldUseUpdate = isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE) && !isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); + expect(shouldUseUpdate).toBe(false); + }); + }); +}); From c7c711f617b9a32e936bce23edf75ebd3a21199b Mon Sep 17 00:00:00 2001 From: Yifan Goh Date: Sat, 14 Mar 2026 10:23:06 +0800 Subject: [PATCH 2/3] test: add unit tests for NetSuite credential commands and extract shared helper Follow-up to PR #85200. Changes: - Extract shouldUseUpdateNetSuiteTokens helper into connections/index.ts, shared by both NetSuiteTokenInputForm and the test suite - Update NetSuiteTokenInputForm to use the shared helper - Add unit tests covering connectPolicyToNetSuite, updateNetSuiteTokens, and the shouldUseUpdateNetSuiteTokens command selection logic Fixes #85196 Co-Authored-By: Claude Opus 4.6 --- src/libs/actions/connections/index.ts | 9 ++ .../subPages/NetSuiteTokenInputForm.tsx | 4 +- tests/actions/connections/NetSuite.ts | 89 +++++++------------ 3 files changed, 41 insertions(+), 61 deletions(-) diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts index 99b30b127c900..1d2a1865d0442 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -280,6 +280,14 @@ function isConnectionUnverified(policy: OnyxEntry, connectionName: Polic return !(policy?.connections?.[connectionName]?.lastSync?.isConnected ?? true); } +/** + * Determines whether to use updateNetSuiteTokens (preserves config) or connectPolicyToNetSuite (full init) + * based on the connection's authentication and verification state. + */ +function shouldUseUpdateNetSuiteTokens(policy: OnyxEntry): boolean { + return isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE) && !isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); +} + function setConnectionError(policyID: string, connectionName: PolicyConnectionName, errorMessage?: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { connections: { @@ -359,4 +367,5 @@ export { isConnectionInProgress, hasSynchronizationErrorMessage, setConnectionError, + shouldUseUpdateNetSuiteTokens, }; diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenInputForm.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenInputForm.tsx index 671722aa583ed..286e34f50e055 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenInputForm.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenInputForm.tsx @@ -10,7 +10,7 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isAuthenticationError, isConnectionUnverified} from '@libs/actions/connections'; +import {shouldUseUpdateNetSuiteTokens} from '@libs/actions/connections'; import {connectPolicyToNetSuite, updateNetSuiteTokens} from '@libs/actions/connections/NetSuiteCommands'; import {isMobileSafari} from '@libs/Browser'; import {addErrorMessage} from '@libs/ErrorUtils'; @@ -49,7 +49,7 @@ function NetSuiteTokenInputForm({onNext, policyID}: CustomSubPageTokenInputProps return; } - if (isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE) && !isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE)) { + if (shouldUseUpdateNetSuiteTokens(policy)) { updateNetSuiteTokens(policyID, formValues); } else { connectPolicyToNetSuite(policyID, formValues); diff --git a/tests/actions/connections/NetSuite.ts b/tests/actions/connections/NetSuite.ts index b44029e7ea4a7..de297a977b999 100644 --- a/tests/actions/connections/NetSuite.ts +++ b/tests/actions/connections/NetSuite.ts @@ -1,6 +1,6 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {isAuthenticationError, isConnectionUnverified} from '@libs/actions/connections'; +import {shouldUseUpdateNetSuiteTokens} from '@libs/actions/connections'; import {connectPolicyToNetSuite, updateNetSuiteTokens} from '@libs/actions/connections/NetSuiteCommands'; // eslint-disable-next-line no-restricted-syntax -- this is required to allow mocking import * as API from '@libs/API'; @@ -32,6 +32,20 @@ function getFirstWriteCall(): {command: WriteCommand; onyxData?: AnyOnyxData} { return {command, onyxData}; } +function createPolicy(options: {isAuthError?: boolean; verified?: boolean}): OnyxEntry { + return { + id: MOCK_POLICY_ID, + connections: { + netsuite: { + verified: options.verified ?? false, + lastSync: { + isAuthenticationError: options.isAuthError ?? false, + }, + }, + }, + } as unknown as Policy; +} + describe('actions/connections/NetSuite', () => { beforeAll(() => { Onyx.init({ @@ -118,72 +132,29 @@ describe('actions/connections/NetSuite', () => { }); }); - describe('credential command selection logic', () => { - // These tests verify the branching logic used in NetSuiteTokenInputForm: - // - Auth error + verified connection → updateNetSuiteTokens (preserves config) - // - Auth error + unverified connection → connectPolicyToNetSuite (full init) - // - No auth error → connectPolicyToNetSuite - - function createPolicy(options: {isAuthError?: boolean; verified?: boolean}): OnyxEntry { - return { - id: MOCK_POLICY_ID, - connections: { - netsuite: { - verified: options.verified ?? false, - lastSync: { - isAuthenticationError: options.isAuthError ?? false, - }, - }, - }, - } as unknown as Policy; - } - - it('returns true for isAuthenticationError when lastSync has authentication error', () => { - const policy = createPolicy({isAuthError: true}); - expect(isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE)).toBe(true); - }); - - it('returns false for isAuthenticationError when lastSync has no authentication error', () => { - const policy = createPolicy({isAuthError: false}); - expect(isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE)).toBe(false); - }); - - it('returns false for isAuthenticationError when policy is null', () => { - expect(isAuthenticationError(null, CONST.POLICY.CONNECTIONS.NAME.NETSUITE)).toBe(false); - }); - - it('returns true for isConnectionUnverified when NetSuite connection is not verified', () => { - const policy = createPolicy({verified: false}); - expect(isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE)).toBe(true); - }); - - it('returns false for isConnectionUnverified when NetSuite connection is verified', () => { - const policy = createPolicy({verified: true}); - expect(isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE)).toBe(false); - }); - - it('should use connectPolicyToNetSuite for unverified connection with auth error (regression case)', () => { + describe('shouldUseUpdateNetSuiteTokens', () => { + it('returns false for unverified connection with auth error (regression case)', () => { const policy = createPolicy({isAuthError: true, verified: false}); - - // This is the regression scenario: first-time connection failed with bad tokens - // isAuthenticationError is true but connection is unverified, so we must NOT use updateNetSuiteTokens - const shouldUseUpdate = isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE) && !isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); - expect(shouldUseUpdate).toBe(false); + expect(shouldUseUpdateNetSuiteTokens(policy)).toBe(false); }); - it('should use updateNetSuiteTokens for verified connection with auth error', () => { + it('returns true for verified connection with auth error', () => { const policy = createPolicy({isAuthError: true, verified: true}); - - // Existing verified connection lost auth — use updateNetSuiteTokens to preserve config - const shouldUseUpdate = isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE) && !isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); - expect(shouldUseUpdate).toBe(true); + expect(shouldUseUpdateNetSuiteTokens(policy)).toBe(true); }); - it('should use connectPolicyToNetSuite when there is no auth error', () => { + it('returns false when there is no auth error', () => { const policy = createPolicy({isAuthError: false, verified: true}); + expect(shouldUseUpdateNetSuiteTokens(policy)).toBe(false); + }); + + it('returns false when policy is null', () => { + expect(shouldUseUpdateNetSuiteTokens(null)).toBe(false); + }); - const shouldUseUpdate = isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE) && !isConnectionUnverified(policy, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); - expect(shouldUseUpdate).toBe(false); + it('returns false for unverified connection without auth error', () => { + const policy = createPolicy({isAuthError: false, verified: false}); + expect(shouldUseUpdateNetSuiteTokens(policy)).toBe(false); }); }); }); From ef2ea1d09792b447d5321b24ae06e6789650526f Mon Sep 17 00:00:00 2001 From: Yifan Goh Date: Tue, 17 Mar 2026 12:41:28 +0800 Subject: [PATCH 3/3] Fix TypeScript error: use undefined instead of null for OnyxEntry OnyxEntry is defined as T | undefined (not T | null), so the test was passing null which is not assignable to the parameter type. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/actions/connections/NetSuite.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/actions/connections/NetSuite.ts b/tests/actions/connections/NetSuite.ts index de297a977b999..eec23cb6d1fe9 100644 --- a/tests/actions/connections/NetSuite.ts +++ b/tests/actions/connections/NetSuite.ts @@ -148,8 +148,8 @@ describe('actions/connections/NetSuite', () => { expect(shouldUseUpdateNetSuiteTokens(policy)).toBe(false); }); - it('returns false when policy is null', () => { - expect(shouldUseUpdateNetSuiteTokens(null)).toBe(false); + it('returns false when policy is undefined', () => { + expect(shouldUseUpdateNetSuiteTokens(undefined)).toBe(false); }); it('returns false for unverified connection without auth error', () => {