diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index 5a809f07ddb7..3a01af6a5d3c 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -3,6 +3,8 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import type {Policy, Report, Transaction} from '@src/types/onyx'; import {getCurrencySymbol} from './CurrencyUtils'; +import type {WorkingUpdates} from './OptimisticReportNamesCache'; +import type {UpdateContext} from './OptimisticReportNamesConnectionManager'; import {getAllReportActions} from './ReportActionsUtils'; import {getReportTransactions} from './ReportUtils'; import {getCreated, isPartialTransaction} from './TransactionUtils'; @@ -25,6 +27,8 @@ type FormulaContext = { report: Report; policy: OnyxEntry; transaction?: Transaction; + workingUpdates: WorkingUpdates; + updateContext: UpdateContext; }; const FORMULA_PART_TYPES = { @@ -471,7 +475,7 @@ function formatType(type: string | undefined): string { /** * Get the date of the oldest transaction for a given report */ -function getOldestTransactionDate(reportID: string, context?: FormulaContext): string | undefined { +function getOldestTransactionDate(reportID: string, context: FormulaContext): string | undefined { if (!reportID) { return undefined; } @@ -485,6 +489,8 @@ function getOldestTransactionDate(reportID: string, context?: FormulaContext): s transactions.forEach((transaction) => { // Use updated transaction data if available and matches this transaction + + // FormulaContext transaction is the most current, so it takes priority const currentTransaction = context?.transaction && transaction.transactionID === context.transaction.transactionID ? context.transaction : transaction; const created = getCreated(currentTransaction); diff --git a/src/libs/OptimisticReportNames.ts b/src/libs/OptimisticReportNames.ts index 6e83bedd0a78..40993bc63d37 100644 --- a/src/libs/OptimisticReportNames.ts +++ b/src/libs/OptimisticReportNames.ts @@ -9,31 +9,46 @@ import type Report from '@src/types/onyx/Report'; import type {FormulaContext} from './Formula'; import {compute, FORMULA_PART_TYPES, parse} from './Formula'; import Log from './Log'; +import {applyUpdateToCache, getCachedPolicyByID, getCachedReportByID, getCachedReportNameValuePairsByID, getCachedTransactionByID} from './OptimisticReportNamesCache'; +import type {WorkingUpdates} from './OptimisticReportNamesCache'; import type {UpdateContext} from './OptimisticReportNamesConnectionManager'; import Permissions from './Permissions'; import {isArchivedReport} from './ReportUtils'; +const UPDATE_TYPES = { + REPORT: 'report', + TRANSACTION: 'transaction', + POLICY: 'policy', + REPORT_NAME_VALUE_PAIRS: 'reportNameValuePairs', +} as const; + +type UpdateType = (typeof UPDATE_TYPES)[keyof typeof UPDATE_TYPES]; + /** * Get the title field from report name value pairs */ -function getTitleFieldFromRNVP(reportID: string, context: UpdateContext) { - const reportNameValuePairs = context.allReportNameValuePairs[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; +function getTitleFieldFromRNVP(reportID: string, context: UpdateContext, workingUpdates: WorkingUpdates) { + const reportNameValuePairs = getCachedReportNameValuePairsByID(reportID, context, workingUpdates); return reportNameValuePairs?.expensify_text_title; } /** * Get the object type from an Onyx key */ -function determineObjectTypeByKey(key: string): 'report' | 'policy' | 'transaction' | 'unknown' { +function determineObjectTypeByKey(key: string): UpdateType | 'unknown' { if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { - return 'report'; + return UPDATE_TYPES.REPORT; } if (key.startsWith(ONYXKEYS.COLLECTION.POLICY)) { - return 'policy'; + return UPDATE_TYPES.POLICY; } if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { - return 'transaction'; + return UPDATE_TYPES.TRANSACTION; + } + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS)) { + return UPDATE_TYPES.REPORT_NAME_VALUE_PAIRS; } + return 'unknown'; } @@ -41,7 +56,7 @@ function determineObjectTypeByKey(key: string): 'report' | 'policy' | 'transacti * Extract report ID from an Onyx key */ function getReportIDFromKey(key: string): string { - return key.replace(ONYXKEYS.COLLECTION.REPORT, ''); + return key.replace(ONYXKEYS.COLLECTION.REPORT, '').replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, ''); } /** @@ -59,28 +74,21 @@ 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 { +function getPolicyByID(policyID: string | undefined, context: UpdateContext, workingUpdates: WorkingUpdates): Policy | undefined { if (!policyID) { return; } - return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + return getCachedPolicyByID(policyID, context, workingUpdates); } /** * Get transaction by ID from the transactions collection */ -function getTransactionByID(transactionID: string, allTransactions: Record): Transaction | undefined { - return allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; +function getTransactionByID(transactionID: string, context: UpdateContext, workingUpdates: WorkingUpdates): Transaction | undefined { + return getCachedTransactionByID(transactionID, context, workingUpdates); } /** @@ -119,19 +127,18 @@ function getReportsForNameComputation(policyID: string, allReports: Record)} : 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, + report: targetReport, + policy, + transaction, + workingUpdates, + updateContext: context, }; const newName = compute(formula, formulaContext); @@ -245,7 +243,6 @@ function computeReportNameIfNeeded(report: Report | undefined, incomingUpdate: O if (newName && newName !== targetReport.reportName) { Log.info('[OptimisticReportNames] Report name computed', false, { updateType, - isNewReport: !report, }); return newName; @@ -271,70 +268,55 @@ function updateOptimisticReportNamesFromUpdates(updates: OnyxUpdate[], context: }); const additionalUpdates: OnyxUpdate[] = []; + let workingUpdates: WorkingUpdates = {}; + const affectedReports = new Map< + string, + { + update: OnyxUpdate; + } + >(); for (const update of updates) { const objectType = determineObjectTypeByKey(update.key); + workingUpdates = applyUpdateToCache(workingUpdates, update, context); 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) { - additionalUpdates.push({ - key: getReportKey(reportID), - onyxMethod: Onyx.METHOD.MERGE, - value: { - reportName: reportNameUpdate, - }, - }); - } + affectedReports.set(reportID, { + update, + }); 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) { - additionalUpdates.push({ - key: getReportKey(report.reportID), - onyxMethod: Onyx.METHOD.MERGE, - value: { - reportName: reportNameUpdate, - }, - }); - } + const reports = getReportsForNameComputation(policyID, allReports, context); + for (const report of reports) { + affectedReports.set(report.reportID, {update}); } break; } case 'transaction': { - let report: Report | undefined; + let reportID: string | undefined; const transactionUpdate = update.value as Partial; + if (transactionUpdate.reportID) { - report = getReportByID(transactionUpdate.reportID, allReports); + reportID = transactionUpdate.reportID; } else { - report = getReportByTransactionID(getTransactionIDFromKey(update.key), context); + reportID = getReportIDByTransactionID(getTransactionIDFromKey(update.key), context, workingUpdates); + } + if (reportID) { + affectedReports.set(reportID, {update}); } + break; + } + case 'reportNameValuePairs': { + const reportID = getReportIDFromKey(update.key); - if (report) { - const reportNameUpdate = computeReportNameIfNeeded(report, update, context); - - if (reportNameUpdate) { - additionalUpdates.push({ - key: getReportKey(report.reportID), - onyxMethod: Onyx.METHOD.MERGE, - value: { - reportName: reportNameUpdate, - }, - }); - } + if (reportID) { + affectedReports.set(reportID, {update}); } break; } @@ -344,6 +326,20 @@ function updateOptimisticReportNamesFromUpdates(updates: OnyxUpdate[], context: } } + for (const [reportID, {update}] of affectedReports) { + const reportNameUpdate = computeReportNameIfNeeded(reportID, context, update, workingUpdates); + + if (reportNameUpdate) { + additionalUpdates.push({ + key: getReportKey(reportID), + onyxMethod: Onyx.METHOD.MERGE, + value: { + reportName: reportNameUpdate, + }, + }); + } + } + Log.info('[OptimisticReportNames] Processing completed', false, { additionalUpdatesCount: additionalUpdates.length, }); @@ -351,5 +347,5 @@ function updateOptimisticReportNamesFromUpdates(updates: OnyxUpdate[], context: return updates.concat(additionalUpdates); } -export {computeReportNameIfNeeded, getReportByTransactionID, shouldComputeReportName, updateOptimisticReportNamesFromUpdates}; +export {computeReportNameIfNeeded, shouldComputeReportName, updateOptimisticReportNamesFromUpdates}; export type {UpdateContext}; diff --git a/src/libs/OptimisticReportNamesCache.ts b/src/libs/OptimisticReportNamesCache.ts new file mode 100644 index 000000000000..db0b633a7761 --- /dev/null +++ b/src/libs/OptimisticReportNamesCache.ts @@ -0,0 +1,236 @@ +/** + * Functionality provided by this file should be only used in OptimisticReportNames context + */ +import type {OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxKey} from '@src/ONYXKEYS'; +import type {Policy, Report, Transaction} from '@src/types/onyx'; +import type ReportNameValuePairs from '@src/types/onyx/ReportNameValuePairs'; +import type {UpdateContext} from './OptimisticReportNamesConnectionManager'; + +/** + * Checks whether the given value can be merged. It has to be an object, but not an array, RegExp or Date. + * Based on react-native-onyx's isMergeableObject function. + */ +function isMergeableObject(value: unknown): value is Record { + const isNonNullObject = value != null && typeof value === 'object'; + return isNonNullObject && !(value instanceof RegExp) && !(value instanceof Date) && !Array.isArray(value); +} + +/** + * Deeply merges two objects using the same algorithm as react-native-onyx. + * This is based on Onyx's internal fastMerge function. + */ +function deepMerge(target: Record | undefined, change: Record | undefined): Record { + // Handle null/undefined cases + if (!change) { + return target ?? {}; + } + if (!target) { + return change; + } + + // Arrays and non-objects should replace, not merge + if (Array.isArray(change) || typeof change !== 'object') { + return change; + } + + const result = {...target}; + + // Merge source properties into target + Object.keys(change).forEach((key) => { + const sourceValue = change[key]; + const targetValue = target[key]; + + if (sourceValue === undefined) { + return; + } + + // If source value is null, it should replace the target value + if (sourceValue === null) { + result[key] = null; + return; + } + + // If both values are mergeable objects, recursively merge them + if (isMergeableObject(targetValue) && isMergeableObject(sourceValue)) { + result[key] = deepMerge(targetValue, sourceValue); + return; + } + + // Otherwise, source value replaces target value + result[key] = sourceValue; + }); + + return result; +} + +/** Types that can be stored in workingUpdates cache */ +type WorkingUpdateValue = Report | Policy | Transaction | ReportNameValuePairs; + +/** Type for the workingUpdates cache */ +type WorkingUpdates = Partial>; + +/** + * Get report by ID, checking working updates cache first + */ +function getCachedReportByID(reportID: string, context: UpdateContext, workingUpdates: WorkingUpdates): Report | undefined { + const key = `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as OnyxKey; + return (workingUpdates[key] as Report) ?? context.allReports[key]; +} + +/** + * Get policy by ID, checking working updates cache first + */ +function getCachedPolicyByID(policyID: string | undefined, context: UpdateContext, workingUpdates: WorkingUpdates): Policy | undefined { + if (!policyID) { + return; + } + const key = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as OnyxKey; + return (workingUpdates[key] as Policy) ?? context.allPolicies[key]; +} + +/** + * Get transaction by ID, checking working updates cache first + */ +function getCachedTransactionByID(transactionID: string, context: UpdateContext, workingUpdates: WorkingUpdates): Transaction | undefined { + const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as OnyxKey; + return (workingUpdates[key] as Transaction) ?? context.allTransactions[key]; +} + +/** + * Get report name value pairs by report ID, checking working updates cache first + */ +function getCachedReportNameValuePairsByID(reportID: string, context: UpdateContext, workingUpdates: WorkingUpdates): ReportNameValuePairs | undefined { + const key = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}` as OnyxKey; + return (workingUpdates[key] as ReportNameValuePairs) ?? context.allReportNameValuePairs[key]; +} + +/** + * Apply an Onyx update to the working updates cache + */ +function applyUpdateToCache(workingUpdates: WorkingUpdates, update: OnyxUpdate, context: UpdateContext): WorkingUpdates { + const key = update.key as OnyxKey; + + switch (update.onyxMethod) { + case Onyx.METHOD.SET: + return { + ...workingUpdates, + [key]: update.value as WorkingUpdateValue, + }; + case Onyx.METHOD.MERGE_COLLECTION: { + // Handle collection merge operations - merge data into multiple collection items + // The value object contains full Onyx keys (e.g., report_123) as keys, not just item IDs + const collectionData = update.value as Record; + + if (!collectionData || typeof collectionData !== 'object') { + return workingUpdates; + } + + const updatedCache = {...workingUpdates}; + + // Iterate through each item in the collection data + Object.entries(collectionData).forEach(([fullKey, itemValue]) => { + const onyxKey = fullKey as OnyxKey; + const currentValue = updatedCache[onyxKey] ?? getOriginalValueByKey(fullKey, context); + + // Merge the item value with existing data + updatedCache[onyxKey] = deepMerge(currentValue as Record, itemValue as Record) as WorkingUpdateValue; + }); + + return updatedCache; + } + + case Onyx.METHOD.SET_COLLECTION: { + // Handle collection set operations - replace data in multiple collection items + // The value object contains full Onyx keys (e.g., report_123) as keys, not just item IDs + const collectionData = update.value as Record; + + if (!collectionData || typeof collectionData !== 'object') { + return workingUpdates; + } + + const collectionKey = update.key as string; + const updatedCache = {...workingUpdates}; + + // First, remove all existing keys for this collection from the cache + Object.keys(updatedCache).forEach((objectKey) => { + if (!objectKey.startsWith(collectionKey)) { + return; + } + delete updatedCache[objectKey as OnyxKey]; + }); + + // Then, set the new values for the collection + Object.entries(collectionData).forEach(([fullKey, itemValue]) => { + const onyxKey = fullKey as OnyxKey; + // Set the item value directly, replacing existing data + updatedCache[onyxKey] = itemValue; + }); + + return updatedCache; + } + + case Onyx.METHOD.MULTI_SET: { + // Handle multi-set operations - set multiple individual keys across collections + const multiSetData = update.value as Record; + + if (!multiSetData || typeof multiSetData !== 'object') { + return workingUpdates; + } + + const updatedCache = {...workingUpdates}; + + // Iterate through each key-value pair in the multi-set data + Object.entries(multiSetData).forEach(([fullKey, itemValue]) => { + const onyxKey = fullKey as OnyxKey; + + // Only cache values that are relevant to our working updates + // Check if the key matches our supported collections + const isRelevantKey = + fullKey.startsWith(ONYXKEYS.COLLECTION.REPORT) || + fullKey.startsWith(ONYXKEYS.COLLECTION.POLICY) || + fullKey.startsWith(ONYXKEYS.COLLECTION.TRANSACTION) || + fullKey.startsWith(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + + if (isRelevantKey && itemValue != null) { + updatedCache[onyxKey] = itemValue as WorkingUpdateValue; + } + }); + + return updatedCache; + } + case Onyx.METHOD.MERGE: + default: { + // Get the current value (from cache or original context) + const currentValue = workingUpdates[key] ?? getOriginalValueByKey(key, context); + return { + ...workingUpdates, + [key]: deepMerge(currentValue as Record, update.value as Record) as WorkingUpdateValue, + }; + } + } +} + +/** + * Get original value by key from context collections + */ +function getOriginalValueByKey(key: string, context: UpdateContext): WorkingUpdateValue | undefined { + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { + return context.allReports[key]; + } + if (key.startsWith(ONYXKEYS.COLLECTION.POLICY)) { + return context.allPolicies[key]; + } + if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { + return context.allTransactions[key]; + } + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS)) { + return context.allReportNameValuePairs[key]; + } + return undefined; +} + +export {getCachedPolicyByID, getCachedReportByID, getCachedReportNameValuePairsByID, getCachedTransactionByID, applyUpdateToCache, deepMerge}; +export type {WorkingUpdates, WorkingUpdateValue}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index db9ea0390ab4..64785a261cdb 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -98,7 +98,7 @@ import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import Pusher from '@libs/Pusher'; import type {UserIsLeavingRoomEvent, UserIsTypingEvent} from '@libs/Pusher/types'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import {updateTitleFieldToMatchPolicy} from '@libs/ReportTitleUtils'; +import {removeTitleFieldFromReport, updateTitleFieldToMatchPolicy} from '@libs/ReportTitleUtils'; import type {OptimisticAddCommentReportAction, OptimisticChatReport, SelfDMParameters} from '@libs/ReportUtils'; import { buildOptimisticAddCommentReportAction, @@ -2341,6 +2341,7 @@ function toggleSubscribeToChildReport( function updateReportName(reportID: string, value: string, previousValue: string) { const optimisticData: OnyxUpdate[] = [ + ...removeTitleFieldFromReport(reportID), { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, diff --git a/tests/perf-test/OptimisticReportNames.perf-test.ts b/tests/perf-test/OptimisticReportNames.perf-test.ts index 8add5f932bed..ce2d3083dbb9 100644 --- a/tests/perf-test/OptimisticReportNames.perf-test.ts +++ b/tests/perf-test/OptimisticReportNames.perf-test.ts @@ -92,7 +92,8 @@ describe('[OptimisticReportNames] Performance Tests', () => { value: {total: -20000}, }; - await measureFunction(() => computeReportNameIfNeeded(report, update, mockContext)); + // eslint-disable-next-line rulesdir/no-default-id-values + await measureFunction(() => computeReportNameIfNeeded(report?.reportID ?? '', mockContext, update, {})); }); }); diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index 1298f0414b35..a29719d1ffd0 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -2,6 +2,7 @@ import * as CurrencyUtils from '@libs/CurrencyUtils'; import type {FormulaContext} from '@libs/Formula'; import {compute, extract, parse} from '@libs/Formula'; +import type {UpdateContext} from '@libs/OptimisticReportNamesConnectionManager'; // 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 @@ -99,7 +100,7 @@ describe('CustomFormula', () => { }); describe('compute()', () => { - const mockContext: FormulaContext = { + const mockFormulaContext: FormulaContext = { report: { reportID: '123', reportName: '', @@ -112,6 +113,8 @@ describe('CustomFormula', () => { policy: { name: 'Test Policy', } as Policy, + updateContext: {allTransactions: {}} as UpdateContext, + workingUpdates: {}, }; beforeEach(() => { @@ -171,46 +174,46 @@ describe('CustomFormula', () => { }); test('should compute basic report formula', () => { - const result = compute('{report:type} {report:total}', mockContext); + const result = compute('{report:type} {report:total}', mockFormulaContext); expect(result).toBe('Expense Report $100.00'); // No space between parts }); test('should compute startdate formula using transactions', () => { - const result = compute('{report:startdate}', mockContext); + const result = compute('{report:startdate}', mockFormulaContext); expect(result).toBe('2025-01-08'); // Should use oldest transaction date (2025-01-08) }); test('should compute created formula using report actions', () => { - const result = compute('{report:created}', mockContext); + const result = compute('{report:created}', mockFormulaContext); expect(result).toBe('2025-01-10'); // Should use oldest report action date (2025-01-10) }); test('should compute startdate with custom format', () => { - const result = compute('{report:startdate:MM/dd/yyyy}', mockContext); + const result = compute('{report:startdate:MM/dd/yyyy}', mockFormulaContext); expect(result).toBe('01/08/2025'); // Should use oldest transaction date with yyyy-MM-dd format }); test('should compute created with custom format', () => { - const result = compute('{report:created:MMMM dd, yyyy}', mockContext); + const result = compute('{report:created:MMMM dd, yyyy}', mockFormulaContext); expect(result).toBe('January 10, 2025'); // Should use oldest report action date with MMMM dd, yyyy format }); test('should compute startdate with short month format', () => { - const result = compute('{report:startdate:dd MMM yyyy}', mockContext); + const result = compute('{report:startdate:dd MMM yyyy}', mockFormulaContext); expect(result).toBe('08 Jan 2025'); // Should use oldest transaction date with dd MMM yyyy format }); test('should compute policy name', () => { - const result = compute('{report:policyname}', mockContext); + const result = compute('{report:policyname}', mockFormulaContext); expect(result).toBe('Test Policy'); }); test('should handle empty formula', () => { - expect(compute('', mockContext)).toBe(''); + expect(compute('', mockFormulaContext)).toBe(''); }); test('should handle unknown formula parts', () => { - const result = compute('{report:unknown}', mockContext); + const result = compute('{report:unknown}', mockFormulaContext); expect(result).toBe('{report:unknown}'); }); @@ -218,18 +221,20 @@ describe('CustomFormula', () => { const contextWithMissingData: FormulaContext = { report: {} as unknown as Report, policy: null as unknown as Policy, + updateContext: {allTransactions: {}} as UpdateContext, + workingUpdates: {}, }; const result = compute('{report:total} {report:policyname}', contextWithMissingData); expect(result).toBe('{report:total} {report:policyname}'); // Empty data is replaced with definition }); test('should preserve free text', () => { - const result = compute('Expense Report - {report:total}', mockContext); + const result = compute('Expense Report - {report:total}', mockFormulaContext); expect(result).toBe('Expense Report - $100.00'); }); test('should preserve exact spacing around formula parts', () => { - const result = compute('Report with type after 4 spaces {report:type}-and no space after computed part', mockContext); + const result = compute('Report with type after 4 spaces {report:type}-and no space after computed part', mockFormulaContext); expect(result).toBe('Report with type after 4 spaces Expense Report-and no space after computed part'); }); }); @@ -244,6 +249,8 @@ describe('CustomFormula', () => { const context: FormulaContext = { report: {total: undefined} as Report, policy: null as unknown as Policy, + updateContext: {allTransactions: {}} as UpdateContext, + workingUpdates: {}, }; const result = compute('{report:total}', context); expect(result).toBe('{report:total}'); @@ -254,6 +261,8 @@ describe('CustomFormula', () => { const context: FormulaContext = { report: {reportID: '123'} as Report, policy: null as unknown as Policy, + updateContext: {allTransactions: {}} as UpdateContext, + workingUpdates: {}, }; const result = compute('{report:created}', context); @@ -265,6 +274,8 @@ describe('CustomFormula', () => { const context: FormulaContext = { report: {reportID: '123'} as Report, policy: null as unknown as Policy, + updateContext: {allTransactions: {}} as UpdateContext, + workingUpdates: {}, }; const today = new Date(); const expected = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; @@ -276,6 +287,8 @@ describe('CustomFormula', () => { const context: FormulaContext = { report: {reportID: 'test-report-123'} as Report, policy: null as unknown as Policy, + updateContext: {allTransactions: {}} as UpdateContext, + workingUpdates: {}, }; compute('{report:startdate}', context); @@ -286,6 +299,8 @@ describe('CustomFormula', () => { const context: FormulaContext = { report: {reportID: 'test-report-456'} as Report, policy: null as unknown as Policy, + updateContext: {allTransactions: {}} as UpdateContext, + workingUpdates: {}, }; compute('{report:created}', context); @@ -318,6 +333,8 @@ describe('CustomFormula', () => { const context: FormulaContext = { report: {reportID: 'test-report-123'} as Report, policy: null as unknown as Policy, + updateContext: {allTransactions: {}} as UpdateContext, + workingUpdates: {}, }; const result = compute('{report:startdate}', context); @@ -351,6 +368,8 @@ describe('CustomFormula', () => { const context: FormulaContext = { report: {reportID: 'test-report-123'} as Report, policy: null as unknown as Policy, + updateContext: {allTransactions: {}} as UpdateContext, + workingUpdates: {}, }; const result = compute('{report:startdate}', context); diff --git a/tests/unit/OptimisticReportNamesCacheTest.ts b/tests/unit/OptimisticReportNamesCacheTest.ts new file mode 100644 index 000000000000..f15dc2d0f3e2 --- /dev/null +++ b/tests/unit/OptimisticReportNamesCacheTest.ts @@ -0,0 +1,1192 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxUpdate} from 'react-native-onyx'; +import {applyUpdateToCache, deepMerge, getCachedPolicyByID, getCachedReportByID, getCachedReportNameValuePairsByID, getCachedTransactionByID} from '@libs/OptimisticReportNamesCache'; +import type {WorkingUpdates} from '@libs/OptimisticReportNamesCache'; +import type {UpdateContext} from '@libs/OptimisticReportNamesConnectionManager'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxKey} from '@src/ONYXKEYS'; +import type {Policy, Report, Transaction} from '@src/types/onyx'; +import type ReportNameValuePairs from '@src/types/onyx/ReportNameValuePairs'; + +describe('OptimisticReportNamesCache', () => { + const mockReport: Report = { + reportID: '123', + reportName: 'Test Report', + type: 'expense', + total: -10000, + currency: 'USD', + policyID: 'policy1', + } as Report; + + const mockPolicy: Policy = { + id: 'policy1', + name: 'Test Policy', + fieldList: {}, + } as Policy; + + const mockTransaction: Transaction = { + transactionID: 'txn123', + reportID: '123', + created: '2024-01-15T10:00:00Z', + amount: -5000, + currency: 'USD', + merchant: 'Test Store', + } as Transaction; + + const mockReportNameValuePairs: ReportNameValuePairs = { + private_isArchived: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + expensify_text_title: { + defaultValue: '{report:type} - {report:total}', + } as unknown as ReportNameValuePairs['expensify_text_title'], + }; + + const mockContext: UpdateContext = { + betas: [], + betaConfiguration: {}, + allReports: { + [`${ONYXKEYS.COLLECTION.REPORT}123`]: mockReport, + }, + allPolicies: { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: mockPolicy, + }, + allTransactions: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn123`]: mockTransaction, + }, + allReportNameValuePairs: { + [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}123`]: mockReportNameValuePairs, + }, + }; + + describe('getCachedReportByID', () => { + it('should return cached report when available in workingUpdates', () => { + const cachedReport: Report = { + ...mockReport, + reportName: 'Updated Report Name', + total: -20000, + }; + + const workingUpdates: WorkingUpdates = { + [`${ONYXKEYS.COLLECTION.REPORT}123`]: cachedReport, + }; + + const result = getCachedReportByID('123', mockContext, workingUpdates); + + expect(result).toEqual(cachedReport); + expect(result?.reportName).toBe('Updated Report Name'); + expect(result?.total).toBe(-20000); + }); + + it('should return original report from context when not in workingUpdates', () => { + const workingUpdates: WorkingUpdates = {}; + + const result = getCachedReportByID('123', mockContext, workingUpdates); + + expect(result).toEqual(mockReport); + }); + + it('should return undefined when report not found anywhere', () => { + const workingUpdates: WorkingUpdates = {}; + const emptyContext: UpdateContext = { + ...mockContext, + allReports: {}, + }; + + const result = getCachedReportByID('nonexistent', emptyContext, workingUpdates); + + expect(result).toBeUndefined(); + }); + + it('should prioritize workingUpdates over context data', () => { + const cachedReport: Report = { + ...mockReport, + reportName: 'Cached Name', + }; + + const workingUpdates: WorkingUpdates = { + [`${ONYXKEYS.COLLECTION.REPORT}123`]: cachedReport, + }; + + const result = getCachedReportByID('123', mockContext, workingUpdates); + + expect(result?.reportName).toBe('Cached Name'); + expect(result?.reportName).not.toBe(mockReport.reportName); + }); + }); + + describe('getCachedPolicyByID', () => { + it('should return cached policy when available in workingUpdates', () => { + const cachedPolicy: Policy = { + ...mockPolicy, + name: 'Updated Policy Name', + }; + + const workingUpdates: WorkingUpdates = { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: cachedPolicy, + }; + + const result = getCachedPolicyByID('policy1', mockContext, workingUpdates); + + expect(result).toEqual(cachedPolicy); + expect(result?.name).toBe('Updated Policy Name'); + }); + + it('should return original policy from context when not in workingUpdates', () => { + const workingUpdates: WorkingUpdates = {}; + + const result = getCachedPolicyByID('policy1', mockContext, workingUpdates); + + expect(result).toEqual(mockPolicy); + }); + + it('should return undefined when policyID is undefined', () => { + const workingUpdates: WorkingUpdates = {}; + + const result = getCachedPolicyByID(undefined, mockContext, workingUpdates); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when policyID is empty string', () => { + const workingUpdates: WorkingUpdates = {}; + + const result = getCachedPolicyByID('', mockContext, workingUpdates); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when policy not found anywhere', () => { + const workingUpdates: WorkingUpdates = {}; + const emptyContext: UpdateContext = { + ...mockContext, + allPolicies: {}, + }; + + const result = getCachedPolicyByID('nonexistent', emptyContext, workingUpdates); + + expect(result).toBeUndefined(); + }); + }); + + describe('getCachedTransactionByID', () => { + it('should return cached transaction when available in workingUpdates', () => { + const cachedTransaction: Transaction = { + ...mockTransaction, + amount: -7500, + merchant: 'Updated Store', + }; + + const workingUpdates: WorkingUpdates = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn123`]: cachedTransaction, + }; + + const result = getCachedTransactionByID('txn123', mockContext, workingUpdates); + + expect(result).toEqual(cachedTransaction); + expect(result?.amount).toBe(-7500); + expect(result?.merchant).toBe('Updated Store'); + }); + + it('should return original transaction from context when not in workingUpdates', () => { + const workingUpdates: WorkingUpdates = {}; + + const result = getCachedTransactionByID('txn123', mockContext, workingUpdates); + + expect(result).toEqual(mockTransaction); + }); + + it('should return undefined when transaction not found anywhere', () => { + const workingUpdates: WorkingUpdates = {}; + const emptyContext: UpdateContext = { + ...mockContext, + allTransactions: {}, + }; + + const result = getCachedTransactionByID('nonexistent', emptyContext, workingUpdates); + + expect(result).toBeUndefined(); + }); + + it('should handle transaction with updated created date', () => { + const cachedTransaction: Transaction = { + ...mockTransaction, + created: '2024-02-20T15:30:00Z', + modifiedCreated: '2024-02-20T15:30:00Z', + }; + + const workingUpdates: WorkingUpdates = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn123`]: cachedTransaction, + }; + + const result = getCachedTransactionByID('txn123', mockContext, workingUpdates); + + expect(result?.created).toBe('2024-02-20T15:30:00Z'); + }); + }); + + describe('getCachedReportNameValuePairsByID', () => { + it('should return cached RNVP when available in workingUpdates', () => { + const cachedRNVP: ReportNameValuePairs = { + ...mockReportNameValuePairs, + // eslint-disable-next-line @typescript-eslint/naming-convention + expensify_text_title: { + defaultValue: 'Updated Formula: {report:type}', + } as ReportNameValuePairs['expensify_text_title'], + }; + + const workingUpdates: WorkingUpdates = { + [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}123`]: cachedRNVP, + }; + + const result = getCachedReportNameValuePairsByID('123', mockContext, workingUpdates); + + expect(result).toEqual(cachedRNVP); + expect(result?.expensify_text_title?.defaultValue).toBe('Updated Formula: {report:type}'); + }); + + it('should return original RNVP from context when not in workingUpdates', () => { + const workingUpdates: WorkingUpdates = {}; + + const result = getCachedReportNameValuePairsByID('123', mockContext, workingUpdates); + + expect(result).toEqual(mockReportNameValuePairs); + }); + + it('should return undefined when RNVP not found anywhere', () => { + const workingUpdates: WorkingUpdates = {}; + const emptyContext: UpdateContext = { + ...mockContext, + allReportNameValuePairs: {}, + }; + + const result = getCachedReportNameValuePairsByID('nonexistent', emptyContext, workingUpdates); + + expect(result).toBeUndefined(); + }); + + it('should handle RNVP with archived status', () => { + const cachedRNVP: ReportNameValuePairs = { + ...mockReportNameValuePairs, + private_isArchived: '2024-01-15', + }; + + const workingUpdates: WorkingUpdates = { + [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}123`]: cachedRNVP, + }; + + const result = getCachedReportNameValuePairsByID('123', mockContext, workingUpdates); + + expect(result?.private_isArchived).toBe('2024-01-15'); + }); + }); + + describe('applyUpdateToCache', () => { + it('should apply SET update to cache', () => { + const workingUpdates: WorkingUpdates = {}; + const newReport: Report = { + ...mockReport, + reportID: '456', + reportName: 'New Report', + }; + + const update = { + key: `${ONYXKEYS.COLLECTION.REPORT}456` as OnyxKey, + onyxMethod: Onyx.METHOD.SET, + value: newReport, + }; + + const result = applyUpdateToCache(workingUpdates, update, mockContext); + + expect(result).toEqual({ + [`${ONYXKEYS.COLLECTION.REPORT}456`]: newReport, + }); + }); + + it('should apply MERGE update to existing cached data', () => { + const workingUpdates: WorkingUpdates = { + [`${ONYXKEYS.COLLECTION.REPORT}123`]: { + ...mockReport, + reportName: 'Cached Name', + }, + }; + + const update = { + key: `${ONYXKEYS.COLLECTION.REPORT}123` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {total: -15000}, + }; + + const result = applyUpdateToCache(workingUpdates, update, mockContext); + + expect(result[`${ONYXKEYS.COLLECTION.REPORT}123` as OnyxKey]).toEqual({ + ...mockReport, + reportName: 'Cached Name', + total: -15000, + }); + }); + + it('should apply MERGE update to context data when not in cache', () => { + const workingUpdates: WorkingUpdates = {}; + + const update = { + key: `${ONYXKEYS.COLLECTION.REPORT}123` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {total: -15000}, + }; + + const result = applyUpdateToCache(workingUpdates, update, mockContext); + + expect(result[`${ONYXKEYS.COLLECTION.REPORT}123` as OnyxKey]).toEqual({ + ...mockReport, + total: -15000, + }); + }); + + it('should apply MERGE_COLLECTION update to multiple items', () => { + const workingUpdates: WorkingUpdates = {}; + const report1: Report = { + reportID: '456', + reportName: 'Report 1', + type: 'expense', + policyID: 'policy1', + } as Report; + + const report2: Report = { + reportID: '789', + reportName: 'Report 2', + type: 'expense', + policyID: 'policy1', + } as Report; + + const update = { + key: ONYXKEYS.COLLECTION.REPORT, + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + value: { + [`${ONYXKEYS.COLLECTION.REPORT}456`]: report1, + [`${ONYXKEYS.COLLECTION.REPORT}789`]: report2, + }, + }; + + const result = applyUpdateToCache(workingUpdates, update, mockContext); + + expect(result[`${ONYXKEYS.COLLECTION.REPORT}456` as OnyxKey]).toEqual(report1); + expect(result[`${ONYXKEYS.COLLECTION.REPORT}789` as OnyxKey]).toEqual(report2); + }); + + it('should apply MERGE_COLLECTION update merging with existing data', () => { + const existingReport: Report = { + reportID: '456', + reportName: 'Existing Report', + type: 'expense', + total: -5000, + policyID: 'policy1', + } as Report; + + const workingUpdates: WorkingUpdates = { + [`${ONYXKEYS.COLLECTION.REPORT}456`]: existingReport, + }; + + const update = { + key: ONYXKEYS.COLLECTION.REPORT, + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + value: { + [`${ONYXKEYS.COLLECTION.REPORT}456`]: {reportName: 'Updated Report', total: -7500}, + }, + }; + + const result = applyUpdateToCache(workingUpdates, update, mockContext); + + expect(result[`${ONYXKEYS.COLLECTION.REPORT}456` as OnyxKey]).toEqual({ + reportID: '456', + reportName: 'Updated Report', + type: 'expense', + total: -7500, + policyID: 'policy1', + }); + }); + + it('should apply MERGE_COLLECTION update merging with context data', () => { + const workingUpdates: WorkingUpdates = {}; + + const update = { + key: ONYXKEYS.COLLECTION.REPORT, + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + value: { + [`${ONYXKEYS.COLLECTION.REPORT}123`]: {reportName: 'Context Merged Report', total: -8000}, + }, + }; + + const result = applyUpdateToCache(workingUpdates, update, mockContext); + + expect(result[`${ONYXKEYS.COLLECTION.REPORT}123` as OnyxKey]).toEqual({ + ...mockReport, + reportName: 'Context Merged Report', + total: -8000, + }); + }); + + it('should apply SET_COLLECTION update to multiple items', () => { + const workingUpdates: WorkingUpdates = {}; + const transaction1: Transaction = { + transactionID: 'txn456', + reportID: '456', + amount: -3000, + merchant: 'Store A', + } as Transaction; + + const transaction2: Transaction = { + transactionID: 'txn789', + reportID: '789', + amount: -4500, + merchant: 'Store B', + } as Transaction; + + const update = { + key: ONYXKEYS.COLLECTION.TRANSACTION, + onyxMethod: Onyx.METHOD.SET_COLLECTION, + value: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn456`]: transaction1, + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn789`]: transaction2, + }, + }; + + const result = applyUpdateToCache(workingUpdates, update, mockContext); + + expect(result[`${ONYXKEYS.COLLECTION.TRANSACTION}txn456` as OnyxKey]).toEqual(transaction1); + expect(result[`${ONYXKEYS.COLLECTION.TRANSACTION}txn789` as OnyxKey]).toEqual(transaction2); + }); + + it('should apply SET_COLLECTION update replacing existing data', () => { + const existingTransaction: Transaction = { + transactionID: 'txn456', + reportID: '456', + amount: -3000, + merchant: 'Old Store', + created: '2024-01-01T10:00:00Z', + } as Transaction; + + const workingUpdates: WorkingUpdates = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn456`]: existingTransaction, + }; + + const newTransaction: Transaction = { + transactionID: 'txn456', + reportID: '456', + amount: -5000, + merchant: 'New Store', + } as Transaction; + + const update = { + key: ONYXKEYS.COLLECTION.TRANSACTION, + onyxMethod: Onyx.METHOD.SET_COLLECTION, + value: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn456`]: newTransaction, + }, + }; + + const result = applyUpdateToCache(workingUpdates, update, mockContext); + + expect(result[`${ONYXKEYS.COLLECTION.TRANSACTION}txn456` as OnyxKey]).toEqual(newTransaction); + expect((result[`${ONYXKEYS.COLLECTION.TRANSACTION}txn456` as OnyxKey] as Transaction)?.created).toBeUndefined(); + }); + + it('should apply MULTI_SET update across multiple collections', () => { + const workingUpdates: WorkingUpdates = {}; + const report: Report = { + reportID: '456', + reportName: 'Multi Report', + type: 'expense', + policyID: 'policy1', + } as Report; + + const transaction: Transaction = { + transactionID: 'txn456', + reportID: '456', + amount: -2500, + merchant: 'Multi Store', + } as Transaction; + + const policy: Policy = { + id: 'policy2', + name: 'Multi Policy', + } as Policy; + + const update = { + key: 'MULTI_SET_KEY' as OnyxKey, + onyxMethod: Onyx.METHOD.MULTI_SET, + value: { + [`${ONYXKEYS.COLLECTION.REPORT}456`]: report, + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn456`]: transaction, + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: policy, + }, + } as unknown as OnyxUpdate; + + const result = applyUpdateToCache(workingUpdates, update, mockContext); + + expect(result[`${ONYXKEYS.COLLECTION.REPORT}456` as OnyxKey]).toEqual(report); + expect(result[`${ONYXKEYS.COLLECTION.TRANSACTION}txn456` as OnyxKey]).toEqual(transaction); + expect(result[`${ONYXKEYS.COLLECTION.POLICY}policy2` as OnyxKey]).toEqual(policy); + }); + + it('should apply MULTI_SET update with report name value pairs', () => { + const workingUpdates: WorkingUpdates = {}; + const reportNameValuePairs: ReportNameValuePairs = { + private_isArchived: '2024-01-15', + // eslint-disable-next-line @typescript-eslint/naming-convention + expensify_text_title: { + defaultValue: '{report:type} for {report:policyname}', + } as ReportNameValuePairs['expensify_text_title'], + }; + + const update = { + key: 'MULTI_SET_KEY' as OnyxKey, + onyxMethod: Onyx.METHOD.MULTI_SET, + value: { + [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}456`]: reportNameValuePairs, + }, + } as unknown as OnyxUpdate; + + const result = applyUpdateToCache(workingUpdates, update, mockContext); + + expect(result[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}456` as OnyxKey]).toEqual(reportNameValuePairs); + }); + }); + + describe('integration scenarios', () => { + it('should work with complex sequential updates', () => { + let workingUpdates: WorkingUpdates = {} as WorkingUpdates; + + // First update: SET new report + const setUpdate = { + key: `${ONYXKEYS.COLLECTION.REPORT}456` as OnyxKey, + onyxMethod: Onyx.METHOD.SET, + value: { + reportID: '456', + reportName: 'New Report', + total: -5000, + policyID: 'policy1', + }, + }; + workingUpdates = applyUpdateToCache(workingUpdates, setUpdate, mockContext); + + // Second update: MERGE to existing report + const mergeUpdate = { + key: `${ONYXKEYS.COLLECTION.REPORT}456` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {total: -10000, currency: 'EUR'}, + }; + workingUpdates = applyUpdateToCache(workingUpdates, mergeUpdate, mockContext); + + // Verify final state + const result = getCachedReportByID('456', mockContext, workingUpdates); + expect(result).toEqual({ + reportID: '456', + reportName: 'New Report', + total: -10000, + currency: 'EUR', + policyID: 'policy1', + }); + }); + + it('should maintain cache consistency across multiple object types', () => { + let workingUpdates: WorkingUpdates = {} as WorkingUpdates; + + // Update report + const reportUpdate = { + key: `${ONYXKEYS.COLLECTION.REPORT}123` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {reportName: 'Updated Report'}, + }; + workingUpdates = applyUpdateToCache(workingUpdates, reportUpdate, mockContext); + + // Update policy + const policyUpdate = { + key: `${ONYXKEYS.COLLECTION.POLICY}policy1` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {name: 'Updated Policy'}, + }; + workingUpdates = applyUpdateToCache(workingUpdates, policyUpdate, mockContext); + + // Update transaction + const transactionUpdate = { + key: `${ONYXKEYS.COLLECTION.TRANSACTION}txn123` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {amount: -7500}, + }; + workingUpdates = applyUpdateToCache(workingUpdates, transactionUpdate, mockContext); + + // Verify all updates are cached correctly + expect(getCachedReportByID('123', mockContext, workingUpdates)?.reportName).toBe('Updated Report'); + expect(getCachedPolicyByID('policy1', mockContext, workingUpdates)?.name).toBe('Updated Policy'); + expect(getCachedTransactionByID('txn123', mockContext, workingUpdates)?.amount).toBe(-7500); + }); + + it('should work with MERGE_COLLECTION followed by individual updates', () => { + let workingUpdates: WorkingUpdates = {} as WorkingUpdates; + + // First: MERGE_COLLECTION for multiple reports + const mergeCollectionUpdate = { + key: ONYXKEYS.COLLECTION.REPORT, + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + value: { + [`${ONYXKEYS.COLLECTION.REPORT}456`]: {reportID: '456', reportName: 'Bulk Report 1', total: -1000}, + [`${ONYXKEYS.COLLECTION.REPORT}789`]: {reportID: '789', reportName: 'Bulk Report 2', total: -2000}, + }, + }; + workingUpdates = applyUpdateToCache(workingUpdates, mergeCollectionUpdate, mockContext); + + // Second: Individual MERGE update to one of the reports + const individualMergeUpdate = { + key: `${ONYXKEYS.COLLECTION.REPORT}456` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {total: -1500, currency: 'EUR'}, + }; + workingUpdates = applyUpdateToCache(workingUpdates, individualMergeUpdate, mockContext); + + // Verify final state + const report456 = getCachedReportByID('456', mockContext, workingUpdates); + const report789 = getCachedReportByID('789', mockContext, workingUpdates); + + expect(report456).toEqual({ + reportID: '456', + reportName: 'Bulk Report 1', + total: -1500, + currency: 'EUR', + }); + expect(report789).toEqual({ + reportID: '789', + reportName: 'Bulk Report 2', + total: -2000, + }); + }); + + it('should work with SET_COLLECTION followed by MERGE updates', () => { + let workingUpdates: WorkingUpdates = {} as WorkingUpdates; + + // First: SET_COLLECTION for multiple transactions + const setCollectionUpdate = { + key: ONYXKEYS.COLLECTION.TRANSACTION, + onyxMethod: Onyx.METHOD.SET_COLLECTION, + value: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn456`]: {transactionID: 'txn456', reportID: '456', amount: -1000, merchant: 'Store A'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn789`]: {transactionID: 'txn789', reportID: '789', amount: -2000, merchant: 'Store B'}, + }, + }; + workingUpdates = applyUpdateToCache(workingUpdates, setCollectionUpdate, mockContext); + + // Second: MERGE update to add additional properties + const mergeUpdate = { + key: `${ONYXKEYS.COLLECTION.TRANSACTION}txn456` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {category: 'Travel', tag: 'Business'}, + }; + workingUpdates = applyUpdateToCache(workingUpdates, mergeUpdate, mockContext); + + // Verify final state + const txn456 = getCachedTransactionByID('txn456', mockContext, workingUpdates); + const txn789 = getCachedTransactionByID('txn789', mockContext, workingUpdates); + + expect(txn456).toEqual({ + transactionID: 'txn456', + reportID: '456', + amount: -1000, + merchant: 'Store A', + category: 'Travel', + tag: 'Business', + }); + expect(txn789).toEqual({ + transactionID: 'txn789', + reportID: '789', + amount: -2000, + merchant: 'Store B', + }); + }); + + it('should work with MULTI_SET followed by collection updates', () => { + let workingUpdates: WorkingUpdates = {} as WorkingUpdates; + + // First: MULTI_SET across different collections + const multiSetUpdate = { + key: 'MULTI_SET_KEY' as OnyxKey, + onyxMethod: Onyx.METHOD.MULTI_SET, + value: { + [`${ONYXKEYS.COLLECTION.REPORT}456`]: {reportID: '456', reportName: 'Multi Report', type: 'expense'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn456`]: {transactionID: 'txn456', reportID: '456', amount: -1500}, + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: {id: 'policy2', name: 'Multi Policy'}, + }, + } as unknown as OnyxUpdate; + workingUpdates = applyUpdateToCache(workingUpdates, multiSetUpdate, mockContext); + + // Second: MERGE_COLLECTION to update policies + const mergeCollectionUpdate = { + key: ONYXKEYS.COLLECTION.POLICY, + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + value: { + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: {name: 'Updated Multi Policy', settings: {theme: 'dark'}}, + }, + }; + workingUpdates = applyUpdateToCache(workingUpdates, mergeCollectionUpdate, mockContext); + + // Verify final state + const report = getCachedReportByID('456', mockContext, workingUpdates); + const transaction = getCachedTransactionByID('txn456', mockContext, workingUpdates); + const policy = getCachedPolicyByID('policy2', mockContext, workingUpdates); + + expect(report).toEqual({ + reportID: '456', + reportName: 'Multi Report', + type: 'expense', + }); + expect(transaction).toEqual({ + transactionID: 'txn456', + reportID: '456', + amount: -1500, + }); + expect(policy).toEqual({ + id: 'policy2', + name: 'Updated Multi Policy', + settings: {theme: 'dark'}, + }); + }); + + it('should handle complex scenario with all new methods', () => { + let workingUpdates: WorkingUpdates = {} as WorkingUpdates; + + // Step 1: MULTI_SET to initialize data across collections + const multiSetUpdate = { + key: 'MULTI_SET_KEY' as OnyxKey, + onyxMethod: Onyx.METHOD.MULTI_SET, + value: { + [`${ONYXKEYS.COLLECTION.REPORT}100`]: {reportID: '100', reportName: 'Report 100', total: -1000}, + [`${ONYXKEYS.COLLECTION.REPORT}200`]: {reportID: '200', reportName: 'Report 200', total: -2000}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}t100`]: {transactionID: 't100', reportID: '100', amount: -500}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}t200`]: {transactionID: 't200', reportID: '200', amount: -1000}, + }, + } as unknown as OnyxUpdate; + workingUpdates = applyUpdateToCache(workingUpdates, multiSetUpdate, mockContext); + + // Step 2: MERGE_COLLECTION to update multiple reports + const mergeCollectionUpdate = { + key: ONYXKEYS.COLLECTION.REPORT, + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + value: { + [`${ONYXKEYS.COLLECTION.REPORT}100`]: {total: -1500, currency: 'USD'}, + [`${ONYXKEYS.COLLECTION.REPORT}200`]: {total: -2500, currency: 'EUR'}, + }, + }; + workingUpdates = applyUpdateToCache(workingUpdates, mergeCollectionUpdate, mockContext); + + // Step 3: SET_COLLECTION to replace transactions + const setCollectionUpdate = { + key: ONYXKEYS.COLLECTION.TRANSACTION, + onyxMethod: Onyx.METHOD.SET_COLLECTION, + value: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}t100`]: {transactionID: 't100', reportID: '100', amount: -750, merchant: 'New Store'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}t300`]: {transactionID: 't300', reportID: '100', amount: -250, merchant: 'Another Store'}, + }, + }; + workingUpdates = applyUpdateToCache(workingUpdates, setCollectionUpdate, mockContext); + + // Step 4: Individual MERGE to fine-tune + const individualMergeUpdate = { + key: `${ONYXKEYS.COLLECTION.REPORT}100` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {status: 'open', lastModified: '2024-01-15'}, + }; + workingUpdates = applyUpdateToCache(workingUpdates, individualMergeUpdate, mockContext); + + // Verify final state + const report100 = getCachedReportByID('100', mockContext, workingUpdates); + const report200 = getCachedReportByID('200', mockContext, workingUpdates); + const transaction100 = getCachedTransactionByID('t100', mockContext, workingUpdates); + const transaction200 = getCachedTransactionByID('t200', mockContext, workingUpdates); + const transaction300 = getCachedTransactionByID('t300', mockContext, workingUpdates); + + expect(report100).toEqual({ + reportID: '100', + reportName: 'Report 100', + total: -1500, + currency: 'USD', + status: 'open', + lastModified: '2024-01-15', + }); + + expect(report200).toEqual({ + reportID: '200', + reportName: 'Report 200', + total: -2500, + currency: 'EUR', + }); + + expect(transaction100).toEqual({ + transactionID: 't100', + reportID: '100', + amount: -750, + merchant: 'New Store', + }); + + // Transaction t200 was replaced by SET_COLLECTION, so it shouldn't exist + expect(transaction200).toBeUndefined(); + + expect(transaction300).toEqual({ + transactionID: 't300', + reportID: '100', + amount: -250, + merchant: 'Another Store', + }); + }); + }); + + describe('deepMerge', () => { + describe('Basic merging', () => { + it('should merge simple objects', () => { + const target = {a: 1, b: 2}; + const source = {b: 3, c: 4}; + const result = deepMerge(target, source); + + expect(result).toEqual({a: 1, b: 3, c: 4}); + }); + + it('should add new properties from source', () => { + const target = {a: 1}; + const source = {b: 2, c: 3}; + const result = deepMerge(target, source); + + expect(result).toEqual({a: 1, b: 2, c: 3}); + }); + + it('should preserve target properties not in source', () => { + const target = {a: 1, b: 2, c: 3}; + const source = {b: 4}; + const result = deepMerge(target, source); + + expect(result).toEqual({a: 1, b: 4, c: 3}); + }); + }); + + describe('Deep merging', () => { + it('should deeply merge nested objects', () => { + const target = { + user: {name: 'John', age: 30}, + settings: {theme: 'dark', notifications: true}, + }; + const source = { + user: {age: 31, city: 'NYC'}, + settings: {notifications: false}, + }; + const result = deepMerge(target, source); + + expect(result).toEqual({ + user: {name: 'John', age: 31, city: 'NYC'}, + settings: {theme: 'dark', notifications: false}, + }); + }); + + it('should merge multiple levels of nesting', () => { + const target = { + level1: { + level2: { + level3: {a: 1, b: 2}, + }, + }, + }; + const source = { + level1: { + level2: { + level3: {b: 3, c: 4}, + }, + }, + }; + const result = deepMerge(target, source); + + expect(result).toEqual({ + level1: { + level2: { + level3: {a: 1, b: 3, c: 4}, + }, + }, + }); + }); + }); + + describe('Array handling', () => { + it('should replace arrays instead of merging them', () => { + const target = {items: [1, 2, 3]}; + const source = {items: [4, 5]}; + const result = deepMerge(target, source); + + expect(result).toEqual({items: [4, 5]}); + }); + + it('should handle arrays in nested objects', () => { + const target = { + data: { + list: [1, 2, 3], + name: 'test', + }, + }; + const source = { + data: { + list: [4, 5], + }, + }; + const result = deepMerge(target, source); + + expect(result).toEqual({ + data: { + list: [4, 5], + name: 'test', + }, + }); + }); + }); + + describe('Null and undefined handling', () => { + it('should handle null source', () => { + const target = {a: 1, b: 2}; + const source = undefined; + const result = deepMerge(target, source); + + expect(result).toEqual({a: 1, b: 2}); + }); + + it('should handle null target', () => { + const target = undefined; + const source = {a: 1, b: 2}; + const result = deepMerge(target, source); + + expect(result).toEqual({a: 1, b: 2}); + }); + + it('should handle both null target and source', () => { + const target = undefined; + const source = undefined; + const result = deepMerge(target, source); + + expect(result).toEqual({}); + }); + + it('should replace target property with null from source', () => { + const target = {a: 1, b: {c: 3}}; + const source = {a: null}; + const result = deepMerge(target, source); + + expect(result).toEqual({a: null, b: {c: 3}}); + }); + + it('should ignore undefined values in source', () => { + const target = {a: 1, b: 2}; + const source = {a: undefined, c: 3}; + const result = deepMerge(target, source); + + expect(result).toEqual({a: 1, b: 2, c: 3}); + }); + }); + + describe('Special object types', () => { + it('should replace Date objects instead of merging', () => { + const target = {date: new Date('2023-01-01')}; + const source = {date: new Date('2023-12-31')}; + const result = deepMerge(target, source); + + expect(result.date).toEqual(new Date('2023-12-31')); + }); + + it('should replace RegExp objects instead of merging', () => { + const target = {pattern: /old/}; + const source = {pattern: /new/}; + const result = deepMerge(target, source); + + expect(result.pattern).toEqual(/new/); + }); + + it('should handle mixed object types', () => { + const target = { + date: new Date('2023-01-01'), + nested: {a: 1}, + }; + const source = { + date: new Date('2023-12-31'), + nested: {b: 2}, + }; + const result = deepMerge(target, source); + + expect(result).toEqual({ + date: new Date('2023-12-31'), + nested: {a: 1, b: 2}, + }); + }); + }); + + describe('Primitive type handling', () => { + it('should replace primitives with objects', () => { + const target = {value: 'string'}; + const source = {value: {nested: 'object'}}; + const result = deepMerge(target, source); + + expect(result).toEqual({value: {nested: 'object'}}); + }); + + it('should replace objects with primitives', () => { + const target = {value: {nested: 'object'}}; + const source = {value: 'string'}; + const result = deepMerge(target, source); + + expect(result).toEqual({value: 'string'}); + }); + }); + + describe('Real-world scenarios', () => { + it('should merge report objects correctly', () => { + const target = { + reportID: '123', + reportName: 'Test Report', + participants: {user1: true}, + metadata: {created: '2023-01-01'}, + }; + const source = { + reportName: 'Updated Report', + participants: {user2: true}, + metadata: {updated: '2023-12-31'}, + }; + const result = deepMerge(target, source); + + expect(result).toEqual({ + reportID: '123', + reportName: 'Updated Report', + participants: {user1: true, user2: true}, + metadata: {created: '2023-01-01', updated: '2023-12-31'}, + }); + }); + + it('should handle transaction updates', () => { + const target = { + transactionID: '456', + amount: 100, + receipt: {url: 'old-url', filename: 'old.jpg'}, + }; + const source = { + amount: 150, + receipt: {filename: 'new.jpg'}, + }; + const result = deepMerge(target, source); + + expect(result).toEqual({ + transactionID: '456', + amount: 150, + receipt: {url: 'old-url', filename: 'new.jpg'}, + }); + }); + + it('should handle policy configuration merges', () => { + const target = { + policyID: '789', + settings: { + approvalMode: 'basic', + notifications: {email: true, push: false}, + }, + categories: {travel: true}, + }; + const source = { + settings: { + notifications: {push: true, sms: true}, + }, + categories: {meals: true}, + }; + const result = deepMerge(target, source); + + expect(result).toEqual({ + policyID: '789', + settings: { + approvalMode: 'basic', + notifications: {email: true, push: true, sms: true}, + }, + categories: {travel: true, meals: true}, + }); + }); + + it('should handle complex nested updates', () => { + const target = { + reportID: 'report_123', + reportName: 'Old Name', + policyID: 'policy_456', + stateNum: 0, + participants: { + user1: {role: 'admin'}, + user2: {role: 'member'}, + }, + errors: {}, + pendingFields: {reportName: 'update'}, + }; + + const source = { + reportName: 'New Name', + stateNum: 1, + participants: { + user1: {role: 'admin', lastRead: '2023-12-31'}, + }, + errors: {general: 'Some error'}, + }; + + const result = deepMerge(target, source); + + expect(result).toEqual({ + reportID: 'report_123', + reportName: 'New Name', + policyID: 'policy_456', + stateNum: 1, + participants: { + user1: {role: 'admin', lastRead: '2023-12-31'}, + user2: {role: 'member'}, + }, + errors: {general: 'Some error'}, + pendingFields: {reportName: 'update'}, + }); + }); + }); + + describe('Edge cases', () => { + it('should handle empty objects', () => { + const target = {}; + const source = {a: 1}; + const result = deepMerge(target, source); + + expect(result).toEqual({a: 1}); + }); + + it('should handle merging with empty source', () => { + const target = {a: 1}; + const source = {}; + const result = deepMerge(target, source); + + expect(result).toEqual({a: 1}); + }); + + it('should handle deeply nested null replacement', () => { + const target = { + level1: { + level2: { + level3: {value: 'old'}, + }, + }, + }; + const source = { + level1: { + level2: { + level3: null, + }, + }, + }; + const result = deepMerge(target, source); + + expect(result).toEqual({ + level1: { + level2: { + level3: null, + }, + }, + }); + }); + }); + }); +}); diff --git a/tests/unit/OptimisticReportNamesTest.ts b/tests/unit/OptimisticReportNamesTest.ts index d80f95c07fd7..1fbcd936936f 100644 --- a/tests/unit/OptimisticReportNamesTest.ts +++ b/tests/unit/OptimisticReportNamesTest.ts @@ -1,10 +1,12 @@ import Onyx from 'react-native-onyx'; import type {UpdateContext} from '@libs/OptimisticReportNames'; -import {computeReportNameIfNeeded, getReportByTransactionID, shouldComputeReportName, updateOptimisticReportNamesFromUpdates} from '@libs/OptimisticReportNames'; +import {computeReportNameIfNeeded, shouldComputeReportName, updateOptimisticReportNamesFromUpdates} from '@libs/OptimisticReportNames'; +import type {WorkingUpdates} from '@libs/OptimisticReportNamesCache'; +import {applyUpdateToCache} from '@libs/OptimisticReportNamesCache'; // eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock import * as ReportUtils from '@libs/ReportUtils'; import type {OnyxKey} from '@src/ONYXKEYS'; -import type {Policy, Report, ReportNameValuePairs, Transaction} from '@src/types/onyx'; +import type {Policy, Report, ReportNameValuePairs} from '@src/types/onyx'; // Mock dependencies jest.mock('@libs/ReportUtils', () => ({ @@ -65,7 +67,7 @@ describe('OptimisticReportNames', () => { describe('shouldComputeReportName()', () => { test('should return true for report with title field formula', () => { - const result = shouldComputeReportName(mockReport, mockContext); + const result = shouldComputeReportName(mockReport, mockContext, {}); expect(result).toBe(true); }); @@ -80,7 +82,7 @@ describe('OptimisticReportNames', () => { }, }, }; - const result = shouldComputeReportName(mockReport, context); + const result = shouldComputeReportName(mockReport, context, {}); expect(result).toBe(false); }); @@ -93,6 +95,7 @@ describe('OptimisticReportNames', () => { type: 'iou', } as Report, mockContext, + {}, ); expect(result).toBe(false); }); @@ -106,7 +109,9 @@ describe('OptimisticReportNames', () => { value: {total: -20000}, }; - const result = computeReportNameIfNeeded(mockReport, update, mockContext); + const workingUpdates = applyUpdateToCache({}, update, mockContext); + + const result = computeReportNameIfNeeded('123', mockContext, update, workingUpdates); expect(result).toEqual('Expense Report - $200.00'); }); @@ -117,21 +122,33 @@ describe('OptimisticReportNames', () => { value: {description: 'Updated description'}, }; - const result = computeReportNameIfNeeded( - { - ...mockReport, - reportName: 'Expense Report - $100.00', + const context = { + ...mockContext, + allReports: { + // eslint-disable-next-line @typescript-eslint/naming-convention + report_123: {...mockReport, reportName: 'Expense Report - $100.00'}, }, - update, - mockContext, - ); + }; + const workingUpdates = applyUpdateToCache({}, update, context); + + const result = computeReportNameIfNeeded('456', context, update, workingUpdates); expect(result).toBeNull(); }); }); describe('updateOptimisticReportNamesFromUpdates()', () => { - test.skip('should detect new report creation and add name update', () => { + test('should detect new report creation and add name update', () => { const updates = [ + { + key: 'reportNameValuePairs_456' as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + expensify_text_title: { + defaultValue: 'Expense Report - {report:total}', + }, + }, + }, { key: 'report_456' as OnyxKey, onyxMethod: Onyx.METHOD.SET, @@ -146,8 +163,8 @@ describe('OptimisticReportNames', () => { ]; const result = updateOptimisticReportNamesFromUpdates(updates, mockContext); - expect(result).toHaveLength(2); // Original + name update - expect(result.at(1)).toEqual({ + expect(result).toHaveLength(3); // Original updates + name update + expect(result.at(2)).toEqual({ key: 'report_456', onyxMethod: Onyx.METHOD.MERGE, value: {reportName: 'Expense Report - $150.00'}, @@ -250,7 +267,7 @@ describe('OptimisticReportNames', () => { value: {total: -10000}, }; - const result = computeReportNameIfNeeded(undefined, update, mockContext); + const result = computeReportNameIfNeeded('NO_EXIST', mockContext, update, {} as WorkingUpdates); expect(result).toBeNull(); }); }); @@ -289,52 +306,6 @@ describe('OptimisticReportNames', () => { expect(result.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,