diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e1b5247c7136..f13801258fdd 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1658,8 +1658,6 @@ const CONST = { APPLY_AIRSHIP_UPDATES: 'apply_airship_updates', APPLY_PUSHER_UPDATES: 'apply_pusher_updates', APPLY_HTTPS_UPDATES: 'apply_https_updates', - COMPUTE_REPORT_NAME: 'compute_report_name', - COMPUTE_REPORT_NAME_FOR_NEW_REPORT: 'compute_report_name_for_new_report', COLD: 'cold', WARM: 'warm', REPORT_ACTION_ITEM_LAYOUT_DEBOUNCE_TIME: 1500, diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index 552092dbd1ef..996fa7e9479f 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -8,8 +8,6 @@ import {handleDeletedAccount, HandleUnusedOptimisticID, Logging, Pagination, Rea import FraudMonitoring from '@libs/Middleware/FraudMonitoring'; import {isOffline} from '@libs/Network/NetworkStore'; import {push as pushToSequentialQueue, waitForIdle as waitForSequentialQueueIdle} from '@libs/Network/SequentialQueue'; -import * as OptimisticReportNames from '@libs/OptimisticReportNames'; -import {getUpdateContext, initialize as initializeOptimisticReportNamesContext} from '@libs/OptimisticReportNamesConnectionManager'; import Pusher from '@libs/Pusher'; import {addMiddleware, processWithMiddleware} from '@libs/Request'; import {getAll, getLength as getPersistedRequestsLength} from '@userActions/PersistedRequests'; @@ -18,7 +16,7 @@ import type OnyxRequest from '@src/types/onyx/Request'; import type {PaginatedRequest, PaginationConfig, RequestConflictResolver} from '@src/types/onyx/Request'; import type Response from '@src/types/onyx/Response'; import type {ApiCommand, ApiRequestCommandParameters, ApiRequestType, CommandOfType, ReadCommand, SideEffectRequestCommand, WriteCommand} from './types'; -import {READ_COMMANDS, WRITE_COMMANDS} from './types'; +import {READ_COMMANDS} from './types'; // Setup API middlewares. Each request made will pass through a series of middleware functions that will get called in sequence (each one passing the result of the previous to the next). // Note: The ordering here is intentional as we want to Log, Recheck Connection, Reauthenticate, and Save the Response in Onyx. Errors thrown in one middleware will bubble to the next. @@ -51,11 +49,6 @@ addMiddleware(SaveResponseInOnyx); // FraudMonitoring - Tags the request with the appropriate Fraud Protection event. addMiddleware(FraudMonitoring); -// Initialize OptimisticReportNames context on module load -initializeOptimisticReportNamesContext().catch(() => { - Log.warn('Failed to initialize OptimisticReportNames context'); -}); - let requestIndex = 0; type OnyxData = { @@ -87,30 +80,9 @@ function prepareRequest( const {optimisticData, successData, failureData, ...onyxDataWithoutOptimisticData} = onyxData; - let processedSuccessData = successData; - let processedFailureData = failureData; - if (optimisticData && shouldApplyOptimisticData) { Log.info('[API] Applying optimistic data', false, {command, type}); - - // Process optimistic data through report name middleware - // Skip for OpenReport command to avoid unnecessary processing - if (command === WRITE_COMMANDS.OPEN_REPORT) { - Onyx.update(optimisticData); - } else { - try { - const context = getUpdateContext(); - const processedData = OptimisticReportNames.updateOptimisticReportNamesFromUpdates(optimisticData, context, successData, failureData); - - Onyx.update(processedData.optimisticData); - processedSuccessData = processedData.successData; - processedFailureData = processedData.failureData; - } catch (error) { - Log.hmmm('[API] Failed to process optimistic report names', {error}); - // Fallback to original optimistic data if processing fails - Onyx.update(optimisticData); - } - } + Onyx.update(optimisticData); } const isWriteRequest = type === CONST.API_REQUEST_TYPE.WRITE; @@ -137,8 +109,8 @@ function prepareRequest( initiatedOffline: isOffline(), requestID: requestIndex++, ...onyxDataWithoutOptimisticData, - successData: processedSuccessData, - failureData: processedFailureData, + successData, + failureData, ...conflictResolver, }; diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index 1859abd2ded3..ad4e1c812d56 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -198,40 +198,6 @@ function parsePart(definition: string): FormulaPart { return part; } -/** - * Check if a formula requires backend computation (e.g., currency conversion with exchange rates) - * This is used by OptimisticReportNames to skip optimistic updates when online and backend is needed - */ -function requiresBackendComputation(parts: FormulaPart[], context?: FormulaContext): boolean { - if (!context) { - return false; - } - - const {report} = context; - - for (const part of parts) { - if (part.type === FORMULA_PART_TYPES.REPORT) { - const [field, ...additionalPath] = part.fieldPath; - // Reconstruct format string by joining additional path elements with ':' - // This handles format strings with colons like 'HH:mm:ss' - const format = additionalPath.length > 0 ? additionalPath.join(':') : undefined; - const fieldName = field?.toLowerCase(); - - if (fieldName === 'total' || fieldName === 'reimbursable') { - // Use formatAmount to check whether a currency conversion is needed. - // A null return means the backend must handle the conversion. - // We rely on report.total because zero values can be computed optimistically. - const result = formatAmount(report.total, report.currency, format); - if (result === null) { - return true; - } - } - } - } - - return false; -} - /** * Check if the report field formula value is containing circular references, e.g example: A -> A, A->B->A, A->B->C->A, etc */ @@ -942,6 +908,6 @@ function computePersonalDetailsField(path: string[], personalDetails: PersonalDe } } -export {FORMULA_PART_TYPES, compute, extract, getAutoReportingDates, parse, hasCircularReferences, requiresBackendComputation}; +export {FORMULA_PART_TYPES, compute, parse, hasCircularReferences}; -export type {FormulaContext, FormulaPart, FieldList}; +export type {FormulaContext, FieldList}; diff --git a/src/libs/OptimisticReportNames.ts b/src/libs/OptimisticReportNames.ts deleted file mode 100644 index 1c72f58ed40b..000000000000 --- a/src/libs/OptimisticReportNames.ts +++ /dev/null @@ -1,435 +0,0 @@ -import type {OnyxUpdate} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; -import CONST from '@src/CONST'; -import type {OnyxKey} from '@src/ONYXKEYS'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Transaction} from '@src/types/onyx'; -import type Policy from '@src/types/onyx/Policy'; -import type Report from '@src/types/onyx/Report'; -import type {FormulaContext} from './Formula'; -import {compute, FORMULA_PART_TYPES, parse, requiresBackendComputation} from './Formula'; -import Log from './Log'; -import type {UpdateContext} from './OptimisticReportNamesConnectionManager'; -import Permissions from './Permissions'; -import {getTitleReportField, isArchivedReport} from './ReportUtils'; - -type ReportNameUpdate = { - name: string | undefined; - isPending?: boolean; -}; - -type ProcessedUpdates = { - optimisticData: OnyxUpdate[]; - successData?: OnyxUpdate[]; - failureData?: OnyxUpdate[]; -}; - -type AdditionalUpdates = { - optimisticData: OnyxUpdate[]; - successData: OnyxUpdate[]; - failureData: OnyxUpdate[]; -}; - -/** - * Get the title field from report name value pairs - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- this will be used in near future -function getTitleFieldFromRNVP(reportID: string, context: UpdateContext) { - const reportNameValuePairs = context.allReportNameValuePairs[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; - return reportNameValuePairs?.expensify_text_title; -} - -/** - * Temporary function to get the title field from a policy. Eventually we want to move this to report name value pairs. - * @param policyId - * @param context - */ -function getTitleFieldFromPolicy(policyId: string | undefined, context: UpdateContext) { - if (!policyId) { - return; - } - - const policy = context.allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyId}`]; - if (!policy || !policy.fieldList) { - return; - } - - return getTitleReportField(policy.fieldList); -} - -/** - * Get the object type from an Onyx key - */ -function determineObjectTypeByKey(key: string): 'report' | 'policy' | 'transaction' | 'unknown' { - if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { - return 'report'; - } - if (key.startsWith(ONYXKEYS.COLLECTION.POLICY)) { - return 'policy'; - } - if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { - return 'transaction'; - } - return 'unknown'; -} - -/** - * Extract report ID from an Onyx key - */ -function getReportIDFromKey(key: string): string { - return key.replace(ONYXKEYS.COLLECTION.REPORT, ''); -} - -/** - * Extract policy ID from an Onyx key - */ -function getPolicyIDFromKey(key: string): string { - return key.replace(ONYXKEYS.COLLECTION.POLICY, ''); -} - -/** - * Extract transaction ID from an Onyx key - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- this will be used in near future -function getTransactionIDFromKey(key: string): string { - return key.replace(ONYXKEYS.COLLECTION.TRANSACTION, ''); -} - -/** - * Get report by ID from the reports collection - */ -function getReportByID(reportID: string, allReports: Record): Report | undefined { - return allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; -} - -/** - * Get policy by ID from the policies collection - */ -function getPolicyByID(policyID: string | undefined, allPolicies: Record): Policy | undefined { - if (!policyID) { - return; - } - return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; -} - -/** - * Get transaction by ID from the transactions collection - */ -function getTransactionByID(transactionID: string, allTransactions: Record): Transaction | undefined { - return allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; -} - -/** - * Get all reports associated with a policy ID - */ -function getReportsForNameComputation(policyID: string, allReports: Record, context: UpdateContext): Report[] { - if (policyID === CONST.POLICY.ID_FAKE) { - return []; - } - return Object.values(allReports).filter((report) => { - if (report?.policyID !== policyID) { - return false; - } - - // Filter by type - only reports that support custom names - if (!isValidReportType(report.type)) { - return false; - } - - // Filter by state - exclude reports in high states (like approved or higher) - const stateThreshold = CONST.REPORT.STATE_NUM.APPROVED; - if (report.stateNum && report.stateNum > stateThreshold) { - return false; - } - - // Filter by isArchived - exclude archived reports - const reportNameValuePairs = context.allReportNameValuePairs[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`]; - if (isArchivedReport(reportNameValuePairs)) { - return false; - } - - return true; - }); -} - -/** - * Get the report associated with a transaction ID - */ -function getReportByTransactionID(transactionID: string, context: UpdateContext): Report | undefined { - if (!transactionID) { - return undefined; - } - - const transaction = getTransactionByID(transactionID, context.allTransactions); - - if (!transaction?.reportID) { - return undefined; - } - - // Get the report using the transaction's reportID from context - return getReportByID(transaction.reportID, context.allReports); -} - -/** - * Generate the Onyx key for a report - */ -function getReportKey(reportID: string): OnyxKey { - return `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as OnyxKey; -} - -/** - * Check if a report should have its name automatically computed - */ -function shouldComputeReportName(report: Report, context: UpdateContext): boolean { - if (!report) { - return false; - } - - if (!isValidReportType(report.type)) { - return false; - } - - // Only compute names for expense reports with policies that have title fields - // Check if the report has a title field with a formula in policy - const policyTitleField = getTitleFieldFromPolicy(report.policyID, context); - if (!policyTitleField?.defaultValue) { - return false; - } - return true; -} - -function isValidReportType(reportType?: string): boolean { - if (!reportType) { - return false; - } - return ( - reportType === CONST.REPORT.TYPE.EXPENSE || - reportType === CONST.REPORT.TYPE.INVOICE || - reportType === CONST.REPORT.UNSUPPORTED_TYPE.BILL || - reportType === CONST.REPORT.UNSUPPORTED_TYPE.PAYCHECK || - reportType === 'trip' - ); -} - -/** - * Compute a new report name if needed based on an optimistic update - */ -function computeReportNameIfNeeded(report: Report | undefined, incomingUpdate: OnyxUpdate, context: UpdateContext): ReportNameUpdate | null { - const {allPolicies, isOffline} = context; - - // If no report is provided, extract it from the update (for new reports) - const targetReport = report ?? (incomingUpdate.value as Report); - - if (!targetReport) { - return null; - } - - const policy = getPolicyByID(targetReport.policyID, allPolicies); - - if (!shouldComputeReportName(targetReport, context)) { - return null; - } - - const titleField = getTitleFieldFromPolicy(targetReport.policyID, context); - - // Quick check: see if the update might affect the report name - const updateType = determineObjectTypeByKey(incomingUpdate.key); - const formula = titleField?.defaultValue; - const formulaParts = parse(formula); - - let transaction: Transaction | undefined; - if (updateType === 'transaction') { - transaction = getTransactionByID((incomingUpdate.value as Transaction).transactionID, context.allTransactions); - } - - // Check if any formula part might be affected by this update - const isAffected = formulaParts.some((part) => { - if (part.type === FORMULA_PART_TYPES.REPORT) { - // Checking if the formula part is affected in this manner works, but it could certainly be more precise. - // For example, a policy update only affects the part if the formula in the policy changed, or if the report part references a field on the policy. - // However, if we run into performance problems, this would be a good place to optimize. - return updateType === 'report' || updateType === 'transaction' || updateType === 'policy'; - } - if (part.type === FORMULA_PART_TYPES.FIELD) { - return updateType === 'report'; - } - return false; - }); - - if (!isAffected) { - return null; - } - - // Build context with the updated data - const updatedReport = - updateType === 'report' && targetReport.reportID === getReportIDFromKey(incomingUpdate.key) ? {...targetReport, ...(incomingUpdate.value as Partial)} : targetReport; - - const updatedPolicy = updateType === 'policy' && targetReport.policyID === getPolicyIDFromKey(incomingUpdate.key) ? {...(policy ?? {}), ...(incomingUpdate.value as Policy)} : policy; - - const updatedTransaction = updateType === 'transaction' ? {...(transaction ?? {}), ...(incomingUpdate.value as Transaction)} : undefined; - - // Compute the new name - const formulaContext: FormulaContext = { - report: updatedReport, - policy: updatedPolicy, - transaction: updatedTransaction, - allTransactions: context.allTransactions, - }; - - // When we cannot properly compute the formula (e.g., currency conversion requires exchange rates), - // computing while online causes flickering between incorrect optimistic values and correct backend values. - // Return null to skip optimistic updates and let the backend provide the accurate result. - if (requiresBackendComputation(formulaParts, formulaContext)) { - if (!isOffline) { - return null; - } - - return { - name: targetReport.reportName, - isPending: true, - }; - } - - const newName = compute(formula, formulaContext); - - // Only return an update if the name actually changed - if (newName && newName !== targetReport.reportName) { - Log.info('[OptimisticReportNames] Report name computed', false, { - updateType, - isNewReport: !report, - }); - - return { - name: newName, - }; - } - - return null; -} - -/** - * Add computed report name updates to the provided update buckets. - * Appends optimistic data and clears pending fields on success/failure if needed. - */ -function addAdditionalUpdates(reportID: string, reportNameUpdate: ReportNameUpdate, additionalUpdates: AdditionalUpdates) { - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - additionalUpdates.optimisticData.push({ - key: getReportKey(reportID), - onyxMethod: Onyx.METHOD.MERGE, - value: { - reportName: reportNameUpdate.name, - ...(reportNameUpdate.isPending && { - pendingFields: {reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, - }), - }, - }); - - // If there's a pending field, add updates to clear it in success/failure data - if (reportNameUpdate.isPending) { - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const clearPendingUpdate: OnyxUpdate = { - key: getReportKey(reportID), - onyxMethod: Onyx.METHOD.MERGE, - value: {pendingFields: {reportName: null}}, - }; - - additionalUpdates.successData.push(clearPendingUpdate); - additionalUpdates.failureData.push(clearPendingUpdate); - } -} - -/** - * Update optimistic report names based on incoming updates - * This is the main middleware function that processes optimistic data - */ -function updateOptimisticReportNamesFromUpdates(optimisticData: OnyxUpdate[], context: UpdateContext, successData?: OnyxUpdate[], failureData?: OnyxUpdate[]): ProcessedUpdates { - const {betas, allReports, betaConfiguration} = context; - - // Check if the feature is enabled - if (!Permissions.isBetaEnabled(CONST.BETAS.CUSTOM_REPORT_NAMES, betas, betaConfiguration)) { - return { - optimisticData, - successData, - failureData, - }; - } - - Log.info('[OptimisticReportNames] Processing optimistic updates for report names', false, { - updatesCount: optimisticData.length, - }); - - const additionalUpdates: AdditionalUpdates = { - optimisticData: [], - successData: [], - failureData: [], - }; - - for (const update of optimisticData) { - const objectType = determineObjectTypeByKey(update.key); - - switch (objectType) { - case 'report': { - const reportID = getReportIDFromKey(update.key); - const report = getReportByID(reportID, allReports); - - // Handle both existing and new reports with the same function - const reportNameUpdate = computeReportNameIfNeeded(report, update, context); - - if (reportNameUpdate) { - addAdditionalUpdates(reportID, reportNameUpdate, additionalUpdates); - } - break; - } - - case 'policy': { - const policyID = getPolicyIDFromKey(update.key); - const affectedReports = getReportsForNameComputation(policyID, allReports, context); - for (const report of affectedReports) { - const reportNameUpdate = computeReportNameIfNeeded(report, update, context); - - if (reportNameUpdate) { - addAdditionalUpdates(report.reportID, reportNameUpdate, additionalUpdates); - } - } - break; - } - - case 'transaction': { - let report: Report | undefined; - const transactionUpdate = update.value as Partial; - if (transactionUpdate.reportID) { - report = getReportByID(transactionUpdate.reportID, allReports); - } else { - report = getReportByTransactionID(getTransactionIDFromKey(update.key), context); - } - - if (report) { - const reportNameUpdate = computeReportNameIfNeeded(report, update, context); - - if (reportNameUpdate) { - addAdditionalUpdates(report.reportID, reportNameUpdate, additionalUpdates); - } - } - break; - } - - default: - continue; - } - } - - Log.info('[OptimisticReportNames] Processing completed', false, { - additionalOptimisticUpdatesCount: additionalUpdates.optimisticData.length, - additionalSuccessUpdatesCount: additionalUpdates.successData.length, - additionalFailureUpdatesCount: additionalUpdates.failureData.length, - }); - - return { - optimisticData: optimisticData.concat(additionalUpdates.optimisticData), - successData: successData ? successData.concat(additionalUpdates.successData) : additionalUpdates.successData, - failureData: failureData ? failureData.concat(additionalUpdates.failureData) : additionalUpdates.failureData, - }; -} - -export {computeReportNameIfNeeded, getReportByTransactionID, shouldComputeReportName, updateOptimisticReportNamesFromUpdates}; -export type {UpdateContext}; diff --git a/src/libs/OptimisticReportNamesConnectionManager.ts b/src/libs/OptimisticReportNamesConnectionManager.ts deleted file mode 100644 index eb932e0f569c..000000000000 --- a/src/libs/OptimisticReportNamesConnectionManager.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type {OnyxEntry} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Beta, BetaConfiguration, Policy, Report, Transaction} from '@src/types/onyx'; -import type ReportNameValuePairs from '@src/types/onyx/ReportNameValuePairs'; - -type UpdateContext = { - betas: OnyxEntry; - betaConfiguration: OnyxEntry; - allReports: Record; - allPolicies: Record; - allReportNameValuePairs: Record; - allTransactions: Record; - isOffline: boolean; -}; - -let betas: OnyxEntry; -let betaConfiguration: OnyxEntry; -let allReports: Record; -let allPolicies: Record; -let allReportNameValuePairs: Record; -let allTransactions: Record; -let isOffline: OnyxEntry; -let isInitialized = false; -let connectionsInitializedCount = 0; -const totalConnections = 7; -let initializationPromise: Promise | null = null; - -/** - * Initialize persistent connections to Onyx data needed for OptimisticReportNames - * This is called lazily when OptimisticReportNames functionality is first used - * Returns a Promise that resolves when all connections have received their initial data - * - * We use Onyx.connectWithoutView because we do not use this in React components and this logic is not tied directly to the UI. - * This is a centralized system that needs access to all objects of several types, so that when any updates affect - * the computed report names, we can compute the new names according to the formula and add the necessary updates. - * It wouldn't be possible to do this without connecting to all the data. - * - */ -function initialize(): Promise { - if (isInitialized) { - return Promise.resolve(); - } - - if (initializationPromise) { - return initializationPromise; - } - - initializationPromise = new Promise((resolve) => { - const incrementInitialization = () => { - connectionsInitializedCount++; - if (connectionsInitializedCount === totalConnections) { - isInitialized = true; - resolve(); - } - }; - - // Connect to BETAS - Onyx.connectWithoutView({ - key: ONYXKEYS.BETAS, - callback: (val) => { - betas = val; - incrementInitialization(); - }, - }); - - // Connect to BETA_CONFIGURATION - Onyx.connectWithoutView({ - key: ONYXKEYS.BETA_CONFIGURATION, - callback: (val) => { - betaConfiguration = val; - incrementInitialization(); - }, - }); - - // Connect to all REPORTS - Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (val) => { - allReports = (val as Record) ?? {}; - incrementInitialization(); - }, - }); - - // Connect to all POLICIES - Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.POLICY, - waitForCollectionCallback: true, - callback: (val) => { - allPolicies = (val as Record) ?? {}; - incrementInitialization(); - }, - }); - - // Connect to all REPORT_NAME_VALUE_PAIRS - Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, - waitForCollectionCallback: true, - callback: (val) => { - allReportNameValuePairs = (val as Record) ?? {}; - incrementInitialization(); - }, - }); - - // Connect to all TRANSACTIONS - Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - allTransactions = (val as Record) ?? {}; - incrementInitialization(); - }, - }); - - // Connect to NETWORK to track offline status - Onyx.connectWithoutView({ - key: ONYXKEYS.NETWORK, - callback: (val) => { - isOffline = val?.isOffline; - incrementInitialization(); - }, - }); - }); - - return initializationPromise; -} - -/** - * Get the current update context synchronously - * Must be called after initialize() has completed - */ -function getUpdateContext(): UpdateContext { - if (!isInitialized) { - throw new Error('OptimisticReportNamesConnectionManager not initialized. Call initialize() first.'); - } - - return { - betas, - betaConfiguration, - allReports: allReports ?? {}, - allPolicies: allPolicies ?? {}, - allReportNameValuePairs: allReportNameValuePairs ?? {}, - allTransactions: allTransactions ?? {}, - isOffline: isOffline ?? false, - }; -} -export {initialize, getUpdateContext}; -export type {UpdateContext}; diff --git a/tests/perf-test/OptimisticReportNames.perf-test.ts b/tests/perf-test/OptimisticReportNames.perf-test.ts deleted file mode 100644 index e40cca17aabb..000000000000 --- a/tests/perf-test/OptimisticReportNames.perf-test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import Onyx from 'react-native-onyx'; -import {measureFunction} from 'reassure'; -import type {UpdateContext} from '@libs/OptimisticReportNames'; -import {computeReportNameIfNeeded, updateOptimisticReportNamesFromUpdates} from '@libs/OptimisticReportNames'; -// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock -import * as ReportUtils from '@libs/ReportUtils'; -import CONST from '@src/CONST'; -import type {OnyxKey} from '@src/ONYXKEYS'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report} from '@src/types/onyx'; -import createCollection from '../utils/collections/createCollection'; -import {createRandomReport} from '../utils/collections/reports'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; - -// Mock dependencies -jest.mock('@libs/ReportUtils', () => ({ - // jest.requireActual is necessary to include multi-layered module imports (eg. Report.ts has processReportIDDeeplink() which uses parseReportRouteParams() imported from getReportIDFromUrl.ts) - // Without jest.requireActual, parseReportRouteParams would be undefined, causing the test to fail. - ...jest.requireActual('@libs/ReportUtils'), - // These methods are mocked below in the beforeAll function to return specific values - isExpenseReport: jest.fn(), - getTitleReportField: jest.fn(), -})); -jest.mock('@libs/Log', () => ({ - info: jest.fn(), -})); - -const mockReportUtils = ReportUtils as jest.Mocked; - -describe('[OptimisticReportNames] Performance Tests', () => { - const REPORTS_COUNT = 1000; - const POLICIES_COUNT = 100; - - const mockPolicy = { - id: 'policy1', - name: 'Test Policy', - fieldList: { - // eslint-disable-next-line @typescript-eslint/naming-convention - text_title: { - defaultValue: '{report:type} - {report:startdate} - {report:total} {report:currency}', - }, - }, - } as unknown as Policy; - - const mockPolicies = createCollection( - (item) => `policy_${item.id}`, - (index) => ({ - ...mockPolicy, - id: `policy${index}`, - name: `Policy ${index}`, - }), - POLICIES_COUNT, - ); - - const mockReports = createCollection( - (item) => `${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`, - (index) => ({ - ...createRandomReport(index, undefined), - policyID: `policy${index % POLICIES_COUNT}`, - total: -(Math.random() * 100000), // Random negative amount - currency: 'USD', - lastVisibleActionCreated: new Date().toISOString(), - }), - REPORTS_COUNT, - ); - - const mockContext: UpdateContext = { - betas: [CONST.BETAS.CUSTOM_REPORT_NAMES], - betaConfiguration: {}, - allReports: mockReports, - allPolicies: mockPolicies, - allReportNameValuePairs: {}, - allTransactions: {}, - isOffline: false, - }; - - beforeAll(async () => { - Onyx.init({keys: ONYXKEYS}); - mockReportUtils.isExpenseReport.mockReturnValue(true); - mockReportUtils.getTitleReportField.mockReturnValue(mockPolicy.fieldList?.text_title); - await waitForBatchedUpdates(); - }); - - afterAll(() => { - Onyx.clear(); - }); - - describe('Single Report Name Computation', () => { - test('[OptimisticReportNames] computeReportNameIfNeeded() single report', async () => { - const report = Object.values(mockReports).at(0); - const update = { - key: `report_${report?.reportID}` as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -20000}, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => computeReportNameIfNeeded(report, update, mockContext)); - }); - }); - - describe('Batch Processing Performance', () => { - test('[OptimisticReportNames] updateOptimisticReportNamesFromUpdates() with 10 new reports', async () => { - const updates = Array.from({length: 10}, (_, i) => ({ - key: `report_new${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: `new${i}`, - policyID: `policy${i % 10}`, - total: -(Math.random() * 50000), - currency: 'USD', - lastVisibleActionCreated: new Date().toISOString(), - }, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, mockContext)); - }); - - test('[OptimisticReportNames] updateOptimisticReportNamesFromUpdates() with 50 existing report updates', async () => { - const reportKeys = Object.keys(mockReports).slice(0, 50) as OnyxKey[]; - const updates = reportKeys.map((key) => ({ - key, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -(Math.random() * 100000)}, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, mockContext)); - }); - - test('[OptimisticReportNames] updateOptimisticReportNamesFromUpdates() with 100 mixed updates', async () => { - const newReportUpdates = Array.from({length: 50}, (_, i) => ({ - key: `report_batch${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: `batch${i}`, - policyID: `policy${i % 20}`, - total: -(Math.random() * 75000), - currency: 'USD', - lastVisibleActionCreated: new Date().toISOString(), - }, - })); - - const existingReportUpdates = Object.keys(mockReports) - .slice(0, 50) - .map((key) => ({ - key: key as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -(Math.random() * 125000)}, - })); - - const allUpdates = [...newReportUpdates, ...existingReportUpdates]; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(allUpdates, mockContext)); - }); - }); - - describe('Policy Update Impact Performance', () => { - test('[OptimisticReportNames] policy update affecting multiple reports', async () => { - const policyUpdate = { - key: 'policy_policy1' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {name: 'Updated Policy Name'}, - }; - - // This should trigger name computation for all reports using policy1 - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates([policyUpdate], mockContext)); - }); - - test('[OptimisticReportNames] multiple policy updates', async () => { - const policyUpdates = Array.from({length: 10}, (_, i) => ({ - key: `policy_policy${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {name: `Bulk Updated Policy ${i}`}, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(policyUpdates, mockContext)); - }); - }); - - describe('Large Dataset Performance', () => { - test('[OptimisticReportNames] processing with large context (1000 reports)', async () => { - const updates = Array.from({length: 1000}, (_, i) => ({ - key: `report_large${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: `large${i}`, - policyID: `policy${i % 50}`, - total: -(Math.random() * 200000), - currency: 'USD', - lastVisibleActionCreated: new Date().toISOString(), - }, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, mockContext)); - }); - - test('[OptimisticReportNames] worst case: many irrelevant updates', async () => { - // Create updates that won't trigger name computation to test filtering performance - const irrelevantUpdates = Array.from({length: 100}, (_, i) => ({ - key: `transaction_${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {description: `Updated transaction ${i}`}, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(irrelevantUpdates, mockContext)); - }); - }); - - describe('Edge Cases Performance', () => { - test('[OptimisticReportNames] reports without formulas', async () => { - // Mock reports with policies that don't have formulas - const contextWithoutFormulas: UpdateContext = { - ...mockContext, - allPolicies: createCollection( - (item) => `policy_${item.id}`, - (index) => - ({ - id: `policy${index}`, - name: `Policy ${index}`, - fieldList: { - // eslint-disable-next-line @typescript-eslint/naming-convention - text_title: { - name: 'Title', - defaultValue: 'Static Title', - fieldID: 'text_title', - orderWeight: 0, - type: 'text' as const, - deletable: true, - values: [], - keys: [], - externalIDs: [], - disabledOptions: [], - isTax: false, - }, - }, - }) as unknown as Policy, - 50, - ), - allReportNameValuePairs: {}, - isOffline: false, - }; - - const updates = Array.from({length: 20}, (_, i) => ({ - key: `report_static${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: `static${i}`, - policyID: `policy${i % 10}`, - total: -10000, - currency: 'USD', - }, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, contextWithoutFormulas)); - }); - - test('[OptimisticReportNames] missing policies and reports', async () => { - const contextWithMissingData: UpdateContext = { - betas: [CONST.BETAS.CUSTOM_REPORT_NAMES], - betaConfiguration: {}, - allReports: {}, - allPolicies: {}, - allReportNameValuePairs: {}, - allTransactions: {}, - isOffline: false, - }; - - const updates = Array.from({length: 10}, (_, i) => ({ - key: `report_missing${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: `missing${i}`, - policyID: 'nonexistent', - total: -10000, - currency: 'USD', - }, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, contextWithMissingData)); - }); - }); -}); diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index d4dda90765b6..c782fbb26397 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line no-restricted-syntax -- disabled because we need CurrencyUtils to mock import * as CurrencyUtils from '@libs/CurrencyUtils'; import type {FormulaContext} from '@libs/Formula'; -import {compute, extract, hasCircularReferences, parse, requiresBackendComputation} from '@libs/Formula'; +import {compute, hasCircularReferences, parse} from '@libs/Formula'; // eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportActionsUtils to mock import * as ReportActionsUtils from '@libs/ReportActionsUtils'; // eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock @@ -28,28 +28,6 @@ const mockReportUtils = ReportUtils as jest.Mocked; const mockCurrencyUtils = CurrencyUtils as jest.Mocked; describe('CustomFormula', () => { - describe('extract()', () => { - test('should extract formula parts with default braces', () => { - expect(extract('{report:type} - {report:total}')).toEqual(['{report:type}', '{report:total}']); - }); - - test('should handle nested braces', () => { - expect(extract('{report:{report:submit:from:firstName|substr:2}}')).toEqual(['{report:{report:submit:from:firstName|substr:2}}']); - }); - - test('should handle escaped braces', () => { - expect(extract('\\{not-formula} {report:type}')).toEqual(['{report:type}']); - }); - - test('should handle empty formula', () => { - expect(extract('')).toEqual([]); - }); - - test('should handle formula without braces', () => { - expect(extract('no braces here')).toEqual([]); - }); - }); - describe('parse()', () => { test('should parse report formula parts', () => { const parts = parse('{report:type} {report:startdate}'); @@ -486,30 +464,17 @@ describe('CustomFormula', () => { test('nosymbol - should format without currency symbol', () => { const result = compute('{report:total:nosymbol}', currencyContext); expect(result).toBe('100.00'); - - // Should not require backend computation - const parts = parse('{report:total:nosymbol}'); - expect(requiresBackendComputation(parts, currencyContext)).toBe(false); }); - test('same currency - should format normally (case insensitive)', () => { currencyContext.report.currency = 'EUR'; expect(compute('{report:total:EUR}', currencyContext)).toBe('€100.00'); expect(compute('{report:total:eur}', currencyContext)).toBe('€100.00'); - - // Should not require backend computation when currencies match - const parts = parse('{report:total:EUR}'); - expect(requiresBackendComputation(parts, currencyContext)).toBe(false); }); test('default (no format) - should use report currency', () => { currencyContext.report.currency = 'NPR'; const result = compute('{report:total}', currencyContext); expect(result).toBe('NPR\u00A0100.00'); - - // Should not require backend computation - const parts = parse('{report:total}'); - expect(requiresBackendComputation(parts, currencyContext)).toBe(false); }); }); @@ -520,10 +485,6 @@ describe('CustomFormula', () => { // Various currencies requiring conversion expect(compute('{report:total:EUR}', currencyContext)).toBe('{report:total:EUR}'); expect(compute('{report:total:JPY}', currencyContext)).toBe('{report:total:JPY}'); - - // Should require backend computation - const parts = parse('{report:total:EUR}'); - expect(requiresBackendComputation(parts, currencyContext)).toBe(true); }); test('case and whitespace handling - should normalize and detect conversion', () => { @@ -534,18 +495,6 @@ describe('CustomFormula', () => { expect(compute('{report:total: EUR }', currencyContext)).toBe('{report:total: EUR }'); expect(compute('{report:total:eur }', currencyContext)).toBe('{report:total:eur }'); }); - - test('in complex formulas - should detect conversion need', () => { - currencyContext.report.currency = 'USD'; - - // Multiple parts where one needs conversion - const parts = parse('Report: {report:type} Total: {report:total:EUR}'); - expect(requiresBackendComputation(parts, currencyContext)).toBe(true); - - // Non-total fields don't need backend - const simpleParts = parse('{report:type} {report:policyname}'); - expect(requiresBackendComputation(simpleParts, currencyContext)).toBe(false); - }); }); describe('Edge cases', () => { diff --git a/tests/unit/OptimisticReportNamesTest.ts b/tests/unit/OptimisticReportNamesTest.ts deleted file mode 100644 index bd005f1003e2..000000000000 --- a/tests/unit/OptimisticReportNamesTest.ts +++ /dev/null @@ -1,471 +0,0 @@ -import Onyx from 'react-native-onyx'; -// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock -import type * as CurrencyUtils from '@libs/CurrencyUtils'; -import type {UpdateContext} from '@libs/OptimisticReportNames'; -import {computeReportNameIfNeeded, getReportByTransactionID, shouldComputeReportName, updateOptimisticReportNamesFromUpdates} from '@libs/OptimisticReportNames'; -// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock -import * as ReportUtils from '@libs/ReportUtils'; -import CONST from '@src/CONST'; -import type {OnyxKey} from '@src/ONYXKEYS'; -import type {Policy, Report, ReportNameValuePairs, Transaction} from '@src/types/onyx'; - -// Mock dependencies -jest.mock('@libs/ReportUtils', () => ({ - ...jest.requireActual('@libs/ReportUtils'), - isExpenseReport: jest.fn(), - getReportTransactions: jest.fn(), -})); - -jest.mock('@libs/CurrencyUtils', () => ({ - ...jest.requireActual('@libs/CurrencyUtils'), - isValidCurrencyCode: jest.fn().mockImplementation((code: string) => ['USD'].includes(code)), -})); - -const mockReportUtils = ReportUtils as jest.Mocked; - -describe('OptimisticReportNames', () => { - const mockPolicy = { - id: 'policy1', - fieldList: { - [CONST.REPORT_FIELD_TITLE_FIELD_ID]: { - fieldID: CONST.REPORT_FIELD_TITLE_FIELD_ID, - defaultValue: '{report:type} - {report:total}', - }, - }, - } as unknown as Policy; - - const mockReport = { - reportID: '123', - reportName: 'Old Name', - policyID: 'policy1', - total: -10000, - currency: 'USD', - lastVisibleActionCreated: '2025-01-15T10:30:00Z', - type: 'expense', - } as Report; - - const mockContext: UpdateContext = { - betas: [CONST.BETAS.CUSTOM_REPORT_NAMES], - betaConfiguration: {}, - allReports: { - // eslint-disable-next-line @typescript-eslint/naming-convention - report_123: mockReport, - }, - allPolicies: { - // eslint-disable-next-line @typescript-eslint/naming-convention - policy_policy1: mockPolicy, - }, - allReportNameValuePairs: { - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_123: { - private_isArchived: '', - // eslint-disable-next-line @typescript-eslint/naming-convention - expensify_text_title: { - defaultValue: '{report:type} - {report:total}', - }, - } as unknown as ReportNameValuePairs, - }, - allTransactions: {}, - isOffline: false, - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockReportUtils.isExpenseReport.mockReturnValue(true); - }); - - describe('shouldComputeReportName()', () => { - test('should return true for report with title field formula', () => { - const result = shouldComputeReportName(mockReport, mockContext); - expect(result).toBe(true); - }); - - test('should return false when no title field', () => { - const context = { - ...mockContext, - allReportNameValuePairs: { - ...mockContext.allReportNameValuePairs, - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_123: { - private_isArchived: '', - }, - }, - allPolicies: {}, - }; - const result = shouldComputeReportName(mockReport, context); - expect(result).toBe(false); - }); - - test('should return false for reports with unsupported type', () => { - mockReportUtils.isExpenseReport.mockReturnValue(false); - - const result = shouldComputeReportName( - { - ...mockReport, - type: 'iou', - } as Report, - mockContext, - ); - expect(result).toBe(false); - }); - }); - - describe('computeReportNameIfNeeded()', () => { - test('should compute name when report data changes', () => { - const update = { - key: 'report_123' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -20000}, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = computeReportNameIfNeeded(mockReport, update, mockContext); - expect(result?.name).toEqual('Expense Report - $200.00'); - }); - - test('should return null when name would not change', () => { - const update = { - key: 'report_456' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {description: 'Updated description'}, - }; - - const result = computeReportNameIfNeeded( - { - ...mockReport, - reportName: 'Expense Report - $100.00', - }, - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - update, - mockContext, - ); - expect(result).toBeNull(); - }); - }); - - describe('updateOptimisticReportNamesFromUpdates()', () => { - test('should detect new report creation and add name update', () => { - const updates = [ - { - key: 'report_456' as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: '456', - policyID: 'policy1', - total: -15000, - currency: 'USD', - type: 'expense', - }, - }, - ]; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates(updates, mockContext); - expect(result.optimisticData).toHaveLength(2); // Original + name update - expect(result.optimisticData.at(1)).toEqual({ - key: 'report_456', - onyxMethod: Onyx.METHOD.MERGE, - value: {reportName: 'Expense Report - $150.00'}, - }); - }); - - test('should handle existing report updates', () => { - const updates = [ - { - key: 'report_123' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -25000}, - }, - ]; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates(updates, mockContext); - expect(result.optimisticData).toHaveLength(2); // Original + name update - expect(result.optimisticData.at(1)?.value).toEqual({reportName: 'Expense Report - $250.00'}); - }); - - test('should handle policy updates affecting multiple reports', () => { - const contextWithMultipleReports = { - ...mockContext, - allReports: { - // eslint-disable-next-line @typescript-eslint/naming-convention - report_123: {...mockReport, reportID: '123'}, - // eslint-disable-next-line @typescript-eslint/naming-convention - report_456: {...mockReport, reportID: '456'}, - // eslint-disable-next-line @typescript-eslint/naming-convention - report_789: {...mockReport, reportID: '789'}, - }, - allPolicies: { - // eslint-disable-next-line @typescript-eslint/naming-convention - policy_policy1: { - id: 'policy1', - fieldList: { - [CONST.REPORT_FIELD_TITLE_FIELD_ID]: { - fieldID: CONST.REPORT_FIELD_TITLE_FIELD_ID, - defaultValue: 'Policy: {report:policyname}', - }, - }, - } as unknown as Policy, - }, - allReportNameValuePairs: { - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_123: {private_isArchived: '', expensify_text_title: {defaultValue: 'Policy: {report:policyname}'}} as unknown as ReportNameValuePairs, - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_456: {private_isArchived: '', expensify_text_title: {defaultValue: 'Policy: {report:policyname}'}} as unknown as ReportNameValuePairs, - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_789: {private_isArchived: '', expensify_text_title: {defaultValue: 'Policy: {report:policyname}'}} as unknown as ReportNameValuePairs, - }, - }; - - const updates = [ - { - key: 'policy_policy1' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {name: 'Updated Policy Name'}, - }, - ]; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates(updates, contextWithMultipleReports); - - expect(result.optimisticData).toHaveLength(4); - - // Assert the original policy update - expect(result.optimisticData.at(0)).toEqual({ - key: 'policy_policy1', - onyxMethod: Onyx.METHOD.MERGE, - value: {name: 'Updated Policy Name'}, - }); - - // Assert individual report name updates - expect(result.optimisticData.at(1)).toEqual({ - key: 'report_123', - onyxMethod: Onyx.METHOD.MERGE, - value: {reportName: 'Policy: Updated Policy Name'}, - }); - - expect(result.optimisticData.at(2)).toEqual({ - key: 'report_456', - onyxMethod: Onyx.METHOD.MERGE, - value: {reportName: 'Policy: Updated Policy Name'}, - }); - - expect(result.optimisticData.at(3)).toEqual({ - key: 'report_789', - onyxMethod: Onyx.METHOD.MERGE, - value: {reportName: 'Policy: Updated Policy Name'}, - }); - }); - - test('should handle unknown object types gracefully', () => { - const updates = [ - { - key: 'unknown_123' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {someData: 'value'}, - }, - ]; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates(updates, mockContext); - expect(result.optimisticData).toEqual(updates); // Unchanged - }); - }); - - describe('Edge Cases', () => { - test('should handle missing report gracefully', () => { - const update = { - key: 'report_999' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -10000}, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = computeReportNameIfNeeded(undefined, update, mockContext); - expect(result).toBeNull(); - }); - }); - - describe('Transaction Updates', () => { - test('should process transaction updates and trigger report name updates', () => { - const contextWithTransaction = { - ...mockContext, - allTransactions: { - // eslint-disable-next-line @typescript-eslint/naming-convention - transactions_txn123: { - transactionID: 'txn123', - reportID: '123', - created: '2024-01-01', - amount: -5000, - currency: 'USD', - merchant: 'Original Merchant', - }, - }, - }; - - const update = { - key: 'transactions_txn123' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: { - created: '2024-02-15', // Updated date - reportID: '123', - }, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates([update], contextWithTransaction); - - // Should include original update + new report name update - expect(result.optimisticData).toHaveLength(2); - expect(result.optimisticData.at(0)).toEqual(update); // Original transaction update - expect(result.optimisticData.at(1)?.key).toBe('report_123'); // New report update - }); - - test('getReportByTransactionID should find report from transaction', () => { - const contextWithTransaction = { - ...mockContext, - allTransactions: { - // eslint-disable-next-line @typescript-eslint/naming-convention - transactions_abc123: { - transactionID: 'abc123', - reportID: '123', - amount: -7500, - created: '2024-01-15', - currency: 'USD', - merchant: 'Test Store', - }, - }, - }; - - const result = getReportByTransactionID('abc123', contextWithTransaction); - - expect(result).toEqual(mockReport); - expect(result?.reportID).toBe('123'); - }); - - test('getReportByTransactionID should return undefined for missing transaction', () => { - const result = getReportByTransactionID('nonexistent', mockContext); - expect(result).toBeUndefined(); - }); - - test('getReportByTransactionID should return undefined for transaction without reportID', () => { - const contextWithIncompleteTransaction = { - ...mockContext, - allTransactions: { - // eslint-disable-next-line @typescript-eslint/naming-convention - transactions_incomplete: { - transactionID: 'incomplete' as OnyxKey, - amount: -1000, - currency: 'USD', - merchant: 'Store', - // Missing reportID - } as unknown as Transaction, - }, - }; - - const result = getReportByTransactionID('incomplete', contextWithIncompleteTransaction); - expect(result).toBeUndefined(); - }); - - test('should handle transaction updates that rely on context lookup', () => { - const contextWithTransaction = { - ...mockContext, - allTransactions: { - // eslint-disable-next-line @typescript-eslint/naming-convention - transactions_xyz789: { - transactionID: 'xyz789', - reportID: '123', - created: '2024-01-01', - amount: -3000, - currency: 'EUR', - merchant: 'Context Store', - }, - }, - }; - - // Transaction update without reportID in the value - const update = { - key: 'transactions_xyz789' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: { - amount: -4000, // Updated amount - // No reportID provided in update - }, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates([update], contextWithTransaction); - - // Should still find the report through context lookup and generate update - expect(result.optimisticData).toHaveLength(2); - expect(result.optimisticData.at(1)?.key).toBe('report_123'); - }); - - test('should use optimistic transaction data in formula computation', () => { - const contextWithTransaction = { - ...mockContext, - allReportNameValuePairs: { - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_123: { - // eslint-disable-next-line @typescript-eslint/naming-convention - expensify_text_title: { - defaultValue: 'Report from {report:startdate}', - }, - } as unknown as ReportNameValuePairs, - }, - allPolicies: { - // eslint-disable-next-line @typescript-eslint/naming-convention - policy_policy1: { - id: 'policy1', - fieldList: { - [CONST.REPORT_FIELD_TITLE_FIELD_ID]: { - fieldID: CONST.REPORT_FIELD_TITLE_FIELD_ID, - defaultValue: 'Report from {report:startdate}', - }, - }, - } as unknown as Policy, - }, - allTransactions: { - // eslint-disable-next-line @typescript-eslint/naming-convention - transactions_formula123: { - transactionID: 'formula123', - reportID: '123', - created: '2024-01-01', // Original date - amount: -5000, - currency: 'USD', - merchant: 'Original Store', - }, - }, - }; - - // Mock getReportTransactions to return the original transaction - // eslint-disable-next-line @typescript-eslint/dot-notation - mockReportUtils.getReportTransactions.mockReturnValue([contextWithTransaction.allTransactions['transactions_formula123']]); - - const update = { - key: 'transactions_formula123' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: { - transactionID: 'formula123', - created: '2024-03-15', // Updated date that should be used in formula - modifiedCreated: '2024-03-15', - }, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates([update], contextWithTransaction); - - expect(result.optimisticData).toHaveLength(2); - - // The key test: verify exact report name with optimistic date - const reportUpdate = result.optimisticData.at(1); - expect(reportUpdate).toEqual({ - key: 'report_123', - onyxMethod: Onyx.METHOD.MERGE, - value: { - reportName: 'Report from 2024-03-15', // Exact expected result with updated date - }, - }); - }); - }); -});