From c5ca87d597f44119a1daa9f599fbcdd338740ae8 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:25:55 +0000 Subject: [PATCH 1/2] Refactor navigation history, duplicate transaction and bank connection types Seatbelt-Operation: session4-sign-28673b16-6202-4c89-baec-1773e1dedf28 --- .../addPushParamsRouterExtension.ts | 35 +++--- .../addRootHistoryRouterExtension.ts | 22 ++-- .../addRootHistoryRouterExtensionUtils.ts | 43 +++---- src/libs/TransactionUtils/duplicates.ts | 8 +- src/libs/TransactionUtils/index.ts | 20 +++- .../getCompanyCardBankConnection/index.tsx | 9 +- .../unit/GetCompanyCardBankConnectionTest.ts | 94 +++++++++++++++ .../addPushParamsRouterExtension.test.ts | 85 +++++++++++++- .../addRootHistoryRouterExtension.test.ts | 77 +++++++++++- tests/unit/TransactionUtilsTest.ts | 111 ++++++++++++++++++ 10 files changed, 430 insertions(+), 74 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts b/src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts index ebe8acbf39a3..b7e2506cb53a 100644 --- a/src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts +++ b/src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts @@ -2,20 +2,21 @@ import compoundParamsKey from '@libs/compoundParamsKey'; import type {PlatformStackNavigationState, PlatformStackRouterFactory, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types'; import type {GoBackAction, SetParamsAction} from '@libs/Navigation/types'; import {cancelPendingFocusRestore, notifyPushParamsBackward, notifyPushParamsForward} from '@libs/NavigationFocusReturn'; +import {isRecord} from '@libs/ObjectUtils'; import CONST from '@src/CONST'; -import type {NavigationRoute, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native'; +import type {ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native'; import {CommonActions} from '@react-navigation/native'; -import type {CustomHistoryEntry, PushParamsActionType, PushParamsRouterAction} from './types'; +import type {PushParamsActionType, PushParamsRouterAction} from './types'; import {enhanceStateWithHistory} from './utils'; -function preserveHistoryForRoutes(oldHistory: CustomHistoryEntry[], routes: Array<{key?: string}>): CustomHistoryEntry[] { +function preserveHistoryForRoutes(oldHistory: unknown[], routes: Array<{key?: string}>): unknown[] { const remainingKeys = new Set(routes.map((r) => r.key)); - return oldHistory.filter((entry) => typeof entry === 'string' || remainingKeys.has(entry.key)); + return oldHistory.filter((entry) => !isRecord(entry) || typeof entry.key !== 'string' || remainingKeys.has(entry.key)); } // noop=match at cursor; backward/forward=move cursor to match; ambiguous=same compound at cursor±1 (see ambiguous branch); unknown=target not in history. @@ -25,7 +26,7 @@ type ResetOutcome = {type: 'noop'; cursor: number} | {type: 'backward'; cursor: * Classifies a RESET's target against our PUSH_PARAMS history + cursor so the caller fires the right focus-return notification and updates the cursor. * Without the 'noop' branch, `useNavigationResetOnLayoutChange`'s reflexive `reset(getState())` on window resize would be treated as a real navigation — cancelling any pending Esc-triggered focus restore so focus never returns to the trigger. */ -function resolveCursorForReset(history: CustomHistoryEntry[], currentCursor: number, newFocused: {key: string; params: unknown}): ResetOutcome { +function resolveCursorForReset(history: unknown[], currentCursor: number, newFocused: {key: string; params: unknown}): ResetOutcome { const inRange = currentCursor >= 0 && currentCursor < history.length; // Snapped cursor drives direction inference only; adjacent probes are gated on inRange. const cursor = inRange ? currentCursor : history.length - 1; @@ -36,7 +37,7 @@ function resolveCursorForReset(history: CustomHistoryEntry[], currentCursor: num return false; } const entry = history.at(index); - if (typeof entry === 'string' || !entry || entry.key !== newFocused.key) { + if (!isRecord(entry) || entry.key !== newFocused.key) { return false; } return compoundParamsKey(entry.key, entry.params) === newCompound; @@ -134,7 +135,10 @@ function addPushParamsRouterExtension>, configOptions: RouterConfigOptions) => { + const getRehydratedState = ( + partialState: PartialState> | PlatformStackNavigationState, + configOptions: RouterConfigOptions, + ) => { const state = router.getRehydratedState(partialState, configOptions); return seedCursor(enhanceStateWithHistory(state)); }; @@ -178,29 +182,29 @@ function addPushParamsRouterExtension => typeof entry !== 'string'); + const routeHistoryEntries = state.history.filter((entry) => typeof entry !== 'string'); if (routeHistoryEntries.length > state.routes.length) { // Index-based, not at(-1) — must match the key PUSH_PARAMS captured under. const focusedRoute = state.routes.at(state.index) ?? state.routes.at(-1); if (focusedRoute) { // Cursor-relative, not last-two: last-two drifts out of sync after a mid-cursor browser-back RESET. - const history = state.history as CustomHistoryEntry[]; + const history = state.history; const currentIdx = pushParamsHistoryPosition >= 0 && pushParamsHistoryPosition < history.length ? pushParamsHistoryPosition : history.length - 1; const currentEntry = history.at(currentIdx); - if (currentEntry && typeof currentEntry !== 'string' && currentEntry.key === focusedRoute.key) { + if (isRecord(currentEntry) && currentEntry.key === focusedRoute.key) { let prevIdx = -1; for (let i = currentIdx - 1; i >= 0; i -= 1) { const e = history.at(i); - if (e && typeof e !== 'string' && e.key === focusedRoute.key) { + if (isRecord(e) && e.key === focusedRoute.key) { prevIdx = i; break; } } - if (prevIdx >= 0) { - const prevEntry = history.at(prevIdx) as NavigationRoute; + const prevEntry = history.at(prevIdx); + if (prevIdx >= 0 && isRecord(prevEntry) && (prevEntry.params === undefined || (typeof prevEntry.params === 'object' && prevEntry.params !== null))) { const targetParams = prevEntry.params; const routes = [...state.routes]; routes[state.index] = { @@ -232,7 +236,7 @@ function addPushParamsRouterExtension 0 ? preservedHistory.length - 1 : -1; return { @@ -254,14 +258,13 @@ function addPushParamsRouterExtension>, configOptions: RouterConfigOptions) => { + const getRehydratedState = (partialState: PartialState | RootHistoryState, configOptions: RouterConfigOptions) => { const state = router.getRehydratedState(partialState, configOptions); const stateWithInitialHistory = enhanceStateWithHistory(state); @@ -47,17 +46,16 @@ function addRootHistoryRouterExtension 0) { - stateWithInitialHistory.history = [...(asCustomHistory(stateWithInitialHistory.history) ?? []), ...trailingSentinels]; + const trailingStringEntries = getTrailingStringEntries(state.history); + if (trailingStringEntries.length > 0) { + stateWithInitialHistory.history = [...(stateWithInitialHistory.history ?? []), ...trailingStringEntries]; } return stateWithInitialHistory; }; - // Centralizes the `PartialState | FullState` cast to `getRehydratedState`'s input shape. function rehydrate(newState: PartialState | RootHistoryState, configOptions: RouterConfigOptions) { - return getRehydratedState(newState as PartialState, configOptions); + return getRehydratedState(newState, configOptions); } const getStateForAction = (state: RootHistoryState, action: RootStackNavigatorAction, configOptions: RouterConfigOptions) => { @@ -112,8 +110,8 @@ function addRootHistoryRouterExtension; type PendingReveal = {rhpKey: string; routesLengthAtCapture: number; historyLengthAtCapture: number}; type RehydrateRootHistoryState = (newState: PartialState | RootHistoryState, configOptions: RouterConfigOptions) => RootHistoryState; @@ -31,7 +29,7 @@ function isDismissModalAction(action: RootStackNavigatorAction): action is Dismi } /** Returns true if the entry is a per-instance Modal back-guard (`CUSTOM_HISTORY_ENTRY_MODAL:`). */ -function isModalHistorySentinel(entry: CustomHistoryEntry | undefined): boolean { +function isModalHistoryGuardEntry(entry: unknown): boolean { return typeof entry === 'string' && entry.startsWith(`${CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MODAL}:`); } @@ -40,21 +38,20 @@ function isModalHistorySentinel(entry: CustomHistoryEntry | undefined): boolean * re-appended after `enhanceStateWithHistory` rebuilds `history` from `routes`. This keeps those * overlays' browser entries alive through benign history rebuilds (e.g. RESET / resize). */ -function getTrailingStringSentinels(history: unknown[] | undefined): string[] { - const typed = asCustomHistory(history); - if (!typed?.length) { +function getTrailingStringEntries(history: unknown[] | undefined): string[] { + if (!history?.length) { return []; } - let cutoff = typed.length; - while (cutoff > 0 && typeof typed.at(cutoff - 1) === 'string') { + let cutoff = history.length; + while (cutoff > 0 && typeof history.at(cutoff - 1) === 'string') { cutoff -= 1; } - return typed.slice(cutoff).filter((entry): entry is string => typeof entry === 'string'); + return history.slice(cutoff).filter((entry): entry is string => typeof entry === 'string'); } /** Removes only the topmost modal back-guard entry (used to consume one guard on forward navigation). */ -function stripTrailingModalSentinels(history: CustomHistoryEntry[]): CustomHistoryEntry[] { - if (isModalHistorySentinel(history.at(-1))) { +function removeTopModalHistoryGuardEntry(history: unknown[]): unknown[] { + if (isModalHistoryGuardEntry(history.at(-1))) { return history.slice(0, -1); } return history; @@ -64,13 +61,8 @@ function isRightModalNavigatorRouteName(name: string | undefined): boolean { return name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; } -/** Centralizes the cast from PlatformStackNavigationState's `unknown[]` history slot to our typed array. */ -function asCustomHistory(history: unknown[] | undefined): CustomHistoryEntry[] | undefined { - return history as CustomHistoryEntry[] | undefined; -} - /** Counts the leading `CUSTOM_HISTORY_ENTRY_REVEAL_PADDING` padding entries in a history array. */ -function countLeadingRevealPadding(history: CustomHistoryEntry[] | undefined): number { +function countLeadingRevealPadding(history: unknown[] | undefined): number { if (!history?.length) { return 0; } @@ -87,11 +79,11 @@ function countLeadingRevealPadding(history: CustomHistoryEntry[] | undefined): n } /** Returns a fresh history array with `offset` reveal-padding entries prepended. */ -function buildPaddedHistory(baseHistory: CustomHistoryEntry[], offset: number): CustomHistoryEntry[] { +function buildPaddedHistory(baseHistory: unknown[], offset: number): unknown[] { if (offset <= 0) { return [...baseHistory]; } - const padding = new Array(offset).fill(CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_REVEAL_PADDING); + const padding = new Array(offset).fill(CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_REVEAL_PADDING); return [...padding, ...baseHistory]; } @@ -154,7 +146,7 @@ function getRevealDismissState( // Apply the reveal fix only when this DISMISS closes the same RHP we snapshotted. if (dismissingTopKey === pendingReveal.rhpKey && depthMatches && historyDepthMatches) { const rehydrated = rehydrate(newState, configOptions); - const rehydratedHistory = asCustomHistory(rehydrated.history) ?? []; + const rehydratedHistory = rehydrated.history ?? []; // rehydratedHistory already includes any trailing SIDE_PANEL entry, // so it does not inflate the computed offset. const lengthDelta = (state.history?.length ?? 0) - rehydratedHistory.length; @@ -183,9 +175,9 @@ function getRevealDismissState( function applyRevealPaddingOffset(state: RootHistoryState, rehydrated: RootHistoryState): RootHistoryState { // Regular navigation rebuilds history from routes; put back any fake slots already in use. - const offset = countLeadingRevealPadding(asCustomHistory(state.history)); + const offset = countLeadingRevealPadding(state.history); if (offset > 0) { - return {...rehydrated, history: buildPaddedHistory(asCustomHistory(rehydrated.history) ?? [], offset)}; + return {...rehydrated, history: buildPaddedHistory(rehydrated.history ?? [], offset)}; } return rehydrated; } @@ -193,14 +185,13 @@ function applyRevealPaddingOffset(state: RootHistoryState, rehydrated: RootHisto export type {PendingReveal, RootHistoryState}; export { applyRevealPaddingOffset, - asCustomHistory, getFrozenHistoryStateForRemoveFullscreenUnderRHP, getFrozenHistoryStateForReplaceFullscreenUnderRHP, getRevealDismissState, - getTrailingStringSentinels, + getTrailingStringEntries, isDismissModalAction, - isModalHistorySentinel, + isModalHistoryGuardEntry, isRemoveFullscreenUnderRHPAction, isReplaceFullscreenUnderRHPAction, - stripTrailingModalSentinels, + removeTopModalHistoryGuardEntry, }; diff --git a/src/libs/TransactionUtils/duplicates.ts b/src/libs/TransactionUtils/duplicates.ts index 7097c3b79ebc..2df6d9021094 100644 --- a/src/libs/TransactionUtils/duplicates.ts +++ b/src/libs/TransactionUtils/duplicates.ts @@ -209,7 +209,7 @@ function isReportMergeableForDuplicates(report: OnyxEntry): boolean { * merge request that would fail server-side. */ function removeSettledAndApprovedTransactions(transactions: Array>): Transaction[] { - return transactions.filter((transaction) => !!transaction && isReportMergeableForDuplicates(getReportOrDraftReport(transaction.reportID))) as Transaction[]; + return transactions.filter((transaction): transaction is Transaction => !!transaction && isReportMergeableForDuplicates(getReportOrDraftReport(transaction.reportID))); } /** @@ -443,11 +443,11 @@ function buildMergeDuplicatesParams( originalTransaction: Partial, ): MergeDuplicatesParams { return { - amount: -getAmount(originalTransaction as OnyxEntry, true), + amount: -getAmount(originalTransaction, true), reportID: originalTransaction?.reportID, receiptID: originalTransaction?.receipt?.receiptID ?? CONST.DEFAULT_NUMBER_ID, - currency: getCurrency(originalTransaction as OnyxEntry), - created: getFormattedCreated(originalTransaction as OnyxEntry), + currency: getCurrency(originalTransaction), + created: getFormattedCreated(originalTransaction), transactionID: reviewDuplicates?.transactionID, transactionIDList: removeSettledAndApprovedTransactions(duplicatedTransactions ?? []).map((transaction) => transaction.transactionID), billable: reviewDuplicates?.billable ?? false, diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 55710a43a93d..8d84a602b83f 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -466,7 +466,7 @@ function isAmountMissing(transaction: OnyxEntry, isFromExpenseRepor return (transaction?.amount === 0 || transaction?.amount === undefined) && (!transaction?.modifiedAmount || transaction?.modifiedAmount === 0 || transaction?.modifiedAmount === ''); } -function hasValidModifiedAmount(transaction: OnyxEntry | null): boolean { +function hasValidModifiedAmount(transaction: OnyxInputOrEntry>>): boolean { if (!transaction) { return false; } @@ -516,7 +516,13 @@ function getDescription(transaction: OnyxInputOrEntry): string { /** * Return the amount field from the transaction, return the modifiedAmount if present. */ -function getAmount(transaction: OnyxInputOrEntry, isFromExpenseReport = false, isFromTrackedExpense = false, allowNegative = false, disableOppositeConversion = false): number { +function getAmount( + transaction: OnyxInputOrEntry>>, + isFromExpenseReport = false, + isFromTrackedExpense = false, + allowNegative = false, + disableOppositeConversion = false, +): number { // IOU requests cannot have negative values, but they can be stored as negative values, let's return absolute value if (!isFromExpenseReport && !isFromTrackedExpense && !allowNegative) { const amount = Number(transaction?.modifiedAmount) ?? 0; @@ -588,7 +594,7 @@ function getFormattedPostedDate(transaction: OnyxInputOrEntry, date /** * Return the currency field from the transaction, return the modifiedCurrency if present. */ -function getCurrency(transaction: OnyxInputOrEntry>): string { +function getCurrency(transaction: OnyxInputOrEntry>>): string { const currency = transaction?.modifiedCurrency ?? ''; if (currency) { return currency; @@ -985,7 +991,7 @@ function getTagForDisplay(transaction: OnyxEntry>, tagI return getCommaSeparatedTagNameWithSanitizedColons(getTag(transaction, tagIndex)); } -function getCreated(transaction: OnyxInputOrEntry): string { +function getCreated(transaction: OnyxInputOrEntry>>): string { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return transaction?.modifiedCreated ? transaction.modifiedCreated : transaction?.created || ''; } @@ -993,7 +999,11 @@ function getCreated(transaction: OnyxInputOrEntry): string { /** * Return the created field from the transaction, return the modifiedCreated if present. */ -function getFormattedCreated(transaction: OnyxInputOrEntry, dateFormat: string = CONST.DATE.FNS_FORMAT_STRING, dateFnsLocale?: DateFnsLocale): string { +function getFormattedCreated( + transaction: OnyxInputOrEntry>>, + dateFormat: string = CONST.DATE.FNS_FORMAT_STRING, + dateFnsLocale?: DateFnsLocale, +): string { const created = getCreated(transaction); return DateUtils.formatWithUTCTimeZone(created, dateFormat, dateFnsLocale); } diff --git a/src/libs/actions/getCompanyCardBankConnection/index.tsx b/src/libs/actions/getCompanyCardBankConnection/index.tsx index 877fc03ed653..ac171738a9d9 100644 --- a/src/libs/actions/getCompanyCardBankConnection/index.tsx +++ b/src/libs/actions/getCompanyCardBankConnection/index.tsx @@ -1,6 +1,7 @@ import {getApiRoot} from '@libs/ApiUtils'; import {splitCardFeedWithDomainID} from '@libs/CardUtils'; import * as NetworkStore from '@libs/Network/NetworkStore'; +import {getObjectKeys, hasKey} from '@libs/ObjectUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import CONST from '@src/CONST'; @@ -21,7 +22,7 @@ type PersonalCardBankConnection = { }; function getCompanyCardBankConnection(policyID?: string, bankName?: string | null, feed?: CompanyCardFeedWithDomainID) { - const bankConnection = Object.keys(CONST.COMPANY_CARDS.BANKS).find((key) => CONST.COMPANY_CARDS.BANKS[key as keyof typeof CONST.COMPANY_CARDS.BANKS] === bankName); + const bankConnection = getObjectKeys(CONST.COMPANY_CARDS.BANKS).find((key) => CONST.COMPANY_CARDS.BANKS[key] === bankName); if (!bankName || !bankConnection || !policyID) { return null; @@ -46,7 +47,7 @@ function getCompanyCardBankConnection(policyID?: string, bankName?: string | nul queryParams.feed = selectedFeed.feedName; } - const bank = CONST.COMPANY_CARDS.BANK_CONNECTIONS[bankConnection as keyof typeof CONST.COMPANY_CARDS.BANK_CONNECTIONS]; + const bank = hasKey(CONST.COMPANY_CARDS.BANK_CONNECTIONS, bankConnection) ? CONST.COMPANY_CARDS.BANK_CONNECTIONS[bankConnection] : undefined; // The Amex connection whitelists only our production servers, so we need to always use the production API for American Express const forceProductionAPI = bank === CONST.COMPANY_CARDS.BANK_CONNECTIONS.AMEX; @@ -60,7 +61,7 @@ function getCompanyCardBankConnection(policyID?: string, bankName?: string | nul } function getPersonalCardBankConnection(bankName?: string | null) { - const bankConnection = Object.keys(CONST.PERSONAL_CARDS.BANKS).find((key) => CONST.PERSONAL_CARDS.BANKS[key as keyof typeof CONST.PERSONAL_CARDS.BANKS] === bankName); + const bankConnection = getObjectKeys(CONST.PERSONAL_CARDS.BANKS).find((key) => CONST.PERSONAL_CARDS.BANKS[key] === bankName); if (!bankName || !bankConnection) { return null; @@ -71,7 +72,7 @@ function getPersonalCardBankConnection(bankName?: string | null) { isNewDot: 'true', scrapeMinDate: '', }; - const bank = CONST.PERSONAL_CARDS.BANK_CONNECTIONS[bankConnection as keyof typeof CONST.PERSONAL_CARDS.BANK_CONNECTIONS]; + const bank = hasKey(CONST.PERSONAL_CARDS.BANK_CONNECTIONS, bankConnection) ? CONST.PERSONAL_CARDS.BANK_CONNECTIONS[bankConnection] : undefined; // The Amex connection whitelists only our production servers, so we need to always use the production API for American Express const forceProductionAPI = bank === CONST.PERSONAL_CARDS.BANK_CONNECTIONS.AMEX; diff --git a/tests/unit/GetCompanyCardBankConnectionTest.ts b/tests/unit/GetCompanyCardBankConnectionTest.ts index 9fe2ae3520a9..9393c88f998f 100644 --- a/tests/unit/GetCompanyCardBankConnectionTest.ts +++ b/tests/unit/GetCompanyCardBankConnectionTest.ts @@ -1,7 +1,9 @@ import {getCompanyCardBankConnection, getPersonalCardBankConnection} from '@libs/actions/getCompanyCardBankConnection'; import type * as ApiUtilsModule from '@libs/ApiUtils'; import {getApiRoot} from '@libs/ApiUtils'; +import {getCardFeedWithDomainID} from '@libs/CardUtils'; import * as NetworkStore from '@libs/Network/NetworkStore'; +import {getObjectKeys, hasKey} from '@libs/ObjectUtils'; import CONST from '@src/CONST'; @@ -12,6 +14,10 @@ jest.mock('@libs/ApiUtils', () => ({ jest.mock('@libs/Network/NetworkStore', () => ({getAuthToken: jest.fn(() => 'test-token')})); describe('getCompanyCardBankConnection', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(NetworkStore.getAuthToken).mockReturnValue('test-token'); + }); it('preserves the selected Amex feed suffix and its originating domain', () => { // Given a domain-level Amex feed displayed in a workspace const feed = 'oauth.americanexpressfdx.com 4001#4055089'; @@ -56,3 +62,91 @@ describe('getCompanyCardBankConnection', () => { expect(parsedURL.searchParams.has('isCorporate')).toBe(false); }); }); + +describe('bank callback contracts', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(NetworkStore.getAuthToken).mockReturnValue('test-token'); + }); + + it.each(getObjectKeys(CONST.COMPANY_CARDS.BANKS))('keeps the company callback mapping for %s', (key) => { + // Given the closed display-bank table used by workspace card setup + const bankName = CONST.COMPANY_CARDS.BANKS[key]; + const bank = hasKey(CONST.COMPANY_CARDS.BANK_CONNECTIONS, key) ? CONST.COMPANY_CARDS.BANK_CONNECTIONS[key] : undefined; + + // When a recognized bank starts a company connection + const url = getCompanyCardBankConnection('ABC123', bankName); + + // Then even recognized unmapped banks retain their historical callback and company fields + expect(url).not.toBeNull(); + const parsed = new URL(url ?? ''); + expect(parsed.pathname).toBe(`/partners/banks/${bank}/oauth_callback.php`); + expect(Object.fromEntries(parsed.searchParams)).toEqual({ + authToken: 'test-token', + isNewDot: 'true', + domainName: `${CONST.EXPENSIFY_POLICY_DOMAIN}abc123${CONST.EXPENSIFY_POLICY_DOMAIN_EXTENSION}`, + isCorporate: 'true', + scrapeMinDate: '', + }); + expect(getApiRoot).toHaveBeenCalledWith({shouldSkipWebProxy: true}, key === 'AMEX'); + }); + + it.each(getObjectKeys(CONST.PERSONAL_CARDS.BANKS))('keeps the personal callback mapping for %s', (key) => { + // Given the display-bank table used by personal card setup + const bankName = CONST.PERSONAL_CARDS.BANKS[key]; + const bank = hasKey(CONST.PERSONAL_CARDS.BANK_CONNECTIONS, key) ? CONST.PERSONAL_CARDS.BANK_CONNECTIONS[key] : undefined; + + // When the cardholder starts a personal connection + const url = getPersonalCardBankConnection(bankName); + + // Then the personal query stays distinct from the workspace query, including unmapped OTHER + expect(url).not.toBeNull(); + const parsed = new URL(url ?? ''); + expect(parsed.pathname).toBe(`/partners/banks/${bank}/oauth_callback.php`); + expect(Object.fromEntries(parsed.searchParams)).toEqual({authToken: 'test-token', isNewDot: 'true', scrapeMinDate: ''}); + expect(getApiRoot).toHaveBeenCalledWith({shouldSkipWebProxy: true, command: ''}, key === 'AMEX'); + }); + + it.each([undefined, null, '', 'unrecognized bank'])('rejects absent or unknown bank %s', (bankName) => { + // Given a missing display selection or a name outside both tables + // When either real connection builder receives it + const company = getCompanyCardBankConnection('ABC123', bankName); + const personal = getPersonalCardBankConnection(bankName); + + // Then no callback URL or API-root lookup is produced + expect(company).toBeNull(); + expect(personal).toBeNull(); + expect(getApiRoot).not.toHaveBeenCalled(); + }); + + it('rejects a missing policy and keeps the empty authentication fallback', () => { + // Given no auth token, as can occur before the network store is initialized + jest.mocked(NetworkStore.getAuthToken).mockReturnValue(undefined); + + // When a company selection lacks a policy or valid selections have no auth token + expect(getCompanyCardBankConnection(undefined, CONST.COMPANY_CARDS.BANKS.AMEX)).toBeNull(); + const company = getCompanyCardBankConnection('ABC123', CONST.COMPANY_CARDS.BANKS.AMEX); + const personal = getPersonalCardBankConnection(CONST.PERSONAL_CARDS.BANKS.AMEX); + + // Then both valid callbacks explicitly send the empty token + expect(company).not.toBeNull(); + expect(personal).not.toBeNull(); + expect(new URL(company ?? '').searchParams.get('authToken')).toBe(''); + expect(new URL(personal ?? '').searchParams.get('authToken')).toBe(''); + }); + + it.each([4055089, 0, 'invalid', '4055089#extra'])('uses the encoded originating domain %s', (domainID) => { + // Given a feed encoded by the real producer, including rejected domain encodings + const feed = getCardFeedWithDomainID('oauth.americanexpressfdx.com 4001', domainID); + + // When reconnecting a feed displayed in a different workspace + const url = getCompanyCardBankConnection('ABC123', CONST.COMPANY_CARDS.BANKS.AMEX, feed); + + // Then the suffix survives, numeric zero stays falsy, and rejected feeds add neither identifier + expect(url).not.toBeNull(); + const parsed = new URL(url ?? ''); + expect(parsed.searchParams.get('domainName')).toBe(`${CONST.EXPENSIFY_POLICY_DOMAIN}abc123${CONST.EXPENSIFY_POLICY_DOMAIN_EXTENSION}`); + expect(parsed.searchParams.get('feed')).toBe(typeof domainID === 'number' ? 'oauth.americanexpressfdx.com 4001' : null); + expect(parsed.searchParams.get('domainAccountID')).toBe(domainID === 4055089 ? '4055089' : null); + }); +}); diff --git a/tests/unit/Navigation/routerExtensions/addPushParamsRouterExtension.test.ts b/tests/unit/Navigation/routerExtensions/addPushParamsRouterExtension.test.ts index 98fb8c702341..eb9142725978 100644 --- a/tests/unit/Navigation/routerExtensions/addPushParamsRouterExtension.test.ts +++ b/tests/unit/Navigation/routerExtensions/addPushParamsRouterExtension.test.ts @@ -111,7 +111,7 @@ function createMockRouterFactory(actionHandler?: (state: TestState, action: Push return makeState([route]); }, - getRehydratedState(partialState: PartialState): TestState { + getRehydratedState(partialState: PartialState | TestState): TestState { const routes = partialState.routes.map((r) => createMock({ key: r.key ?? `${r.name}-rehydrated`, @@ -1083,3 +1083,86 @@ describe('resolveCursorForReset (pure function)', () => { expect(resolveCursorForReset(history, 1, {key: 'other-route', params: {q: 'B'}})).toEqual({type: 'unknown'}); }); }); + +describe('raw PUSH_PARAMS history', () => { + it.each([undefined, Object.freeze({q: 'previous'})])('restores partial keyed snapshots without cloning params %s', (params) => { + // Given framework history whose opaque member contributes to the surplus count + const route = makeRoute('Search', 'search-raw', {q: 'current'}); + const snapshot = Object.freeze({key: route.key, params}); + const opaque = Object.freeze({overlay: 'unrecognized'}); + const history: unknown[] = [snapshot, opaque, {key: route.key, params: route.params}]; + Object.freeze(history); + const state: RouterState = {...makeState([makeRoute('ScreenA', 'other'), route]), history}; + const router = addPushParamsRouterExtension(createMockRouterFactory())(createMock({})); + + // When GO_BACK consumes a partial snapshot with no name/state promise + const result = router.getStateForAction(state, CommonActions.goBack(), CONFIG_OPTIONS); + + // Then raw surplus counting allows restoration and frozen params/opaque entries retain identity + expect(result).not.toBeNull(); + expect(result?.routes).toHaveLength(2); + expect(result?.routes.at(1)?.params).toBe(params); + expect(result?.history).toEqual([snapshot, opaque]); + expect(result?.history?.at(1)).toBe(opaque); + expect(history).toHaveLength(3); + }); + + it('preserves opaque entries and surviving keyed snapshots on RESET', () => { + // Given a framework-typed router seam with partial keyed and opaque history + const route = makeRoute('Search', 'search-raw', Object.freeze({q: 'current'})); + const removedRoute = makeRoute('ScreenB', 'removed'); + const opaque = Object.freeze({overlay: 'unrecognized'}); + const keyed = Object.freeze({key: route.key, params: route.params}); + const history: unknown[] = [opaque, {key: removedRoute.key}, CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_SIDE_PANEL, keyed]; + Object.freeze(history); + const state: RouterState = {...makeState([route, removedRoute]), index: 0, history}; + const factory = (options: PlatformStackRouterOptions): Router => ({ + ...createMockRouterFactory()(options), + getStateForAction: () => ({...state, routes: [route], index: 0}), + }); + const router = addPushParamsRouterExtension(factory)(createMock({})); + + // When the real extension preserves history for the remaining route + const result = router.getStateForAction(state, CommonActions.reset({...state, routes: [route], index: 0}), CONFIG_OPTIONS); + + // Then only recognized removed-route entries disappear, without cloning surviving values + expect(result).not.toBeNull(); + expect(result?.history).toEqual([opaque, CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_SIDE_PANEL, keyed]); + expect(result?.history?.at(0)).toBe(opaque); + expect(result?.history?.at(-1)).toBe(keyed); + expect(route.params).toEqual({q: 'current'}); + }); + + it.each([null, 'invalid params', 42])('does not install malformed erased params %s onto a trusted route', (params) => { + // Given a malformed history field confined to unknown[] storage + const route = makeRoute('Search', 'search-raw', {q: 'current'}); + const state: RouterState = { + ...makeState([route]), + history: [ + {key: route.key, params}, + {key: route.key, params: route.params}, + ], + }; + const router = addPushParamsRouterExtension(createMockRouterFactory())(createMock({})); + + // When GO_BACK tries to restore that erased field + const result = router.getStateForAction(state, CommonActions.goBack(), CONFIG_OPTIONS); + + // Then the single-route stack fallback cancels rather than fabricating valid route params + expect(result).toBeNull(); + expect(route.params).toEqual({q: 'current'}); + }); + + it('matches partial keys and compound params through opaque gaps with forward distance ties', () => { + // Given frozen raw snapshots, including numeric/string URL equivalence and nested key ordering + const history: unknown[] = [{key: 'search-raw', params: {q: 7, nested: {b: 2, a: 1}}}, null, {overlay: true}, false, {key: 'search-raw', params: {nested: {a: 1, b: 2}, q: '7'}}]; + Object.freeze(history); + + // When RESET searches at an opaque midpoint equally distant from two matches + const outcome = resolveCursorForReset(history, 2, {key: 'search-raw', params: {q: '7', nested: {a: 1, b: 2}}}); + + // Then unconsumed fields do not reject snapshots and the forward match wins the tie + expect(outcome).toEqual({type: 'forward', cursor: 4}); + expect(resolveCursorForReset([{key: 'search-raw'}], 0, {key: 'search-raw', params: undefined})).toEqual({type: 'noop', cursor: 0}); + }); +}); diff --git a/tests/unit/Navigation/routerExtensions/addRootHistoryRouterExtension.test.ts b/tests/unit/Navigation/routerExtensions/addRootHistoryRouterExtension.test.ts index 84a58e75b863..abaf5b9946e1 100644 --- a/tests/unit/Navigation/routerExtensions/addRootHistoryRouterExtension.test.ts +++ b/tests/unit/Navigation/routerExtensions/addRootHistoryRouterExtension.test.ts @@ -1,5 +1,12 @@ import type {RootStackNavigatorAction} from '@libs/Navigation/AppNavigator/createRootStackNavigator/types'; import addRootHistoryRouterExtension from '@libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension'; +import { + applyRevealPaddingOffset, + getFrozenHistoryStateForRemoveFullscreenUnderRHP, + getFrozenHistoryStateForReplaceFullscreenUnderRHP, + getTrailingStringEntries, + removeTopModalHistoryGuardEntry, +} from '@libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtensionUtils'; import type {CustomHistoryEntry} from '@libs/Navigation/AppNavigator/routerExtensions/types'; import type {PlatformStackNavigationState, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -45,10 +52,6 @@ function makeState(routes: Array>, overri }; } -function isCustomHistoryEntry(entry: unknown): entry is CustomHistoryEntry { - return typeof entry === 'string' || (typeof entry === 'object' && entry !== null && 'key' in entry && 'name' in entry); -} - const CONFIG_OPTIONS: RouterConfigOptions = { routeNames: ['ScreenA', 'ScreenB'], routeParamList: {}, @@ -65,14 +68,17 @@ function createMockRouterFactory(actionHandler?: (state: TestState, action: Root return makeState([route]); }, - getRehydratedState(partialState: PartialState): TestState { + getRehydratedState(partialState: PartialState | TestState): TestState { + if (partialState.stale === false) { + return partialState; + } const routes = partialState.routes.map((r) => ({ key: r.key ?? `${r.name}-rehydrated`, name: r.name, params: r.params, })); return makeState(routes, { - history: partialState.history?.filter(isCustomHistoryEntry), + history: partialState.history, }); }, @@ -826,3 +832,62 @@ describe('addRootHistoryRouterExtension', () => { }); }); }); + +describe('raw root history preservation', () => { + it('copies opaque entries through reveal freezes and preserves leading-only padding', () => { + // Given framework history with a frozen opaque member and padding after another entry + const opaque = Object.freeze({overlay: 'unrecognized'}); + const route = makeRoute('ScreenA', 'raw-a'); + const history: unknown[] = [REVEAL_PADDING, opaque, REVEAL_PADDING, route]; + Object.freeze(history); + const state = makeState([route], {history}); + const rehydrate = (newState: PartialState | TestState) => + makeState( + newState.routes.map((r) => makeRoute(r.name, r.key)), + {history: [opaque, route]}, + ); + + // When existing reveal helpers freeze or pad this raw framework history + const replaced = getFrozenHistoryStateForReplaceFullscreenUnderRHP(state, state, CONFIG_OPTIONS, null, rehydrate).state; + const removed = getFrozenHistoryStateForRemoveFullscreenUnderRHP(state, state, CONFIG_OPTIONS, rehydrate); + const padded = applyRevealPaddingOffset(state, makeState([route], {history: [opaque, route]})); + + // Then freezes copy only the container and only the leading padding contributes to the offset + expect(replaced.history).toEqual(history); + expect(removed.history).toEqual(history); + expect(replaced.history).not.toBe(history); + expect(removed.history).not.toBe(history); + expect(replaced.history?.at(1)).toBe(opaque); + expect(padded.history).toEqual([REVEAL_PADDING, opaque, route]); + expect(padded.history?.at(1)).toBe(opaque); + }); + + it('rebuilds routes while preserving the trailing overlay run after an opaque boundary', () => { + // Given a full stale:false state and nested modal guards after an opaque entry + const opaque = Object.freeze({overlay: 'unrecognized'}); + const route = makeRoute('ScreenA', 'raw-a'); + const outer = `${CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MODAL}:outer`; + const inner = `${CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MODAL}:inner`; + const history: unknown[] = [route, outer, opaque, SIDE_PANEL, inner]; + Object.freeze(history); + const state = makeState([route], {history}); + const options = createMock({}); + const underlying = createMockRouterFactory()(options); + const rehydration = jest.spyOn(underlying, 'getRehydratedState'); + const router = addRootHistoryRouterExtension(() => underlying)(options); + + // When the real root extension rehydrates and the top guard is consumed + const result = router.getRehydratedState(state, CONFIG_OPTIONS); + const consumed = removeTopModalHistoryGuardEntry(history); + + // Then stale:false reaches the underlying router and only the trailing strings survive the intentional rebuild + expect(rehydration).toHaveBeenCalledWith(state, CONFIG_OPTIONS); + expect(rehydration.mock.calls.at(0)?.at(0)).toBe(state); + expect(result.stale).toBe(false); + expect(result.history).toEqual([route, SIDE_PANEL, inner]); + expect(getTrailingStringEntries(history)).toEqual([SIDE_PANEL, inner]); + expect(consumed).toEqual([route, outer, opaque, SIDE_PANEL]); + expect(consumed.at(2)).toBe(opaque); + expect(history).toHaveLength(5); + }); +}); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 8bc02e5667c8..e674368d946c 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5045,6 +5045,23 @@ describe('TransactionUtils', () => { }; } + it('retains transaction identity and order while removing nullish and settled entries', () => { + // Given real optimistic producers on unresolved, open, submitted and closed reports + const unresolved = generateTransaction({reportID: 'unresolved-duplicate-report'}); + const open = generateTransaction({reportID: MERGE_KEPT_OPEN_REPORT_ID}); + const submitted = generateTransaction({reportID: MERGE_SUBMITTED_REPORT_ID}); + const closed = generateTransaction({reportID: MERGE_CLOSED_REPORT_ID}); + + // When the production filter consumes the nullable Onyx collection values + const result = TransactionUtils.removeSettledAndApprovedTransactions([undefined, unresolved, undefined, open, closed, submitted]); + + // Then the original accepted objects remain in their original order + expect(result).toEqual([unresolved, open, submitted]); + expect(result.at(0)).toBe(unresolved); + expect(result.at(1)).toBe(open); + expect(result.at(2)).toBe(submitted); + }); + it('keeps only duplicates whose report is open or awaiting first approval', () => { const keptTransaction = generateTransaction({reportID: MERGE_KEPT_OPEN_REPORT_ID}); const openDuplicate = generateTransaction({reportID: MERGE_KEPT_OPEN_REPORT_ID}); @@ -5136,6 +5153,100 @@ describe('TransactionUtils', () => { }); describe('buildNewTransactionAfterReviewingDuplicates', () => { + it('passes the real partial producer into merge accessors without an original transaction', () => { + // Given no original Onyx transaction and a valid review payload from the duplicate flow + const source = generateTransaction({comment: {comment: 'original', customUnit: {name: 'Distance', quantity: 8}, waypoints: {waypoint0: {address: 'Origin'}}}}); + const review: ReviewDuplicates = { + transactionID: source.transactionID, + reportID: 'partial-report', + duplicates: [], + merchant: 'Reviewed', + category: '', + tag: '', + taxCode: '', + taxAmount: 0, + description: 'Reviewed description', + comment: source.comment ?? {}, + reimbursable: false, + billable: true, + }; + + // When the real producer builds its partial output and merge consumes it directly + const partial = TransactionUtils.buildNewTransactionAfterReviewingDuplicates(review, undefined); + const params = TransactionUtils.buildMergeDuplicatesParams(review, [undefined], partial); + + // Then absent amount/currency/date are accepted without completing a fictional transaction + expect(Object.hasOwn(partial, 'amount')).toBe(false); + expect(params.amount).toBe(-0); + expect(params.currency).toBe(CONST.CURRENCY.USD); + expect(params.created).toBe(''); + expect(params.transactionIDList).toEqual([]); + expect(partial.comment?.customUnit).toBe(source.comment?.customUnit); + expect(partial.comment?.waypoints).toBe(source.comment?.waypoints); + expect(partial.comment?.comment).toBe('Reviewed description'); + }); + + it('does not fill an absent review comment from the original', () => { + // Given an original distance comment but a review payload that does not supply it + const original = generateTransaction({comment: {customUnit: {name: 'Distance', quantity: 8}, waypoints: {waypoint0: {address: 'Origin'}}}}); + + // When the production merge preview builds a new comment + const partial = TransactionUtils.buildNewTransactionAfterReviewingDuplicates(undefined, original); + + // Then the absent review does not silently inherit distance details from the original + expect(partial.comment).toEqual({comment: undefined}); + expect(Object.hasOwn(partial.comment ?? {}, 'customUnit')).toBe(false); + expect(Object.hasOwn(partial.comment ?? {}, 'waypoints')).toBe(false); + }); + + it.each([undefined, '', 0, '0', -25, '25'])('preserves modified amount coercion and every sign flag for %s', (modifiedAmount) => { + // Given the real preview producer and a modified amount supplied by the transaction model + const partial = TransactionUtils.buildNewTransactionAfterReviewingDuplicates( + undefined, + generateTransaction({amount: -100, currency: 'EUR', modifiedCurrency: '', created: '2023-10-01', modifiedCreated: ''}), + ); + const transaction = {...partial, modifiedAmount}; + const isValid = modifiedAmount !== undefined && modifiedAmount !== ''; + + // When callers select IOU, expense, tracked, negative-allowed or conversion-disabled amounts + const amount = TransactionUtils.getAmount(transaction); + const expense = TransactionUtils.getAmount(transaction, true); + + // Then valid modified zero differs from absent or empty input, and conversion-disabled uses the original + expect(TransactionUtils.hasValidModifiedAmount(transaction)).toBe(isValid); + expect(amount).toBe(isValid ? Math.abs(Number(modifiedAmount)) : 100); + expect(expense).toBe(isValid ? -Number(modifiedAmount) : 100); + expect(TransactionUtils.getAmount(transaction, false, true)).toBe(expense); + expect(TransactionUtils.getAmount(transaction, false, false, true)).toBe(expense); + expect(TransactionUtils.getAmount(transaction, true, false, false, true)).toBe(-100); + expect(TransactionUtils.getCurrency(transaction)).toBe('EUR'); + expect(TransactionUtils.getCreated(transaction)).toBe('2023-10-01'); + expect(TransactionUtils.getFormattedCreated(transaction)).toBe('2023-10-01'); + }); + + it('keeps null-root defaults, original zero and modified currency/date precedence', () => { + // Given the nullable Onyx input accepted by accessors and a real reviewed preview + const transaction = TransactionUtils.buildNewTransactionAfterReviewingDuplicates( + undefined, + generateTransaction({amount: 0, currency: 'EUR', modifiedCurrency: 'GBP', created: '2023-10-01', modifiedCreated: '2023-10-02'}), + ); + + // When reading the input defaults and the preview's modified fields + const amount = TransactionUtils.getAmount(null, true); + const currency = TransactionUtils.getCurrency(transaction); + const created = TransactionUtils.getCreated(transaction); + + // Then original zero avoids negative zero while supplied modified fields take precedence + expect(amount).toBe(0); + expect(TransactionUtils.hasValidModifiedAmount(null)).toBe(false); + expect(TransactionUtils.getCurrency(null)).toBe(CONST.CURRENCY.USD); + expect(TransactionUtils.getCreated(null)).toBe(''); + expect(TransactionUtils.getAmount(transaction, true)).toBe(0); + expect(currency).toBe('GBP'); + expect(created).toBe('2023-10-02'); + expect(TransactionUtils.getFormattedCreated(transaction)).toBe('2023-10-02'); + }); + it('preserves the kept transaction tax amount when the selected tax code matches the existing tax code', () => { const duplicatedTransaction = generateTransaction({ transactionID: 'transaction1', From 94d5c173beb87160391078f325b75345add8191e Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:35:36 +0000 Subject: [PATCH 2/2] Preserve partial transaction input compatibility in duplicate review Seatbelt-Operation: session4-corrected-sign-b69973f5-49aa-4f55-b41a-dfcdc1d1c3a7 --- src/libs/TransactionUtils/index.ts | 2 +- tests/unit/TransactionUtilsTest.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 8d84a602b83f..0f35aef98eee 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -517,7 +517,7 @@ function getDescription(transaction: OnyxInputOrEntry): string { * Return the amount field from the transaction, return the modifiedAmount if present. */ function getAmount( - transaction: OnyxInputOrEntry>>, + transaction: OnyxInputOrEntry>, isFromExpenseReport = false, isFromTrackedExpense = false, allowNegative = false, diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index e674368d946c..9dc3fb3c6df2 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5153,6 +5153,26 @@ describe('TransactionUtils', () => { }); describe('buildNewTransactionAfterReviewingDuplicates', () => { + it('keeps amount and transaction ID absent when neither preview source exists', () => { + // Given neither source is available while the duplicate review data loads + const partial = TransactionUtils.buildNewTransactionAfterReviewingDuplicates(undefined, undefined); + + // When the amount accessor and merge consume the incomplete preview directly + const amount = TransactionUtils.getAmount(partial); + const expenseAmount = TransactionUtils.getAmount(partial, true); + const params = TransactionUtils.buildMergeDuplicatesParams(undefined, [undefined], partial); + + // Then missing metadata stays absent and the existing amount and merge defaults still apply + expect(Object.hasOwn(partial, 'amount')).toBe(false); + expect(Object.hasOwn(partial, 'transactionID')).toBe(false); + expect(amount).toBe(0); + expect(expenseAmount).toBe(0); + expect(params.amount).toBe(-0); + expect(params.currency).toBe(CONST.CURRENCY.USD); + expect(params.created).toBe(''); + expect(params.transactionIDList).toEqual([]); + }); + it('passes the real partial producer into merge accessors without an original transaction', () => { // Given no original Onyx transaction and a valid review payload from the duplicate flow const source = generateTransaction({comment: {comment: 'original', customUnit: {name: 'Distance', quantity: 8}, waypoints: {waypoint0: {address: 'Origin'}}}}); @@ -5177,6 +5197,7 @@ describe('TransactionUtils', () => { // Then absent amount/currency/date are accepted without completing a fictional transaction expect(Object.hasOwn(partial, 'amount')).toBe(false); + expect(partial.transactionID).toBe(review.transactionID); expect(params.amount).toBe(-0); expect(params.currency).toBe(CONST.CURRENCY.USD); expect(params.created).toBe('');