From a05814641d153bcb10d97ce11b2c7d543aa9c16f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Sep 2025 11:48:08 +0100 Subject: [PATCH 01/44] feat: improve attachment validation --- src/CONST/index.ts | 33 +- src/hooks/useFilesValidation.tsx | 317 ++++++++++-------- src/libs/AttachmentUtils.ts | 105 ------ src/libs/AttachmentValidation.ts | 205 +++++++++++ src/libs/fileDownload/FileUtils.ts | 40 +-- .../routes/ReportAttachmentModalContent.tsx | 13 +- tests/unit/AttachmentValidationTest.ts | 49 +++ tests/unit/FileUtilsTest.ts | 43 --- 8 files changed, 470 insertions(+), 335 deletions(-) delete mode 100644 src/libs/AttachmentUtils.ts create mode 100644 src/libs/AttachmentValidation.ts create mode 100644 tests/unit/AttachmentValidationTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 69ac6b6d40b66..9edfa7662b9fd 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -265,6 +265,26 @@ const CONST = { MAX_FILE_LIMIT: 30, }, + ATTACHMENT_VALIDATION_ERRORS: { + SINGLE_FILE: { + NO_FILE_PROVIDED: 'noFileProvided', + FILE_INVALID: 'fileInvalid', + WRONG_FILE_TYPE: 'wrongFileType', + FILE_TOO_LARGE: 'fileTooLarge', + FILE_TOO_SMALL: 'fileTooSmall', + FILE_CORRUPTED: 'fileCorrupted', + PROTECTED_FILE: 'protectedFile', + FOLDER_NOT_ALLOWED: 'folderNotAllowed', + HEIC_OR_HEIF_IMAGE: 'heicOrHeifImage', + }, + MULTIPLE_FILES: { + WRONG_FILE_TYPE: 'multipleAttachmentsWrongFileType', + FILE_TOO_LARGE: 'multipleAttachmentsFileTooLarge', + FOLDER_NOT_ALLOWED: 'multipleAttachmentsFolderNotAllowed', + MAX_FILE_LIMIT_EXCEEDED: 'multipleAttachmentsMaxFileLimitExceeded', + }, + }, + // Allowed extensions for spreadsheets import ALLOWED_SPREADSHEET_EXTENSIONS: ['xls', 'xlsx', 'csv', 'txt'], @@ -1992,19 +2012,6 @@ const CONST = { // Video MimeTypes allowed by iOS photos app. VIDEO: /\.(mov|mp4)$/, }, - - FILE_VALIDATION_ERRORS: { - WRONG_FILE_TYPE: 'wrongFileType', - WRONG_FILE_TYPE_MULTIPLE: 'wrongFileTypeMultiple', - FILE_TOO_LARGE: 'fileTooLarge', - FILE_TOO_LARGE_MULTIPLE: 'fileTooLargeMultiple', - FILE_TOO_SMALL: 'fileTooSmall', - FILE_CORRUPTED: 'fileCorrupted', - FOLDER_NOT_ALLOWED: 'folderNotAllowed', - MAX_FILE_LIMIT_EXCEEDED: 'fileLimitExceeded', - PROTECTED_FILE: 'protectedFile', - }, - IOS_CAMERA_ROLL_ACCESS_ERROR: 'Access to photo library was denied', ADD_PAYMENT_MENU_POSITION_Y: 226, ADD_PAYMENT_MENU_POSITION_X: 356, diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index d047edbb5031e..488fb1cb5e537 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -1,29 +1,22 @@ import {Str} from 'expensify-common'; import React, {useCallback, useRef, useState} from 'react'; import {InteractionManager} from 'react-native'; -import type {ValueOf} from 'type-fest'; -import type {FileObject} from '@components/AttachmentModal'; import ConfirmModal from '@components/ConfirmModal'; import {useFullScreenLoader} from '@components/FullScreenLoaderContext'; import PDFThumbnail from '@components/PDFThumbnail'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import { - getFileValidationErrorText, - isHeicOrHeifImage, - normalizeFileObject, - resizeImageIfNeeded, - splitExtensionFromFileName, - validateAttachment, - validateImageForCorruption, -} from '@libs/fileDownload/FileUtils'; +import {validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; +import type {MultipleAttachmentsValidationError, SingleAttachmentInvalidResult, SingleAttachmentValidationError} from '@libs/AttachmentValidation'; +import {getFileValidationErrorText, resizeImageIfNeeded} from '@libs/fileDownload/FileUtils'; import convertHeicImage from '@libs/fileDownload/heicConverter'; +import type {FileObject} from '@pages/media/AttachmentModalScreen/types'; import CONST from '@src/CONST'; import useLocalize from './useLocalize'; import useThemeStyles from './useThemeStyles'; type ErrorObject = { - error: ValueOf; + error: SingleAttachmentValidationError; fileExtension?: string; }; @@ -31,13 +24,13 @@ const sortFilesByOriginalOrder = (files: FileObject[], orderMap: Map (orderMap.get(a.uri ?? '') ?? 0) - (orderMap.get(b.uri ?? '') ?? 0)); }; -function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => void, isValidatingReceipts = true) { +function useFilesValidation(onFilesValidated: (files: FileObject[]) => void, isValidatingReceipts = true, onSourceChanged?: (source: string | undefined) => void) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); - const [fileError, setFileError] = useState | null>(null); + const [fileError, setFileError] = useState(); const [pdfFilesToRender, setPdfFilesToRender] = useState([]); - const [validFilesToUpload, setValidFilesToUpload] = useState([] as FileObject[]); + const [validFilesToUpload, setValidFilesToUpload] = useState([]); const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); const [invalidFileExtension, setInvalidFileExtension] = useState(''); const [errorQueue, setErrorQueue] = useState([]); @@ -47,6 +40,7 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => voi const validatedPDFs = useRef([]); const validFiles = useRef([]); const filesToValidate = useRef([]); + const invalidFileResults = useRef([]); const dataTransferItemList = useRef([]); const collectedErrors = useRef([]); const originalFileOrder = useRef>(new Map()); @@ -76,7 +70,7 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => voi setIsLoaderVisible(false); setValidFilesToUpload([]); setIsValidatingMultipleFiles(false); - setFileError(null); + setFileError(undefined); setInvalidFileExtension(''); setErrorQueue([]); setCurrentErrorIndex(0); @@ -95,42 +89,11 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => voi }); }, [resetValidationState]); - const setErrorAndOpenModal = (error: ValueOf) => { + const setErrorAndOpenModal = (error: SingleAttachmentValidationError) => { setFileError(error); setIsErrorModalVisible(true); }; - const isValidFile = (originalFile: FileObject, item: DataTransferItem | undefined, isCheckingMultipleFiles?: boolean) => { - if (item && item.kind === 'file' && 'webkitGetAsEntry' in item) { - const entry = item.webkitGetAsEntry(); - - if (entry?.isDirectory) { - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED}); - return Promise.resolve(false); - } - } - - return normalizeFileObject(originalFile) - .then((normalizedFile) => - validateImageForCorruption(normalizedFile).then(() => { - const error = validateAttachment(normalizedFile, isCheckingMultipleFiles, isValidatingReceipts); - if (error) { - const errorData = { - error, - fileExtension: error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE ? splitExtensionFromFileName(normalizedFile.name ?? '').fileExtension : undefined, - }; - collectedErrors.current.push(errorData); - return false; - } - return true; - }), - ) - .catch(() => { - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); - return false; - }); - }; - const convertHeicImageToJpegPromise = (file: FileObject): Promise => { return new Promise((resolve, reject) => { convertHeicImage(file, { @@ -169,120 +132,196 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => voi } } else if (validFiles.current.length > 0) { const sortedFiles = sortFilesByOriginalOrder(validFiles.current, originalFileOrder.current); - proceedWithFilesAction(sortedFiles); + onFilesValidated(sortedFiles); resetValidationState(); } - }, [deduplicateErrors, pdfFilesToRender.length, proceedWithFilesAction, resetValidationState]); + }, [deduplicateErrors, pdfFilesToRender.length, onFilesValidated, resetValidationState]); + + // Helper function to process remaining files (resizing and final resolution) + const processRemainingFiles = useCallback( + ( + convertedFiles: FileObject[], + validFilesToProcess: FileObject[], + filesToResize: FileObject[], + pdfsToLoad: FileObject[], + resolve: (value: {processedFiles: FileObject[]; pdfsToLoad: FileObject[]}) => void, + ) => { + if (filesToResize.length > 0) { + setIsLoaderVisible(true); + + Promise.all(filesToResize.map((file) => resizeImageIfNeeded(file))) + .then((resizedFiles) => { + resizedFiles.forEach((resizedFile, index) => { + updateFileOrderMapping(filesToResize.at(index), resizedFile); + }); - const validateAndResizeFiles = (files: FileObject[], items: DataTransferItem[]) => { - // Early return for empty files - if (files.length === 0) { - return; - } + setIsLoaderVisible(false); + const allProcessedFiles = [...convertedFiles, ...validFilesToProcess, ...resizedFiles]; + resolve({processedFiles: allProcessedFiles, pdfsToLoad}); + }) + .catch((error) => { + console.error('Error resizing files:', error); + setIsLoaderVisible(false); + // Fallback to files without resizing + const allProcessedFiles = [...convertedFiles, ...validFilesToProcess, ...filesToResize]; + resolve({processedFiles: allProcessedFiles, pdfsToLoad}); + }); + } else { + // No resizing needed, return all processed files + const allProcessedFiles = [...convertedFiles, ...validFilesToProcess]; + resolve({processedFiles: allProcessedFiles, pdfsToLoad}); + } + }, + [setIsLoaderVisible, updateFileOrderMapping], + ); - // Reset collected errors for new validation - collectedErrors.current = []; + const convertAndResizeFiles = (invalidResults: SingleAttachmentInvalidResult[], files: FileObject[]) => { + new Promise<{processedFiles: FileObject[]; pdfsToLoad: FileObject[]}>((resolve) => { + const filteredResults = files.filter((result): result is FileObject => result !== null); + const pdfsToLoad = filteredResults.filter((file) => Str.isPDF(file.name ?? '')); + const otherFiles = filteredResults.filter((file) => !Str.isPDF(file.name ?? '')); - files.forEach((file, index) => { - originalFileOrder.current.set(file.uri ?? '', index); - }); + // Group invalid results by error type for efficient processing + const heicOrHeifResults = invalidResults.filter((result) => result.error === CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE); + const fileTooLargeResults = invalidResults.filter((result) => result.error === CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); - Promise.all(files.map((file, index) => isValidFile(file, items.at(index), files.length > 1).then((isValid) => (isValid ? file : null)))) - .then((validationResults) => { - const filteredResults = validationResults.filter((result): result is FileObject => result !== null); - const pdfsToLoad = filteredResults.filter((file) => Str.isPDF(file.name ?? '')); - const otherFiles = filteredResults.filter((file) => !Str.isPDF(file.name ?? '')); + // Get files that need conversion (HEIC/HEIF) + const filesToConvert = heicOrHeifResults.map((result) => result.file); - // Check if we need to convert images - if (otherFiles.some((file) => isHeicOrHeifImage(file))) { - setIsLoaderVisible(true); + // Get files that need resizing (too large) + const filesToResize = fileTooLargeResults.map((result) => result.file); - return Promise.all(otherFiles.map((file) => convertHeicImageToJpegPromise(file))).then((convertedImages) => { - convertedImages.forEach((convertedFile, index) => { - updateFileOrderMapping(otherFiles.at(index), convertedFile); + // Get files that are valid and don't need processing + const validFilesToProcess = otherFiles.filter( + (file) => !filesToConvert.some((convertFile) => convertFile.uri === file.uri) && !filesToResize.some((resizeFile) => resizeFile.uri === file.uri), + ); + + // Process files that need conversion + if (filesToConvert.length > 0) { + setIsLoaderVisible(true); + + Promise.all(filesToConvert.map((file) => convertHeicImageToJpegPromise(file))) + .then((convertedFiles) => { + // Update file order mapping for converted files + convertedFiles.forEach((convertedFile, index) => { + updateFileOrderMapping(filesToConvert.at(index), convertedFile); }); - // Check if we need to resize images - if (convertedImages.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { - return Promise.all(convertedImages.map((file) => resizeImageIfNeeded(file))).then((processedFiles) => { - processedFiles.forEach((resizedFile, index) => { - updateFileOrderMapping(convertedImages.at(index), resizedFile); + // Check if converted files also need resizing + const convertedFilesNeedingResize = convertedFiles.filter((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); + + if (convertedFilesNeedingResize.length > 0) { + // Resize converted files that are too large + Promise.all(convertedFilesNeedingResize.map((file) => resizeImageIfNeeded(file))).then((resizedConvertedFiles) => { + resizedConvertedFiles.forEach((resizedFile, index) => { + updateFileOrderMapping(convertedFilesNeedingResize.at(index), resizedFile); }); - setIsLoaderVisible(false); - return Promise.resolve({processedFiles, pdfsToLoad}); + + // Process remaining files that need resizing (not converted) + processRemainingFiles(convertedFiles, validFilesToProcess, filesToResize, pdfsToLoad, resolve); }); + } else { + // No resizing needed for converted files, process remaining files + processRemainingFiles(convertedFiles, validFilesToProcess, filesToResize, pdfsToLoad, resolve); } - - // No resizing needed, just return the converted images - setIsLoaderVisible(false); - return Promise.resolve({processedFiles: convertedImages, pdfsToLoad}); - }); - } - - // No conversion needed, but check if we need to resize images - if (otherFiles.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { - setIsLoaderVisible(true); - return Promise.all(otherFiles.map((file) => resizeImageIfNeeded(file))).then((processedFiles) => { - processedFiles.forEach((resizedFile, index) => { - updateFileOrderMapping(otherFiles.at(index), resizedFile); - }); + }) + .catch((error) => { + console.error('Error converting HEIC/HEIF files:', error); setIsLoaderVisible(false); - return Promise.resolve({processedFiles, pdfsToLoad}); + // Fallback to processing remaining files without conversion + processRemainingFiles([], validFilesToProcess, filesToResize, pdfsToLoad, resolve); }); + } else { + // No conversion needed, process remaining files + processRemainingFiles([], validFilesToProcess, filesToResize, pdfsToLoad, resolve); + } + }).then(({processedFiles, pdfsToLoad}) => { + if (pdfsToLoad.length) { + validFiles.current = processedFiles; + setPdfFilesToRender(pdfsToLoad); + } else { + if (processedFiles.length > 0) { + setValidFilesToUpload(processedFiles); } - // No conversion or resizing needed, just return the valid images - return Promise.resolve({processedFiles: otherFiles, pdfsToLoad}); - }) - .then(({processedFiles, pdfsToLoad}) => { - if (pdfsToLoad.length) { - validFiles.current = processedFiles; - setPdfFilesToRender(pdfsToLoad); - } else { - if (processedFiles.length > 0) { - setValidFilesToUpload(processedFiles); - } - - if (collectedErrors.current.length > 0) { - const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as ErrorObject); - setErrorQueue(uniqueErrors); - setCurrentErrorIndex(0); - const firstError = uniqueErrors.at(0); - if (firstError) { - setFileError(firstError.error); - if (firstError.fileExtension) { - setInvalidFileExtension(firstError.fileExtension); - } - setIsErrorModalVisible(true); + if (collectedErrors.current.length > 0) { + const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as ErrorObject); + setErrorQueue(uniqueErrors); + setCurrentErrorIndex(0); + const firstError = uniqueErrors.at(0); + if (firstError) { + setFileError(firstError.error); + if (firstError.fileExtension) { + setInvalidFileExtension(firstError.fileExtension); } - } else if (processedFiles.length > 0) { - const sortedFiles = sortFilesByOriginalOrder(processedFiles, originalFileOrder.current); - proceedWithFilesAction(sortedFiles); - resetValidationState(); + setIsErrorModalVisible(true); } + } else if (processedFiles.length > 0) { + const sortedFiles = sortFilesByOriginalOrder(processedFiles, originalFileOrder.current); + onFilesValidated(sortedFiles); + resetValidationState(); } - }); + } + }); }; - const validateFiles = (files: FileObject[], items?: DataTransferItem[]) => { - if (files.length > 1) { + const validateFiles = (files: File | FileObject[], items?: DataTransferItem[]) => { + if (!files) { + return; + } + + // Reset collected errors for new validation + collectedErrors.current = []; + + if (Array.isArray(files)) { setIsValidatingMultipleFiles(true); + + files.forEach((file, index) => { + originalFileOrder.current.set(file.uri ?? '', index); + }); + + validateMultipleAttachmentFiles(files, items).then((result) => { + if (result.isValid) { + onSourceChanged?.(result.validatedFiles.at(0)?.source); + onFilesValidated(result.validatedFiles); + return; + } + + if (result.error === CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { + filesToValidate.current = files.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); + if (items) { + dataTransferItemList.current = items.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); + } + } + + invalidFileResults.current = result.fileResults.filter((r) => r.isValid === false); + convertAndResizeFiles(invalidFileResults.current, files); + collectedErrors.current.push({error: result.error}); + setErrorAndOpenModal(result.error); + }); + return; } - if (files.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { - filesToValidate.current = files.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); - if (items) { - dataTransferItemList.current = items.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); + + originalFileOrder.current.set(files.uri ?? '', 0); + + validateAttachmentFile(files, items?.at(0)).then((result) => { + if (result.isValid) { + onSourceChanged?.(result.validatedFile.source); + onFilesValidated([result.validatedFile.file]); + return; } - setErrorAndOpenModal(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); - } else { - validateAndResizeFiles(files, items ?? []); - } + + invalidFileResults.current = [result].filter((r) => r.isValid === false); + convertAndResizeFiles(invalidFileResults.current, [files]); + collectedErrors.current.push({error: result.error}); + setErrorAndOpenModal(result.error); + }); }; const onConfirm = () => { - if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { + if (fileError === CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { setIsErrorModalVisible(false); - validateAndResizeFiles(filesToValidate.current, dataTransferItemList.current); + convertAndResizeFiles(invalidFileResults.current, filesToValidate.current); return; } @@ -307,13 +346,13 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => voi setIsErrorModalVisible(false); InteractionManager.runAfterInteractions(() => { if (sortedFiles.length !== 0) { - proceedWithFilesAction(sortedFiles); + onFilesValidated(sortedFiles); } resetValidationState(); }); } else { if (sortedFiles.length !== 0) { - proceedWithFilesAction(sortedFiles); + onFilesValidated(sortedFiles); } hideModalAndReset(); } @@ -333,7 +372,7 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => voi onPassword={() => { validatedPDFs.current.push(file); if (isValidatingReceipts) { - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE}); + collectedErrors.current.push({error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.PROTECTED_FILE}); } else { validFiles.current.push(file); } @@ -341,7 +380,7 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => voi }} onLoadError={() => { validatedPDFs.current.push(file); - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); + collectedErrors.current.push({error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_CORRUPTED}); checkIfAllValidatedAndProceed(); }} /> @@ -353,7 +392,7 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => voi return ''; } const prompt = getFileValidationErrorText(fileError, {fileType: invalidFileExtension}, isValidatingReceipts).reason; - if (fileError === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE || fileError === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE) { + if (fileError === CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE || fileError === CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE) { return ( {prompt} @@ -378,8 +417,8 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => voi ); return { - PDFValidationComponent, validateFiles, + PDFValidationComponent, ErrorModal, }; } diff --git a/src/libs/AttachmentUtils.ts b/src/libs/AttachmentUtils.ts deleted file mode 100644 index eea91499fa661..0000000000000 --- a/src/libs/AttachmentUtils.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type {FileObject} from '@pages/media/AttachmentModalScreen/types'; -import CONST from '@src/CONST'; -import {cleanFileName, validateImageForCorruption} from './fileDownload/FileUtils'; - -type AttachmentValidationError = CorruptionError | 'fileDoesNotExist' | 'fileInvalid'; -type ValidResult = { - isValid: true; - fileType: 'file' | 'uri'; - source: string; - file: FileObject; -}; -type InvalidResult = { - isValid: false; - error: AttachmentValidationError; -}; - -type AttachmentValidationResult = ValidResult | InvalidResult; - -function validateAttachmentFile(file: FileObject): Promise { - if (!file || !isDirectoryCheck(file)) { - return Promise.resolve({isValid: false, error: 'fileDoesNotExist'}); - } - - let fileObject = file; - if ('getAsFile' in file && typeof file.getAsFile === 'function') { - fileObject = file.getAsFile() as FileObject; - } - if (!fileObject) { - return Promise.resolve({isValid: false, error: 'fileInvalid'}); - } - - return isFileCorrupted(fileObject).then((corruptionResult) => { - if (!corruptionResult.isValid) { - return corruptionResult as InvalidResult; - } - - if (fileObject instanceof File) { - /** - * Cleaning file name, done here so that it covers all cases: - * upload, drag and drop, copy-paste - */ - let updatedFile = fileObject; - const cleanName = cleanFileName(updatedFile.name); - if (updatedFile.name !== cleanName) { - updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type}); - } - const inputSource = URL.createObjectURL(updatedFile); - updatedFile.uri = inputSource; - - return {isValid: true, fileType: 'file', source: inputSource, file: updatedFile} as ValidResult; - } - - return {isValid: true, fileType: 'uri', source: fileObject.uri, file: fileObject} as ValidResult; - }); -} - -type CorruptionError = 'tooLarge' | 'tooSmall' | 'error'; -type NoCorruptionResult = { - isValid: true; -}; -type CorruptionResult = { - isValid: false; - error: CorruptionError; -}; -type AttachmentCorruptionValidationResult = NoCorruptionResult | CorruptionResult; - -function isFileCorrupted(fileObject: FileObject): Promise { - return validateImageForCorruption(fileObject) - .then(() => { - if (fileObject.size && fileObject.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - return { - isValid: false, - error: 'tooLarge', - } satisfies AttachmentCorruptionValidationResult; - } - - if (fileObject.size && fileObject.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - return { - isValid: false, - error: 'tooSmall', - } satisfies AttachmentCorruptionValidationResult; - } - - return { - isValid: true, - } satisfies AttachmentCorruptionValidationResult; - }) - .catch(() => { - return { - isValid: false, - error: 'error', - }; - }); -} - -function isDirectoryCheck(data: FileObject) { - if ('webkitGetAsEntry' in data && (data as DataTransferItem).webkitGetAsEntry()?.isDirectory) { - return false; - } - - return true; -} - -export default validateAttachmentFile; -export type {AttachmentValidationResult}; diff --git a/src/libs/AttachmentValidation.ts b/src/libs/AttachmentValidation.ts new file mode 100644 index 0000000000000..a83d2dc89a010 --- /dev/null +++ b/src/libs/AttachmentValidation.ts @@ -0,0 +1,205 @@ +import {Str} from 'expensify-common'; +import type {ValueOf} from 'type-fest'; +import type {FileObject} from '@pages/media/AttachmentModalScreen/types'; +import CONST from '@src/CONST'; +import {cleanFileName, isHeicOrHeifImage, isValidReceiptExtension, normalizeFileObject, validateImageForCorruption} from './fileDownload/FileUtils'; + +type ValidatedFile = { + fileType: 'file' | 'uri'; + source: string; + file: FileObject; +}; + +type SingleAttachmentValidResult = { + isValid: true; + validatedFile: ValidatedFile; +}; + +type SingleAttachmentValidationError = ValueOf; +type SingleAttachmentInvalidResult = { + isValid: false; + error: SingleAttachmentValidationError; + file: FileObject; +}; + +type SingleAttachmentValidationResult = SingleAttachmentValidResult | SingleAttachmentInvalidResult; + +function isSingleAttachmentValidationResult(result: unknown): result is SingleAttachmentValidationResult { + return typeof result === 'object' && result !== null && 'isValid' in result && typeof result.isValid === 'boolean' && ('validatedFile' in result || 'error' in result); +} + +function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipt?: boolean): Promise { + if (!file) { + return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED, file}); + } + + const maxFileSize = isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; + + const isImage = Str.isImage(file.name ?? ''); + + if (isImage && isHeicOrHeifImage(file)) { + return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE, file}); + } + + if (!isImage && !isHeicOrHeifImage(file) && (file?.size ?? 0) > maxFileSize) { + return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, file}); + } + + if (isValidatingReceipt && (file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, file}); + } + + if (isValidatingReceipt && !isValidReceiptExtension(file)) { + return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE, file}); + } + + let fileObject = file; + if ('getAsFile' in file && typeof file.getAsFile === 'function') { + fileObject = file.getAsFile() as FileObject; + } + + if (!fileObject) { + return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, file}); + } + + if (item && item.kind === 'file' && 'webkitGetAsEntry' in item) { + const entry = item.webkitGetAsEntry(); + + if (entry?.isDirectory) { + return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FOLDER_NOT_ALLOWED, file}); + } + } + + return isFileCorrupted(fileObject).then((corruptionResult) => { + if (!corruptionResult.isValid) { + return corruptionResult; + } + + let validatedFile: ValidatedFile; + + if (fileObject instanceof File) { + /** + * Cleaning file name, done here so that it covers all cases: + * upload, drag and drop, copy-paste + */ + let updatedFile = fileObject; + const cleanName = cleanFileName(updatedFile.name); + if (updatedFile.name !== cleanName) { + updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type}); + } + const inputSource = URL.createObjectURL(updatedFile); + updatedFile.uri = inputSource; + + validatedFile = { + fileType: 'file', + source: inputSource, + file: updatedFile, + }; + + return {isValid: true, validatedFile}; + } + + validatedFile = { + fileType: 'uri', + source: fileObject.uri ?? '', + file: fileObject, + }; + + return {isValid: true, validatedFile}; + }); +} + +type MultipleAttachmentsValidResult = { + isValid: true; + validatedFiles: ValidatedFile[]; +}; + +type MultipleAttachmentsValidationError = ValueOf; +type MultipleAttachmentsInvalidResult = { + isValid: false; + error: MultipleAttachmentsValidationError; + fileResults: SingleAttachmentValidationResult[]; + files: FileObject[]; +}; +type MultipleAttachmentsValidationResult = MultipleAttachmentsValidResult | MultipleAttachmentsInvalidResult; + +function isMultipleAttachmentsValidationResult(result: unknown): result is MultipleAttachmentsValidationResult { + return typeof result === 'object' && result !== null && 'isValid' in result && typeof result.isValid === 'boolean' && ('validatedFiles' in result || 'fileResults' in result); +} + +function validateMultipleAttachmentFiles(files: FileObject[], items?: DataTransferItem[]): Promise { + if (!files?.length || files.some((f) => isDirectory(f))) { + return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED, fileResults: [], files}); + } + + if (files.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { + return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED, fileResults: [], files}); + } + + return Promise.all(files.map((f, index) => validateAttachmentFile(f, items?.at(index)))).then((results) => { + if (results.every((result) => result.isValid)) { + return { + isValid: true, + validatedFiles: results.map((result) => result.validatedFile), + }; + } + + return { + isValid: false, + error: CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE, + fileResults: results, + files, + }; + }); +} + +function isFileCorrupted(fileObject: FileObject): Promise { + return normalizeFileObject(fileObject).then((normalizedFile) => { + return validateImageForCorruption(normalizedFile) + .then(() => { + if (normalizedFile.size && normalizedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + return { + isValid: false, + error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, + } as SingleAttachmentInvalidResult; + } + + if (normalizedFile.size && normalizedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + return { + isValid: false, + error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, + } as SingleAttachmentInvalidResult; + } + + return { + isValid: true, + } as SingleAttachmentValidResult; + }) + .catch(() => { + return { + isValid: false, + error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, + } as SingleAttachmentInvalidResult; + }); + }); +} + +function isDirectory(data: FileObject) { + if ('webkitGetAsEntry' in data && (data as DataTransferItem).webkitGetAsEntry()?.isDirectory) { + return true; + } + + return false; +} + +export {validateAttachmentFile, validateMultipleAttachmentFiles, isSingleAttachmentValidationResult, isMultipleAttachmentsValidationResult}; +export type { + SingleAttachmentValidationResult, + SingleAttachmentValidResult, + SingleAttachmentInvalidResult, + SingleAttachmentValidationError, + MultipleAttachmentsValidationResult, + MultipleAttachmentsValidResult, + MultipleAttachmentsInvalidResult, + MultipleAttachmentsValidationError, +}; diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index e0db610c62159..bf22bb6f763be 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -3,7 +3,8 @@ import {Alert, Linking, Platform} from 'react-native'; import type {ReactNativeBlobUtilReadStream} from 'react-native-blob-util'; import ReactNativeBlobUtil from 'react-native-blob-util'; import ImageSize from 'react-native-image-size'; -import type {TupleToUnion, ValueOf} from 'type-fest'; +import type {TupleToUnion} from 'type-fest'; +import type {MultipleAttachmentsValidationError, SingleAttachmentValidationError} from '@libs/AttachmentValidation'; import DateUtils from '@libs/DateUtils'; import getPlatform from '@libs/getPlatform'; import {translateLocal} from '@libs/Localize'; @@ -550,22 +551,6 @@ const normalizeFileObject = (file: FileObject): Promise => { }); }; -const validateAttachment = (file: FileObject, isCheckingMultipleFiles?: boolean, isValidatingReceipt?: boolean) => { - const maxFileSize = isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; - if (!Str.isImage(file.name ?? '') && !isHeicOrHeifImage(file) && (file?.size ?? 0) > maxFileSize) { - return isCheckingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; - } - - if (isValidatingReceipt && (file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL; - } - - if (isValidatingReceipt && !isValidReceiptExtension(file)) { - return isCheckingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE; - } - return ''; -}; - type TranslationAdditionalData = { maxUploadSizeInMB?: number; fileLimit?: number; @@ -573,7 +558,7 @@ type TranslationAdditionalData = { }; const getFileValidationErrorText = ( - validationError: ValueOf | null, + validationError: SingleAttachmentValidationError | MultipleAttachmentsValidationError | undefined, additionalData: TranslationAdditionalData = {}, isValidatingReceipt = false, ): { @@ -588,17 +573,17 @@ const getFileValidationErrorText = ( } const maxSize = isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; switch (validationError) { - case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: + case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE: return { title: translateLocal('attachmentPicker.wrongFileType'), reason: translateLocal('attachmentPicker.notAllowedExtension'), }; - case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE: + case CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE: return { title: translateLocal('attachmentPicker.someFilesCantBeUploaded'), reason: translateLocal('attachmentPicker.unsupportedFileType', {fileType: additionalData.fileType ?? ''}), }; - case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: + case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE: return { title: translateLocal('attachmentPicker.attachmentTooLarge'), reason: isValidatingReceipt @@ -607,34 +592,34 @@ const getFileValidationErrorText = ( }) : translateLocal('attachmentPicker.sizeExceeded'), }; - case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE: + case CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.FILE_TOO_LARGE: return { title: translateLocal('attachmentPicker.someFilesCantBeUploaded'), reason: translateLocal('attachmentPicker.sizeLimitExceeded', { maxUploadSizeInMB: additionalData.maxUploadSizeInMB ?? maxSize / 1024 / 1024, }), }; - case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL: + case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL: return { title: translateLocal('attachmentPicker.attachmentTooSmall'), reason: translateLocal('attachmentPicker.sizeNotMet'), }; - case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED: + case CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED: return { title: translateLocal('attachmentPicker.attachmentError'), reason: translateLocal('attachmentPicker.folderNotAllowedMessage'), }; - case CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED: + case CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED: return { title: translateLocal('attachmentPicker.someFilesCantBeUploaded'), reason: translateLocal('attachmentPicker.maxFileLimitExceeded'), }; - case CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED: + case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_CORRUPTED: return { title: translateLocal('attachmentPicker.attachmentError'), reason: translateLocal('attachmentPicker.errorWhileSelectingCorruptedAttachment'), }; - case CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE: + case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.PROTECTED_FILE: return { title: translateLocal('attachmentPicker.attachmentError'), reason: translateLocal('attachmentPicker.protectedPDFNotSupported'), @@ -679,7 +664,6 @@ export { resizeImageIfNeeded, createFile, validateReceipt, - validateAttachment, normalizeFileObject, isValidReceiptExtension, getFileValidationErrorText, diff --git a/src/pages/media/AttachmentModalScreen/routes/ReportAttachmentModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/ReportAttachmentModalContent.tsx index 9b49c044395b1..2aa4911c19a77 100644 --- a/src/pages/media/AttachmentModalScreen/routes/ReportAttachmentModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/ReportAttachmentModalContent.tsx @@ -5,7 +5,7 @@ import type {Attachment} from '@components/Attachments/types'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import {openReport} from '@libs/actions/Report'; -import validateAttachmentFile from '@libs/AttachmentUtils'; +import {validateAttachmentFile} from '@libs/AttachmentValidation'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import {translateLocal} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; @@ -134,15 +134,15 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen setIsAttachmentInvalid?.(true); switch (error) { - case 'tooLarge': + case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE: setAttachmentInvalidReasonTitle?.('attachmentPicker.attachmentTooLarge'); setAttachmentInvalidReason?.('attachmentPicker.sizeExceeded'); break; - case 'tooSmall': + case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL: setAttachmentInvalidReasonTitle?.('attachmentPicker.attachmentTooSmall'); setAttachmentInvalidReason?.('attachmentPicker.sizeNotMet'); break; - case 'fileDoesNotExist': + case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FOLDER_NOT_ALLOWED: setAttachmentInvalidReasonTitle?.('attachmentPicker.attachmentError'); setAttachmentInvalidReason?.('attachmentPicker.folderNotAllowedMessage'); break; @@ -155,10 +155,9 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen return; } - const {source: fileSource} = result; - const inputModalType = getModalType(fileSource, file); + const inputModalType = getModalType(result.validatedFile.source, file); setModalType(inputModalType); - setSource(fileSource); + setSource(result.validatedFile.source); setFile(file); }); }, diff --git a/tests/unit/AttachmentValidationTest.ts b/tests/unit/AttachmentValidationTest.ts new file mode 100644 index 0000000000000..3dea2216ba1e3 --- /dev/null +++ b/tests/unit/AttachmentValidationTest.ts @@ -0,0 +1,49 @@ +import {validateAttachmentFile} from '@libs/AttachmentValidation'; +import CONST from '../../src/CONST'; + +jest.useFakeTimers(); + +const createMockFile = (name: string, size: number) => ({ + name, + size, +}); + +describe('AttachmentValidation', () => { + describe('validateAttachment', () => { + it('should not return SINGLE_FILE.FILE_TOO_SMALL when validating small attachment', () => { + const file = createMockFile('file.csv', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); + const error = validateAttachmentFile(file, false, false); + expect(error).not.toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); + }); + + it('should return SINGLE_FILE.FILE_TOO_SMALL when validating small receipt', () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); + const error = validateAttachmentFile(file, false, true); + expect(error).toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); + }); + + it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image file', () => { + const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); + const error = validateAttachmentFile(file); + expect(error).toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + }); + + it('should return MULTIPLE_FILES.FILE_TOO_LARGE when checking multiple files', () => { + const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); + const error = validateAttachmentFile(file, true); + expect(error).toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.FILE_TOO_LARGE); + }); + + it('should return SINGLE_FILE.WRONG_FILE_TYPE for invalid receipt extension', () => { + const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = validateAttachmentFile(file, false, true); + expect(error).toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); + }); + + it('should return empty string for valid image receipt', () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = validateAttachmentFile(file, false, true); + expect(error).toBe(''); + }); + }); +}); diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index 344d0c705a6b5..a5f4f34fe913b 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -4,11 +4,6 @@ import * as FileUtils from '../../src/libs/fileDownload/FileUtils'; jest.useFakeTimers(); -const createMockFile = (name: string, size: number) => ({ - name, - size, -}); - describe('FileUtils', () => { describe('splitExtensionFromFileName', () => { it('should return correct file name and extension', () => { @@ -43,42 +38,4 @@ describe('FileUtils', () => { expect(actualFileName).toEqual(expectedFileName.replace(CONST.REGEX.ILLEGAL_FILENAME_CHARACTERS, '_')); }); }); - - describe('validateAttachment', () => { - it('should not return FILE_TOO_SMALL when validating small attachment', () => { - const file = createMockFile('file.csv', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); - const error = FileUtils.validateAttachment(file, false, false); - expect(error).not.toBe(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); - }); - - it('should return FILE_TOO_SMALL when validating small receipt', () => { - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); - const error = FileUtils.validateAttachment(file, false, true); - expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); - }); - - it('should return FILE_TOO_LARGE for large non-image file', () => { - const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); - const error = FileUtils.validateAttachment(file); - expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); - }); - - it('should return FILE_TOO_LARGE_MULTIPLE when checking multiple files', () => { - const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); - const error = FileUtils.validateAttachment(file, true); - expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE); - }); - - it('should return WRONG_FILE_TYPE for invalid receipt extension', () => { - const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const error = FileUtils.validateAttachment(file, false, true); - expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); - }); - - it('should return empty string for valid image receipt', () => { - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const error = FileUtils.validateAttachment(file, false, true); - expect(error).toBe(''); - }); - }); }); From 6739696b47c8fca88c131e501a99d748e2c530f2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 18 Sep 2025 17:27:52 +0100 Subject: [PATCH 02/44] remove;: unused import --- src/hooks/useFilesValidation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 488fb1cb5e537..b23922dc90bac 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -7,7 +7,7 @@ import PDFThumbnail from '@components/PDFThumbnail'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import {validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; -import type {MultipleAttachmentsValidationError, SingleAttachmentInvalidResult, SingleAttachmentValidationError} from '@libs/AttachmentValidation'; +import type {SingleAttachmentInvalidResult, SingleAttachmentValidationError} from '@libs/AttachmentValidation'; import {getFileValidationErrorText, resizeImageIfNeeded} from '@libs/fileDownload/FileUtils'; import convertHeicImage from '@libs/fileDownload/heicConverter'; import type {FileObject} from '@pages/media/AttachmentModalScreen/types'; From 0c4a513e6aee0e5479b73923016c89a1b96a5dcb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Feb 2026 10:55:05 +0000 Subject: [PATCH 03/44] refactor: rename CONST variable block --- src/CONST/index.ts | 2 +- src/hooks/useFilesValidation.tsx | 14 ++++++------ src/libs/AttachmentValidation.ts | 30 +++++++++++++------------- src/libs/fileDownload/FileUtils.ts | 22 +++++++++---------- tests/unit/AttachmentValidationTest.ts | 14 ++++++------ tests/unit/FileUtilsTest.ts | 8 +++---- 6 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3ec38c96a044f..faf03001d08ba 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -289,7 +289,7 @@ const CONST = { MAX_FILE_LIMIT: 30, }, - ATTACHMENT_VALIDATION_ERRORS: { + FILE_VALIDATION_ERRORS: { SINGLE_FILE: { NO_FILE_PROVIDED: 'noFileProvided', FILE_INVALID: 'fileInvalid', diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index f967dfb7b8be8..fb6bb82873f5b 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -193,8 +193,8 @@ function useFilesValidation(onFilesValidated: (files: File | FileObject[], dataT const otherFiles = filteredResults.filter((file) => !Str.isPDF(file.name ?? '')); // Group invalid results by error type for efficient processing - const heicOrHeifResults = invalidResults.filter((result) => result.error === CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE); - const fileTooLargeResults = invalidResults.filter((result) => result.error === CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + const heicOrHeifResults = invalidResults.filter((result) => result.error === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE); + const fileTooLargeResults = invalidResults.filter((result) => result.error === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); // Get files that need conversion (HEIC/HEIF) const filesToConvert = heicOrHeifResults.map((result) => result.file); @@ -320,7 +320,7 @@ function useFilesValidation(onFilesValidated: (files: File | FileObject[], dataT return; } - if (result.error === CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { + if (result.error === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { filesToValidate.current = files.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); if (items) { dataTransferItemList.current = items.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); @@ -352,7 +352,7 @@ function useFilesValidation(onFilesValidated: (files: File | FileObject[], dataT }; const onConfirmError = () => { - if (fileError === CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { + if (fileError === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { setIsErrorModalVisible(false); convertAndResizeFiles(invalidFileResults.current, filesToValidate.current); return; @@ -406,7 +406,7 @@ function useFilesValidation(onFilesValidated: (files: File | FileObject[], dataT onPassword={() => { validatedPDFs.current.push(file); if (isValidatingReceipts) { - collectedErrors.current.push({error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.PROTECTED_FILE}); + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.PROTECTED_FILE}); } else { validFiles.current.push(file); } @@ -414,7 +414,7 @@ function useFilesValidation(onFilesValidated: (files: File | FileObject[], dataT }} onLoadError={() => { validatedPDFs.current.push(file); - collectedErrors.current.push({error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_CORRUPTED}); + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_CORRUPTED}); checkIfAllValidatedAndProceed(); }} /> @@ -426,7 +426,7 @@ function useFilesValidation(onFilesValidated: (files: File | FileObject[], dataT return ''; } const prompt = getFileValidationErrorText(translate, fileError, {fileType: invalidFileExtension}, isValidatingReceipts).reason; - if (fileError === CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE || fileError === CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE) { + if (fileError === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE || fileError === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE) { return ( {prompt} diff --git a/src/libs/AttachmentValidation.ts b/src/libs/AttachmentValidation.ts index db309a50499d3..be36e2d2bf682 100644 --- a/src/libs/AttachmentValidation.ts +++ b/src/libs/AttachmentValidation.ts @@ -15,7 +15,7 @@ type SingleAttachmentValidResult = { validatedFile: ValidatedFile; }; -type SingleAttachmentValidationError = ValueOf; +type SingleAttachmentValidationError = ValueOf; type SingleAttachmentInvalidResult = { isValid: false; error: SingleAttachmentValidationError; @@ -30,7 +30,7 @@ function isSingleAttachmentValidationResult(result: unknown): result is SingleAt function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipts = false): Promise { if (!file) { - return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED, file}); } const maxFileSize = isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; @@ -38,19 +38,19 @@ function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isVal const isImage = Str.isImage(file.name ?? ''); if (isImage && hasHeicOrHeifExtension(file)) { - return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE, file}); } if (!isImage && !hasHeicOrHeifExtension(file) && (file?.size ?? 0) > maxFileSize) { - return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, file}); } if (isValidatingReceipts && (file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, file}); } if (isValidatingReceipts && !isValidReceiptExtension(file)) { - return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE, file}); } let fileObject = file; @@ -60,14 +60,14 @@ function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isVal } if (!fileObject) { - return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, file}); } if (item && item.kind === 'file' && 'webkitGetAsEntry' in item) { const entry = item.webkitGetAsEntry(); if (entry?.isDirectory) { - return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FOLDER_NOT_ALLOWED, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FOLDER_NOT_ALLOWED, file}); } } @@ -115,7 +115,7 @@ type MultipleAttachmentsValidResult = { validatedFiles: ValidatedFile[]; }; -type MultipleAttachmentsValidationError = ValueOf; +type MultipleAttachmentsValidationError = ValueOf; type MultipleAttachmentsInvalidResult = { isValid: false; error: MultipleAttachmentsValidationError; @@ -130,11 +130,11 @@ function isMultipleAttachmentsValidationResult(result: unknown): result is Multi function validateMultipleAttachmentFiles(files: FileObject[], items?: DataTransferItem[], isValidatingReceipts = false): Promise { if (!files?.length || files.some((f) => isDirectory(f))) { - return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED, fileResults: [], files}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED, fileResults: [], files}); } if (files.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { - return Promise.resolve({isValid: false, error: CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED, fileResults: [], files}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED, fileResults: [], files}); } return Promise.all(files.map((f, index) => validateAttachmentFile(f, items?.at(index), isValidatingReceipts))).then((results) => { @@ -147,7 +147,7 @@ function validateMultipleAttachmentFiles(files: FileObject[], items?: DataTransf return { isValid: false, - error: CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE, + error: CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE, fileResults: results, files, }; @@ -161,14 +161,14 @@ function isFileCorrupted(fileObject: FileObject): Promise CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { return { isValid: false, - error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, + error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, } as SingleAttachmentInvalidResult; } if (normalizedFile.size && normalizedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { return { isValid: false, - error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, + error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, } as SingleAttachmentInvalidResult; } @@ -179,7 +179,7 @@ function isFileCorrupted(fileObject: FileObject): Promise { return { isValid: false, - error: CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, + error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, } as SingleAttachmentInvalidResult; }); }); diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 2e294c8cef5dc..96744e5ed563f 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -526,7 +526,7 @@ const calculateScaledDimensions = (width: number, height: number): {width: numbe const totalPixels = width * height; if (totalPixels > CONST.MAX_IMAGE_PIXEL_COUNT) { - throw new Error(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); + throw new Error(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); } const scaleFactor = CONST.MAX_IMAGE_DIMENSION / (width < height ? height : width); @@ -681,17 +681,17 @@ const getFileValidationErrorText = ( } const maxSize = isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; switch (validationError) { - case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE: + case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE: return { title: translate('attachmentPicker.wrongFileType'), reason: translate('attachmentPicker.notAllowedExtension'), }; - case CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE: + case CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE: return { title: translate('attachmentPicker.someFilesCantBeUploaded'), reason: translate('attachmentPicker.unsupportedFileType', additionalData.fileType ?? ''), }; - case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE: + case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE: return { title: translate('attachmentPicker.attachmentTooLarge'), reason: isValidatingReceipt @@ -700,39 +700,39 @@ const getFileValidationErrorText = ( }) : translate('attachmentPicker.sizeExceeded'), }; - case CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.FILE_TOO_LARGE: + case CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FILE_TOO_LARGE: return { title: translate('attachmentPicker.someFilesCantBeUploaded'), reason: translate('attachmentPicker.sizeLimitExceeded', { maxUploadSizeInMB: additionalData.maxUploadSizeInMB ?? maxSize / 1024 / 1024, }), }; - case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL: + case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL: return { title: translate('attachmentPicker.attachmentTooSmall'), reason: translate('attachmentPicker.sizeNotMet'), }; - case CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED: + case CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED: return { title: translate('attachmentPicker.attachmentError'), reason: translate('attachmentPicker.folderNotAllowedMessage'), }; - case CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED: + case CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED: return { title: translate('attachmentPicker.someFilesCantBeUploaded'), reason: translate('attachmentPicker.maxFileLimitExceeded'), }; - case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_CORRUPTED: + case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_CORRUPTED: return { title: translate('attachmentPicker.attachmentError'), reason: translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'), }; - case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.PROTECTED_FILE: + case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.PROTECTED_FILE: return { title: translate('attachmentPicker.attachmentError'), reason: translate('attachmentPicker.protectedPDFNotSupported'), }; - case CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE: + case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE: return { title: translate('attachmentPicker.attachmentError'), reason: translate('attachmentPicker.imageDimensionsTooLarge'), diff --git a/tests/unit/AttachmentValidationTest.ts b/tests/unit/AttachmentValidationTest.ts index e42ee0663423f..bbd24fc83d5dc 100644 --- a/tests/unit/AttachmentValidationTest.ts +++ b/tests/unit/AttachmentValidationTest.ts @@ -13,43 +13,43 @@ describe('AttachmentValidation', () => { it('should not return SINGLE_FILE.FILE_TOO_SMALL when validating small attachment', () => { const file = createMockFile('file.csv', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); const error = validateAttachmentFile(file); - expect(error).not.toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); + expect(error).not.toBe(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); }); it('should return SINGLE_FILE.FILE_TOO_SMALL when validating small receipt', () => { const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); const error = validateAttachmentFile(file, undefined, true); - expect(error).toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); + expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); }); it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image file', () => { const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); const error = validateAttachmentFile(file); - expect(error).toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); }); it('should return MULTIPLE_FILES.FILE_TOO_LARGE when checking multiple files', () => { const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); const error = validateAttachmentFile(file, undefined, false); - expect(error).toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.FILE_TOO_LARGE); + expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FILE_TOO_LARGE); }); it('should return SINGLE_FILE.WRONG_FILE_TYPE for invalid receipt extension', () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = validateAttachmentFile(file, undefined, true); - expect(error).toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); + expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); }); it('should prioritize SINGLE_FILE.WRONG_FILE_TYPE over SINGLE_FILE.FILE_TOO_LARGE for receipts', () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); const error = validateAttachmentFile(file, undefined, true); - expect(error).toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); + expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); }); it('should return WRONG_FILE_TYPE_MULTIPLE when checking multiple invalid receipt files', () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); const error = validateMultipleAttachmentFiles([file], undefined, true); - expect(error).toBe(CONST.ATTACHMENT_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE); + expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE); }); it('should return empty string for valid image receipt', () => { diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index 3a0df2e1c6e52..841e42bf73024 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -218,7 +218,7 @@ describe('FileUtils', () => { const file = {uri: 'file://large-image.jpg', name: 'large-image.jpg', type: 'image/jpeg'}; - await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); + await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); }); it('should not throw for images at exactly the maximum pixel count', async () => { @@ -373,7 +373,7 @@ describe('FileUtils', () => { const file = {uri: 'blob:http://localhost/large-image', name: 'large.jpg', type: 'image/jpeg'}; - await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); + await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); }); it('should throw IMAGE_DIMENSIONS_TOO_LARGE for large PNG via blob URL', async () => { @@ -383,7 +383,7 @@ describe('FileUtils', () => { const file = {uri: 'blob:http://localhost/large-png', name: 'large.png', type: 'image/png'}; - await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); + await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); }); it('should fallback to ImageSize.getSize when header parsing fails', async () => { @@ -465,7 +465,7 @@ describe('FileUtils', () => { const mockTranslate = ((path: string) => path) as LocaleContextProps['translate']; it('should return correct error text for IMAGE_DIMENSIONS_TOO_LARGE', () => { - const result = getFileValidationErrorText(mockTranslate, CONST.ATTACHMENT_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); + const result = getFileValidationErrorText(mockTranslate, CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); expect(result.title).toBe('attachmentPicker.attachmentError'); expect(result.reason).toBe('attachmentPicker.imageDimensionsTooLarge'); From 76ee52146d09af3bc0532e17b368919ce66a0f71 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Feb 2026 10:56:24 +0000 Subject: [PATCH 04/44] fix: add missing CONST values --- src/CONST/index.ts | 55 ++++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index faf03001d08ba..ca22d7b09bd79 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -289,27 +289,6 @@ const CONST = { MAX_FILE_LIMIT: 30, }, - FILE_VALIDATION_ERRORS: { - SINGLE_FILE: { - NO_FILE_PROVIDED: 'noFileProvided', - FILE_INVALID: 'fileInvalid', - WRONG_FILE_TYPE: 'wrongFileType', - FILE_TOO_LARGE: 'fileTooLarge', - FILE_TOO_SMALL: 'fileTooSmall', - FILE_CORRUPTED: 'fileCorrupted', - PROTECTED_FILE: 'protectedFile', - FOLDER_NOT_ALLOWED: 'folderNotAllowed', - HEIC_OR_HEIF_IMAGE: 'heicOrHeifImage', - IMAGE_DIMENSIONS_TOO_LARGE: 'imageDimensionsTooLarge', - }, - MULTIPLE_FILES: { - WRONG_FILE_TYPE: 'multipleAttachmentsWrongFileType', - FILE_TOO_LARGE: 'multipleAttachmentsFileTooLarge', - FOLDER_NOT_ALLOWED: 'multipleAttachmentsFolderNotAllowed', - MAX_FILE_LIMIT_EXCEEDED: 'multipleAttachmentsMaxFileLimitExceeded', - }, - }, - // Allowed extensions for spreadsheets import ALLOWED_SPREADSHEET_EXTENSIONS: ['xls', 'xlsx', 'csv', 'txt'], @@ -2290,6 +2269,40 @@ const CONST = { // Video MimeTypes allowed by iOS photos app. VIDEO: /\.(mov|mp4)$/, }, + + FILE_VALIDATION_ERRORS: { + SINGLE_FILE: { + NO_FILE_PROVIDED: 'noFileProvided', + FILE_INVALID: 'fileInvalid', + WRONG_FILE_TYPE: 'wrongFileType', + FILE_TOO_LARGE: 'fileTooLarge', + FILE_TOO_SMALL: 'fileTooSmall', + FILE_CORRUPTED: 'fileCorrupted', + PROTECTED_FILE: 'protectedFile', + FOLDER_NOT_ALLOWED: 'folderNotAllowed', + HEIC_OR_HEIF_IMAGE: 'heicOrHeifImage', + IMAGE_DIMENSIONS_TOO_LARGE: 'imageDimensionsTooLarge', + }, + MULTIPLE_FILES: { + WRONG_FILE_TYPE: 'multipleAttachmentsWrongFileType', + FILE_TOO_LARGE: 'multipleAttachmentsFileTooLarge', + FOLDER_NOT_ALLOWED: 'multipleAttachmentsFolderNotAllowed', + MAX_FILE_LIMIT_EXCEEDED: 'multipleAttachmentsMaxFileLimitExceeded', + }, + }, + + IOS_CAMERA_ROLL_ACCESS_ERROR: 'Access to photo library was denied', + ADD_PAYMENT_MENU_POSITION_Y: 226, + ADD_PAYMENT_MENU_POSITION_X: 356, + EMOJI_PICKER_ITEM_TYPES: { + HEADER: 'header', + EMOJI: 'emoji', + SPACER: 'spacer', + }, + EMOJI_PICKER_SIZE: { + WIDTH: 320, + HEIGHT: 416, + }, SEARCH_ITEM_LIMIT: 15, CATEGORY_SHORTCUT_BAR_HEIGHT: 32, SMALL_EMOJI_PICKER_SIZE: { From 0079017a7f7ca48de1893b8c280ae7c352d4e724 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Feb 2026 10:57:32 +0000 Subject: [PATCH 05/44] fix: legacy CONST key --- src/components/AttachmentPicker/index.native.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index f0e618a661246..a80eacf941e78 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -321,7 +321,7 @@ function AttachmentPicker({ (error: unknown) => { const errorMessage = error instanceof Error ? error.message : undefined; - if (errorMessage === CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE) { + if (errorMessage === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE) { showGeneralAlert(translate('attachmentPicker.imageDimensionsTooLarge')); } else if (errorMessage) { showGeneralAlert(errorMessage); From ff9c18c7f4beb2cbacd5901e1ac9236728c35134 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Feb 2026 11:06:13 +0000 Subject: [PATCH 06/44] fix: incorrect usage of `useFilesValidation` hook --- src/hooks/useFilesValidation.tsx | 6 +++--- src/hooks/useReceiptScanDrop.tsx | 6 ++++-- src/libs/actions/Share.ts | 5 +++-- .../useAttachmentUploadValidation.ts | 14 ++++++-------- .../request/step/IOURequestStepConfirmation.tsx | 5 +++-- .../step/IOURequestStepOdometerImage/index.tsx | 8 +++----- .../step/IOURequestStepScan/index.native.tsx | 11 ++++++----- .../iou/request/step/IOURequestStepScan/index.tsx | 11 ++++++----- 8 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index fb6bb82873f5b..9cb906ef20601 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -27,11 +27,13 @@ type ValidationOptions = { isValidatingReceipts?: boolean; }; +type OnFilesValidated = (files: File | FileObject[], dataTransferItems: DataTransferItem[]) => void; + const sortFilesByOriginalOrder = (files: FileObject[], orderMap: Map) => { return files.sort((a, b) => (orderMap.get(a.uri ?? '') ?? 0) - (orderMap.get(b.uri ?? '') ?? 0)); }; -function useFilesValidation(onFilesValidated: (files: File | FileObject[], dataTransferItems: DataTransferItem[]) => void, onSourceChanged?: (source: string | undefined) => void) { +function useFilesValidation(onFilesValidated: OnFilesValidated) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -315,7 +317,6 @@ function useFilesValidation(onFilesValidated: (files: File | FileObject[], dataT validateMultipleAttachmentFiles(files, items).then((result) => { if (result.isValid) { - onSourceChanged?.(result.validatedFiles.at(0)?.source); onFilesValidated(result.validatedFiles, dataTransferItemList.current); return; } @@ -339,7 +340,6 @@ function useFilesValidation(onFilesValidated: (files: File | FileObject[], dataT validateAttachmentFile(files, items?.at(0)).then((result) => { if (result.isValid) { - onSourceChanged?.(result.validatedFile.source); onFilesValidated([result.validatedFile.file], dataTransferItemList.current); return; } diff --git a/src/hooks/useReceiptScanDrop.tsx b/src/hooks/useReceiptScanDrop.tsx index 9b1a57248f930..c743f3b85004f 100644 --- a/src/hooks/useReceiptScanDrop.tsx +++ b/src/hooks/useReceiptScanDrop.tsx @@ -39,7 +39,7 @@ function useReceiptScanDrop() { const hasOnlyPersonalPolicies = useMemo(() => hasOnlyPersonalPoliciesUtil(policies), [policies]); - const saveFileAndInitMoneyRequest = (files: FileObject[]) => { + const saveFileAndInitMoneyRequest = (files: FileObject | FileObject[]) => { const initialTransaction = initMoneyRequest({ isFromGlobalCreate: true, isFromFloatingActionButton: true, @@ -56,7 +56,9 @@ function useReceiptScanDrop() { const newReceiptFiles: ReceiptFile[] = []; - for (const [index, file] of files.entries()) { + const fileItems = Array.isArray(files) ? files : [files]; + + for (const [index, file] of fileItems.entries()) { const source = URL.createObjectURL(file as Blob); const transaction = index === 0 diff --git a/src/libs/actions/Share.ts b/src/libs/actions/Share.ts index 769c6b3b8d174..c6647db11bb91 100644 --- a/src/libs/actions/Share.ts +++ b/src/libs/actions/Share.ts @@ -29,8 +29,9 @@ function addTempShareFile(file: ShareTempFile) { * * @param file Array of validated file objects to be saved */ -function addValidatedShareFile(file: FileObject[]) { - Onyx.set(ONYXKEYS.VALIDATED_FILE_OBJECT, file.at(0)); +function addValidatedShareFile(file: FileObject | FileObject[]) { + const fileItems = Array.isArray(file) ? file : [file]; + Onyx.set(ONYXKEYS.VALIDATED_FILE_OBJECT, fileItems.at(0)); } /** diff --git a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts index d55863fda5d01..322f42e3d8c59 100644 --- a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts +++ b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts @@ -77,19 +77,17 @@ function useAttachmentUploadValidation({ ); const attachmentUploadType = useRef<'receipt' | 'attachment'>(undefined); - const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { - if (files.length === 0) { - return; - } - + const onFilesValidated = (files: FileObject | FileObject[], dataTransferItems: DataTransferItem[]) => { if (attachmentUploadType.current === 'attachment') { showAttachmentModalScreen(files, dataTransferItems); return; } + const fileItems = Array.isArray(files) ? files : [files]; + if (shouldAddOrReplaceReceipt && transactionID) { - const source = URL.createObjectURL(files.at(0) as Blob); - replaceReceipt({transactionID, file: files.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); + const source = URL.createObjectURL(fileItems.at(0) as Blob); + replaceReceipt({transactionID, file: fileItems.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); return; } @@ -105,7 +103,7 @@ function useAttachmentUploadValidation({ draftTransactions, }); - for (const [index, file] of files.entries()) { + for (const [index, file] of fileItems.entries()) { const source = URL.createObjectURL(file as Blob); const newTransaction = index === 0 diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 0a9cc770411bf..9e172f8c01c05 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -1329,8 +1329,9 @@ function IOURequestStepConfirmation({ /** * Sets the Receipt object when dragging and dropping a file */ - const setReceiptOnDrop = (files: FileObject[]) => { - const file = files.at(0); + const setReceiptOnDrop = (files: FileObject | FileObject[]) => { + const fileItems = Array.isArray(files) ? files : [files]; + const file = fileItems.at(0); if (!file) { return; } diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx index 0600c037240c6..17cf7747648ef 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -77,11 +77,9 @@ function IOURequestStepOdometerImage({ [transactionID, imageType, isTransactionDraft, navigateBack], ); - const {validateFiles, ErrorModal} = useFilesValidation((files: FileObject[]) => { - if (files.length === 0) { - return; - } - const file = files.at(0); + const {validateFiles, ErrorModal} = useFilesValidation((files: FileObject | FileObject[]) => { + const fileItems = Array.isArray(files) ? files : [files]; + const file = fileItems.at(0); if (!file) { return; } diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 45e96ec3a345f..cbcdcba95026a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -385,15 +385,16 @@ function IOURequestStepScan({ /** * Sets the Receipt objects and navigates the user to the next page */ - const setReceiptFilesAndNavigate = (files: FileObject[]) => { - if (files.length === 0) { + const setReceiptFilesAndNavigate = (files: FileObject | FileObject[]) => { + const fileItems = Array.isArray(files) ? files : [files]; + if (fileItems.length === 0) { return; } // Store the receipt on the transaction object in Onyx const newReceiptFiles: ReceiptFile[] = []; if (isEditing) { - const file = files.at(0); + const file = fileItems.at(0); if (!file) { return; } @@ -406,7 +407,7 @@ function IOURequestStepScan({ removeDraftTransactions(true); } - for (const [index, file] of files.entries()) { + for (const [index, file] of fileItems.entries()) { const transaction = shouldReuseInitialTransaction(initialTransaction, shouldAcceptMultipleFiles, index, isMultiScanEnabled, transactions) ? (initialTransaction as Partial) : buildOptimisticTransactionAndCreateDraft({ @@ -422,7 +423,7 @@ function IOURequestStepScan({ if (shouldSkipConfirmation) { setReceiptFiles(newReceiptFiles); - const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && files.length; + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && fileItems.length; if (gpsRequired) { const beginLocationPermissionFlow = shouldStartLocationPermissionFlow(); diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 02f65ee53af48..58975d95b1e7c 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -398,15 +398,16 @@ function IOURequestStepScan({ [initialTransactionID, navigateBack, policy, policyCategories], ); - const setReceiptFilesAndNavigate = (files: FileObject[]) => { - if (files.length === 0) { + const setReceiptFilesAndNavigate = (files: FileObject | FileObject[]) => { + const fileItems = Array.isArray(files) ? files : [files]; + if (fileItems.length === 0) { return; } // Store the receipt on the transaction object in Onyx const newReceiptFiles: ReceiptFile[] = []; if (isEditing) { - const file = files.at(0); + const file = fileItems.at(0); if (!file) { return; } @@ -420,7 +421,7 @@ function IOURequestStepScan({ removeDraftTransactions(true); } - for (const [index, file] of files.entries()) { + for (const [index, file] of fileItems.entries()) { const source = URL.createObjectURL(file as Blob); const transaction = shouldReuseInitialTransaction(initialTransaction, shouldAcceptMultipleFiles, index, isMultiScanEnabled, transactions) ? (initialTransaction as Partial) @@ -437,7 +438,7 @@ function IOURequestStepScan({ if (shouldSkipConfirmation) { setReceiptFiles(newReceiptFiles); - const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && files.length; + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && fileItems.length; if (gpsRequired) { const beginLocationPermissionFlow = shouldStartLocationPermissionFlow(); From 6035f9a157a39274729e617480c54051b3aceaf1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Feb 2026 11:18:14 +0000 Subject: [PATCH 07/44] fix: update usage of validation functions --- .../ReportAddAttachmentModalContent/index.tsx | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx index e15d2f567d16d..e00aabe517441 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx @@ -4,8 +4,6 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {openReport} from '@libs/actions/Report'; -import validateAttachmentFile from '@libs/AttachmentUtils'; -import type {AttachmentValidationResult} from '@libs/AttachmentUtils'; import {getValidatedImageSource} from '@libs/AvatarUtils'; import Navigation from '@libs/Navigation/Navigation'; import {canUserPerformWriteAction, isReportNotFound} from '@libs/ReportUtils'; @@ -20,6 +18,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {isMultipleAttachmentsValidationResult, validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; +import type {MultipleAttachmentsValidationResult, SingleAttachmentValidationResult} from '@libs/AttachmentValidation'; import AddAttachmentModalCarouselView from './AddAttachmentModalCarouselView'; function ReportAddAttachmentModalContent({route, navigation}: AttachmentModalScreenProps) { @@ -90,18 +90,13 @@ function ReportAddAttachmentModalContent({route, navigation}: AttachmentModalScr return; } - function updateState(result: AttachmentValidationResult | AttachmentValidationResult[]) { - if (Array.isArray(result)) { - const validResults = result.filter((r) => r.isValid); - if (validResults.length === 0) { - return; + function updateState(result: SingleAttachmentValidationResult | MultipleAttachmentsValidationResult) { + if (isMultipleAttachmentsValidationResult(result)) { + if (result.isValid) { + setSource(result.validatedFiles.at(0)?.source ?? ''); + setValidFiles(result.validatedFiles.map((r) => r.file)); } - const validatedFiles = validResults.map((r) => r.file); - const firstValidSource = validResults.at(0)?.source; - - setSource(firstValidSource); - setValidFiles(validatedFiles); return; } @@ -109,12 +104,12 @@ function ReportAddAttachmentModalContent({route, navigation}: AttachmentModalScr return; } - setSource(result.source); - setValidFiles(result.file); + setSource(result.validatedFile.source); + setValidFiles(result.validatedFile.file); } if (Array.isArray(fileParam)) { - Promise.all(fileParam.map((f) => validateAttachmentFile(f))).then(updateState); + validateMultipleAttachmentFiles(fileParam).then(updateState); return; } From 3be89c07604803705df8074fc478b4283966982b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 09:36:21 +0000 Subject: [PATCH 08/44] fix: `getFileValidationErrorText` type error --- src/hooks/useFilesValidation.tsx | 4 ++-- src/libs/fileDownload/FileUtils.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 9cb906ef20601..99869a116bd62 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -42,7 +42,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); - const [fileError, setFileError] = useState(); + const [fileError, setFileError] = useState(null); const [pdfFilesToRender, setPdfFilesToRender] = useState([]); const [validFilesToUpload, setValidFilesToUpload] = useState([]); const [invalidFileExtension, setInvalidFileExtension] = useState(''); @@ -85,7 +85,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { setPdfFilesToRender([]); setIsLoaderVisible(false); setValidFilesToUpload([]); - setFileError(undefined); + setFileError(null); setInvalidFileExtension(''); setErrorQueue([]); setCurrentErrorIndex(0); diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 96744e5ed563f..aabe42e127843 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -666,7 +666,7 @@ type TranslationAdditionalData = { const getFileValidationErrorText = ( translate: LocalizedTranslate, - validationError: SingleAttachmentValidationError | MultipleAttachmentsValidationError | undefined, + validationError: SingleAttachmentValidationError | MultipleAttachmentsValidationError | null, additionalData: TranslationAdditionalData = {}, isValidatingReceipt = false, ): { From 4f9692cf81fc071ff0028d3e038120d2192e8f41 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 09:36:31 +0000 Subject: [PATCH 09/44] fix: use `Log` instaed of `console` --- src/hooks/useFilesValidation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 99869a116bd62..a31fa2e0f610d 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -238,7 +238,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { processRemainingFiles(convertedFiles, validFilesToProcess, filesToResize, pdfsToLoad, resolve); }) .catch((error) => { - console.error('Error converting HEIC/HEIF files:', error); + Log.alert('Error converting HEIC/HEIF files:', {error}); setIsLoaderVisible(false); resolve({processedFiles: validFilesToProcess, pdfsToLoad}); }); From 662e1892d8438f55f22422dd8628c9509a366091 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 09:36:48 +0000 Subject: [PATCH 10/44] fix: pass down validation options to validator function --- src/hooks/useFilesValidation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index a31fa2e0f610d..2942e5772678d 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -315,7 +315,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { originalFileOrder.current.set(file.uri ?? '', index); } - validateMultipleAttachmentFiles(files, items).then((result) => { + validateMultipleAttachmentFiles(files, items, validationOptions?.isValidatingReceipts).then((result) => { if (result.isValid) { onFilesValidated(result.validatedFiles, dataTransferItemList.current); return; @@ -338,7 +338,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { originalFileOrder.current.set(files.uri ?? '', 0); - validateAttachmentFile(files, items?.at(0)).then((result) => { + validateAttachmentFile(files, items?.at(0), validationOptions?.isValidatingReceipts).then((result) => { if (result.isValid) { onFilesValidated([result.validatedFile.file], dataTransferItemList.current); return; From 34063ca6b3b55457557ad235aad590c990033ae9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 09:36:59 +0000 Subject: [PATCH 11/44] fix: pass file objects instead of wrapper struct --- src/hooks/useFilesValidation.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 2942e5772678d..166350069cff0 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -317,7 +317,8 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { validateMultipleAttachmentFiles(files, items, validationOptions?.isValidatingReceipts).then((result) => { if (result.isValid) { - onFilesValidated(result.validatedFiles, dataTransferItemList.current); + const fileObjects = result.validatedFiles.map((file) => file.file); + onFilesValidated(fileObjects, dataTransferItemList.current); return; } From f014e7f497bc92aae3754200e8d8f3039b8fbb6f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 09:37:12 +0000 Subject: [PATCH 12/44] refactor: remove unused `getConfirmModalPrompt` function --- src/libs/fileDownload/FileUtils.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index aabe42e127843..64acbe2f233ba 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -745,16 +745,6 @@ const getFileValidationErrorText = ( } }; -const getConfirmModalPrompt = (translate: LocalizedTranslate, attachmentInvalidReason: TranslationPaths | undefined) => { - if (!attachmentInvalidReason) { - return ''; - } - if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') { - return translate(attachmentInvalidReason, {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)}); - } - return translate(attachmentInvalidReason); -}; - const MAX_CANVAS_SIZE = 4096; const JPEG_QUALITY = 0.85; @@ -869,7 +859,6 @@ export { isValidReceiptExtension, getFileValidationErrorText, hasHeicOrHeifExtension, - getConfirmModalPrompt, canvasFallback, getFilesFromClipboardEvent, cleanFileObject, From 33cd2beaa47acdfda19fc152b60da81719e82c9d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 10:45:20 +0000 Subject: [PATCH 13/44] refactor: refactor `useFilesValidation` hook for more readability --- src/hooks/useFilesValidation.tsx | 353 +++++++++++++++++-------------- 1 file changed, 200 insertions(+), 153 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 166350069cff0..f0833593ab484 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -29,10 +29,78 @@ type ValidationOptions = { type OnFilesValidated = (files: File | FileObject[], dataTransferItems: DataTransferItem[]) => void; +type ClassifiedFiles = { + pdfsToLoad: FileObject[]; + nonPdfFiles: FileObject[]; + filesToConvert: FileObject[]; + filesToResize: FileObject[]; + validFilesToProcess: FileObject[]; +}; + +/** Splits validated files into PDFs vs non-PDFs and groups invalid results by HEIC/HEIF vs FILE_TOO_LARGE. */ +const classifyValidatedFiles = (files: FileObject[], invalidResults: SingleAttachmentInvalidResult[]): ClassifiedFiles => { + type FileMap = Map; + + const pdfFilesMap: FileMap = new Map(); + const nonPdfFilesMap: FileMap = new Map(); + for (const file of files) { + if (file === null) { + continue; + } + const uri = file.uri ?? ''; + if (Str.isPDF(file.name ?? '')) { + pdfFilesMap.set(uri, file); + } else { + nonPdfFilesMap.set(uri, file); + } + } + + const filesToConvert: FileObject[] = []; + const filesToResize: FileObject[] = []; + const urisToConvert = new Set(); + const urisToResize = new Set(); + for (const result of invalidResults) { + const uri = result.file.uri ?? ''; + if (result.error === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE) { + filesToConvert.push(result.file); + urisToConvert.add(uri); + } else if (result.error === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE) { + filesToResize.push(result.file); + urisToResize.add(uri); + } + } + + const validFilesToProcess: FileObject[] = []; + for (const [uri, file] of nonPdfFilesMap) { + if (!urisToConvert.has(uri) && !urisToResize.has(uri)) { + validFilesToProcess.push(file); + } + } + + return { + pdfsToLoad: Array.from(pdfFilesMap.values()), + nonPdfFiles: Array.from(nonPdfFilesMap.values()), + filesToConvert, + filesToResize, + validFilesToProcess, + }; +}; + const sortFilesByOriginalOrder = (files: FileObject[], orderMap: Map) => { return files.sort((a, b) => (orderMap.get(a.uri ?? '') ?? 0) - (orderMap.get(b.uri ?? '') ?? 0)); }; +/** Replaces entries in convertedFiles that needed resize with their resized versions (by index in convertedNeedingResize). */ +const mergeResizedIntoConverted = (convertedFiles: FileObject[], convertedNeedingResize: FileObject[], resizedConverted: FileObject[]): FileObject[] => { + return convertedFiles.map((file) => { + const j = convertedNeedingResize.indexOf(file); + if (j >= 0) { + return resizedConverted.at(j) ?? file; + } + return file; + }); +}; + function useFilesValidation(onFilesValidated: OnFilesValidated) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -153,138 +221,149 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { } }; - // Helper function to process remaining files (resizing and final resolution) - const processRemainingFiles = ( + /** Resizes files with loader; updates order mapping. Returns originals on error. */ + const resizeFilesWithLoader = (files: FileObject[]): Promise => { + if (files.length === 0) { + return Promise.resolve([]); + } + setIsLoaderVisible(true); + return Promise.all(files.map((file) => resizeImageIfNeeded(file))) + .then((resizedFiles) => { + for (const [index, resizedFile] of resizedFiles.entries()) { + updateFileOrderMapping(files.at(index), resizedFile); + } + setIsLoaderVisible(false); + return resizedFiles; + }) + .catch((error) => { + console.error('Error resizing files:', error); + setIsLoaderVisible(false); + return files; + }); + }; + + /** Resizes filesToResize (if any), then returns combined processedFiles and pdfsToLoad. */ + const finishProcessing = ( convertedFiles: FileObject[], validFilesToProcess: FileObject[], filesToResize: FileObject[], pdfsToLoad: FileObject[], - resolve: (value: {processedFiles: FileObject[]; pdfsToLoad: FileObject[]}) => void, - ) => { - if (filesToResize.length > 0) { - setIsLoaderVisible(true); + ): Promise<{processedFiles: FileObject[]; pdfsToLoad: FileObject[]}> => { + if (filesToResize.length === 0) { + const processedFiles = [...convertedFiles, ...validFilesToProcess]; + return Promise.resolve({processedFiles, pdfsToLoad}); + } + return resizeFilesWithLoader(filesToResize).then((resizedFiles) => { + const processedFiles = [...convertedFiles, ...validFilesToProcess, ...resizedFiles]; + return {processedFiles, pdfsToLoad}; + }); + }; - Promise.all(filesToResize.map((file) => resizeImageIfNeeded(file))) - .then((resizedFiles) => { - for (const [index, resizedFile] of resizedFiles.entries()) { - updateFileOrderMapping(filesToResize.at(index), resizedFile); - } + /** Applies the result of convert+resize: either show PDFs for validation or complete with errors/onFilesValidated. */ + const applyProcessedResult = ({processedFiles, pdfsToLoad}: {processedFiles: FileObject[]; pdfsToLoad: FileObject[]}) => { + if (pdfsToLoad.length > 0) { + validFiles.current = processedFiles; + setPdfFilesToRender(pdfsToLoad); + return; + } - setIsLoaderVisible(false); - const allProcessedFiles = [...convertedFiles, ...validFilesToProcess, ...resizedFiles]; - resolve({processedFiles: allProcessedFiles, pdfsToLoad}); - }) - .catch((error) => { - console.error('Error resizing files:', error); - setIsLoaderVisible(false); - // Fallback to files without resizing - const allProcessedFiles = [...convertedFiles, ...validFilesToProcess, ...filesToResize]; - resolve({processedFiles: allProcessedFiles, pdfsToLoad}); - }); - } else { - // No resizing needed, return all processed files - const allProcessedFiles = [...convertedFiles, ...validFilesToProcess]; - resolve({processedFiles: allProcessedFiles, pdfsToLoad}); + if (processedFiles.length > 0) { + setValidFilesToUpload(processedFiles); + } + + if (collectedErrors.current.length > 0) { + const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as ErrorObject); + setErrorQueue(uniqueErrors); + setCurrentErrorIndex(0); + const firstError = uniqueErrors.at(0); + if (firstError) { + setFileError(firstError.error); + if (firstError.fileExtension) { + setInvalidFileExtension(firstError.fileExtension); + } + setIsErrorModalVisible(true); + } + } else if (processedFiles.length > 0) { + const sortedFiles = sortFilesByOriginalOrder(processedFiles, originalFileOrder.current); + onFilesValidated(sortedFiles, dataTransferItemList.current); + resetValidationState(); } }; const convertAndResizeFiles = (invalidResults: SingleAttachmentInvalidResult[], files: FileObject[]) => { - new Promise<{processedFiles: FileObject[]; pdfsToLoad: FileObject[]}>((resolve) => { - const filteredResults = files.filter((result): result is FileObject => result !== null); - const pdfsToLoad = filteredResults.filter((file) => Str.isPDF(file.name ?? '')); - const otherFiles = filteredResults.filter((file) => !Str.isPDF(file.name ?? '')); + const {pdfsToLoad, nonPdfFiles, filesToConvert, filesToResize, validFilesToProcess} = classifyValidatedFiles(files, invalidResults); - // Group invalid results by error type for efficient processing - const heicOrHeifResults = invalidResults.filter((result) => result.error === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE); - const fileTooLargeResults = invalidResults.filter((result) => result.error === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + const runConversionThenFinish = (): Promise<{processedFiles: FileObject[]; pdfsToLoad: FileObject[]}> => { + setIsLoaderVisible(true); + return Promise.all(filesToConvert.map((file) => convertHeicImageToJpegPromise(file))) + .then((convertedFiles) => { + for (const [index, convertedFile] of convertedFiles.entries()) { + updateFileOrderMapping(filesToConvert.at(index), convertedFile); + } + const convertedNeedingResize = convertedFiles.filter((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); + if (convertedNeedingResize.length > 0) { + return resizeFilesWithLoader(convertedNeedingResize).then((resizedConverted) => { + const mergedConverted = mergeResizedIntoConverted(convertedFiles, convertedNeedingResize, resizedConverted); + return finishProcessing(mergedConverted, validFilesToProcess, filesToResize, pdfsToLoad); + }); + } + return finishProcessing(convertedFiles, validFilesToProcess, filesToResize, pdfsToLoad); + }) + .catch((error) => { + Log.alert('Error converting HEIC/HEIF files:', {error}); + return {processedFiles: validFilesToProcess, pdfsToLoad}; + }) + .finally(() => { + setIsLoaderVisible(false); + }); + }; - // Get files that need conversion (HEIC/HEIF) - const filesToConvert = heicOrHeifResults.map((result) => result.file); + const runNoConversionPath = (): Promise<{processedFiles: FileObject[]; pdfsToLoad: FileObject[]}> => { + const anyNonPdfTooLarge = nonPdfFiles.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); + if (anyNonPdfTooLarge) { + return resizeFilesWithLoader(nonPdfFiles).then((processedFiles) => ({processedFiles, pdfsToLoad})); + } + return finishProcessing([], validFilesToProcess, filesToResize, pdfsToLoad); + }; - // Get files that need resizing (too large) - const filesToResize = fileTooLargeResults.map((result) => result.file); + const promise = filesToConvert.length > 0 ? runConversionThenFinish() : runNoConversionPath(); - // Get files that are valid and don't need processing - const validFilesToProcess = otherFiles.filter( - (file) => !filesToConvert.some((convertFile) => convertFile.uri === file.uri) && !filesToResize.some((resizeFile) => resizeFile.uri === file.uri), - ); + promise.then(applyProcessedResult); + }; - // Process files that need conversion - if (filesToConvert.length > 0) { - setIsLoaderVisible(true); - - Promise.all(filesToConvert.map((file) => convertHeicImageToJpegPromise(file))) - .then((convertedFiles) => { - // Update file order mapping for converted files - for (const [index, convertedFile] of convertedFiles.entries()) { - updateFileOrderMapping(filesToConvert.at(index), convertedFile); - } - - // Check if converted files also need resizing - const convertedFilesNeedingResize = convertedFiles.filter((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); - - if (convertedFilesNeedingResize.length > 0) { - // Resize converted files that are too large - Promise.all(convertedFilesNeedingResize.map((file) => resizeImageIfNeeded(file))).then((resizedConvertedFiles) => { - for (const [index, resizedFile] of resizedConvertedFiles.entries()) { - updateFileOrderMapping(convertedFilesNeedingResize.at(index), resizedFile); - } - - // Process remaining files that need resizing (not converted) - processRemainingFiles(convertedFiles, validFilesToProcess, filesToResize, pdfsToLoad, resolve); - }); - } - // No resizing needed for converted files, process remaining files - processRemainingFiles(convertedFiles, validFilesToProcess, filesToResize, pdfsToLoad, resolve); - }) - .catch((error) => { - Log.alert('Error converting HEIC/HEIF files:', {error}); - setIsLoaderVisible(false); - resolve({processedFiles: validFilesToProcess, pdfsToLoad}); - }); - } + /** Handles result of multiple-file validation: either completes with valid files or starts convert/resize for invalid. */ + const handleMultipleFilesResult = (result: Awaited>, files: FileObject[], items?: DataTransferItem[]) => { + if (result.isValid) { + const fileObjects = result.validatedFiles.map((f) => f.file); + onFilesValidated(fileObjects, dataTransferItemList.current); + return; + } - // No conversion needed, but check if we need to resize images - if (otherFiles.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { - setIsLoaderVisible(true); - Promise.all(otherFiles.map((file) => resizeImageIfNeeded(file))).then((processedFiles) => { - for (const [index, resizedFile] of processedFiles.entries()) { - updateFileOrderMapping(otherFiles.at(index), resizedFile); - } - setIsLoaderVisible(false); - resolve({processedFiles, pdfsToLoad}); - }); + if (result.error === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { + filesToValidate.current = files.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); + if (items) { + dataTransferItemList.current = items.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); } - // No conversion needed, process remaining files - processRemainingFiles([], validFilesToProcess, filesToResize, pdfsToLoad, resolve); - }).then(({processedFiles, pdfsToLoad}) => { - if (pdfsToLoad.length) { - validFiles.current = processedFiles; - setPdfFilesToRender(pdfsToLoad); - } else { - if (processedFiles.length > 0) { - setValidFilesToUpload(processedFiles); - } + } - if (collectedErrors.current.length > 0) { - const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as ErrorObject); - setErrorQueue(uniqueErrors); - setCurrentErrorIndex(0); - const firstError = uniqueErrors.at(0); - if (firstError) { - setFileError(firstError.error); - if (firstError.fileExtension) { - setInvalidFileExtension(firstError.fileExtension); - } - setIsErrorModalVisible(true); - } - } else if (processedFiles.length > 0) { - const sortedFiles = sortFilesByOriginalOrder(processedFiles, originalFileOrder.current); - onFilesValidated(sortedFiles, dataTransferItemList.current); - resetValidationState(); - } - } - }); + const invalidResults = result.fileResults.filter((r) => r.isValid === false); + invalidFileResults.current = invalidResults; + collectedErrors.current.push({error: result.error}); + setErrorAndOpenModal(result.error); + convertAndResizeFiles(invalidResults, files); + }; + + /** Handles result of single-file validation: either completes with valid file or starts convert/resize for invalid. */ + const handleSingleFileResult = (result: Awaited>, file: FileObject) => { + if (result.isValid) { + onFilesValidated([result.validatedFile.file], dataTransferItemList.current); + return; + } + + invalidFileResults.current = [result]; + collectedErrors.current.push({error: result.error}); + setErrorAndOpenModal(result.error); + convertAndResizeFiles(invalidFileResults.current, [file]); }; const validateFiles = (files: File | FileObject[], items?: DataTransferItem[], validationOptions?: ValidationOptions) => { @@ -293,62 +372,30 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { return; } - setIsValidatingFiles(true); - if (!files) { return; } - const validationOptionsWithDefaults = { - ...validationOptions, - isValidatingReceipts: validationOptions?.isValidatingReceipts ?? DEFAULT_IS_VALIDATING_RECEIPTS, - }; - setIsValidatingReceipts(validationOptionsWithDefaults.isValidatingReceipts); + setIsValidatingFiles(true); - // Reset collected errors for new validation + const isReceiptValidation = validationOptions?.isValidatingReceipts ?? DEFAULT_IS_VALIDATING_RECEIPTS; + setIsValidatingReceipts(isReceiptValidation); collectedErrors.current = []; if (Array.isArray(files)) { setIsValidatingMultipleFiles(true); - for (const [index, file] of files.entries()) { originalFileOrder.current.set(file.uri ?? '', index); } - - validateMultipleAttachmentFiles(files, items, validationOptions?.isValidatingReceipts).then((result) => { - if (result.isValid) { - const fileObjects = result.validatedFiles.map((file) => file.file); - onFilesValidated(fileObjects, dataTransferItemList.current); - return; - } - - if (result.error === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { - filesToValidate.current = files.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); - if (items) { - dataTransferItemList.current = items.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); - } - } - - invalidFileResults.current = result.fileResults.filter((r) => r.isValid === false); - convertAndResizeFiles(invalidFileResults.current, files); - collectedErrors.current.push({error: result.error}); - setErrorAndOpenModal(result.error); + validateMultipleAttachmentFiles(files, items, isReceiptValidation).then((result) => { + handleMultipleFilesResult(result, files, items); }); return; } originalFileOrder.current.set(files.uri ?? '', 0); - - validateAttachmentFile(files, items?.at(0), validationOptions?.isValidatingReceipts).then((result) => { - if (result.isValid) { - onFilesValidated([result.validatedFile.file], dataTransferItemList.current); - return; - } - - invalidFileResults.current = [result].filter((r) => r.isValid === false); - convertAndResizeFiles(invalidFileResults.current, [files]); - collectedErrors.current.push({error: result.error}); - setErrorAndOpenModal(result.error); + validateAttachmentFile(files, items?.at(0), isReceiptValidation).then((result) => { + handleSingleFileResult(result, files); }); }; From 843acd4b2cc16f2c1c795d9b9c2f314ed04d49f0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 10:45:30 +0000 Subject: [PATCH 14/44] test: fix test cases --- src/libs/AttachmentValidation.ts | 39 +++++----- tests/unit/AttachmentValidationTest.ts | 100 ++++++++++++++++++------- 2 files changed, 91 insertions(+), 48 deletions(-) diff --git a/src/libs/AttachmentValidation.ts b/src/libs/AttachmentValidation.ts index be36e2d2bf682..ba0998699f4e0 100644 --- a/src/libs/AttachmentValidation.ts +++ b/src/libs/AttachmentValidation.ts @@ -6,7 +6,7 @@ import {cleanFileName, hasHeicOrHeifExtension, isValidReceiptExtension, normaliz type ValidatedFile = { fileType: 'file' | 'uri'; - source: string; + source?: string; file: FileObject; }; @@ -33,7 +33,9 @@ function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isVal return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED, file}); } - const maxFileSize = isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; + if (isValidatingReceipts && !isValidReceiptExtension(file)) { + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE, file}); + } const isImage = Str.isImage(file.name ?? ''); @@ -41,6 +43,7 @@ function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isVal return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE, file}); } + const maxFileSize = isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; if (!isImage && !hasHeicOrHeifExtension(file) && (file?.size ?? 0) > maxFileSize) { return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, file}); } @@ -49,10 +52,6 @@ function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isVal return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, file}); } - if (isValidatingReceipts && !isValidReceiptExtension(file)) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE, file}); - } - let fileObject = file; const fileConverted = file.getAsFile?.(); if (fileConverted) { @@ -71,19 +70,18 @@ function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isVal } } - return isFileCorrupted(fileObject).then((corruptionResult) => { + return isFileCorrupted(fileObject, isValidatingReceipts).then((corruptionResult) => { if (!corruptionResult.isValid) { return corruptionResult; } - let validatedFile: ValidatedFile; - - if (fileObject instanceof File) { + const corruptionFreeFile = corruptionResult.validatedFile.file; + if (corruptionFreeFile instanceof File) { /** * Cleaning file name, done here so that it covers all cases: * upload, drag and drop, copy-paste */ - let updatedFile = fileObject; + let updatedFile = corruptionFreeFile; const cleanName = cleanFileName(updatedFile.name); if (updatedFile.name !== cleanName) { updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type}); @@ -91,7 +89,7 @@ function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isVal const inputSource = URL.createObjectURL(updatedFile); updatedFile.uri = inputSource; - validatedFile = { + const validatedFile: ValidatedFile = { fileType: 'file', source: inputSource, file: updatedFile, @@ -100,10 +98,10 @@ function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isVal return {isValid: true, validatedFile}; } - validatedFile = { + const validatedFile: ValidatedFile = { fileType: 'uri', - source: fileObject.uri ?? '', - file: fileObject, + source: corruptionFreeFile.uri ?? '', + file: corruptionFreeFile, }; return {isValid: true, validatedFile}; @@ -118,7 +116,7 @@ type MultipleAttachmentsValidResult = { type MultipleAttachmentsValidationError = ValueOf; type MultipleAttachmentsInvalidResult = { isValid: false; - error: MultipleAttachmentsValidationError; + error?: MultipleAttachmentsValidationError; fileResults: SingleAttachmentValidationResult[]; files: FileObject[]; }; @@ -147,14 +145,13 @@ function validateMultipleAttachmentFiles(files: FileObject[], items?: DataTransf return { isValid: false, - error: CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE, fileResults: results, files, }; }); } -function isFileCorrupted(fileObject: FileObject): Promise { +function isFileCorrupted(fileObject: FileObject, isValidatingReceipts?: boolean): Promise { return normalizeFileObject(fileObject).then((normalizedFile) => { return validateImageForCorruption(normalizedFile) .then(() => { @@ -165,7 +162,7 @@ function isFileCorrupted(fileObject: FileObject): Promise { diff --git a/tests/unit/AttachmentValidationTest.ts b/tests/unit/AttachmentValidationTest.ts index bbd24fc83d5dc..be5492cc8a973 100644 --- a/tests/unit/AttachmentValidationTest.ts +++ b/tests/unit/AttachmentValidationTest.ts @@ -9,53 +9,95 @@ const createMockFile = (name: string, size: number) => ({ }); describe('AttachmentValidation', () => { - describe('validateAttachment', () => { - it('should not return SINGLE_FILE.FILE_TOO_SMALL when validating small attachment', () => { + describe('validateAttachmentFile', () => { + it('should not return SINGLE_FILE.FILE_TOO_SMALL when validating small attachment', async () => { const file = createMockFile('file.csv', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); - const error = validateAttachmentFile(file); - expect(error).not.toBe(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); + const result = await validateAttachmentFile(file); + + expect(result.isValid).toBe(true); }); - it('should return SINGLE_FILE.FILE_TOO_SMALL when validating small receipt', () => { + it('should return SINGLE_FILE.FILE_TOO_SMALL when validating small receipt', async () => { const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); - const error = validateAttachmentFile(file, undefined, true); - expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); - }); + const result = await validateAttachmentFile(file, undefined, true); - it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image file', () => { - const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); - const error = validateAttachmentFile(file); - expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + if (result.isValid) { + fail('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); }); - it('should return MULTIPLE_FILES.FILE_TOO_LARGE when checking multiple files', () => { + it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image file', async () => { const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); - const error = validateAttachmentFile(file, undefined, false); - expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FILE_TOO_LARGE); + const result = await validateAttachmentFile(file); + + if (result.isValid) { + fail('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); }); - it('should return SINGLE_FILE.WRONG_FILE_TYPE for invalid receipt extension', () => { + it('should return SINGLE_FILE.WRONG_FILE_TYPE for invalid receipt extension', async () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const error = validateAttachmentFile(file, undefined, true); - expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); - }); + const result = await validateAttachmentFile(file, undefined, true); - it('should prioritize SINGLE_FILE.WRONG_FILE_TYPE over SINGLE_FILE.FILE_TOO_LARGE for receipts', () => { - const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); - const error = validateAttachmentFile(file, undefined, true); - expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); + if (result.isValid) { + fail('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); }); - it('should return WRONG_FILE_TYPE_MULTIPLE when checking multiple invalid receipt files', () => { + it('should prioritize SINGLE_FILE.WRONG_FILE_TYPE over SINGLE_FILE.FILE_TOO_LARGE for receipts', async () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); - const error = validateMultipleAttachmentFiles([file], undefined, true); - expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE); + const result = await validateAttachmentFile(file, undefined, true); + + if (result.isValid) { + fail('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); }); - it('should return empty string for valid image receipt', () => { + it('should return empty string for valid image receipt', async () => { const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const error = validateAttachmentFile(file, undefined, true); - expect(error).toBe(''); + const result = await validateAttachmentFile(file, undefined, true); + + expect(result.isValid).toBe(true); + }); + }); + + describe('validateMultipleAttachmentFiles', () => { + it('should return MULTIPLE_FILES.FILE_TOO_LARGE when checking multiple files', async () => { + const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); + const result = await validateMultipleAttachmentFiles([file], undefined, false); + + if (result.isValid) { + fail('validateMultipleAttachmentFiles should return an invalid result'); + } + + expect(result.error).toEqual(undefined); + + const firstFileResult = result.fileResults.at(0); + + if (!firstFileResult || firstFileResult.isValid) { + fail('firstFileResult should be defined and valid'); + } + + expect(firstFileResult.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + }); + + it('should return WRONG_FILE_TYPE_MULTIPLE when checking multiple invalid receipt files', async () => { + const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); + const result = await validateMultipleAttachmentFiles([file], undefined, true); + + if (result.isValid) { + fail('validateMultipleAttachmentFiles should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); }); }); }); From 02e9868017e134669b9125d1c959afbb8580cafa Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 11:13:03 +0000 Subject: [PATCH 15/44] test: fix file error cases with multiple files --- src/CONST/index.ts | 2 - src/hooks/useFilesValidation.tsx | 32 ++++++----- src/libs/fileDownload/FileUtils.ts | 73 ++++++++++++++++---------- tests/unit/AttachmentValidationTest.ts | 8 ++- 4 files changed, 70 insertions(+), 45 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 248317510dde8..43619469cf571 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2280,8 +2280,6 @@ const CONST = { IMAGE_DIMENSIONS_TOO_LARGE: 'imageDimensionsTooLarge', }, MULTIPLE_FILES: { - WRONG_FILE_TYPE: 'multipleAttachmentsWrongFileType', - FILE_TOO_LARGE: 'multipleAttachmentsFileTooLarge', FOLDER_NOT_ALLOWED: 'multipleAttachmentsFolderNotAllowed', MAX_FILE_LIMIT_EXCEEDED: 'multipleAttachmentsMaxFileLimitExceeded', }, diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index f0833593ab484..f30662c176893 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -348,8 +348,12 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const invalidResults = result.fileResults.filter((r) => r.isValid === false); invalidFileResults.current = invalidResults; - collectedErrors.current.push({error: result.error}); - setErrorAndOpenModal(result.error); + + if (result.error) { + collectedErrors.current.push({error: result.error}); + setErrorAndOpenModal(result.error); + } + convertAndResizeFiles(invalidResults, files); }; @@ -361,8 +365,12 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { } invalidFileResults.current = [result]; - collectedErrors.current.push({error: result.error}); - setErrorAndOpenModal(result.error); + + if (result.error) { + collectedErrors.current.push({error: result.error}); + setErrorAndOpenModal(result.error); + } + convertAndResizeFiles(invalidFileResults.current, [file]); }; @@ -387,16 +395,12 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { for (const [index, file] of files.entries()) { originalFileOrder.current.set(file.uri ?? '', index); } - validateMultipleAttachmentFiles(files, items, isReceiptValidation).then((result) => { - handleMultipleFilesResult(result, files, items); - }); + validateMultipleAttachmentFiles(files, items, isReceiptValidation).then((result) => handleMultipleFilesResult(result, files, items)); return; } originalFileOrder.current.set(files.uri ?? '', 0); - validateAttachmentFile(files, items?.at(0), isReceiptValidation).then((result) => { - handleSingleFileResult(result, files); - }); + validateAttachmentFile(files, items?.at(0), isReceiptValidation).then((result) => handleSingleFileResult(result, files)); }; const onConfirmError = () => { @@ -469,12 +473,14 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { )) : undefined; + const fileValidationErrorText = getFileValidationErrorText(translate, fileError, {fileType: invalidFileExtension}, isValidatingReceipts); + const getModalPrompt = () => { if (!fileError) { return ''; } - const prompt = getFileValidationErrorText(translate, fileError, {fileType: invalidFileExtension}, isValidatingReceipts).reason; - if (fileError === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.WRONG_FILE_TYPE || fileError === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE) { + const prompt = fileValidationErrorText.reason; + if (fileError === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE) { return ( {prompt} @@ -487,7 +493,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const ErrorModal = ( { fail('validateMultipleAttachmentFiles should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); + const firstFileResult = result.fileResults.at(0); + + if (!firstFileResult || firstFileResult.isValid) { + fail('firstFileResult should be defined and valid'); + } + + expect(firstFileResult.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); }); }); }); From 24019c5b253d6e0fd3078c31737381ac149f0a54 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 11:26:53 +0000 Subject: [PATCH 16/44] test: add more test cases for attachment file validation --- tests/unit/AttachmentValidationTest.ts | 349 ++++++++++++++++++++++++- 1 file changed, 341 insertions(+), 8 deletions(-) diff --git a/tests/unit/AttachmentValidationTest.ts b/tests/unit/AttachmentValidationTest.ts index 4c13b95906506..a3e8e627e2a26 100644 --- a/tests/unit/AttachmentValidationTest.ts +++ b/tests/unit/AttachmentValidationTest.ts @@ -1,4 +1,5 @@ import {validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; +import type {FileObject} from '@src/types/utils/Attachment'; import CONST from '../../src/CONST'; jest.useFakeTimers(); @@ -22,7 +23,7 @@ describe('AttachmentValidation', () => { const result = await validateAttachmentFile(file, undefined, true); if (result.isValid) { - fail('validateAttachmentFile should return an invalid result'); + throw new Error('validateAttachmentFile should return an invalid result'); } expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); @@ -33,7 +34,7 @@ describe('AttachmentValidation', () => { const result = await validateAttachmentFile(file); if (result.isValid) { - fail('validateAttachmentFile should return an invalid result'); + throw new Error('validateAttachmentFile should return an invalid result'); } expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); @@ -44,7 +45,7 @@ describe('AttachmentValidation', () => { const result = await validateAttachmentFile(file, undefined, true); if (result.isValid) { - fail('validateAttachmentFile should return an invalid result'); + throw new Error('validateAttachmentFile should return an invalid result'); } expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); @@ -55,7 +56,7 @@ describe('AttachmentValidation', () => { const result = await validateAttachmentFile(file, undefined, true); if (result.isValid) { - fail('validateAttachmentFile should return an invalid result'); + throw new Error('validateAttachmentFile should return an invalid result'); } expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); @@ -67,6 +68,183 @@ describe('AttachmentValidation', () => { expect(result.isValid).toBe(true); }); + + it('should return SINGLE_FILE.NO_FILE_PROVIDED when file is null', async () => { + const result = await validateAttachmentFile(null as unknown as FileObject); + + if (result.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED); + expect(result.file).toBeNull(); + }); + + it('should return SINGLE_FILE.NO_FILE_PROVIDED when file is undefined', async () => { + const result = await validateAttachmentFile(undefined as unknown as FileObject); + + if (result.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED); + expect(result.file).toBeUndefined(); + }); + + it('should return SINGLE_FILE.HEIC_OR_HEIF_IMAGE for HEIC file', async () => { + const file = createMockFile('image.heic', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const result = await validateAttachmentFile(file); + + if (result.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE); + }); + + it('should return SINGLE_FILE.HEIC_OR_HEIF_IMAGE for HEIF file', async () => { + const file = createMockFile('image.heif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const result = await validateAttachmentFile(file); + + if (result.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE); + }); + + it('should return SINGLE_FILE.FILE_TOO_LARGE for large image receipt', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 1); + const result = await validateAttachmentFile(file, undefined, true); + + if (result.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + }); + + it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image receipt', async () => { + const file = createMockFile('receipt.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 1); + const result = await validateAttachmentFile(file, undefined, true); + + if (result.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + }); + + it('should accept file at exact MIN_SIZE for receipt', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE); + const result = await validateAttachmentFile(file, undefined, true); + + expect(result.isValid).toBe(true); + }); + + it('should accept file at exact RECEIPT_MAX_SIZE for receipt', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); + const result = await validateAttachmentFile(file, undefined, true); + + expect(result.isValid).toBe(true); + }); + + it('should accept file at exact MAX_SIZE for non-receipt', async () => { + const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE); + const result = await validateAttachmentFile(file); + + expect(result.isValid).toBe(true); + }); + + it('should accept valid PDF receipt', async () => { + const file = createMockFile('receipt.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const result = await validateAttachmentFile(file, undefined, true); + + expect(result.isValid).toBe(true); + }); + + it('should accept valid non-image receipt (doc)', async () => { + const file = createMockFile('receipt.doc', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const result = await validateAttachmentFile(file, undefined, true); + + expect(result.isValid).toBe(true); + }); + + it('should accept valid non-image receipt (txt)', async () => { + const file = createMockFile('receipt.txt', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const result = await validateAttachmentFile(file, undefined, true); + + expect(result.isValid).toBe(true); + }); + + it('should accept valid PNG receipt', async () => { + const file = createMockFile('receipt.png', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const result = await validateAttachmentFile(file, undefined, true); + + expect(result.isValid).toBe(true); + }); + + it('should accept valid GIF receipt', async () => { + const file = createMockFile('receipt.gif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const result = await validateAttachmentFile(file, undefined, true); + + expect(result.isValid).toBe(true); + }); + + it('should accept valid JPEG receipt', async () => { + const file = createMockFile('receipt.jpeg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const result = await validateAttachmentFile(file, undefined, true); + + expect(result.isValid).toBe(true); + }); + + it('should accept valid non-receipt attachment (csv)', async () => { + const file = createMockFile('data.csv', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); + const result = await validateAttachmentFile(file); + + expect(result.isValid).toBe(true); + }); + + it('should accept valid non-receipt attachment (image)', async () => { + const file = createMockFile('image.png', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); + const result = await validateAttachmentFile(file); + + expect(result.isValid).toBe(true); + }); + + it('should handle file with no name', async () => { + const file = createMockFile('', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const result = await validateAttachmentFile(file, undefined, true); + + // File without name should still validate (name is optional) + expect(result.isValid).toBe(true); + }); + + it('should handle file with no size', async () => { + const file: FileObject = {name: 'receipt.jpg', size: undefined}; + const result = await validateAttachmentFile(file, undefined, true); + + // File without size should still validate (size is optional) + expect(result.isValid).toBe(true); + }); + + it('should return SINGLE_FILE.FOLDER_NOT_ALLOWED when DataTransferItem is a directory', async () => { + const mockItem = { + kind: 'file' as const, + webkitGetAsEntry: jest.fn(() => ({ + isDirectory: true, + })), + } as unknown as DataTransferItem; + + const file = createMockFile('folder', 0); + const result = await validateAttachmentFile(file, mockItem); + + if (result.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FOLDER_NOT_ALLOWED); + }); }); describe('validateMultipleAttachmentFiles', () => { @@ -75,7 +253,7 @@ describe('AttachmentValidation', () => { const result = await validateMultipleAttachmentFiles([file], undefined, false); if (result.isValid) { - fail('validateMultipleAttachmentFiles should return an invalid result'); + throw new Error('validateMultipleAttachmentFiles should return an invalid result'); } expect(result.error).toEqual(undefined); @@ -83,7 +261,7 @@ describe('AttachmentValidation', () => { const firstFileResult = result.fileResults.at(0); if (!firstFileResult || firstFileResult.isValid) { - fail('firstFileResult should be defined and valid'); + throw new Error('firstFileResult should be defined and valid'); } expect(firstFileResult.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); @@ -94,16 +272,171 @@ describe('AttachmentValidation', () => { const result = await validateMultipleAttachmentFiles([file], undefined, true); if (result.isValid) { - fail('validateMultipleAttachmentFiles should return an invalid result'); + throw new Error('validateMultipleAttachmentFiles should return an invalid result'); } const firstFileResult = result.fileResults.at(0); if (!firstFileResult || firstFileResult.isValid) { - fail('firstFileResult should be defined and valid'); + throw new Error('firstFileResult should be defined and valid'); } expect(firstFileResult.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); }); + + it('should return MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED when more than MAX_FILE_LIMIT files', async () => { + const files = Array.from({length: CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT + 1}, () => createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1)); + const result = await validateMultipleAttachmentFiles(files); + + if (result.isValid) { + throw new Error('validateMultipleAttachmentFiles should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED); + }); + + it('should accept exactly MAX_FILE_LIMIT files', async () => { + const files = Array.from({length: CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT}, () => createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1)); + const result = await validateMultipleAttachmentFiles(files); + + expect(result.isValid).toBe(true); + if (result.isValid) { + expect(result.validatedFiles).toHaveLength(CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); + } + }); + + it('should return MULTIPLE_FILES.FOLDER_NOT_ALLOWED when empty array provided', async () => { + const result = await validateMultipleAttachmentFiles([]); + + if (result.isValid) { + throw new Error('validateMultipleAttachmentFiles should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED); + }); + + it('should return MULTIPLE_FILES.FOLDER_NOT_ALLOWED when directory is included', async () => { + const directoryFile: FileObject = { + name: 'folder', + size: 0, + webkitGetAsEntry: jest.fn(() => ({ + isDirectory: true, + })), + } as unknown as FileObject; + + const result = await validateMultipleAttachmentFiles([directoryFile]); + + if (result.isValid) { + throw new Error('validateMultipleAttachmentFiles should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED); + }); + + it('should return valid result when all files are valid', async () => { + const files = [ + createMockFile('file1.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1), + createMockFile('file2.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1), + createMockFile('file3.png', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1), + ]; + const result = await validateMultipleAttachmentFiles(files); + + expect(result.isValid).toBe(true); + if (result.isValid) { + expect(result.validatedFiles).toHaveLength(3); + } + }); + + it('should return invalid result with mixed valid and invalid files', async () => { + const files = [ + createMockFile('file1.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1), + createMockFile('file2.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1), + createMockFile('file3.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1), + ]; + const result = await validateMultipleAttachmentFiles(files); + + if (result.isValid) { + throw new Error('validateMultipleAttachmentFiles should return an invalid result'); + } + + expect(result.fileResults).toHaveLength(3); + expect(result.fileResults.at(0)?.isValid).toBe(true); + expect(result.fileResults.at(1)?.isValid).toBe(false); + expect(result.fileResults.at(2)?.isValid).toBe(true); + }); + + it('should handle multiple files with different error types', async () => { + const files = [ + createMockFile('file1.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), // WRONG_FILE_TYPE + createMockFile('file2.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 1), // FILE_TOO_LARGE + createMockFile('file3.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1), // FILE_TOO_SMALL + ]; + const result = await validateMultipleAttachmentFiles(files, undefined, true); + + if (result.isValid) { + throw new Error('validateMultipleAttachmentFiles should return an invalid result'); + } + + expect(result.fileResults).toHaveLength(3); + const errors = result.fileResults.map((r) => (r.isValid ? null : r.error)); + expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); + expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); + }); + + it('should handle multiple receipt files with HEIC/HEIF', async () => { + const files = [ + createMockFile('image1.heic', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), + createMockFile('image2.heif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), + ]; + const result = await validateMultipleAttachmentFiles(files, undefined, true); + + if (result.isValid) { + throw new Error('validateMultipleAttachmentFiles should return an invalid result'); + } + + expect(result.fileResults).toHaveLength(2); + const errors = result.fileResults.map((r) => (r.isValid ? null : r.error)); + expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE); + }); + + it('should handle multiple valid receipt files', async () => { + const files = [ + createMockFile('receipt1.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), + createMockFile('receipt2.png', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), + createMockFile('receipt3.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), + ]; + const result = await validateMultipleAttachmentFiles(files, undefined, true); + + expect(result.isValid).toBe(true); + if (result.isValid) { + expect(result.validatedFiles).toHaveLength(3); + } + }); + + it('should handle single file in array', async () => { + const files = [createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1)]; + const result = await validateMultipleAttachmentFiles(files); + + expect(result.isValid).toBe(true); + if (result.isValid) { + expect(result.validatedFiles).toHaveLength(1); + } + }); + + it('should handle files with DataTransferItems', async () => { + const files = [createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1)]; + const items = [ + { + kind: 'file' as const, + webkitGetAsEntry: jest.fn(() => ({ + isDirectory: false, + })), + } as unknown as DataTransferItem, + ]; + const result = await validateMultipleAttachmentFiles(files, items); + + expect(result.isValid).toBe(true); + }); }); }); From 5e585aa3087cc4c465bab1329ab9097742c58caa Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 11:42:17 +0000 Subject: [PATCH 17/44] fix: failing tests --- src/libs/AttachmentValidation.ts | 13 ++++++++----- tests/unit/AttachmentValidationTest.ts | 22 +++++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/libs/AttachmentValidation.ts b/src/libs/AttachmentValidation.ts index ba0998699f4e0..223f54d1d744c 100644 --- a/src/libs/AttachmentValidation.ts +++ b/src/libs/AttachmentValidation.ts @@ -33,22 +33,25 @@ function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isVal return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED, file}); } + if (!file.name || file.size == null) { + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, file}); + } + if (isValidatingReceipts && !isValidReceiptExtension(file)) { return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE, file}); } - const isImage = Str.isImage(file.name ?? ''); - - if (isImage && hasHeicOrHeifExtension(file)) { + if (hasHeicOrHeifExtension(file)) { return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE, file}); } + const isImage = Str.isImage(file.name); const maxFileSize = isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; - if (!isImage && !hasHeicOrHeifExtension(file) && (file?.size ?? 0) > maxFileSize) { + if (!isImage && !hasHeicOrHeifExtension(file) && file.size > maxFileSize) { return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, file}); } - if (isValidatingReceipts && (file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + if (isValidatingReceipts && file.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, file}); } diff --git a/tests/unit/AttachmentValidationTest.ts b/tests/unit/AttachmentValidationTest.ts index a3e8e627e2a26..15296fd76efb2 100644 --- a/tests/unit/AttachmentValidationTest.ts +++ b/tests/unit/AttachmentValidationTest.ts @@ -10,6 +10,9 @@ const createMockFile = (name: string, size: number) => ({ }); describe('AttachmentValidation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); describe('validateAttachmentFile', () => { it('should not return SINGLE_FILE.FILE_TOO_SMALL when validating small attachment', async () => { const file = createMockFile('file.csv', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); @@ -114,7 +117,8 @@ describe('AttachmentValidation', () => { }); it('should return SINGLE_FILE.FILE_TOO_LARGE for large image receipt', async () => { - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 1); + // Note: Image receipts are checked against MAX_SIZE (24MB) in isFileCorrupted, not RECEIPT_MAX_SIZE (10MB) + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); const result = await validateAttachmentFile(file, undefined, true); if (result.isValid) { @@ -170,8 +174,8 @@ describe('AttachmentValidation', () => { expect(result.isValid).toBe(true); }); - it('should accept valid non-image receipt (txt)', async () => { - const file = createMockFile('receipt.txt', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + it('should accept valid non-image receipt (text)', async () => { + const file = createMockFile('receipt.text', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const result = await validateAttachmentFile(file, undefined, true); expect(result.isValid).toBe(true); @@ -217,7 +221,11 @@ describe('AttachmentValidation', () => { const result = await validateAttachmentFile(file, undefined, true); // File without name should still validate (name is optional) - expect(result.isValid).toBe(true); + if (result.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID); }); it('should handle file with no size', async () => { @@ -225,7 +233,11 @@ describe('AttachmentValidation', () => { const result = await validateAttachmentFile(file, undefined, true); // File without size should still validate (size is optional) - expect(result.isValid).toBe(true); + if (result.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID); }); it('should return SINGLE_FILE.FOLDER_NOT_ALLOWED when DataTransferItem is a directory', async () => { From fade3fde052cde4b3fadb4f52afa4ec6f0a14d1e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 11:45:01 +0000 Subject: [PATCH 18/44] fix: invalid options param --- src/hooks/useFilesValidation.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index f30662c176893..4664ff1d3f7db 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -106,7 +106,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const {translate} = useLocalize(); const [isValidatingFiles, setIsValidatingFiles] = useState(false); - const [isValidatingReceipts, setIsValidatingReceipts] = useState(); + const [isValidatingReceipt, setIsValidatingReceipt] = useState(); const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); @@ -147,7 +147,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const resetValidationState = () => { setIsValidatingFiles(false); - setIsValidatingReceipts(undefined); + setIsValidatingReceipt(undefined); setIsValidatingMultipleFiles(false); setIsErrorModalVisible(false); setPdfFilesToRender([]); @@ -387,7 +387,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { setIsValidatingFiles(true); const isReceiptValidation = validationOptions?.isValidatingReceipts ?? DEFAULT_IS_VALIDATING_RECEIPTS; - setIsValidatingReceipts(isReceiptValidation); + setIsValidatingReceipt(isReceiptValidation); collectedErrors.current = []; if (Array.isArray(files)) { @@ -427,7 +427,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const sortedFiles = sortFilesByOriginalOrder(validFilesToUpload, originalFileOrder.current); // If we're validating attachments we need to use InteractionManager to ensure // the error modal is dismissed before opening the attachment modal - if (isValidatingReceipts === false && fileError) { + if (isValidatingReceipt === false && fileError) { setIsErrorModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { @@ -457,7 +457,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { }} onPassword={() => { validatedPDFs.current.push(file); - if (isValidatingReceipts) { + if (isValidatingReceipt) { collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.PROTECTED_FILE}); } else { validFiles.current.push(file); @@ -473,7 +473,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { )) : undefined; - const fileValidationErrorText = getFileValidationErrorText(translate, fileError, {fileType: invalidFileExtension}, isValidatingReceipts); + const fileValidationErrorText = getFileValidationErrorText(translate, fileError, {fileType: invalidFileExtension}, {isValidatingReceipt, isValidatingMultipleFiles}); const getModalPrompt = () => { if (!fileError) { From 662308cb19830e1bc0d1f658ce97bf07c90eb036 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 11:46:18 +0000 Subject: [PATCH 19/44] refactor: convert to async functions --- src/libs/AttachmentValidation.ts | 82 +++++++++++++++----------------- 1 file changed, 38 insertions(+), 44 deletions(-) diff --git a/src/libs/AttachmentValidation.ts b/src/libs/AttachmentValidation.ts index 223f54d1d744c..284e72ebe36a2 100644 --- a/src/libs/AttachmentValidation.ts +++ b/src/libs/AttachmentValidation.ts @@ -28,7 +28,7 @@ function isSingleAttachmentValidationResult(result: unknown): result is SingleAt return typeof result === 'object' && result !== null && 'isValid' in result && typeof result.isValid === 'boolean' && ('validatedFile' in result || 'error' in result); } -function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipts = false): Promise { +async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipts = false): Promise { if (!file) { return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED, file}); } @@ -73,42 +73,38 @@ function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isVal } } - return isFileCorrupted(fileObject, isValidatingReceipts).then((corruptionResult) => { - if (!corruptionResult.isValid) { - return corruptionResult; - } - - const corruptionFreeFile = corruptionResult.validatedFile.file; - if (corruptionFreeFile instanceof File) { - /** - * Cleaning file name, done here so that it covers all cases: - * upload, drag and drop, copy-paste - */ - let updatedFile = corruptionFreeFile; - const cleanName = cleanFileName(updatedFile.name); - if (updatedFile.name !== cleanName) { - updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type}); - } - const inputSource = URL.createObjectURL(updatedFile); - updatedFile.uri = inputSource; - - const validatedFile: ValidatedFile = { - fileType: 'file', - source: inputSource, - file: updatedFile, - }; - - return {isValid: true, validatedFile}; + const corruptionResult = await isFileCorrupted(fileObject, isValidatingReceipts); + if (!corruptionResult.isValid) { + return corruptionResult; + } + const corruptionFreeFile = corruptionResult.validatedFile.file; + if (corruptionFreeFile instanceof File) { + /** + * Cleaning file name, done here so that it covers all cases: + * upload, drag and drop, copy-paste + */ + let updatedFile = corruptionFreeFile; + const cleanName = cleanFileName(updatedFile.name); + if (updatedFile.name !== cleanName) { + updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type}); } + const inputSource = URL.createObjectURL(updatedFile); + updatedFile.uri = inputSource; const validatedFile: ValidatedFile = { - fileType: 'uri', - source: corruptionFreeFile.uri ?? '', - file: corruptionFreeFile, + fileType: 'file', + source: inputSource, + file: updatedFile, }; return {isValid: true, validatedFile}; - }); + } + const validatedFile: ValidatedFile = { + fileType: 'uri', + source: corruptionFreeFile.uri ?? '', + file: corruptionFreeFile, + }; + return {isValid: true, validatedFile}; } type MultipleAttachmentsValidResult = { @@ -129,7 +125,7 @@ function isMultipleAttachmentsValidationResult(result: unknown): result is Multi return typeof result === 'object' && result !== null && 'isValid' in result && typeof result.isValid === 'boolean' && ('validatedFiles' in result || 'fileResults' in result); } -function validateMultipleAttachmentFiles(files: FileObject[], items?: DataTransferItem[], isValidatingReceipts = false): Promise { +async function validateMultipleAttachmentFiles(files: FileObject[], items?: DataTransferItem[], isValidatingReceipts = false): Promise { if (!files?.length || files.some((f) => isDirectory(f))) { return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED, fileResults: [], files}); } @@ -138,20 +134,18 @@ function validateMultipleAttachmentFiles(files: FileObject[], items?: DataTransf return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED, fileResults: [], files}); } - return Promise.all(files.map((f, index) => validateAttachmentFile(f, items?.at(index), isValidatingReceipts))).then((results) => { - if (results.every((result) => result.isValid)) { - return { - isValid: true, - validatedFiles: results.map((result) => result.validatedFile), - }; - } - + const results = await Promise.all(files.map((f, index) => validateAttachmentFile(f, items?.at(index), isValidatingReceipts))); + if (results.every((result) => result.isValid)) { return { - isValid: false, - fileResults: results, - files, + isValid: true, + validatedFiles: results.map((r) => r.validatedFile), }; - }); + } + return { + isValid: false, + fileResults: results, + files, + }; } function isFileCorrupted(fileObject: FileObject, isValidatingReceipts?: boolean): Promise { From 9b5a71c0a5df1bea5d20870fe289c8d916d9e14e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 11:51:46 +0000 Subject: [PATCH 20/44] fix: remove unnecessary array vs. single instance checks --- src/hooks/useFilesValidation.tsx | 2 +- src/hooks/useReceiptScanDrop.tsx | 6 ++---- src/libs/actions/Share.ts | 5 ++--- .../useAttachmentUploadValidation.ts | 10 ++++------ .../iou/request/step/IOURequestStepConfirmation.tsx | 5 ++--- .../step/IOURequestStepOdometerImage/index.tsx | 5 ++--- .../request/step/IOURequestStepScan/index.native.tsx | 11 +++++------ .../iou/request/step/IOURequestStepScan/index.tsx | 11 +++++------ 8 files changed, 23 insertions(+), 32 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 4664ff1d3f7db..e48f1e4e5a876 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -27,7 +27,7 @@ type ValidationOptions = { isValidatingReceipts?: boolean; }; -type OnFilesValidated = (files: File | FileObject[], dataTransferItems: DataTransferItem[]) => void; +type OnFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => void; type ClassifiedFiles = { pdfsToLoad: FileObject[]; diff --git a/src/hooks/useReceiptScanDrop.tsx b/src/hooks/useReceiptScanDrop.tsx index c743f3b85004f..9b1a57248f930 100644 --- a/src/hooks/useReceiptScanDrop.tsx +++ b/src/hooks/useReceiptScanDrop.tsx @@ -39,7 +39,7 @@ function useReceiptScanDrop() { const hasOnlyPersonalPolicies = useMemo(() => hasOnlyPersonalPoliciesUtil(policies), [policies]); - const saveFileAndInitMoneyRequest = (files: FileObject | FileObject[]) => { + const saveFileAndInitMoneyRequest = (files: FileObject[]) => { const initialTransaction = initMoneyRequest({ isFromGlobalCreate: true, isFromFloatingActionButton: true, @@ -56,9 +56,7 @@ function useReceiptScanDrop() { const newReceiptFiles: ReceiptFile[] = []; - const fileItems = Array.isArray(files) ? files : [files]; - - for (const [index, file] of fileItems.entries()) { + for (const [index, file] of files.entries()) { const source = URL.createObjectURL(file as Blob); const transaction = index === 0 diff --git a/src/libs/actions/Share.ts b/src/libs/actions/Share.ts index c6647db11bb91..769c6b3b8d174 100644 --- a/src/libs/actions/Share.ts +++ b/src/libs/actions/Share.ts @@ -29,9 +29,8 @@ function addTempShareFile(file: ShareTempFile) { * * @param file Array of validated file objects to be saved */ -function addValidatedShareFile(file: FileObject | FileObject[]) { - const fileItems = Array.isArray(file) ? file : [file]; - Onyx.set(ONYXKEYS.VALIDATED_FILE_OBJECT, fileItems.at(0)); +function addValidatedShareFile(file: FileObject[]) { + Onyx.set(ONYXKEYS.VALIDATED_FILE_OBJECT, file.at(0)); } /** diff --git a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts index 322f42e3d8c59..7e260a4901a1d 100644 --- a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts +++ b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts @@ -77,17 +77,15 @@ function useAttachmentUploadValidation({ ); const attachmentUploadType = useRef<'receipt' | 'attachment'>(undefined); - const onFilesValidated = (files: FileObject | FileObject[], dataTransferItems: DataTransferItem[]) => { + const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { if (attachmentUploadType.current === 'attachment') { showAttachmentModalScreen(files, dataTransferItems); return; } - const fileItems = Array.isArray(files) ? files : [files]; - if (shouldAddOrReplaceReceipt && transactionID) { - const source = URL.createObjectURL(fileItems.at(0) as Blob); - replaceReceipt({transactionID, file: fileItems.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); + const source = URL.createObjectURL(files.at(0) as Blob); + replaceReceipt({transactionID, file: files.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); return; } @@ -103,7 +101,7 @@ function useAttachmentUploadValidation({ draftTransactions, }); - for (const [index, file] of fileItems.entries()) { + for (const [index, file] of files.entries()) { const source = URL.createObjectURL(file as Blob); const newTransaction = index === 0 diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 9e172f8c01c05..0a9cc770411bf 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -1329,9 +1329,8 @@ function IOURequestStepConfirmation({ /** * Sets the Receipt object when dragging and dropping a file */ - const setReceiptOnDrop = (files: FileObject | FileObject[]) => { - const fileItems = Array.isArray(files) ? files : [files]; - const file = fileItems.at(0); + const setReceiptOnDrop = (files: FileObject[]) => { + const file = files.at(0); if (!file) { return; } diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx index 17cf7747648ef..be602c5edfb46 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -77,9 +77,8 @@ function IOURequestStepOdometerImage({ [transactionID, imageType, isTransactionDraft, navigateBack], ); - const {validateFiles, ErrorModal} = useFilesValidation((files: FileObject | FileObject[]) => { - const fileItems = Array.isArray(files) ? files : [files]; - const file = fileItems.at(0); + const {validateFiles, ErrorModal} = useFilesValidation((files: FileObject[]) => { + const file = files.at(0); if (!file) { return; } diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index cbcdcba95026a..45e96ec3a345f 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -385,16 +385,15 @@ function IOURequestStepScan({ /** * Sets the Receipt objects and navigates the user to the next page */ - const setReceiptFilesAndNavigate = (files: FileObject | FileObject[]) => { - const fileItems = Array.isArray(files) ? files : [files]; - if (fileItems.length === 0) { + const setReceiptFilesAndNavigate = (files: FileObject[]) => { + if (files.length === 0) { return; } // Store the receipt on the transaction object in Onyx const newReceiptFiles: ReceiptFile[] = []; if (isEditing) { - const file = fileItems.at(0); + const file = files.at(0); if (!file) { return; } @@ -407,7 +406,7 @@ function IOURequestStepScan({ removeDraftTransactions(true); } - for (const [index, file] of fileItems.entries()) { + for (const [index, file] of files.entries()) { const transaction = shouldReuseInitialTransaction(initialTransaction, shouldAcceptMultipleFiles, index, isMultiScanEnabled, transactions) ? (initialTransaction as Partial) : buildOptimisticTransactionAndCreateDraft({ @@ -423,7 +422,7 @@ function IOURequestStepScan({ if (shouldSkipConfirmation) { setReceiptFiles(newReceiptFiles); - const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && fileItems.length; + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && files.length; if (gpsRequired) { const beginLocationPermissionFlow = shouldStartLocationPermissionFlow(); diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 58975d95b1e7c..02f65ee53af48 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -398,16 +398,15 @@ function IOURequestStepScan({ [initialTransactionID, navigateBack, policy, policyCategories], ); - const setReceiptFilesAndNavigate = (files: FileObject | FileObject[]) => { - const fileItems = Array.isArray(files) ? files : [files]; - if (fileItems.length === 0) { + const setReceiptFilesAndNavigate = (files: FileObject[]) => { + if (files.length === 0) { return; } // Store the receipt on the transaction object in Onyx const newReceiptFiles: ReceiptFile[] = []; if (isEditing) { - const file = fileItems.at(0); + const file = files.at(0); if (!file) { return; } @@ -421,7 +420,7 @@ function IOURequestStepScan({ removeDraftTransactions(true); } - for (const [index, file] of fileItems.entries()) { + for (const [index, file] of files.entries()) { const source = URL.createObjectURL(file as Blob); const transaction = shouldReuseInitialTransaction(initialTransaction, shouldAcceptMultipleFiles, index, isMultiScanEnabled, transactions) ? (initialTransaction as Partial) @@ -438,7 +437,7 @@ function IOURequestStepScan({ if (shouldSkipConfirmation) { setReceiptFiles(newReceiptFiles); - const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && fileItems.length; + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && files.length; if (gpsRequired) { const beginLocationPermissionFlow = shouldStartLocationPermissionFlow(); From b6993e7c222d0c7974e51bd0fd0c1e7f786817ed Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 19 Feb 2026 11:52:23 +0000 Subject: [PATCH 21/44] fix: replace `console.error` --- src/hooks/useFilesValidation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index e48f1e4e5a876..dce6aa0332473 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -236,7 +236,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { return resizedFiles; }) .catch((error) => { - console.error('Error resizing files:', error); + Log.alert('Error resizing files:', {error}); setIsLoaderVisible(false); return files; }); From b341077adbf33ed5916559c454dd687560808802 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 26 Feb 2026 13:27:04 +0000 Subject: [PATCH 22/44] fix: prettier --- src/hooks/useFilesValidation.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index dce6aa0332473..2fad575908c8f 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -6,13 +6,13 @@ import {useFullScreenLoaderActions} from '@components/FullScreenLoaderContext'; import PDFThumbnail from '@components/PDFThumbnail'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import {validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; +import type {MultipleAttachmentsValidationError, SingleAttachmentInvalidResult, SingleAttachmentValidationError} from '@libs/AttachmentValidation'; import {getFileValidationErrorText, resizeImageIfNeeded} from '@libs/fileDownload/FileUtils'; import convertHeicImage from '@libs/fileDownload/heicConverter'; +import Log from '@libs/Log'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; -import {validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; -import type {MultipleAttachmentsValidationError, SingleAttachmentInvalidResult, SingleAttachmentValidationError} from '@libs/AttachmentValidation'; -import Log from '@libs/Log'; import useLocalize from './useLocalize'; import useThemeStyles from './useThemeStyles'; From 89479bb4b3da6789618b817318fe1502265699ba Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 27 Feb 2026 10:39:05 +0000 Subject: [PATCH 23/44] refactor: AttachmentValidation functions --- src/libs/AttachmentValidation.ts | 81 +++++++++++++++++------------- src/libs/fileDownload/FileUtils.ts | 26 ++++------ 2 files changed, 56 insertions(+), 51 deletions(-) diff --git a/src/libs/AttachmentValidation.ts b/src/libs/AttachmentValidation.ts index 284e72ebe36a2..796bb65f60b9a 100644 --- a/src/libs/AttachmentValidation.ts +++ b/src/libs/AttachmentValidation.ts @@ -75,9 +75,10 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, const corruptionResult = await isFileCorrupted(fileObject, isValidatingReceipts); if (!corruptionResult.isValid) { - return corruptionResult; + return {isValid: false, error: corruptionResult.error, file: fileObject}; } - const corruptionFreeFile = corruptionResult.validatedFile.file; + + const corruptionFreeFile = corruptionResult.file; if (corruptionFreeFile instanceof File) { /** * Cleaning file name, done here so that it covers all cases: @@ -99,11 +100,13 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, return {isValid: true, validatedFile}; } + const validatedFile: ValidatedFile = { fileType: 'uri', source: corruptionFreeFile.uri ?? '', file: corruptionFreeFile, }; + return {isValid: true, validatedFile}; } @@ -148,39 +151,47 @@ async function validateMultipleAttachmentFiles(files: FileObject[], items?: Data }; } -function isFileCorrupted(fileObject: FileObject, isValidatingReceipts?: boolean): Promise { - return normalizeFileObject(fileObject).then((normalizedFile) => { - return validateImageForCorruption(normalizedFile) - .then(() => { - if (normalizedFile.size && normalizedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - return { - isValid: false, - error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, - } as SingleAttachmentInvalidResult; - } - - if (isValidatingReceipts !== false && normalizedFile.size && normalizedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - return { - isValid: false, - error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, - } as SingleAttachmentInvalidResult; - } - - return { - isValid: true, - validatedFile: { - fileType: 'file', - file: normalizedFile, - }, - } as SingleAttachmentValidResult; - }) - .catch(() => { - return { - isValid: false, - error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, - } as SingleAttachmentInvalidResult; - }); - }); +type FileCorruptionValidResult = { + isValid: true; + file: FileObject; +}; +type FileCorruptionInvalidResult = { + isValid: false; + error: SingleAttachmentValidationError; +}; + +type FileCorruptionResult = FileCorruptionValidResult | FileCorruptionInvalidResult; + +async function isFileCorrupted(fileObject: FileObject, isValidatingReceipts?: boolean): Promise { + const normalizedFile = await normalizeFileObject(fileObject); + + try { + await validateImageForCorruption(normalizedFile); + + if (normalizedFile.size && normalizedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + return { + isValid: false, + error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, + } satisfies FileCorruptionInvalidResult; + } + + if (isValidatingReceipts !== false && normalizedFile.size && normalizedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + return { + isValid: false, + error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, + } satisfies FileCorruptionInvalidResult; + } + + return { + isValid: true, + file: normalizedFile, + } satisfies FileCorruptionValidResult; + } catch (error) { + return { + isValid: false, + error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, + } satisfies FileCorruptionInvalidResult; + } } function isDirectory(data: FileObject) { diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 63c03ec531569..ef8a6f4a0aecf 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -5,8 +5,8 @@ import type {ReactNativeBlobUtilReadStream} from 'react-native-blob-util'; import ReactNativeBlobUtil from 'react-native-blob-util'; import ImageSize from 'react-native-image-size'; import type {TupleToUnion} from 'type-fest'; -import type {MultipleAttachmentsValidationError, SingleAttachmentValidationError} from '@libs/AttachmentValidation'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {MultipleAttachmentsValidationError, SingleAttachmentValidationError} from '@libs/AttachmentValidation'; import DateUtils from '@libs/DateUtils'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; @@ -628,9 +628,9 @@ const hasHeicOrHeifExtension = (file: FileObject) => { * Otherwise, it attempts to fetch the file via its URI and reconstruct a File * with full metadata (name, size, type). */ -const normalizeFileObject = (file: FileObject): Promise => { +const normalizeFileObject = async (file: FileObject): Promise => { if (file instanceof File || file instanceof Blob) { - return Promise.resolve(file); + return file; } const isAndroidNative = getPlatform() === CONST.PLATFORM.ANDROID; @@ -638,24 +638,18 @@ const normalizeFileObject = (file: FileObject): Promise => { const isNativePlatform = isAndroidNative || isIOSNative; if (!isNativePlatform || 'size' in file) { - return Promise.resolve(file); + return file; } if (typeof file.uri !== 'string') { - return Promise.resolve(file); + return file; } - return fetch(file.uri) - .then((response) => response.blob()) - .then((blob) => { - const name = file.name ?? 'unknown'; - const type = file.type ?? blob.type ?? 'application/octet-stream'; - const normalizedFile = new File([blob], name, {type}); - return normalizedFile; - }) - .catch((error) => { - return Promise.reject(error); - }); + const response = await fetch(file.uri); + const blob = await response.blob(); + const name = file.name ?? 'unknown'; + const type = file.type ?? blob.type ?? 'application/octet-stream'; + return new File([blob], name, {type}); }; type TranslationAdditionalData = { From 3f73a1e3a4fd9f19cba3f5f8c8e6a005d233c5aa Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 27 Feb 2026 10:45:56 +0000 Subject: [PATCH 24/44] refactor: further simplify validation logic --- src/hooks/useFilesValidation.tsx | 60 ++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 2fad575908c8f..bb9d183d627a9 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -101,6 +101,18 @@ const mergeResizedIntoConverted = (convertedFiles: FileObject[], convertedNeedin }); }; +/** Extracts the ordered file list from validation result. Downstream logic does not need the original files param. */ +function getFilesFromValidationResult(result: Awaited>): FileObject[] { + if (result.isValid) { + return result.validatedFiles.map((f) => f.file); + } + // fileResults preserves original order; each result has .file or .validatedFile.file + if (result.fileResults?.length) { + return result.fileResults.map((r) => (r.isValid ? r.validatedFile.file : r.file)); + } + return result.files ?? []; +} + function useFilesValidation(onFilesValidated: OnFilesValidated) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -121,7 +133,6 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const validatedPDFs = useRef([]); const validFiles = useRef([]); const filesToValidate = useRef([]); - const invalidFileResults = useRef([]); const dataTransferItemList = useRef([]); const collectedErrors = useRef([]); const originalFileOrder = useRef>(new Map()); @@ -242,8 +253,8 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { }); }; - /** Resizes filesToResize (if any), then returns combined processedFiles and pdfsToLoad. */ - const finishProcessing = ( + /** Merges converted, resized, and valid files into one list; resizes filesToResize if any. */ + const mergeConvertedResizedAndValid = ( convertedFiles: FileObject[], validFilesToProcess: FileObject[], filesToResize: FileObject[], @@ -259,8 +270,8 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { }); }; - /** Applies the result of convert+resize: either show PDFs for validation or complete with errors/onFilesValidated. */ - const applyProcessedResult = ({processedFiles, pdfsToLoad}: {processedFiles: FileObject[]; pdfsToLoad: FileObject[]}) => { + /** Completes validation: either show PDFs for validation or finish with errors/onFilesValidated. */ + const completeWithProcessedFiles = ({processedFiles, pdfsToLoad}: {processedFiles: FileObject[]; pdfsToLoad: FileObject[]}) => { if (pdfsToLoad.length > 0) { validFiles.current = processedFiles; setPdfFilesToRender(pdfsToLoad); @@ -304,10 +315,10 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { if (convertedNeedingResize.length > 0) { return resizeFilesWithLoader(convertedNeedingResize).then((resizedConverted) => { const mergedConverted = mergeResizedIntoConverted(convertedFiles, convertedNeedingResize, resizedConverted); - return finishProcessing(mergedConverted, validFilesToProcess, filesToResize, pdfsToLoad); + return mergeConvertedResizedAndValid(mergedConverted, validFilesToProcess, filesToResize, pdfsToLoad); }); } - return finishProcessing(convertedFiles, validFilesToProcess, filesToResize, pdfsToLoad); + return mergeConvertedResizedAndValid(convertedFiles, validFilesToProcess, filesToResize, pdfsToLoad); }) .catch((error) => { Log.alert('Error converting HEIC/HEIF files:', {error}); @@ -323,38 +334,47 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { if (anyNonPdfTooLarge) { return resizeFilesWithLoader(nonPdfFiles).then((processedFiles) => ({processedFiles, pdfsToLoad})); } - return finishProcessing([], validFilesToProcess, filesToResize, pdfsToLoad); + return mergeConvertedResizedAndValid([], validFilesToProcess, filesToResize, pdfsToLoad); }; const promise = filesToConvert.length > 0 ? runConversionThenFinish() : runNoConversionPath(); - promise.then(applyProcessedResult); + promise.then(completeWithProcessedFiles); }; /** Handles result of multiple-file validation: either completes with valid files or starts convert/resize for invalid. */ - const handleMultipleFilesResult = (result: Awaited>, files: FileObject[], items?: DataTransferItem[]) => { + const handleMultipleFilesResult = (result: Awaited>, items?: DataTransferItem[]) => { + if (items) { + dataTransferItemList.current = items; + } + if (result.isValid) { const fileObjects = result.validatedFiles.map((f) => f.file); onFilesValidated(fileObjects, dataTransferItemList.current); return; } + const derivedFiles = getFilesFromValidationResult(result); + if (result.error === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { - filesToValidate.current = files.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); + filesToValidate.current = result.files?.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) ?? []; if (items) { dataTransferItemList.current = items.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); } } - const invalidResults = result.fileResults.filter((r) => r.isValid === false); - invalidFileResults.current = invalidResults; + for (const [index, file] of derivedFiles.entries()) { + originalFileOrder.current.set(file.uri ?? '', index); + } + + const invalidResults = result.fileResults?.filter((r) => r.isValid === false) ?? []; if (result.error) { collectedErrors.current.push({error: result.error}); setErrorAndOpenModal(result.error); } - convertAndResizeFiles(invalidResults, files); + convertAndResizeFiles(invalidResults, derivedFiles); }; /** Handles result of single-file validation: either completes with valid file or starts convert/resize for invalid. */ @@ -364,14 +384,12 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { return; } - invalidFileResults.current = [result]; - if (result.error) { collectedErrors.current.push({error: result.error}); setErrorAndOpenModal(result.error); } - convertAndResizeFiles(invalidFileResults.current, [file]); + convertAndResizeFiles([result], [file]); }; const validateFiles = (files: File | FileObject[], items?: DataTransferItem[], validationOptions?: ValidationOptions) => { @@ -385,6 +403,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { } setIsValidatingFiles(true); + dataTransferItemList.current = items ?? []; const isReceiptValidation = validationOptions?.isValidatingReceipts ?? DEFAULT_IS_VALIDATING_RECEIPTS; setIsValidatingReceipt(isReceiptValidation); @@ -392,10 +411,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { if (Array.isArray(files)) { setIsValidatingMultipleFiles(true); - for (const [index, file] of files.entries()) { - originalFileOrder.current.set(file.uri ?? '', index); - } - validateMultipleAttachmentFiles(files, items, isReceiptValidation).then((result) => handleMultipleFilesResult(result, files, items)); + validateMultipleAttachmentFiles(files, items, isReceiptValidation).then((result) => handleMultipleFilesResult(result, items)); return; } @@ -406,7 +422,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const onConfirmError = () => { if (fileError === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { setIsErrorModalVisible(false); - convertAndResizeFiles(invalidFileResults.current, filesToValidate.current); + convertAndResizeFiles([], filesToValidate.current); return; } From 667393fe5966d45730d93090dbe531be83ff7620 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 27 Feb 2026 10:51:34 +0000 Subject: [PATCH 25/44] fix: reset on valid validation --- src/hooks/useFilesValidation.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index bb9d183d627a9..79161beea155f 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -351,6 +351,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { if (result.isValid) { const fileObjects = result.validatedFiles.map((f) => f.file); onFilesValidated(fileObjects, dataTransferItemList.current); + resetValidationState(); return; } @@ -381,6 +382,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const handleSingleFileResult = (result: Awaited>, file: FileObject) => { if (result.isValid) { onFilesValidated([result.validatedFile.file], dataTransferItemList.current); + resetValidationState(); return; } From 77ea218f663c8cf9a5c5be55628c8ec79d5558e6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 2 Mar 2026 20:03:14 +0000 Subject: [PATCH 26/44] fix: prettier --- .../routes/report/ReportAddAttachmentModalContent/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx index cc6fe37ffb689..0b4b9f0507084 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx @@ -4,6 +4,8 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {openReport} from '@libs/actions/Report'; +import {isMultipleAttachmentsValidationResult, validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; +import type {MultipleAttachmentsValidationResult, SingleAttachmentValidationResult} from '@libs/AttachmentValidation'; import {getValidatedImageSource} from '@libs/AvatarUtils'; import Navigation from '@libs/Navigation/Navigation'; import {canUserPerformWriteAction, isReportNotFound} from '@libs/ReportUtils'; @@ -18,8 +20,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {isMultipleAttachmentsValidationResult, validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; -import type {MultipleAttachmentsValidationResult, SingleAttachmentValidationResult} from '@libs/AttachmentValidation'; import AddAttachmentModalCarouselView from './AddAttachmentModalCarouselView'; function ReportAddAttachmentModalContent({route, navigation}: AttachmentModalScreenProps) { From 431f5beadec7c33b380c08d533759dc4070840f8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 6 Mar 2026 16:23:40 +0000 Subject: [PATCH 27/44] fix: undo invalid change --- .../ReportActionCompose/useAttachmentUploadValidation.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts index 66565d53a9851..e51b4e873dc21 100644 --- a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts +++ b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts @@ -78,6 +78,10 @@ function useAttachmentUploadValidation({ const attachmentUploadType = useRef<'receipt' | 'attachment'>(undefined); const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { + if (files.length === 0) { + return; + } + if (attachmentUploadType.current === 'attachment') { showAttachmentModalScreen(files, dataTransferItems); return; From 696c820d909baccf995e0ae25c77c8fd7b5aae8e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 12 Mar 2026 15:22:52 +0000 Subject: [PATCH 28/44] fix: validate corrupted files --- src/hooks/useFilesValidation.tsx | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 79161beea155f..fd92becb77017 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -7,7 +7,13 @@ import PDFThumbnail from '@components/PDFThumbnail'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import {validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; -import type {MultipleAttachmentsValidationError, SingleAttachmentInvalidResult, SingleAttachmentValidationError} from '@libs/AttachmentValidation'; +import type { + MultipleAttachmentsValidationError, + MultipleAttachmentsValidationResult, + SingleAttachmentInvalidResult, + SingleAttachmentValidationError, + SingleAttachmentValidationResult, +} from '@libs/AttachmentValidation'; import {getFileValidationErrorText, resizeImageIfNeeded} from '@libs/fileDownload/FileUtils'; import convertHeicImage from '@libs/fileDownload/heicConverter'; import Log from '@libs/Log'; @@ -342,22 +348,15 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { promise.then(completeWithProcessedFiles); }; - /** Handles result of multiple-file validation: either completes with valid files or starts convert/resize for invalid. */ - const handleMultipleFilesResult = (result: Awaited>, items?: DataTransferItem[]) => { + /** Handles result of multiple-file validation: always routes through convert/resize + PDF validation to detect corrupted PDFs. */ + const handleMultipleFilesResult = (result: MultipleAttachmentsValidationResult, items?: DataTransferItem[]) => { if (items) { dataTransferItemList.current = items; } - if (result.isValid) { - const fileObjects = result.validatedFiles.map((f) => f.file); - onFilesValidated(fileObjects, dataTransferItemList.current); - resetValidationState(); - return; - } - const derivedFiles = getFilesFromValidationResult(result); - if (result.error === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { + if (!result.isValid && result.error === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { filesToValidate.current = result.files?.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) ?? []; if (items) { dataTransferItemList.current = items.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); @@ -368,9 +367,9 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { originalFileOrder.current.set(file.uri ?? '', index); } - const invalidResults = result.fileResults?.filter((r) => r.isValid === false) ?? []; + const invalidResults = !result.isValid && 'fileResults' in result ? result.fileResults.filter((fileResult) => !fileResult.isValid) : []; - if (result.error) { + if (!result.isValid && result.error) { collectedErrors.current.push({error: result.error}); setErrorAndOpenModal(result.error); } @@ -378,11 +377,10 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { convertAndResizeFiles(invalidResults, derivedFiles); }; - /** Handles result of single-file validation: either completes with valid file or starts convert/resize for invalid. */ - const handleSingleFileResult = (result: Awaited>, file: FileObject) => { + /** Handles result of single-file validation: always routes through convert/resize + PDF validation to detect corrupted PDFs. */ + const handleSingleFileResult = (result: SingleAttachmentValidationResult, file: FileObject) => { if (result.isValid) { - onFilesValidated([result.validatedFile.file], dataTransferItemList.current); - resetValidationState(); + convertAndResizeFiles([], [result.validatedFile.file]); return; } From 84106523e3c0c9de249f2b12f8ce975b5110458e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 12 Mar 2026 16:04:00 +0000 Subject: [PATCH 29/44] refactor: merge validation error constants --- src/CONST/index.ts | 27 +++++------ .../AttachmentPicker/index.native.tsx | 2 +- src/hooks/useFilesValidation.tsx | 28 +++++------ src/libs/AttachmentValidation.ts | 48 ++++++++----------- src/libs/fileDownload/FileUtils.ts | 26 +++++----- .../ReportAddAttachmentModalContent/index.tsx | 34 ++++--------- tests/unit/AttachmentValidationTest.ts | 46 +++++++++--------- tests/unit/FileUtilsTest.ts | 8 ++-- 8 files changed, 92 insertions(+), 127 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0493352bc9dc5..751431115feb2 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2320,22 +2320,17 @@ const CONST = { }, FILE_VALIDATION_ERRORS: { - SINGLE_FILE: { - NO_FILE_PROVIDED: 'noFileProvided', - FILE_INVALID: 'fileInvalid', - WRONG_FILE_TYPE: 'wrongFileType', - FILE_TOO_LARGE: 'fileTooLarge', - FILE_TOO_SMALL: 'fileTooSmall', - FILE_CORRUPTED: 'fileCorrupted', - PROTECTED_FILE: 'protectedFile', - FOLDER_NOT_ALLOWED: 'folderNotAllowed', - HEIC_OR_HEIF_IMAGE: 'heicOrHeifImage', - IMAGE_DIMENSIONS_TOO_LARGE: 'imageDimensionsTooLarge', - }, - MULTIPLE_FILES: { - FOLDER_NOT_ALLOWED: 'multipleAttachmentsFolderNotAllowed', - MAX_FILE_LIMIT_EXCEEDED: 'multipleAttachmentsMaxFileLimitExceeded', - }, + NO_FILE_PROVIDED: 'noFileProvided', + FILE_INVALID: 'fileInvalid', + WRONG_FILE_TYPE: 'wrongFileType', + FILE_TOO_LARGE: 'fileTooLarge', + FILE_TOO_SMALL: 'fileTooSmall', + FILE_CORRUPTED: 'fileCorrupted', + PROTECTED_FILE: 'protectedFile', + HEIC_OR_HEIF_IMAGE: 'heicOrHeifImage', + IMAGE_DIMENSIONS_TOO_LARGE: 'imageDimensionsTooLarge', + FOLDER_NOT_ALLOWED: 'folderNotAllowed', + MAX_FILE_LIMIT_EXCEEDED: 'maxFileLimitExceeded', }, IOS_CAMERA_ROLL_ACCESS_ERROR: 'Access to photo library was denied', diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index a80eacf941e78..f0e618a661246 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -321,7 +321,7 @@ function AttachmentPicker({ (error: unknown) => { const errorMessage = error instanceof Error ? error.message : undefined; - if (errorMessage === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE) { + if (errorMessage === CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE) { showGeneralAlert(translate('attachmentPicker.imageDimensionsTooLarge')); } else if (errorMessage) { showGeneralAlert(errorMessage); diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index fd92becb77017..ae1c3a3ba5711 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -6,14 +6,8 @@ import {useFullScreenLoaderActions} from '@components/FullScreenLoaderContext'; import PDFThumbnail from '@components/PDFThumbnail'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import {validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; -import type { - MultipleAttachmentsValidationError, - MultipleAttachmentsValidationResult, - SingleAttachmentInvalidResult, - SingleAttachmentValidationError, - SingleAttachmentValidationResult, -} from '@libs/AttachmentValidation'; +import {validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; +import type {FileValidationError, SingleAttachmentInvalidResult} from '@libs/AttachmentValidation'; import {getFileValidationErrorText, resizeImageIfNeeded} from '@libs/fileDownload/FileUtils'; import convertHeicImage from '@libs/fileDownload/heicConverter'; import Log from '@libs/Log'; @@ -25,7 +19,7 @@ import useThemeStyles from './useThemeStyles'; const DEFAULT_IS_VALIDATING_RECEIPTS = true; type ErrorObject = { - error: SingleAttachmentValidationError | MultipleAttachmentsValidationError; + error: FileValidationError; fileExtension?: string; }; @@ -67,10 +61,10 @@ const classifyValidatedFiles = (files: FileObject[], invalidResults: SingleAttac const urisToResize = new Set(); for (const result of invalidResults) { const uri = result.file.uri ?? ''; - if (result.error === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE) { + if (result.error === CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE) { filesToConvert.push(result.file); urisToConvert.add(uri); - } else if (result.error === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE) { + } else if (result.error === CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE) { filesToResize.push(result.file); urisToResize.add(uri); } @@ -128,7 +122,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); - const [fileError, setFileError] = useState(null); + const [fileError, setFileError] = useState(null); const [pdfFilesToRender, setPdfFilesToRender] = useState([]); const [validFilesToUpload, setValidFilesToUpload] = useState([]); const [invalidFileExtension, setInvalidFileExtension] = useState(''); @@ -190,7 +184,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { }); }; - const setErrorAndOpenModal = (error: SingleAttachmentValidationError | MultipleAttachmentsValidationError) => { + function setErrorAndOpenModal(error: FileValidationError) { setFileError(error); setIsErrorModalVisible(true); }; @@ -420,7 +414,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { }; const onConfirmError = () => { - if (fileError === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { + if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { setIsErrorModalVisible(false); convertAndResizeFiles([], filesToValidate.current); return; @@ -474,7 +468,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { onPassword={() => { validatedPDFs.current.push(file); if (isValidatingReceipt) { - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.PROTECTED_FILE}); + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE}); } else { validFiles.current.push(file); } @@ -482,7 +476,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { }} onLoadError={() => { validatedPDFs.current.push(file); - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_CORRUPTED}); + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); checkIfAllValidatedAndProceed(); }} /> @@ -496,7 +490,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { return ''; } const prompt = fileValidationErrorText.reason; - if (fileError === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE) { + if (fileError === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE) { return ( {prompt} diff --git a/src/libs/AttachmentValidation.ts b/src/libs/AttachmentValidation.ts index 796bb65f60b9a..bf19055824655 100644 --- a/src/libs/AttachmentValidation.ts +++ b/src/libs/AttachmentValidation.ts @@ -15,44 +15,40 @@ type SingleAttachmentValidResult = { validatedFile: ValidatedFile; }; -type SingleAttachmentValidationError = ValueOf; +type FileValidationError = ValueOf; type SingleAttachmentInvalidResult = { isValid: false; - error: SingleAttachmentValidationError; + error: FileValidationError; file: FileObject; }; type SingleAttachmentValidationResult = SingleAttachmentValidResult | SingleAttachmentInvalidResult; -function isSingleAttachmentValidationResult(result: unknown): result is SingleAttachmentValidationResult { - return typeof result === 'object' && result !== null && 'isValid' in result && typeof result.isValid === 'boolean' && ('validatedFile' in result || 'error' in result); -} - async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipts = false): Promise { if (!file) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.NO_FILE_PROVIDED, file}); } if (!file.name || file.size == null) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID, file}); } if (isValidatingReceipts && !isValidReceiptExtension(file)) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE, file}); } if (hasHeicOrHeifExtension(file)) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE, file}); } const isImage = Str.isImage(file.name); const maxFileSize = isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; if (!isImage && !hasHeicOrHeifExtension(file) && file.size > maxFileSize) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE, file}); } if (isValidatingReceipts && file.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL, file}); } let fileObject = file; @@ -62,14 +58,14 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, } if (!fileObject) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID, file}); } if (item && item.kind === 'file' && 'webkitGetAsEntry' in item) { const entry = item.webkitGetAsEntry(); if (entry?.isDirectory) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FOLDER_NOT_ALLOWED, file}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED, file}); } } @@ -115,26 +111,21 @@ type MultipleAttachmentsValidResult = { validatedFiles: ValidatedFile[]; }; -type MultipleAttachmentsValidationError = ValueOf; type MultipleAttachmentsInvalidResult = { isValid: false; - error?: MultipleAttachmentsValidationError; + error?: FileValidationError; fileResults: SingleAttachmentValidationResult[]; files: FileObject[]; }; type MultipleAttachmentsValidationResult = MultipleAttachmentsValidResult | MultipleAttachmentsInvalidResult; -function isMultipleAttachmentsValidationResult(result: unknown): result is MultipleAttachmentsValidationResult { - return typeof result === 'object' && result !== null && 'isValid' in result && typeof result.isValid === 'boolean' && ('validatedFiles' in result || 'fileResults' in result); -} - async function validateMultipleAttachmentFiles(files: FileObject[], items?: DataTransferItem[], isValidatingReceipts = false): Promise { if (!files?.length || files.some((f) => isDirectory(f))) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED, fileResults: [], files}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED, fileResults: [], files}); } if (files.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED, fileResults: [], files}); + return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED, fileResults: [], files}); } const results = await Promise.all(files.map((f, index) => validateAttachmentFile(f, items?.at(index), isValidatingReceipts))); @@ -157,7 +148,7 @@ type FileCorruptionValidResult = { }; type FileCorruptionInvalidResult = { isValid: false; - error: SingleAttachmentValidationError; + error: FileValidationError; }; type FileCorruptionResult = FileCorruptionValidResult | FileCorruptionInvalidResult; @@ -171,14 +162,14 @@ async function isFileCorrupted(fileObject: FileObject, isValidatingReceipts?: bo if (normalizedFile.size && normalizedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { return { isValid: false, - error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, + error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE, } satisfies FileCorruptionInvalidResult; } if (isValidatingReceipts !== false && normalizedFile.size && normalizedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { return { isValid: false, - error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, + error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL, } satisfies FileCorruptionInvalidResult; } @@ -189,7 +180,7 @@ async function isFileCorrupted(fileObject: FileObject, isValidatingReceipts?: bo } catch (error) { return { isValid: false, - error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, + error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID, } satisfies FileCorruptionInvalidResult; } } @@ -202,14 +193,13 @@ function isDirectory(data: FileObject) { return false; } -export {validateAttachmentFile, validateMultipleAttachmentFiles, isSingleAttachmentValidationResult, isMultipleAttachmentsValidationResult}; +export {validateAttachmentFile, validateMultipleAttachmentFiles}; export type { SingleAttachmentValidationResult, SingleAttachmentValidResult, SingleAttachmentInvalidResult, - SingleAttachmentValidationError, + FileValidationError, MultipleAttachmentsValidationResult, MultipleAttachmentsValidResult, MultipleAttachmentsInvalidResult, - MultipleAttachmentsValidationError, }; diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 5cf5ea3ae2084..fd2b714aa34f5 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -6,7 +6,7 @@ import ReactNativeBlobUtil from 'react-native-blob-util'; import ImageSize from 'react-native-image-size'; import type {TupleToUnion} from 'type-fest'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; -import type {MultipleAttachmentsValidationError, SingleAttachmentValidationError} from '@libs/AttachmentValidation'; +import type {FileValidationError} from '@libs/AttachmentValidation'; import DateUtils from '@libs/DateUtils'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; @@ -543,7 +543,7 @@ const calculateScaledDimensions = (width: number, height: number): {width: numbe const totalPixels = width * height; if (totalPixels > CONST.MAX_IMAGE_PIXEL_COUNT) { - throw new Error(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); + throw new Error(CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE); } const scaleFactor = CONST.MAX_IMAGE_DIMENSION / (width < height ? height : width); @@ -682,7 +682,7 @@ type GetFileValidationErrorTextOptions = { const getFileValidationErrorText = ( translate: LocalizedTranslate, - validationError: SingleAttachmentValidationError | MultipleAttachmentsValidationError | null, + validationError: FileValidationError | null, additionalData: TranslationAdditionalData = {}, options: GetFileValidationErrorTextOptions = {}, ): { @@ -699,24 +699,24 @@ const getFileValidationErrorText = ( if (options.isValidatingMultipleFiles) { switch (validationError) { - case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE: + case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: return { title: translate('attachmentPicker.someFilesCantBeUploaded'), reason: translate('attachmentPicker.unsupportedFileType', additionalData.fileType ?? ''), }; - case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE: + case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: return { title: translate('attachmentPicker.someFilesCantBeUploaded'), reason: translate('attachmentPicker.sizeLimitExceeded', { maxUploadSizeInMB: additionalData.maxUploadSizeInMB ?? maxSize / 1024 / 1024, }), }; - case CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED: + case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED: return { title: translate('attachmentPicker.attachmentError'), reason: translate('attachmentPicker.folderNotAllowedMessage'), }; - case CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED: + case CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED: return { title: translate('attachmentPicker.someFilesCantBeUploaded'), reason: translate('attachmentPicker.maxFileLimitExceeded'), @@ -727,12 +727,12 @@ const getFileValidationErrorText = ( } switch (validationError) { - case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE: + case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: return { title: translate('attachmentPicker.wrongFileType'), reason: translate('attachmentPicker.notAllowedExtension'), }; - case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE: + case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: return { title: translate('attachmentPicker.attachmentTooLarge'), reason: options.isValidatingReceipt @@ -741,22 +741,22 @@ const getFileValidationErrorText = ( }) : translate('attachmentPicker.sizeExceeded'), }; - case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL: + case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL: return { title: translate('attachmentPicker.attachmentTooSmall'), reason: translate('attachmentPicker.sizeNotMet'), }; - case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_CORRUPTED: + case CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED: return { title: translate('attachmentPicker.attachmentError'), reason: translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'), }; - case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.PROTECTED_FILE: + case CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE: return { title: translate('attachmentPicker.attachmentError'), reason: translate('attachmentPicker.protectedPDFNotSupported'), }; - case CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE: + case CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE: return { title: translate('attachmentPicker.attachmentError'), reason: translate('attachmentPicker.imageDimensionsTooLarge'), diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx index 517a4b8e4a9b0..eea7bebae487d 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx @@ -4,8 +4,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {openReport} from '@libs/actions/Report'; -import {isMultipleAttachmentsValidationResult, validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; -import type {MultipleAttachmentsValidationResult, SingleAttachmentValidationResult} from '@libs/AttachmentValidation'; +import {validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; import {getValidatedImageSource} from '@libs/AvatarUtils'; import Navigation from '@libs/Navigation/Navigation'; import {canUserPerformWriteAction, isReportNotFound} from '@libs/ReportUtils'; @@ -83,34 +82,21 @@ function ReportAddAttachmentModalContent({route, navigation}: AttachmentModalScr const [validFiles, setValidFiles] = useState(fileParam); useEffect(() => { - if (!fileParam) { - return; - } - - function updateState(result: SingleAttachmentValidationResult | MultipleAttachmentsValidationResult) { - if (isMultipleAttachmentsValidationResult(result)) { - if (result.isValid) { - setSource(result.validatedFiles.at(0)?.source ?? ''); - setValidFiles(result.validatedFiles.map((r) => r.file)); - } - - return; - } - - if (!result.isValid) { + async function validateFiles() { + if (!fileParam) { return; } - setSource(result.validatedFile.source); - setValidFiles(result.validatedFile.file); - } + const files = Array.isArray(fileParam) ? fileParam : [fileParam]; + const result = await validateMultipleAttachmentFiles(files); - if (Array.isArray(fileParam)) { - validateMultipleAttachmentFiles(fileParam).then(updateState); - return; + if (result.isValid) { + setSource(result.validatedFiles.at(0)?.source ?? ''); + setValidFiles(result.validatedFiles.map((r) => r.file)); + } } - validateAttachmentFile(fileParam).then(updateState); + validateFiles(); }, [fileParam]); const modalType = useReportAttachmentModalType(source, validFiles); diff --git a/tests/unit/AttachmentValidationTest.ts b/tests/unit/AttachmentValidationTest.ts index 15296fd76efb2..f00fc952ed6f1 100644 --- a/tests/unit/AttachmentValidationTest.ts +++ b/tests/unit/AttachmentValidationTest.ts @@ -29,7 +29,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); }); it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image file', async () => { @@ -40,7 +40,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); }); it('should return SINGLE_FILE.WRONG_FILE_TYPE for invalid receipt extension', async () => { @@ -51,7 +51,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); }); it('should prioritize SINGLE_FILE.WRONG_FILE_TYPE over SINGLE_FILE.FILE_TOO_LARGE for receipts', async () => { @@ -62,7 +62,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); }); it('should return empty string for valid image receipt', async () => { @@ -79,7 +79,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.NO_FILE_PROVIDED); expect(result.file).toBeNull(); }); @@ -90,7 +90,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.NO_FILE_PROVIDED); expect(result.file).toBeUndefined(); }); @@ -102,7 +102,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); }); it('should return SINGLE_FILE.HEIC_OR_HEIF_IMAGE for HEIF file', async () => { @@ -113,7 +113,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); }); it('should return SINGLE_FILE.FILE_TOO_LARGE for large image receipt', async () => { @@ -125,7 +125,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); }); it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image receipt', async () => { @@ -136,7 +136,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); }); it('should accept file at exact MIN_SIZE for receipt', async () => { @@ -225,7 +225,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); }); it('should handle file with no size', async () => { @@ -237,7 +237,7 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); }); it('should return SINGLE_FILE.FOLDER_NOT_ALLOWED when DataTransferItem is a directory', async () => { @@ -255,12 +255,12 @@ describe('AttachmentValidation', () => { throw new Error('validateAttachmentFile should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FOLDER_NOT_ALLOWED); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); }); }); describe('validateMultipleAttachmentFiles', () => { - it('should return MULTIPLE_FILES.FILE_TOO_LARGE when checking multiple files', async () => { + it('should return SINGLE_FILE.FILE_TOO_LARGE when checking multiple files', async () => { const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); const result = await validateMultipleAttachmentFiles([file], undefined, false); @@ -276,7 +276,7 @@ describe('AttachmentValidation', () => { throw new Error('firstFileResult should be defined and valid'); } - expect(firstFileResult.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); + expect(firstFileResult.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); }); it('should return WRONG_FILE_TYPE_MULTIPLE when checking multiple invalid receipt files', async () => { @@ -293,7 +293,7 @@ describe('AttachmentValidation', () => { throw new Error('firstFileResult should be defined and valid'); } - expect(firstFileResult.error).toEqual(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); + expect(firstFileResult.error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); }); it('should return MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED when more than MAX_FILE_LIMIT files', async () => { @@ -304,7 +304,7 @@ describe('AttachmentValidation', () => { throw new Error('validateMultipleAttachmentFiles should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); }); it('should accept exactly MAX_FILE_LIMIT files', async () => { @@ -324,7 +324,7 @@ describe('AttachmentValidation', () => { throw new Error('validateMultipleAttachmentFiles should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); }); it('should return MULTIPLE_FILES.FOLDER_NOT_ALLOWED when directory is included', async () => { @@ -342,7 +342,7 @@ describe('AttachmentValidation', () => { throw new Error('validateMultipleAttachmentFiles should return an invalid result'); } - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED); + expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); }); it('should return valid result when all files are valid', async () => { @@ -391,9 +391,9 @@ describe('AttachmentValidation', () => { expect(result.fileResults).toHaveLength(3); const errors = result.fileResults.map((r) => (r.isValid ? null : r.error)); - expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE); - expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE); - expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL); + expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); + expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); + expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); }); it('should handle multiple receipt files with HEIC/HEIF', async () => { @@ -409,7 +409,7 @@ describe('AttachmentValidation', () => { expect(result.fileResults).toHaveLength(2); const errors = result.fileResults.map((r) => (r.isValid ? null : r.error)); - expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE); + expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); }); it('should handle multiple valid receipt files', async () => { diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index 841e42bf73024..0c3f38a41042d 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -218,7 +218,7 @@ describe('FileUtils', () => { const file = {uri: 'file://large-image.jpg', name: 'large-image.jpg', type: 'image/jpeg'}; - await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); + await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE); }); it('should not throw for images at exactly the maximum pixel count', async () => { @@ -373,7 +373,7 @@ describe('FileUtils', () => { const file = {uri: 'blob:http://localhost/large-image', name: 'large.jpg', type: 'image/jpeg'}; - await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); + await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE); }); it('should throw IMAGE_DIMENSIONS_TOO_LARGE for large PNG via blob URL', async () => { @@ -383,7 +383,7 @@ describe('FileUtils', () => { const file = {uri: 'blob:http://localhost/large-png', name: 'large.png', type: 'image/png'}; - await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); + await expect(getImageDimensionsAfterResize(file)).rejects.toThrow(CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE); }); it('should fallback to ImageSize.getSize when header parsing fails', async () => { @@ -465,7 +465,7 @@ describe('FileUtils', () => { const mockTranslate = ((path: string) => path) as LocaleContextProps['translate']; it('should return correct error text for IMAGE_DIMENSIONS_TOO_LARGE', () => { - const result = getFileValidationErrorText(mockTranslate, CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE); + const result = getFileValidationErrorText(mockTranslate, CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE); expect(result.title).toBe('attachmentPicker.attachmentError'); expect(result.reason).toBe('attachmentPicker.imageDimensionsTooLarge'); From f8fdeeaa7c51d56f166d7c05147804815d4af84f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 12 Mar 2026 16:04:16 +0000 Subject: [PATCH 30/44] refactor: improve `useFilesValidation` code and make use of async-await --- src/hooks/useFilesValidation.tsx | 271 +++++++++++++++---------------- 1 file changed, 134 insertions(+), 137 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index ae1c3a3ba5711..32602af241d47 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -37,8 +37,13 @@ type ClassifiedFiles = { validFilesToProcess: FileObject[]; }; +type ValidatedFiles = { + processedFiles: FileObject[]; + pdfsToLoad: FileObject[]; +}; + /** Splits validated files into PDFs vs non-PDFs and groups invalid results by HEIC/HEIF vs FILE_TOO_LARGE. */ -const classifyValidatedFiles = (files: FileObject[], invalidResults: SingleAttachmentInvalidResult[]): ClassifiedFiles => { +function classifyValidatedFiles(files: FileObject[], invalidResults: SingleAttachmentInvalidResult[]): ClassifiedFiles { type FileMap = Map; const pdfFilesMap: FileMap = new Map(); @@ -84,22 +89,11 @@ const classifyValidatedFiles = (files: FileObject[], invalidResults: SingleAttac filesToResize, validFilesToProcess, }; -}; +} -const sortFilesByOriginalOrder = (files: FileObject[], orderMap: Map) => { +function sortFilesByOriginalOrder(files: FileObject[], orderMap: Map) { return files.sort((a, b) => (orderMap.get(a.uri ?? '') ?? 0) - (orderMap.get(b.uri ?? '') ?? 0)); -}; - -/** Replaces entries in convertedFiles that needed resize with their resized versions (by index in convertedNeedingResize). */ -const mergeResizedIntoConverted = (convertedFiles: FileObject[], convertedNeedingResize: FileObject[], resizedConverted: FileObject[]): FileObject[] => { - return convertedFiles.map((file) => { - const j = convertedNeedingResize.indexOf(file); - if (j >= 0) { - return resizedConverted.at(j) ?? file; - } - return file; - }); -}; +} /** Extracts the ordered file list from validation result. Downstream logic does not need the original files param. */ function getFilesFromValidationResult(result: Awaited>): FileObject[] { @@ -113,6 +107,18 @@ function getFilesFromValidationResult(result: Awaited(); + return errors.filter((error) => { + const key = `${error.error}-${error.fileExtension ?? ''}`; + if (uniqueErrors.has(key)) { + return false; + } + uniqueErrors.add(key); + return true; + }); +} + function useFilesValidation(onFilesValidated: OnFilesValidated) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -137,26 +143,14 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const collectedErrors = useRef([]); const originalFileOrder = useRef>(new Map()); - const updateFileOrderMapping = (oldFile: FileObject | undefined, newFile: FileObject) => { + function updateFileOrderMapping(oldFile: FileObject | undefined, newFile: FileObject) { const originalIndex = originalFileOrder.current.get(oldFile?.uri ?? ''); if (originalIndex !== undefined) { originalFileOrder.current.set(newFile.uri ?? '', originalIndex); } - }; - - const deduplicateErrors = (errors: ErrorObject[]) => { - const uniqueErrors = new Set(); - return errors.filter((error) => { - const key = `${error.error}-${error.fileExtension ?? ''}`; - if (uniqueErrors.has(key)) { - return false; - } - uniqueErrors.add(key); - return true; - }); - }; + } - const resetValidationState = () => { + function reset() { setIsValidatingFiles(false); setIsValidatingReceipt(undefined); setIsValidatingMultipleFiles(false); @@ -174,22 +168,22 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { dataTransferItemList.current = []; collectedErrors.current = []; originalFileOrder.current.clear(); - }; + } const hideModalAndReset = () => { setIsErrorModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { - resetValidationState(); + reset(); }); }; function setErrorAndOpenModal(error: FileValidationError) { setFileError(error); setIsErrorModalVisible(true); - }; + } - const convertHeicImageToJpegPromise = (file: FileObject): Promise => { + function convertHeicImageToJpegPromise(file: FileObject): Promise { return new Promise((resolve, reject) => { convertHeicImage(file, { onSuccess: (convertedFile) => resolve(convertedFile), @@ -198,9 +192,9 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { }, }); }); - }; + } - const checkIfAllValidatedAndProceed = () => { + function checkIfAllValidatedAndProceed() { if (!validatedPDFs.current || !validFiles.current) { return; } @@ -228,50 +222,55 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { } else if (validFiles.current.length > 0) { const sortedFiles = sortFilesByOriginalOrder(validFiles.current, originalFileOrder.current); onFilesValidated(sortedFiles, dataTransferItemList.current); - resetValidationState(); + reset(); } - }; + } /** Resizes files with loader; updates order mapping. Returns originals on error. */ - const resizeFilesWithLoader = (files: FileObject[]): Promise => { + async function resizeFilesWithLoader(files: FileObject[]): Promise { if (files.length === 0) { - return Promise.resolve([]); + return []; } setIsLoaderVisible(true); - return Promise.all(files.map((file) => resizeImageIfNeeded(file))) - .then((resizedFiles) => { - for (const [index, resizedFile] of resizedFiles.entries()) { - updateFileOrderMapping(files.at(index), resizedFile); - } - setIsLoaderVisible(false); - return resizedFiles; - }) - .catch((error) => { - Log.alert('Error resizing files:', {error}); - setIsLoaderVisible(false); - return files; - }); - }; + let resizedFiles: FileObject[] = []; + + try { + resizedFiles = await Promise.all(files.map((file) => resizeImageIfNeeded(file))); + } catch (error) { + Log.alert('Error resizing files:', {error}); + setIsLoaderVisible(false); + return files; + } + + for (const [index, resizedFile] of resizedFiles.entries()) { + const originalFile = files.at(index); + + updateFileOrderMapping(originalFile, resizedFile); + } + + setIsLoaderVisible(false); + return resizedFiles; + } /** Merges converted, resized, and valid files into one list; resizes filesToResize if any. */ - const mergeConvertedResizedAndValid = ( + async function mergeConvertedResizedAndValid( convertedFiles: FileObject[], validFilesToProcess: FileObject[], filesToResize: FileObject[], pdfsToLoad: FileObject[], - ): Promise<{processedFiles: FileObject[]; pdfsToLoad: FileObject[]}> => { + ): Promise { if (filesToResize.length === 0) { const processedFiles = [...convertedFiles, ...validFilesToProcess]; - return Promise.resolve({processedFiles, pdfsToLoad}); - } - return resizeFilesWithLoader(filesToResize).then((resizedFiles) => { - const processedFiles = [...convertedFiles, ...validFilesToProcess, ...resizedFiles]; return {processedFiles, pdfsToLoad}; - }); - }; + } + + const resizedFiles = await resizeFilesWithLoader(filesToResize); + const processedFiles = [...convertedFiles, ...validFilesToProcess, ...resizedFiles]; + return {processedFiles, pdfsToLoad}; + } /** Completes validation: either show PDFs for validation or finish with errors/onFilesValidated. */ - const completeWithProcessedFiles = ({processedFiles, pdfsToLoad}: {processedFiles: FileObject[]; pdfsToLoad: FileObject[]}) => { + const completeValidation = ({processedFiles, pdfsToLoad}: {processedFiles: FileObject[]; pdfsToLoad: FileObject[]}) => { if (pdfsToLoad.length > 0) { validFiles.current = processedFiles; setPdfFilesToRender(pdfsToLoad); @@ -297,102 +296,75 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { } else if (processedFiles.length > 0) { const sortedFiles = sortFilesByOriginalOrder(processedFiles, originalFileOrder.current); onFilesValidated(sortedFiles, dataTransferItemList.current); - resetValidationState(); + reset(); } }; - const convertAndResizeFiles = (invalidResults: SingleAttachmentInvalidResult[], files: FileObject[]) => { + async function convertAndResizeFiles(invalidResults: SingleAttachmentInvalidResult[], files: FileObject[]) { const {pdfsToLoad, nonPdfFiles, filesToConvert, filesToResize, validFilesToProcess} = classifyValidatedFiles(files, invalidResults); - const runConversionThenFinish = (): Promise<{processedFiles: FileObject[]; pdfsToLoad: FileObject[]}> => { - setIsLoaderVisible(true); - return Promise.all(filesToConvert.map((file) => convertHeicImageToJpegPromise(file))) - .then((convertedFiles) => { - for (const [index, convertedFile] of convertedFiles.entries()) { - updateFileOrderMapping(filesToConvert.at(index), convertedFile); - } - const convertedNeedingResize = convertedFiles.filter((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); - if (convertedNeedingResize.length > 0) { - return resizeFilesWithLoader(convertedNeedingResize).then((resizedConverted) => { - const mergedConverted = mergeResizedIntoConverted(convertedFiles, convertedNeedingResize, resizedConverted); - return mergeConvertedResizedAndValid(mergedConverted, validFilesToProcess, filesToResize, pdfsToLoad); - }); - } - return mergeConvertedResizedAndValid(convertedFiles, validFilesToProcess, filesToResize, pdfsToLoad); - }) - .catch((error) => { - Log.alert('Error converting HEIC/HEIF files:', {error}); - return {processedFiles: validFilesToProcess, pdfsToLoad}; - }) - .finally(() => { - setIsLoaderVisible(false); - }); - }; - - const runNoConversionPath = (): Promise<{processedFiles: FileObject[]; pdfsToLoad: FileObject[]}> => { + if (filesToConvert.length === 0) { const anyNonPdfTooLarge = nonPdfFiles.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); if (anyNonPdfTooLarge) { - return resizeFilesWithLoader(nonPdfFiles).then((processedFiles) => ({processedFiles, pdfsToLoad})); + const resizedFiles = await resizeFilesWithLoader(nonPdfFiles); + return completeValidation({processedFiles: resizedFiles, pdfsToLoad}); } - return mergeConvertedResizedAndValid([], validFilesToProcess, filesToResize, pdfsToLoad); - }; + const mergedConvertedResizedFiles = await mergeConvertedResizedAndValid([], validFilesToProcess, filesToResize, pdfsToLoad); - const promise = filesToConvert.length > 0 ? runConversionThenFinish() : runNoConversionPath(); + return completeValidation(mergedConvertedResizedFiles); + } - promise.then(completeWithProcessedFiles); - }; + setIsLoaderVisible(true); - /** Handles result of multiple-file validation: always routes through convert/resize + PDF validation to detect corrupted PDFs. */ - const handleMultipleFilesResult = (result: MultipleAttachmentsValidationResult, items?: DataTransferItem[]) => { - if (items) { - dataTransferItemList.current = items; - } + let convertedFiles: FileObject[] | undefined; + let mergedConvertedResizedFiles: ValidatedFiles | undefined; + try { + convertedFiles = await Promise.all(filesToConvert.map((file) => convertHeicImageToJpegPromise(file))); - const derivedFiles = getFilesFromValidationResult(result); + const convertedNeedingResize = convertedFiles.filter((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); - if (!result.isValid && result.error === CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED) { - filesToValidate.current = result.files?.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) ?? []; - if (items) { - dataTransferItemList.current = items.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); + if (convertedNeedingResize.length === 0) { + mergedConvertedResizedFiles = await mergeConvertedResizedAndValid(convertedFiles, validFilesToProcess, filesToResize, pdfsToLoad); + return completeValidation(mergedConvertedResizedFiles); } - } - for (const [index, file] of derivedFiles.entries()) { - originalFileOrder.current.set(file.uri ?? '', index); - } + const resizedConverted = await resizeFilesWithLoader(convertedNeedingResize); - const invalidResults = !result.isValid && 'fileResults' in result ? result.fileResults.filter((fileResult) => !fileResult.isValid) : []; + const mergedConverted = convertedFiles.map((file) => { + const index = convertedNeedingResize.indexOf(file); + const fileNeededConverting = index >= 0; + if (fileNeededConverting) { + return resizedConverted.at(index) ?? file; + } + return file; + }); - if (!result.isValid && result.error) { - collectedErrors.current.push({error: result.error}); - setErrorAndOpenModal(result.error); - } + setIsLoaderVisible(false); - convertAndResizeFiles(invalidResults, derivedFiles); - }; + mergedConvertedResizedFiles = await mergeConvertedResizedAndValid(mergedConverted, validFilesToProcess, filesToResize, pdfsToLoad); + } catch (error) { + Log.alert('Error converting HEIC/HEIF files:', {error}); + setIsLoaderVisible(false); - /** Handles result of single-file validation: always routes through convert/resize + PDF validation to detect corrupted PDFs. */ - const handleSingleFileResult = (result: SingleAttachmentValidationResult, file: FileObject) => { - if (result.isValid) { - convertAndResizeFiles([], [result.validatedFile.file]); - return; + mergedConvertedResizedFiles = await mergeConvertedResizedAndValid([], validFilesToProcess, filesToResize, pdfsToLoad); } - if (result.error) { - collectedErrors.current.push({error: result.error}); - setErrorAndOpenModal(result.error); + if (convertedFiles) { + for (const [index, convertedFile] of convertedFiles.entries()) { + updateFileOrderMapping(filesToConvert.at(index), convertedFile); + } } - convertAndResizeFiles([result], [file]); - }; + return completeValidation(mergedConvertedResizedFiles); + } - const validateFiles = (files: File | FileObject[], items?: DataTransferItem[], validationOptions?: ValidationOptions) => { + async function validateFiles(filesParam: File | FileObject[], items?: DataTransferItem[], validationOptions?: ValidationOptions) { if (isValidatingFiles) { Log.warn('Files are already being validated. Please wait for the current validation to complete before calling `validateFiles` again.'); return; } - if (!files) { + if (!filesParam) { return; } @@ -403,15 +375,40 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { setIsValidatingReceipt(isReceiptValidation); collectedErrors.current = []; - if (Array.isArray(files)) { + const files = Array.isArray(filesParam) ? filesParam : [filesParam]; + + if (files.length > 0) { setIsValidatingMultipleFiles(true); - validateMultipleAttachmentFiles(files, items, isReceiptValidation).then((result) => handleMultipleFilesResult(result, items)); - return; } - originalFileOrder.current.set(files.uri ?? '', 0); - validateAttachmentFile(files, items?.at(0), isReceiptValidation).then((result) => handleSingleFileResult(result, files)); - }; + const result = await validateMultipleAttachmentFiles(files, items, isReceiptValidation); + + if (items) { + dataTransferItemList.current = items; + } + + const derivedFiles = getFilesFromValidationResult(result); + + if (!result.isValid && result.error === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { + filesToValidate.current = result.files?.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) ?? []; + if (items) { + dataTransferItemList.current = items.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); + } + } + + for (const [index, file] of derivedFiles.entries()) { + originalFileOrder.current.set(file.uri ?? '', index); + } + + const invalidResults = !result.isValid && 'fileResults' in result ? result.fileResults.filter((fileResult) => !fileResult.isValid) : []; + + if (!result.isValid && result.error) { + collectedErrors.current.push({error: result.error}); + setErrorAndOpenModal(result.error); + } + + return convertAndResizeFiles(invalidResults, derivedFiles); + } const onConfirmError = () => { if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { @@ -444,7 +441,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { if (sortedFiles.length !== 0) { onFilesValidated(sortedFiles, dataTransferItemList.current); } - resetValidationState(); + reset(); }); } else { if (sortedFiles.length !== 0) { From a8ae2175273cfe0d99d368997f72fadf0717badb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 12 Mar 2026 16:05:23 +0000 Subject: [PATCH 31/44] fix: only set validate multiple files state if more than 1 --- src/hooks/useFilesValidation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 32602af241d47..25bbe5f7e57c9 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -377,7 +377,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const files = Array.isArray(filesParam) ? filesParam : [filesParam]; - if (files.length > 0) { + if (files.length > 1) { setIsValidatingMultipleFiles(true); } From 581193626ffe7226d905e60a12271ee847a8fe6e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Mar 2026 01:03:43 +0000 Subject: [PATCH 32/44] refactor: simplify attachment validation logic --- src/CONST/index.ts | 1 - src/hooks/useFilesValidation.tsx | 426 +++++++++++++---------------- src/libs/AttachmentValidation.ts | 205 -------------- src/libs/fileDownload/FileUtils.ts | 5 +- 4 files changed, 188 insertions(+), 449 deletions(-) delete mode 100644 src/libs/AttachmentValidation.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 751431115feb2..02124af5c288c 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2320,7 +2320,6 @@ const CONST = { }, FILE_VALIDATION_ERRORS: { - NO_FILE_PROVIDED: 'noFileProvided', FILE_INVALID: 'fileInvalid', WRONG_FILE_TYPE: 'wrongFileType', FILE_TOO_LARGE: 'fileTooLarge', diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 25bbe5f7e57c9..79d2fceb82271 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -1,14 +1,21 @@ import {Str} from 'expensify-common'; import React, {useRef, useState} from 'react'; import {InteractionManager} from 'react-native'; +import type {ValueOf} from 'type-fest'; import ConfirmModal from '@components/ConfirmModal'; import {useFullScreenLoaderActions} from '@components/FullScreenLoaderContext'; import PDFThumbnail from '@components/PDFThumbnail'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import {validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; -import type {FileValidationError, SingleAttachmentInvalidResult} from '@libs/AttachmentValidation'; -import {getFileValidationErrorText, resizeImageIfNeeded} from '@libs/fileDownload/FileUtils'; +import { + getFileValidationErrorText, + hasHeicOrHeifExtension, + isValidReceiptExtension, + normalizeFileObject, + resizeImageIfNeeded, + splitExtensionFromFileName, + validateImageForCorruption, +} from '@libs/fileDownload/FileUtils'; import convertHeicImage from '@libs/fileDownload/heicConverter'; import Log from '@libs/Log'; import CONST from '@src/CONST'; @@ -19,7 +26,7 @@ import useThemeStyles from './useThemeStyles'; const DEFAULT_IS_VALIDATING_RECEIPTS = true; type ErrorObject = { - error: FileValidationError; + error: ValueOf; fileExtension?: string; }; @@ -27,99 +34,68 @@ type ValidationOptions = { isValidatingReceipts?: boolean; }; -type OnFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => void; +type FileValidationError = ValueOf; -type ClassifiedFiles = { - pdfsToLoad: FileObject[]; - nonPdfFiles: FileObject[]; - filesToConvert: FileObject[]; - filesToResize: FileObject[]; - validFilesToProcess: FileObject[]; -}; - -type ValidatedFiles = { - processedFiles: FileObject[]; - pdfsToLoad: FileObject[]; -}; +async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipts = false): Promise { + if (!file.name || file.size == null) { + return CONST.FILE_VALIDATION_ERRORS.FILE_INVALID; + } -/** Splits validated files into PDFs vs non-PDFs and groups invalid results by HEIC/HEIF vs FILE_TOO_LARGE. */ -function classifyValidatedFiles(files: FileObject[], invalidResults: SingleAttachmentInvalidResult[]): ClassifiedFiles { - type FileMap = Map; + if (isValidatingReceipts && !isValidReceiptExtension(file)) { + return CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE; + } - const pdfFilesMap: FileMap = new Map(); - const nonPdfFilesMap: FileMap = new Map(); - for (const file of files) { - if (file === null) { - continue; - } - const uri = file.uri ?? ''; - if (Str.isPDF(file.name ?? '')) { - pdfFilesMap.set(uri, file); - } else { - nonPdfFilesMap.set(uri, file); - } + if (hasHeicOrHeifExtension(file)) { + return CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE; } - const filesToConvert: FileObject[] = []; - const filesToResize: FileObject[] = []; - const urisToConvert = new Set(); - const urisToResize = new Set(); - for (const result of invalidResults) { - const uri = result.file.uri ?? ''; - if (result.error === CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE) { - filesToConvert.push(result.file); - urisToConvert.add(uri); - } else if (result.error === CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE) { - filesToResize.push(result.file); - urisToResize.add(uri); - } + const isImage = Str.isImage(file.name); + const maxFileSize = isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; + if (!isImage && !hasHeicOrHeifExtension(file) && file.size > maxFileSize) { + return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; } - const validFilesToProcess: FileObject[] = []; - for (const [uri, file] of nonPdfFilesMap) { - if (!urisToConvert.has(uri) && !urisToResize.has(uri)) { - validFilesToProcess.push(file); - } + if (isValidatingReceipts && file.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL; } - return { - pdfsToLoad: Array.from(pdfFilesMap.values()), - nonPdfFiles: Array.from(nonPdfFilesMap.values()), - filesToConvert, - filesToResize, - validFilesToProcess, - }; -} + let fileObject = file; + const fileConverted = file.getAsFile?.(); + if (fileConverted) { + fileObject = fileConverted; + } -function sortFilesByOriginalOrder(files: FileObject[], orderMap: Map) { - return files.sort((a, b) => (orderMap.get(a.uri ?? '') ?? 0) - (orderMap.get(b.uri ?? '') ?? 0)); -} + if (!fileObject) { + return CONST.FILE_VALIDATION_ERRORS.FILE_INVALID; + } -/** Extracts the ordered file list from validation result. Downstream logic does not need the original files param. */ -function getFilesFromValidationResult(result: Awaited>): FileObject[] { - if (result.isValid) { - return result.validatedFiles.map((f) => f.file); + if (isDataTransferItemDirectory(item)) { + return CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED; } - // fileResults preserves original order; each result has .file or .validatedFile.file - if (result.fileResults?.length) { - return result.fileResults.map((r) => (r.isValid ? r.validatedFile.file : r.file)); + + const normalizedFile = await normalizeFileObject(fileObject); + try { + await validateImageForCorruption(normalizedFile); + } catch (error) { + return CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED; } - return result.files ?? []; + + return null; } -function deduplicateErrors(errors: ErrorObject[]) { - const uniqueErrors = new Set(); - return errors.filter((error) => { - const key = `${error.error}-${error.fileExtension ?? ''}`; - if (uniqueErrors.has(key)) { - return false; - } - uniqueErrors.add(key); +function isDataTransferItemDirectory(item: DataTransferItem | undefined) { + if (item && item.kind === 'file' && 'webkitGetAsEntry' in item && item.webkitGetAsEntry()?.isDirectory) { return true; - }); + } + + return false; } -function useFilesValidation(onFilesValidated: OnFilesValidated) { +const sortFilesByOriginalOrder = (files: FileObject[], orderMap: Map) => { + return files.sort((a, b) => (orderMap.get(a.uri ?? '') ?? 0) - (orderMap.get(b.uri ?? '') ?? 0)); +}; + +function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransferItems: DataTransferItem[]) => void) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -128,9 +104,9 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); - const [fileError, setFileError] = useState(null); + const [fileError, setFileError] = useState | null>(null); const [pdfFilesToRender, setPdfFilesToRender] = useState([]); - const [validFilesToUpload, setValidFilesToUpload] = useState([]); + const [validFilesToUpload, setValidFilesToUpload] = useState([] as FileObject[]); const [invalidFileExtension, setInvalidFileExtension] = useState(''); const [errorQueue, setErrorQueue] = useState([]); const [currentErrorIndex, setCurrentErrorIndex] = useState(0); @@ -143,14 +119,26 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { const collectedErrors = useRef([]); const originalFileOrder = useRef>(new Map()); - function updateFileOrderMapping(oldFile: FileObject | undefined, newFile: FileObject) { + const updateFileOrderMapping = (oldFile: FileObject | undefined, newFile: FileObject) => { const originalIndex = originalFileOrder.current.get(oldFile?.uri ?? ''); if (originalIndex !== undefined) { originalFileOrder.current.set(newFile.uri ?? '', originalIndex); } - } + }; + + const deduplicateErrors = (errors: ErrorObject[]) => { + const uniqueErrors = new Set(); + return errors.filter((error) => { + const key = `${error.error}-${error.fileExtension ?? ''}`; + if (uniqueErrors.has(key)) { + return false; + } + uniqueErrors.add(key); + return true; + }); + }; - function reset() { + const resetValidationState = () => { setIsValidatingFiles(false); setIsValidatingReceipt(undefined); setIsValidatingMultipleFiles(false); @@ -168,22 +156,22 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { dataTransferItemList.current = []; collectedErrors.current = []; originalFileOrder.current.clear(); - } + }; const hideModalAndReset = () => { setIsErrorModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { - reset(); + resetValidationState(); }); }; - function setErrorAndOpenModal(error: FileValidationError) { + const setErrorAndOpenModal = (error: ValueOf) => { setFileError(error); setIsErrorModalVisible(true); - } + }; - function convertHeicImageToJpegPromise(file: FileObject): Promise { + const convertHeicImageToJpegPromise = (file: FileObject): Promise => { return new Promise((resolve, reject) => { convertHeicImage(file, { onSuccess: (convertedFile) => resolve(convertedFile), @@ -192,9 +180,9 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { }, }); }); - } + }; - function checkIfAllValidatedAndProceed() { + const checkIfAllValidatedAndProceed = () => { if (!validatedPDFs.current || !validFiles.current) { return; } @@ -222,198 +210,156 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { } else if (validFiles.current.length > 0) { const sortedFiles = sortFilesByOriginalOrder(validFiles.current, originalFileOrder.current); onFilesValidated(sortedFiles, dataTransferItemList.current); - reset(); + resetValidationState(); } - } + }; - /** Resizes files with loader; updates order mapping. Returns originals on error. */ - async function resizeFilesWithLoader(files: FileObject[]): Promise { + async function validateAndResizeFiles(files: FileObject[], items: DataTransferItem[], validationOptions?: ValidationOptions) { if (files.length === 0) { - return []; + return; } - setIsLoaderVisible(true); - let resizedFiles: FileObject[] = []; - try { - resizedFiles = await Promise.all(files.map((file) => resizeImageIfNeeded(file))); - } catch (error) { - Log.alert('Error resizing files:', {error}); - setIsLoaderVisible(false); - return files; + // Reset collected errors for new validation + collectedErrors.current = []; + + for (const [index, file] of files.entries()) { + originalFileOrder.current.set(file.uri ?? '', index); } - for (const [index, resizedFile] of resizedFiles.entries()) { - const originalFile = files.at(index); + const validatedFiles = await Promise.all( + files.map(async (file, index) => { + const error = await validateAttachmentFile(file, items.at(index), validationOptions?.isValidatingReceipts ?? isValidatingReceipt); - updateFileOrderMapping(originalFile, resizedFile); - } + if (error) { + const errorData = { + error, + fileExtension: error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE ? splitExtensionFromFileName(file.name ?? '').fileExtension : undefined, + }; + collectedErrors.current.push(errorData); + return null; + } - setIsLoaderVisible(false); - return resizedFiles; - } + return file; + }), + ); - /** Merges converted, resized, and valid files into one list; resizes filesToResize if any. */ - async function mergeConvertedResizedAndValid( - convertedFiles: FileObject[], - validFilesToProcess: FileObject[], - filesToResize: FileObject[], - pdfsToLoad: FileObject[], - ): Promise { - if (filesToResize.length === 0) { - const processedFiles = [...convertedFiles, ...validFilesToProcess]; - return {processedFiles, pdfsToLoad}; - } + const filteredResults = validatedFiles.filter((result): result is FileObject => result !== null); + const pdfsToLoad = filteredResults.filter((file) => Str.isPDF(file.name ?? '')); + let nonPdfFiles = filteredResults.filter((file) => !Str.isPDF(file.name ?? '')); - const resizedFiles = await resizeFilesWithLoader(filesToResize); - const processedFiles = [...convertedFiles, ...validFilesToProcess, ...resizedFiles]; - return {processedFiles, pdfsToLoad}; - } + // Check if we need to convert images + if (nonPdfFiles.some((file) => hasHeicOrHeifExtension(file))) { + setIsLoaderVisible(true); - /** Completes validation: either show PDFs for validation or finish with errors/onFilesValidated. */ - const completeValidation = ({processedFiles, pdfsToLoad}: {processedFiles: FileObject[]; pdfsToLoad: FileObject[]}) => { - if (pdfsToLoad.length > 0) { - validFiles.current = processedFiles; - setPdfFilesToRender(pdfsToLoad); - return; - } + const convertedImages = await Promise.all(nonPdfFiles.map((file) => convertHeicImageToJpegPromise(file))); - if (processedFiles.length > 0) { - setValidFilesToUpload(processedFiles); - } + for (const [index, convertedFile] of convertedImages.entries()) { + updateFileOrderMapping(nonPdfFiles.at(index), convertedFile); + } - if (collectedErrors.current.length > 0) { - const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as ErrorObject); - setErrorQueue(uniqueErrors); - setCurrentErrorIndex(0); - const firstError = uniqueErrors.at(0); - if (firstError) { - setFileError(firstError.error); - if (firstError.fileExtension) { - setInvalidFileExtension(firstError.fileExtension); + // Check if we need to resize images + if (convertedImages.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { + const results = await Promise.allSettled(convertedImages.map((file) => resizeImageIfNeeded(file))); + const processedFiles: FileObject[] = []; + for (const [index, result] of results.entries()) { + if (result.status === 'fulfilled') { + processedFiles.push(result.value); + updateFileOrderMapping(convertedImages.at(index), result.value); + } else { + const errorMessage = result.reason instanceof Error ? result.reason.message : undefined; + if (errorMessage === CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE) { + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE}); + } else { + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); + } + } } - setIsErrorModalVisible(true); + setIsLoaderVisible(false); + nonPdfFiles = processedFiles; } - } else if (processedFiles.length > 0) { - const sortedFiles = sortFilesByOriginalOrder(processedFiles, originalFileOrder.current); - onFilesValidated(sortedFiles, dataTransferItemList.current); - reset(); - } - }; - - async function convertAndResizeFiles(invalidResults: SingleAttachmentInvalidResult[], files: FileObject[]) { - const {pdfsToLoad, nonPdfFiles, filesToConvert, filesToResize, validFilesToProcess} = classifyValidatedFiles(files, invalidResults); - - if (filesToConvert.length === 0) { - const anyNonPdfTooLarge = nonPdfFiles.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); - if (anyNonPdfTooLarge) { - const resizedFiles = await resizeFilesWithLoader(nonPdfFiles); - return completeValidation({processedFiles: resizedFiles, pdfsToLoad}); + } else if (nonPdfFiles.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { + // No conversion needed, but check if we need to resize images + setIsLoaderVisible(true); + const results = await Promise.allSettled(nonPdfFiles.map((file) => resizeImageIfNeeded(file))); + const processedFiles: FileObject[] = []; + for (const [index, result] of results.entries()) { + if (result.status === 'fulfilled') { + processedFiles.push(result.value); + updateFileOrderMapping(nonPdfFiles.at(index), result.value); + } else { + const errorMessage = result.reason instanceof Error ? result.reason.message : undefined; + if (errorMessage === CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE) { + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE}); + } else { + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); + } + } } - const mergedConvertedResizedFiles = await mergeConvertedResizedAndValid([], validFilesToProcess, filesToResize, pdfsToLoad); - - return completeValidation(mergedConvertedResizedFiles); + setIsLoaderVisible(false); + nonPdfFiles = processedFiles; } - setIsLoaderVisible(true); - - let convertedFiles: FileObject[] | undefined; - let mergedConvertedResizedFiles: ValidatedFiles | undefined; - try { - convertedFiles = await Promise.all(filesToConvert.map((file) => convertHeicImageToJpegPromise(file))); - - const convertedNeedingResize = convertedFiles.filter((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); - - if (convertedNeedingResize.length === 0) { - mergedConvertedResizedFiles = await mergeConvertedResizedAndValid(convertedFiles, validFilesToProcess, filesToResize, pdfsToLoad); - return completeValidation(mergedConvertedResizedFiles); + if (pdfsToLoad.length) { + validFiles.current = nonPdfFiles; + setPdfFilesToRender(pdfsToLoad); + } else { + if (nonPdfFiles.length > 0) { + setValidFilesToUpload(nonPdfFiles); } - const resizedConverted = await resizeFilesWithLoader(convertedNeedingResize); - - const mergedConverted = convertedFiles.map((file) => { - const index = convertedNeedingResize.indexOf(file); - const fileNeededConverting = index >= 0; - if (fileNeededConverting) { - return resizedConverted.at(index) ?? file; + if (collectedErrors.current.length > 0) { + const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as ErrorObject); + setErrorQueue(uniqueErrors); + setCurrentErrorIndex(0); + const firstError = uniqueErrors.at(0); + if (firstError) { + setFileError(firstError.error); + if (firstError.fileExtension) { + setInvalidFileExtension(firstError.fileExtension); + } + setIsErrorModalVisible(true); } - return file; - }); - - setIsLoaderVisible(false); - - mergedConvertedResizedFiles = await mergeConvertedResizedAndValid(mergedConverted, validFilesToProcess, filesToResize, pdfsToLoad); - } catch (error) { - Log.alert('Error converting HEIC/HEIF files:', {error}); - setIsLoaderVisible(false); - - mergedConvertedResizedFiles = await mergeConvertedResizedAndValid([], validFilesToProcess, filesToResize, pdfsToLoad); - } - - if (convertedFiles) { - for (const [index, convertedFile] of convertedFiles.entries()) { - updateFileOrderMapping(filesToConvert.at(index), convertedFile); + } else if (nonPdfFiles.length > 0) { + const sortedFiles = sortFilesByOriginalOrder(nonPdfFiles, originalFileOrder.current); + onFilesValidated(sortedFiles, dataTransferItemList.current); + resetValidationState(); } } - - return completeValidation(mergedConvertedResizedFiles); } - async function validateFiles(filesParam: File | FileObject[], items?: DataTransferItem[], validationOptions?: ValidationOptions) { + const validateFiles = (files: FileObject[], items?: DataTransferItem[], validationOptions?: ValidationOptions) => { if (isValidatingFiles) { Log.warn('Files are already being validated. Please wait for the current validation to complete before calling `validateFiles` again.'); return; } - if (!filesParam) { - return; - } - setIsValidatingFiles(true); - dataTransferItemList.current = items ?? []; - - const isReceiptValidation = validationOptions?.isValidatingReceipts ?? DEFAULT_IS_VALIDATING_RECEIPTS; - setIsValidatingReceipt(isReceiptValidation); - collectedErrors.current = []; - const files = Array.isArray(filesParam) ? filesParam : [filesParam]; + const validationOptionsWithDefaults = { + ...validationOptions, + isValidatingReceipts: validationOptions?.isValidatingReceipts ?? DEFAULT_IS_VALIDATING_RECEIPTS, + }; + setIsValidatingReceipt(validationOptionsWithDefaults.isValidatingReceipts); if (files.length > 1) { setIsValidatingMultipleFiles(true); } - const result = await validateMultipleAttachmentFiles(files, items, isReceiptValidation); - - if (items) { - dataTransferItemList.current = items; - } - - const derivedFiles = getFilesFromValidationResult(result); - - if (!result.isValid && result.error === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { - filesToValidate.current = result.files?.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) ?? []; + if (files.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { + filesToValidate.current = files.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); if (items) { dataTransferItemList.current = items.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); } + setErrorAndOpenModal(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); + } else { + validateAndResizeFiles(files, items ?? [], validationOptionsWithDefaults); } - - for (const [index, file] of derivedFiles.entries()) { - originalFileOrder.current.set(file.uri ?? '', index); - } - - const invalidResults = !result.isValid && 'fileResults' in result ? result.fileResults.filter((fileResult) => !fileResult.isValid) : []; - - if (!result.isValid && result.error) { - collectedErrors.current.push({error: result.error}); - setErrorAndOpenModal(result.error); - } - - return convertAndResizeFiles(invalidResults, derivedFiles); - } + }; const onConfirmError = () => { if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { setIsErrorModalVisible(false); - convertAndResizeFiles([], filesToValidate.current); + validateAndResizeFiles(filesToValidate.current, dataTransferItemList.current); return; } @@ -441,7 +387,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { if (sortedFiles.length !== 0) { onFilesValidated(sortedFiles, dataTransferItemList.current); } - reset(); + resetValidationState(); }); } else { if (sortedFiles.length !== 0) { @@ -464,7 +410,7 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { }} onPassword={() => { validatedPDFs.current.push(file); - if (isValidatingReceipt) { + if (isValidatingReceipt === true) { collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE}); } else { validFiles.current.push(file); @@ -512,8 +458,8 @@ function useFilesValidation(onFilesValidated: OnFilesValidated) { ); return { - validateFiles, PDFValidationComponent, + validateFiles, ErrorModal, }; } diff --git a/src/libs/AttachmentValidation.ts b/src/libs/AttachmentValidation.ts deleted file mode 100644 index bf19055824655..0000000000000 --- a/src/libs/AttachmentValidation.ts +++ /dev/null @@ -1,205 +0,0 @@ -import {Str} from 'expensify-common'; -import type {ValueOf} from 'type-fest'; -import CONST from '@src/CONST'; -import type {FileObject} from '@src/types/utils/Attachment'; -import {cleanFileName, hasHeicOrHeifExtension, isValidReceiptExtension, normalizeFileObject, validateImageForCorruption} from './fileDownload/FileUtils'; - -type ValidatedFile = { - fileType: 'file' | 'uri'; - source?: string; - file: FileObject; -}; - -type SingleAttachmentValidResult = { - isValid: true; - validatedFile: ValidatedFile; -}; - -type FileValidationError = ValueOf; -type SingleAttachmentInvalidResult = { - isValid: false; - error: FileValidationError; - file: FileObject; -}; - -type SingleAttachmentValidationResult = SingleAttachmentValidResult | SingleAttachmentInvalidResult; - -async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipts = false): Promise { - if (!file) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.NO_FILE_PROVIDED, file}); - } - - if (!file.name || file.size == null) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID, file}); - } - - if (isValidatingReceipts && !isValidReceiptExtension(file)) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE, file}); - } - - if (hasHeicOrHeifExtension(file)) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE, file}); - } - - const isImage = Str.isImage(file.name); - const maxFileSize = isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; - if (!isImage && !hasHeicOrHeifExtension(file) && file.size > maxFileSize) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE, file}); - } - - if (isValidatingReceipts && file.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL, file}); - } - - let fileObject = file; - const fileConverted = file.getAsFile?.(); - if (fileConverted) { - fileObject = fileConverted; - } - - if (!fileObject) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID, file}); - } - - if (item && item.kind === 'file' && 'webkitGetAsEntry' in item) { - const entry = item.webkitGetAsEntry(); - - if (entry?.isDirectory) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED, file}); - } - } - - const corruptionResult = await isFileCorrupted(fileObject, isValidatingReceipts); - if (!corruptionResult.isValid) { - return {isValid: false, error: corruptionResult.error, file: fileObject}; - } - - const corruptionFreeFile = corruptionResult.file; - if (corruptionFreeFile instanceof File) { - /** - * Cleaning file name, done here so that it covers all cases: - * upload, drag and drop, copy-paste - */ - let updatedFile = corruptionFreeFile; - const cleanName = cleanFileName(updatedFile.name); - if (updatedFile.name !== cleanName) { - updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type}); - } - const inputSource = URL.createObjectURL(updatedFile); - updatedFile.uri = inputSource; - - const validatedFile: ValidatedFile = { - fileType: 'file', - source: inputSource, - file: updatedFile, - }; - - return {isValid: true, validatedFile}; - } - - const validatedFile: ValidatedFile = { - fileType: 'uri', - source: corruptionFreeFile.uri ?? '', - file: corruptionFreeFile, - }; - - return {isValid: true, validatedFile}; -} - -type MultipleAttachmentsValidResult = { - isValid: true; - validatedFiles: ValidatedFile[]; -}; - -type MultipleAttachmentsInvalidResult = { - isValid: false; - error?: FileValidationError; - fileResults: SingleAttachmentValidationResult[]; - files: FileObject[]; -}; -type MultipleAttachmentsValidationResult = MultipleAttachmentsValidResult | MultipleAttachmentsInvalidResult; - -async function validateMultipleAttachmentFiles(files: FileObject[], items?: DataTransferItem[], isValidatingReceipts = false): Promise { - if (!files?.length || files.some((f) => isDirectory(f))) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED, fileResults: [], files}); - } - - if (files.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { - return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED, fileResults: [], files}); - } - - const results = await Promise.all(files.map((f, index) => validateAttachmentFile(f, items?.at(index), isValidatingReceipts))); - if (results.every((result) => result.isValid)) { - return { - isValid: true, - validatedFiles: results.map((r) => r.validatedFile), - }; - } - return { - isValid: false, - fileResults: results, - files, - }; -} - -type FileCorruptionValidResult = { - isValid: true; - file: FileObject; -}; -type FileCorruptionInvalidResult = { - isValid: false; - error: FileValidationError; -}; - -type FileCorruptionResult = FileCorruptionValidResult | FileCorruptionInvalidResult; - -async function isFileCorrupted(fileObject: FileObject, isValidatingReceipts?: boolean): Promise { - const normalizedFile = await normalizeFileObject(fileObject); - - try { - await validateImageForCorruption(normalizedFile); - - if (normalizedFile.size && normalizedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - return { - isValid: false, - error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE, - } satisfies FileCorruptionInvalidResult; - } - - if (isValidatingReceipts !== false && normalizedFile.size && normalizedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - return { - isValid: false, - error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL, - } satisfies FileCorruptionInvalidResult; - } - - return { - isValid: true, - file: normalizedFile, - } satisfies FileCorruptionValidResult; - } catch (error) { - return { - isValid: false, - error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID, - } satisfies FileCorruptionInvalidResult; - } -} - -function isDirectory(data: FileObject) { - if ('webkitGetAsEntry' in data && (data as DataTransferItem).webkitGetAsEntry()?.isDirectory) { - return true; - } - - return false; -} - -export {validateAttachmentFile, validateMultipleAttachmentFiles}; -export type { - SingleAttachmentValidationResult, - SingleAttachmentValidResult, - SingleAttachmentInvalidResult, - FileValidationError, - MultipleAttachmentsValidationResult, - MultipleAttachmentsValidResult, - MultipleAttachmentsInvalidResult, -}; diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index fd2b714aa34f5..d2aba69705807 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -4,9 +4,8 @@ import {Alert, Linking, Platform} from 'react-native'; import type {ReactNativeBlobUtilReadStream} from 'react-native-blob-util'; import ReactNativeBlobUtil from 'react-native-blob-util'; import ImageSize from 'react-native-image-size'; -import type {TupleToUnion} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; -import type {FileValidationError} from '@libs/AttachmentValidation'; import DateUtils from '@libs/DateUtils'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; @@ -682,7 +681,7 @@ type GetFileValidationErrorTextOptions = { const getFileValidationErrorText = ( translate: LocalizedTranslate, - validationError: FileValidationError | null, + validationError: ValueOf | null, additionalData: TranslationAdditionalData = {}, options: GetFileValidationErrorTextOptions = {}, ): { From 893f03a463bb4a9437d99e99319f7c18a4c48a76 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Mar 2026 01:08:39 +0000 Subject: [PATCH 33/44] refactor: extract back `validateAttachmentFile` function --- src/hooks/useFilesValidation.tsx | 68 +----------------------------- src/libs/validateAttachmentFile.ts | 64 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 66 deletions(-) create mode 100644 src/libs/validateAttachmentFile.ts diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 79d2fceb82271..d86606916d50d 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -7,17 +7,10 @@ import {useFullScreenLoaderActions} from '@components/FullScreenLoaderContext'; import PDFThumbnail from '@components/PDFThumbnail'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import { - getFileValidationErrorText, - hasHeicOrHeifExtension, - isValidReceiptExtension, - normalizeFileObject, - resizeImageIfNeeded, - splitExtensionFromFileName, - validateImageForCorruption, -} from '@libs/fileDownload/FileUtils'; +import {getFileValidationErrorText, hasHeicOrHeifExtension, resizeImageIfNeeded, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; import convertHeicImage from '@libs/fileDownload/heicConverter'; import Log from '@libs/Log'; +import validateAttachmentFile from '@libs/validateAttachmentFile'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; import useLocalize from './useLocalize'; @@ -34,63 +27,6 @@ type ValidationOptions = { isValidatingReceipts?: boolean; }; -type FileValidationError = ValueOf; - -async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipts = false): Promise { - if (!file.name || file.size == null) { - return CONST.FILE_VALIDATION_ERRORS.FILE_INVALID; - } - - if (isValidatingReceipts && !isValidReceiptExtension(file)) { - return CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE; - } - - if (hasHeicOrHeifExtension(file)) { - return CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE; - } - - const isImage = Str.isImage(file.name); - const maxFileSize = isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; - if (!isImage && !hasHeicOrHeifExtension(file) && file.size > maxFileSize) { - return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; - } - - if (isValidatingReceipts && file.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL; - } - - let fileObject = file; - const fileConverted = file.getAsFile?.(); - if (fileConverted) { - fileObject = fileConverted; - } - - if (!fileObject) { - return CONST.FILE_VALIDATION_ERRORS.FILE_INVALID; - } - - if (isDataTransferItemDirectory(item)) { - return CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED; - } - - const normalizedFile = await normalizeFileObject(fileObject); - try { - await validateImageForCorruption(normalizedFile); - } catch (error) { - return CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED; - } - - return null; -} - -function isDataTransferItemDirectory(item: DataTransferItem | undefined) { - if (item && item.kind === 'file' && 'webkitGetAsEntry' in item && item.webkitGetAsEntry()?.isDirectory) { - return true; - } - - return false; -} - const sortFilesByOriginalOrder = (files: FileObject[], orderMap: Map) => { return files.sort((a, b) => (orderMap.get(a.uri ?? '') ?? 0) - (orderMap.get(b.uri ?? '') ?? 0)); }; diff --git a/src/libs/validateAttachmentFile.ts b/src/libs/validateAttachmentFile.ts new file mode 100644 index 0000000000000..9c95e9b7ac361 --- /dev/null +++ b/src/libs/validateAttachmentFile.ts @@ -0,0 +1,64 @@ +import {Str} from 'expensify-common'; +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import type {FileObject} from '@src/types/utils/Attachment'; +import {hasHeicOrHeifExtension, isValidReceiptExtension, normalizeFileObject, validateImageForCorruption} from './fileDownload/FileUtils'; + +type AttachmentValidationResult = ValueOf | null; + +async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipts = false): Promise { + if (!file.name || file.size == null) { + return CONST.FILE_VALIDATION_ERRORS.FILE_INVALID; + } + + if (isValidatingReceipts && !isValidReceiptExtension(file)) { + return CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE; + } + + if (hasHeicOrHeifExtension(file)) { + return CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE; + } + + const isImage = Str.isImage(file.name); + const maxFileSize = isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; + if (!isImage && !hasHeicOrHeifExtension(file) && file.size > maxFileSize) { + return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; + } + + if (isValidatingReceipts && file.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL; + } + + let fileObject = file; + const fileConverted = file.getAsFile?.(); + if (fileConverted) { + fileObject = fileConverted; + } + + if (!fileObject) { + return CONST.FILE_VALIDATION_ERRORS.FILE_INVALID; + } + + if (isDataTransferItemDirectory(item)) { + return CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED; + } + + const normalizedFile = await normalizeFileObject(fileObject); + try { + await validateImageForCorruption(normalizedFile); + } catch (error) { + return CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED; + } + + return null; +} + +function isDataTransferItemDirectory(item: DataTransferItem | undefined) { + if (item && item.kind === 'file' && 'webkitGetAsEntry' in item && item.webkitGetAsEntry()?.isDirectory) { + return true; + } + + return false; +} + +export default validateAttachmentFile; From 2cab9ce2343a89c599ffa8be9112b4cb09fed960 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Mar 2026 01:08:47 +0000 Subject: [PATCH 34/44] chore: update tests --- tests/unit/AttachmentValidationTest.ts | 454 ----------------------- tests/unit/ValidateAttachmentFileTest.ts | 195 ++++++++++ 2 files changed, 195 insertions(+), 454 deletions(-) delete mode 100644 tests/unit/AttachmentValidationTest.ts create mode 100644 tests/unit/ValidateAttachmentFileTest.ts diff --git a/tests/unit/AttachmentValidationTest.ts b/tests/unit/AttachmentValidationTest.ts deleted file mode 100644 index f00fc952ed6f1..0000000000000 --- a/tests/unit/AttachmentValidationTest.ts +++ /dev/null @@ -1,454 +0,0 @@ -import {validateAttachmentFile, validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; -import type {FileObject} from '@src/types/utils/Attachment'; -import CONST from '../../src/CONST'; - -jest.useFakeTimers(); - -const createMockFile = (name: string, size: number) => ({ - name, - size, -}); - -describe('AttachmentValidation', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - describe('validateAttachmentFile', () => { - it('should not return SINGLE_FILE.FILE_TOO_SMALL when validating small attachment', async () => { - const file = createMockFile('file.csv', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); - const result = await validateAttachmentFile(file); - - expect(result.isValid).toBe(true); - }); - - it('should return SINGLE_FILE.FILE_TOO_SMALL when validating small receipt', async () => { - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); - const result = await validateAttachmentFile(file, undefined, true); - - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); - }); - - it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image file', async () => { - const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); - const result = await validateAttachmentFile(file); - - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); - }); - - it('should return SINGLE_FILE.WRONG_FILE_TYPE for invalid receipt extension', async () => { - const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const result = await validateAttachmentFile(file, undefined, true); - - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); - }); - - it('should prioritize SINGLE_FILE.WRONG_FILE_TYPE over SINGLE_FILE.FILE_TOO_LARGE for receipts', async () => { - const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); - const result = await validateAttachmentFile(file, undefined, true); - - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); - }); - - it('should return empty string for valid image receipt', async () => { - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const result = await validateAttachmentFile(file, undefined, true); - - expect(result.isValid).toBe(true); - }); - - it('should return SINGLE_FILE.NO_FILE_PROVIDED when file is null', async () => { - const result = await validateAttachmentFile(null as unknown as FileObject); - - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.NO_FILE_PROVIDED); - expect(result.file).toBeNull(); - }); - - it('should return SINGLE_FILE.NO_FILE_PROVIDED when file is undefined', async () => { - const result = await validateAttachmentFile(undefined as unknown as FileObject); - - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.NO_FILE_PROVIDED); - expect(result.file).toBeUndefined(); - }); - - it('should return SINGLE_FILE.HEIC_OR_HEIF_IMAGE for HEIC file', async () => { - const file = createMockFile('image.heic', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const result = await validateAttachmentFile(file); - - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); - }); - - it('should return SINGLE_FILE.HEIC_OR_HEIF_IMAGE for HEIF file', async () => { - const file = createMockFile('image.heif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const result = await validateAttachmentFile(file); - - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); - }); - - it('should return SINGLE_FILE.FILE_TOO_LARGE for large image receipt', async () => { - // Note: Image receipts are checked against MAX_SIZE (24MB) in isFileCorrupted, not RECEIPT_MAX_SIZE (10MB) - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); - const result = await validateAttachmentFile(file, undefined, true); - - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); - }); - - it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image receipt', async () => { - const file = createMockFile('receipt.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 1); - const result = await validateAttachmentFile(file, undefined, true); - - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); - }); - - it('should accept file at exact MIN_SIZE for receipt', async () => { - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE); - const result = await validateAttachmentFile(file, undefined, true); - - expect(result.isValid).toBe(true); - }); - - it('should accept file at exact RECEIPT_MAX_SIZE for receipt', async () => { - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); - const result = await validateAttachmentFile(file, undefined, true); - - expect(result.isValid).toBe(true); - }); - - it('should accept file at exact MAX_SIZE for non-receipt', async () => { - const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE); - const result = await validateAttachmentFile(file); - - expect(result.isValid).toBe(true); - }); - - it('should accept valid PDF receipt', async () => { - const file = createMockFile('receipt.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const result = await validateAttachmentFile(file, undefined, true); - - expect(result.isValid).toBe(true); - }); - - it('should accept valid non-image receipt (doc)', async () => { - const file = createMockFile('receipt.doc', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const result = await validateAttachmentFile(file, undefined, true); - - expect(result.isValid).toBe(true); - }); - - it('should accept valid non-image receipt (text)', async () => { - const file = createMockFile('receipt.text', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const result = await validateAttachmentFile(file, undefined, true); - - expect(result.isValid).toBe(true); - }); - - it('should accept valid PNG receipt', async () => { - const file = createMockFile('receipt.png', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const result = await validateAttachmentFile(file, undefined, true); - - expect(result.isValid).toBe(true); - }); - - it('should accept valid GIF receipt', async () => { - const file = createMockFile('receipt.gif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const result = await validateAttachmentFile(file, undefined, true); - - expect(result.isValid).toBe(true); - }); - - it('should accept valid JPEG receipt', async () => { - const file = createMockFile('receipt.jpeg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const result = await validateAttachmentFile(file, undefined, true); - - expect(result.isValid).toBe(true); - }); - - it('should accept valid non-receipt attachment (csv)', async () => { - const file = createMockFile('data.csv', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); - const result = await validateAttachmentFile(file); - - expect(result.isValid).toBe(true); - }); - - it('should accept valid non-receipt attachment (image)', async () => { - const file = createMockFile('image.png', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); - const result = await validateAttachmentFile(file); - - expect(result.isValid).toBe(true); - }); - - it('should handle file with no name', async () => { - const file = createMockFile('', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const result = await validateAttachmentFile(file, undefined, true); - - // File without name should still validate (name is optional) - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); - }); - - it('should handle file with no size', async () => { - const file: FileObject = {name: 'receipt.jpg', size: undefined}; - const result = await validateAttachmentFile(file, undefined, true); - - // File without size should still validate (size is optional) - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); - }); - - it('should return SINGLE_FILE.FOLDER_NOT_ALLOWED when DataTransferItem is a directory', async () => { - const mockItem = { - kind: 'file' as const, - webkitGetAsEntry: jest.fn(() => ({ - isDirectory: true, - })), - } as unknown as DataTransferItem; - - const file = createMockFile('folder', 0); - const result = await validateAttachmentFile(file, mockItem); - - if (result.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); - }); - }); - - describe('validateMultipleAttachmentFiles', () => { - it('should return SINGLE_FILE.FILE_TOO_LARGE when checking multiple files', async () => { - const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); - const result = await validateMultipleAttachmentFiles([file], undefined, false); - - if (result.isValid) { - throw new Error('validateMultipleAttachmentFiles should return an invalid result'); - } - - expect(result.error).toEqual(undefined); - - const firstFileResult = result.fileResults.at(0); - - if (!firstFileResult || firstFileResult.isValid) { - throw new Error('firstFileResult should be defined and valid'); - } - - expect(firstFileResult.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); - }); - - it('should return WRONG_FILE_TYPE_MULTIPLE when checking multiple invalid receipt files', async () => { - const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); - const result = await validateMultipleAttachmentFiles([file], undefined, true); - - if (result.isValid) { - throw new Error('validateMultipleAttachmentFiles should return an invalid result'); - } - - const firstFileResult = result.fileResults.at(0); - - if (!firstFileResult || firstFileResult.isValid) { - throw new Error('firstFileResult should be defined and valid'); - } - - expect(firstFileResult.error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); - }); - - it('should return MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED when more than MAX_FILE_LIMIT files', async () => { - const files = Array.from({length: CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT + 1}, () => createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1)); - const result = await validateMultipleAttachmentFiles(files); - - if (result.isValid) { - throw new Error('validateMultipleAttachmentFiles should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); - }); - - it('should accept exactly MAX_FILE_LIMIT files', async () => { - const files = Array.from({length: CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT}, () => createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1)); - const result = await validateMultipleAttachmentFiles(files); - - expect(result.isValid).toBe(true); - if (result.isValid) { - expect(result.validatedFiles).toHaveLength(CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); - } - }); - - it('should return MULTIPLE_FILES.FOLDER_NOT_ALLOWED when empty array provided', async () => { - const result = await validateMultipleAttachmentFiles([]); - - if (result.isValid) { - throw new Error('validateMultipleAttachmentFiles should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); - }); - - it('should return MULTIPLE_FILES.FOLDER_NOT_ALLOWED when directory is included', async () => { - const directoryFile: FileObject = { - name: 'folder', - size: 0, - webkitGetAsEntry: jest.fn(() => ({ - isDirectory: true, - })), - } as unknown as FileObject; - - const result = await validateMultipleAttachmentFiles([directoryFile]); - - if (result.isValid) { - throw new Error('validateMultipleAttachmentFiles should return an invalid result'); - } - - expect(result.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); - }); - - it('should return valid result when all files are valid', async () => { - const files = [ - createMockFile('file1.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1), - createMockFile('file2.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1), - createMockFile('file3.png', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1), - ]; - const result = await validateMultipleAttachmentFiles(files); - - expect(result.isValid).toBe(true); - if (result.isValid) { - expect(result.validatedFiles).toHaveLength(3); - } - }); - - it('should return invalid result with mixed valid and invalid files', async () => { - const files = [ - createMockFile('file1.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1), - createMockFile('file2.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1), - createMockFile('file3.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1), - ]; - const result = await validateMultipleAttachmentFiles(files); - - if (result.isValid) { - throw new Error('validateMultipleAttachmentFiles should return an invalid result'); - } - - expect(result.fileResults).toHaveLength(3); - expect(result.fileResults.at(0)?.isValid).toBe(true); - expect(result.fileResults.at(1)?.isValid).toBe(false); - expect(result.fileResults.at(2)?.isValid).toBe(true); - }); - - it('should handle multiple files with different error types', async () => { - const files = [ - createMockFile('file1.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), // WRONG_FILE_TYPE - createMockFile('file2.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 1), // FILE_TOO_LARGE - createMockFile('file3.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1), // FILE_TOO_SMALL - ]; - const result = await validateMultipleAttachmentFiles(files, undefined, true); - - if (result.isValid) { - throw new Error('validateMultipleAttachmentFiles should return an invalid result'); - } - - expect(result.fileResults).toHaveLength(3); - const errors = result.fileResults.map((r) => (r.isValid ? null : r.error)); - expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); - expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); - expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); - }); - - it('should handle multiple receipt files with HEIC/HEIF', async () => { - const files = [ - createMockFile('image1.heic', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), - createMockFile('image2.heif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), - ]; - const result = await validateMultipleAttachmentFiles(files, undefined, true); - - if (result.isValid) { - throw new Error('validateMultipleAttachmentFiles should return an invalid result'); - } - - expect(result.fileResults).toHaveLength(2); - const errors = result.fileResults.map((r) => (r.isValid ? null : r.error)); - expect(errors).toContain(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); - }); - - it('should handle multiple valid receipt files', async () => { - const files = [ - createMockFile('receipt1.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), - createMockFile('receipt2.png', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), - createMockFile('receipt3.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1), - ]; - const result = await validateMultipleAttachmentFiles(files, undefined, true); - - expect(result.isValid).toBe(true); - if (result.isValid) { - expect(result.validatedFiles).toHaveLength(3); - } - }); - - it('should handle single file in array', async () => { - const files = [createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1)]; - const result = await validateMultipleAttachmentFiles(files); - - expect(result.isValid).toBe(true); - if (result.isValid) { - expect(result.validatedFiles).toHaveLength(1); - } - }); - - it('should handle files with DataTransferItems', async () => { - const files = [createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1)]; - const items = [ - { - kind: 'file' as const, - webkitGetAsEntry: jest.fn(() => ({ - isDirectory: false, - })), - } as unknown as DataTransferItem, - ]; - const result = await validateMultipleAttachmentFiles(files, items); - - expect(result.isValid).toBe(true); - }); - }); -}); diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts new file mode 100644 index 0000000000000..7bd662feeecc3 --- /dev/null +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -0,0 +1,195 @@ +import validateAttachmentFile from '@libs/validateAttachmentFile'; +import type {FileObject} from '@src/types/utils/Attachment'; +import CONST from '../../src/CONST'; + +jest.useFakeTimers(); + +const createMockFile = (name: string, size: number) => ({ + name, + size, +}); + +describe('validateAttachmentFile', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('validateAttachmentFile', () => { + it('should not return SINGLE_FILE.FILE_TOO_SMALL when validating small attachment', async () => { + const file = createMockFile('file.csv', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); + const error = await validateAttachmentFile(file); + + expect(error).toBe(null); + }); + + it('should return SINGLE_FILE.FILE_TOO_SMALL when validating small receipt', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); + }); + + it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image file', async () => { + const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); + const error = await validateAttachmentFile(file); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); + }); + + it('should return SINGLE_FILE.WRONG_FILE_TYPE for invalid receipt extension', async () => { + const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); + }); + + it('should prioritize SINGLE_FILE.WRONG_FILE_TYPE over SINGLE_FILE.FILE_TOO_LARGE for receipts', async () => { + const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); + }); + + it('should return empty string for valid image receipt', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toBe(null); + }); + + it('should return SINGLE_FILE.HEIC_OR_HEIF_IMAGE for HEIC file', async () => { + const file = createMockFile('image.heic', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); + }); + + it('should return SINGLE_FILE.HEIC_OR_HEIF_IMAGE for HEIF file', async () => { + const file = createMockFile('image.heif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); + }); + + it('should return SINGLE_FILE.FILE_TOO_LARGE for large image receipt', async () => { + // Note: Image receipts are checked against MAX_SIZE (24MB) in isFileCorrupted, not RECEIPT_MAX_SIZE (10MB) + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); + }); + + it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image receipt', async () => { + const file = createMockFile('receipt.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); + }); + + it('should accept file at exact MIN_SIZE for receipt', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toBe(null); + }); + + it('should accept file at exact RECEIPT_MAX_SIZE for receipt', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toBe(null); + }); + + it('should accept file at exact MAX_SIZE for non-receipt', async () => { + const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE); + const error = await validateAttachmentFile(file); + + expect(error).toBe(null); + }); + + it('should accept valid PDF receipt', async () => { + const file = createMockFile('receipt.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toBe(null); + }); + + it('should accept valid non-image receipt (doc)', async () => { + const file = createMockFile('receipt.doc', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toBe(null); + }); + + it('should accept valid non-image receipt (text)', async () => { + const file = createMockFile('receipt.text', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toBe(null); + }); + + it('should accept valid PNG receipt', async () => { + const file = createMockFile('receipt.png', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toBe(null); + }); + + it('should accept valid GIF receipt', async () => { + const file = createMockFile('receipt.gif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toBe(null); + }); + + it('should accept valid JPEG receipt', async () => { + const file = createMockFile('receipt.jpeg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toBe(null); + }); + + it('should accept valid non-receipt attachment (csv)', async () => { + const file = createMockFile('data.csv', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); + const error = await validateAttachmentFile(file); + + expect(error).toBe(null); + }); + + it('should accept valid non-receipt attachment (image)', async () => { + const file = createMockFile('image.png', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); + const error = await validateAttachmentFile(file); + + expect(error).toBe(null); + }); + + it('should handle file with no name', async () => { + const file = createMockFile('', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); + + // File without name should still validate (name is optional) + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); + }); + + it('should handle file with no size', async () => { + const file: FileObject = {name: 'receipt.jpg', size: undefined}; + const error = await validateAttachmentFile(file, undefined, true); + + // File without size should still validate (size is optional) + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); + }); + + it('should return SINGLE_FILE.FOLDER_NOT_ALLOWED when DataTransferItem is a directory', async () => { + const mockItem = { + kind: 'file' as const, + webkitGetAsEntry: jest.fn(() => ({ + isDirectory: true, + })), + } as unknown as DataTransferItem; + + const file = createMockFile('folder', 0); + const error = await validateAttachmentFile(file, mockItem); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); + }); + }); +}); From 60a8d05089260a4d33b0a644e3f0a6714b43ec22 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Mar 2026 01:15:13 +0000 Subject: [PATCH 35/44] chore: update tests --- tests/unit/ValidateAttachmentFileTest.ts | 214 ++++++++++++++++------- 1 file changed, 146 insertions(+), 68 deletions(-) diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts index 7bd662feeecc3..c30edbcb60280 100644 --- a/tests/unit/ValidateAttachmentFileTest.ts +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -1,10 +1,21 @@ import validateAttachmentFile from '@libs/validateAttachmentFile'; import type {FileObject} from '@src/types/utils/Attachment'; import CONST from '../../src/CONST'; +import * as FileUtils from '../../src/libs/fileDownload/FileUtils'; + +// Mock only normalizeFileObject and validateImageForCorruption; keep real hasHeicOrHeifExtension and isValidReceiptExtension +jest.mock('@src/libs/fileDownload/FileUtils', () => { + const actual = jest.requireActual('@src/libs/fileDownload/FileUtils'); + return { + ...actual, + normalizeFileObject: jest.fn(), + validateImageForCorruption: jest.fn(), + }; +}); -jest.useFakeTimers(); +const mockFileUtils = FileUtils as jest.Mocked; -const createMockFile = (name: string, size: number) => ({ +const createMockFile = (name: string, size: number): FileObject => ({ name, size, }); @@ -12,184 +23,251 @@ const createMockFile = (name: string, size: number) => ({ describe('validateAttachmentFile', () => { beforeEach(() => { jest.clearAllMocks(); + // Default: pass-through so async validation succeeds + mockFileUtils.normalizeFileObject.mockImplementation(async (file) => file); + mockFileUtils.validateImageForCorruption.mockResolvedValue(undefined); }); - describe('validateAttachmentFile', () => { - it('should not return SINGLE_FILE.FILE_TOO_SMALL when validating small attachment', async () => { - const file = createMockFile('file.csv', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); - const error = await validateAttachmentFile(file); - expect(error).toBe(null); + describe('FILE_INVALID', () => { + it('returns FILE_INVALID when file has no name', async () => { + const file = createMockFile('', 100); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); }); - it('should return SINGLE_FILE.FILE_TOO_SMALL when validating small receipt', async () => { - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); + it('returns FILE_INVALID when file has undefined size', async () => { + const file: FileObject = {name: 'receipt.jpg', size: undefined}; const error = await validateAttachmentFile(file, undefined, true); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); }); - it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image file', async () => { - const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); - const error = await validateAttachmentFile(file); + it('returns FILE_INVALID when file has null size', async () => { + const file: FileObject = {name: 'receipt.jpg', size: null as unknown as number}; + const error = await validateAttachmentFile(file, undefined, true); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); }); + }); - it('should return SINGLE_FILE.WRONG_FILE_TYPE for invalid receipt extension', async () => { + describe('WRONG_FILE_TYPE', () => { + it('returns WRONG_FILE_TYPE for invalid receipt extension when validating receipts', async () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); }); - it('should prioritize SINGLE_FILE.WRONG_FILE_TYPE over SINGLE_FILE.FILE_TOO_LARGE for receipts', async () => { + it('returns WRONG_FILE_TYPE (not FILE_TOO_LARGE) when receipt has wrong type and is over size', async () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); const error = await validateAttachmentFile(file, undefined, true); expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); }); - it('should return empty string for valid image receipt', async () => { - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const error = await validateAttachmentFile(file, undefined, true); + it('does not return WRONG_FILE_TYPE when not validating receipts', async () => { + const file = createMockFile('file.exe', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, false); expect(error).toBe(null); }); + }); - it('should return SINGLE_FILE.HEIC_OR_HEIF_IMAGE for HEIC file', async () => { + describe('HEIC_OR_HEIF_IMAGE', () => { + it('returns HEIC_OR_HEIF_IMAGE for .heic file', async () => { const file = createMockFile('image.heic', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file); expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); }); - it('should return SINGLE_FILE.HEIC_OR_HEIF_IMAGE for HEIF file', async () => { + it('returns HEIC_OR_HEIF_IMAGE for .heif file', async () => { const file = createMockFile('image.heif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file); expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); }); + }); - it('should return SINGLE_FILE.FILE_TOO_LARGE for large image receipt', async () => { - // Note: Image receipts are checked against MAX_SIZE (24MB) in isFileCorrupted, not RECEIPT_MAX_SIZE (10MB) - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); - const error = await validateAttachmentFile(file, undefined, true); + describe('FILE_TOO_LARGE', () => { + it('returns FILE_TOO_LARGE for non-image over MAX_SIZE (general attachment)', async () => { + const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); + const error = await validateAttachmentFile(file); expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); }); - it('should return SINGLE_FILE.FILE_TOO_LARGE for large non-image receipt', async () => { + it('returns FILE_TOO_LARGE for non-image receipt over RECEIPT_MAX_SIZE', async () => { const file = createMockFile('receipt.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 1); const error = await validateAttachmentFile(file, undefined, true); expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); }); - it('should accept file at exact MIN_SIZE for receipt', async () => { - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE); + it('does not return FILE_TOO_LARGE for image over RECEIPT_MAX_SIZE (images skip this check)', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); const error = await validateAttachmentFile(file, undefined, true); expect(error).toBe(null); }); - it('should accept file at exact RECEIPT_MAX_SIZE for receipt', async () => { - const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); - const error = await validateAttachmentFile(file, undefined, true); + it('does not return FILE_TOO_LARGE when non-image is exactly at MAX_SIZE', async () => { + const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE); + const error = await validateAttachmentFile(file); expect(error).toBe(null); }); + }); - it('should accept file at exact MAX_SIZE for non-receipt', async () => { - const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE); + describe('FILE_TOO_SMALL', () => { + it('returns FILE_TOO_SMALL for receipt below MIN_SIZE', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); + }); + + it('does not return FILE_TOO_SMALL when not validating receipts', async () => { + const file = createMockFile('file.csv', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); const error = await validateAttachmentFile(file); expect(error).toBe(null); }); - it('should accept valid PDF receipt', async () => { - const file = createMockFile('receipt.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + it('returns null when receipt is exactly at MIN_SIZE', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE); const error = await validateAttachmentFile(file, undefined, true); expect(error).toBe(null); }); + }); + + describe('FOLDER_NOT_ALLOWED', () => { + it('returns FOLDER_NOT_ALLOWED when DataTransferItem is a directory', async () => { + const mockItem = { + kind: 'file' as const, + webkitGetAsEntry: jest.fn(() => ({ + isDirectory: true, + })), + } as unknown as DataTransferItem; + + const file = createMockFile('folder', 0); + const error = await validateAttachmentFile(file, mockItem); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); + }); - it('should accept valid non-image receipt (doc)', async () => { - const file = createMockFile('receipt.doc', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + it('does not return FOLDER_NOT_ALLOWED when item is not a directory', async () => { + const mockItem = { + kind: 'file' as const, + webkitGetAsEntry: jest.fn(() => ({ + isDirectory: false, + })), + } as unknown as DataTransferItem; + + const file = createMockFile('file.pdf', 100); + const error = await validateAttachmentFile(file, mockItem); + + expect(error).toBe(null); + }); + }); + + describe('FILE_CORRUPTED', () => { + it('returns FILE_CORRUPTED when validateImageForCorruption throws', async () => { + mockFileUtils.validateImageForCorruption.mockRejectedValue(new Error('Corrupted')); + + const file = createMockFile('image.png', 1000); + const error = await validateAttachmentFile(file); + + expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); + }); + }); + + describe('success (returns null)', () => { + it('returns null for valid image receipt at valid size', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); expect(error).toBe(null); }); - it('should accept valid non-image receipt (text)', async () => { - const file = createMockFile('receipt.text', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + it('returns null for valid receipt at exact RECEIPT_MAX_SIZE', async () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); + const error = await validateAttachmentFile(file, undefined, true); + + expect(error).toBe(null); + }); + + it('returns null for valid PDF receipt', async () => { + const file = createMockFile('receipt.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); expect(error).toBe(null); }); - it('should accept valid PNG receipt', async () => { + it('returns null for valid PNG receipt', async () => { const file = createMockFile('receipt.png', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); expect(error).toBe(null); }); - it('should accept valid GIF receipt', async () => { + it('returns null for valid GIF receipt', async () => { const file = createMockFile('receipt.gif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); expect(error).toBe(null); }); - it('should accept valid JPEG receipt', async () => { + it('returns null for valid JPEG receipt', async () => { const file = createMockFile('receipt.jpeg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); expect(error).toBe(null); }); - it('should accept valid non-receipt attachment (csv)', async () => { - const file = createMockFile('data.csv', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); - const error = await validateAttachmentFile(file); + it('returns null for valid non-image receipt (doc)', async () => { + const file = createMockFile('receipt.doc', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); expect(error).toBe(null); }); - it('should accept valid non-receipt attachment (image)', async () => { - const file = createMockFile('image.png', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); - const error = await validateAttachmentFile(file); + it('returns null for valid non-image receipt (text)', async () => { + const file = createMockFile('receipt.text', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = await validateAttachmentFile(file, undefined, true); expect(error).toBe(null); }); - it('should handle file with no name', async () => { - const file = createMockFile('', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const error = await validateAttachmentFile(file, undefined, true); + it('returns null for valid non-receipt attachment (CSV)', async () => { + const file = createMockFile('data.csv', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); + const error = await validateAttachmentFile(file); - // File without name should still validate (name is optional) - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); + expect(error).toBe(null); }); - it('should handle file with no size', async () => { - const file: FileObject = {name: 'receipt.jpg', size: undefined}; - const error = await validateAttachmentFile(file, undefined, true); + it('returns null for valid non-receipt image', async () => { + const file = createMockFile('image.png', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); + const error = await validateAttachmentFile(file); - // File without size should still validate (size is optional) - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); + expect(error).toBe(null); }); - it('should return SINGLE_FILE.FOLDER_NOT_ALLOWED when DataTransferItem is a directory', async () => { - const mockItem = { - kind: 'file' as const, - webkitGetAsEntry: jest.fn(() => ({ - isDirectory: true, - })), - } as unknown as DataTransferItem; + it('returns null when file has getAsFile and uses converted file', async () => { + const blob = new Blob(['content'], {type: 'text/plain'}); + const convertedFile = new File([blob], 'file.txt', {type: 'text/plain'}); + const file = { + name: 'file.txt', + size: 7, + getAsFile: () => convertedFile, + } as unknown as FileObject; - const file = createMockFile('folder', 0); - const error = await validateAttachmentFile(file, mockItem); + const error = await validateAttachmentFile(file); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); + expect(error).toBe(null); + expect(mockFileUtils.normalizeFileObject).toHaveBeenCalledWith(convertedFile); }); }); }); From 592e71be0aad1341cf4e09628d2a62a8f59d2842 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Mar 2026 13:22:29 +0000 Subject: [PATCH 36/44] fix: attachment validation in `ReportAddAttachmentModalContent` --- src/hooks/useFilesValidation.tsx | 20 ++++---- src/libs/validateAttachmentFile.ts | 51 ++++++++++++++----- .../ReportAddAttachmentModalContent/index.tsx | 16 ++++-- 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index d86606916d50d..000f860183066 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -164,18 +164,18 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer const validatedFiles = await Promise.all( files.map(async (file, index) => { - const error = await validateAttachmentFile(file, items.at(index), validationOptions?.isValidatingReceipts ?? isValidatingReceipt); - - if (error) { - const errorData = { - error, - fileExtension: error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE ? splitExtensionFromFileName(file.name ?? '').fileExtension : undefined, - }; - collectedErrors.current.push(errorData); - return null; + const result = await validateAttachmentFile(file, items.at(index), validationOptions?.isValidatingReceipts ?? isValidatingReceipt); + + if (result.isValid) { + return result.file; } - return file; + const errorData = { + error: result.error, + fileExtension: result.error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE ? splitExtensionFromFileName(file.name ?? '').fileExtension : undefined, + }; + collectedErrors.current.push(errorData); + return null; }), ); diff --git a/src/libs/validateAttachmentFile.ts b/src/libs/validateAttachmentFile.ts index 9c95e9b7ac361..f46fab1eb982b 100644 --- a/src/libs/validateAttachmentFile.ts +++ b/src/libs/validateAttachmentFile.ts @@ -2,31 +2,41 @@ import {Str} from 'expensify-common'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; -import {hasHeicOrHeifExtension, isValidReceiptExtension, normalizeFileObject, validateImageForCorruption} from './fileDownload/FileUtils'; +import {cleanFileName, hasHeicOrHeifExtension, isValidReceiptExtension, normalizeFileObject, validateImageForCorruption} from './fileDownload/FileUtils'; -type AttachmentValidationResult = ValueOf | null; +type ValidateAttachmentValidResult = { + isValid: true; + file: FileObject; +}; -async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipts = false): Promise { +type ValidateAttachmentInvalidResult = { + isValid: false; + error: ValueOf; +}; + +type ValidateAttachmentResult = ValidateAttachmentValidResult | ValidateAttachmentInvalidResult; + +async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipts = false): Promise { if (!file.name || file.size == null) { - return CONST.FILE_VALIDATION_ERRORS.FILE_INVALID; + return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID}; } if (isValidatingReceipts && !isValidReceiptExtension(file)) { - return CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE; + return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE}; } if (hasHeicOrHeifExtension(file)) { - return CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE; + return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE}; } const isImage = Str.isImage(file.name); const maxFileSize = isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; if (!isImage && !hasHeicOrHeifExtension(file) && file.size > maxFileSize) { - return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; + return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE}; } if (isValidatingReceipts && file.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL; + return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL}; } let fileObject = file; @@ -36,21 +46,37 @@ async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, } if (!fileObject) { - return CONST.FILE_VALIDATION_ERRORS.FILE_INVALID; + return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID}; } if (isDataTransferItemDirectory(item)) { - return CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED; + return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED}; } const normalizedFile = await normalizeFileObject(fileObject); try { await validateImageForCorruption(normalizedFile); } catch (error) { - return CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED; + return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}; + } + + if (normalizedFile instanceof File) { + /** + * Cleaning file name, done here so that it covers all cases: + * upload, drag and drop, copy-paste + */ + let updatedFile = normalizedFile; + const cleanName = cleanFileName(updatedFile.name); + if (updatedFile.name !== cleanName) { + updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type}); + } + const inputSource = URL.createObjectURL(updatedFile); + updatedFile.uri = inputSource; + + return {isValid: true, file: updatedFile}; } - return null; + return {isValid: true, file: normalizedFile}; } function isDataTransferItemDirectory(item: DataTransferItem | undefined) { @@ -62,3 +88,4 @@ function isDataTransferItemDirectory(item: DataTransferItem | undefined) { } export default validateAttachmentFile; +export type {ValidateAttachmentResult, ValidateAttachmentValidResult, ValidateAttachmentInvalidResult}; diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx index eea7bebae487d..5201e0dc2549e 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx @@ -4,10 +4,10 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {openReport} from '@libs/actions/Report'; -import {validateMultipleAttachmentFiles} from '@libs/AttachmentValidation'; import {getValidatedImageSource} from '@libs/AvatarUtils'; import Navigation from '@libs/Navigation/Navigation'; import {canUserPerformWriteAction, isReportNotFound} from '@libs/ReportUtils'; +import validateAttachmentFile from '@libs/validateAttachmentFile'; import type {AttachmentModalBaseContentProps} from '@pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types'; import AttachmentModalContainer from '@pages/media/AttachmentModalScreen/AttachmentModalContainer'; import useDownloadAttachment from '@pages/media/AttachmentModalScreen/routes/hooks/useDownloadAttachment'; @@ -88,12 +88,18 @@ function ReportAddAttachmentModalContent({route, navigation}: AttachmentModalScr } const files = Array.isArray(fileParam) ? fileParam : [fileParam]; - const result = await validateMultipleAttachmentFiles(files); + const results = await Promise.all(files.map(async (file) => validateAttachmentFile(file))); - if (result.isValid) { - setSource(result.validatedFiles.at(0)?.source ?? ''); - setValidFiles(result.validatedFiles.map((r) => r.file)); + const validResults = results.filter((r) => r.isValid); + if (validResults.length === 0) { + return; } + + const validatedFiles = validResults.map((r) => r.file); + const firstValidSource = validResults.at(0)?.file.uri; + + setSource(firstValidSource); + setValidFiles(validatedFiles); } validateFiles(); From 92e4dd5a45ddbf9e4edced9d2242eb50ad3c813f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Mar 2026 13:28:18 +0000 Subject: [PATCH 37/44] test: update tests to match new `validateAttachmentFile` implementation --- tests/unit/ValidateAttachmentFileTest.ts | 166 +++++++++++++++-------- 1 file changed, 107 insertions(+), 59 deletions(-) diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts index c30edbcb60280..ac8e57e72c627 100644 --- a/tests/unit/ValidateAttachmentFileTest.ts +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -29,122 +29,162 @@ describe('validateAttachmentFile', () => { }); describe('FILE_INVALID', () => { - it('returns FILE_INVALID when file has no name', async () => { + it('returns invalid result with FILE_INVALID when file has no name', async () => { const file = createMockFile('', 100); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); }); - it('returns FILE_INVALID when file has undefined size', async () => { + it('returns invalid result with FILE_INVALID when file has undefined size', async () => { const file: FileObject = {name: 'receipt.jpg', size: undefined}; const error = await validateAttachmentFile(file, undefined, true); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); }); - it('returns FILE_INVALID when file has null size', async () => { + it('returns invalid result with FILE_INVALID when file has null size', async () => { const file: FileObject = {name: 'receipt.jpg', size: null as unknown as number}; const error = await validateAttachmentFile(file, undefined, true); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); }); }); describe('WRONG_FILE_TYPE', () => { - it('returns WRONG_FILE_TYPE for invalid receipt extension when validating receipts', async () => { + it('returns invalid result with WRONG_FILE_TYPE for invalid receipt extension when validating receipts', async () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); }); - it('returns WRONG_FILE_TYPE (not FILE_TOO_LARGE) when receipt has wrong type and is over size', async () => { + it('returns invalid result with WRONG_FILE_TYPE (not FILE_TOO_LARGE) when receipt has wrong type and is over size', async () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); }); - it('does not return WRONG_FILE_TYPE when not validating receipts', async () => { + it('returns valid result when not validating receipts, even for invalid receipt extension', async () => { const file = createMockFile('file.exe', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, false); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); }); describe('HEIC_OR_HEIF_IMAGE', () => { - it('returns HEIC_OR_HEIF_IMAGE for .heic file', async () => { + it('returns invalid result with HEIC_OR_HEIF_IMAGE for .heic file', async () => { const file = createMockFile('image.heic', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); }); - it('returns HEIC_OR_HEIF_IMAGE for .heif file', async () => { + it('returns invalid result with HEIC_OR_HEIF_IMAGE for .heif file', async () => { const file = createMockFile('image.heif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE); }); }); describe('FILE_TOO_LARGE', () => { - it('returns FILE_TOO_LARGE for non-image over MAX_SIZE (general attachment)', async () => { + it('returns invalid result with FILE_TOO_LARGE for non-image over MAX_SIZE (general attachment)', async () => { const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); const error = await validateAttachmentFile(file); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); }); - it('returns FILE_TOO_LARGE for non-image receipt over RECEIPT_MAX_SIZE', async () => { + it('returns invalid result with FILE_TOO_LARGE for non-image receipt over RECEIPT_MAX_SIZE', async () => { const file = createMockFile('receipt.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 1); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE); }); - it('does not return FILE_TOO_LARGE for image over RECEIPT_MAX_SIZE (images skip this check)', async () => { + it('returns valid result for image over RECEIPT_MAX_SIZE (images skip non-image size check)', async () => { const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('does not return FILE_TOO_LARGE when non-image is exactly at MAX_SIZE', async () => { + it('returns valid result when non-image is exactly at MAX_SIZE', async () => { const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE); const error = await validateAttachmentFile(file); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); }); describe('FILE_TOO_SMALL', () => { - it('returns FILE_TOO_SMALL for receipt below MIN_SIZE', async () => { + it('returns invalid result with FILE_TOO_SMALL for receipt below MIN_SIZE', async () => { const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); }); - it('does not return FILE_TOO_SMALL when not validating receipts', async () => { + it('returns valid result when not validating receipts, even for small file size', async () => { const file = createMockFile('file.csv', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); const error = await validateAttachmentFile(file); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('returns null when receipt is exactly at MIN_SIZE', async () => { + it('returns valid result when receipt is exactly at MIN_SIZE', async () => { const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); }); describe('FOLDER_NOT_ALLOWED', () => { - it('returns FOLDER_NOT_ALLOWED when DataTransferItem is a directory', async () => { + it('returns invalid result with FOLDER_NOT_ALLOWED when DataTransferItem is a directory', async () => { const mockItem = { kind: 'file' as const, webkitGetAsEntry: jest.fn(() => ({ @@ -155,10 +195,14 @@ describe('validateAttachmentFile', () => { const file = createMockFile('folder', 0); const error = await validateAttachmentFile(file, mockItem); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); }); - it('does not return FOLDER_NOT_ALLOWED when item is not a directory', async () => { + it('returns valid result when DataTransferItem is not a directory', async () => { const mockItem = { kind: 'file' as const, webkitGetAsEntry: jest.fn(() => ({ @@ -169,93 +213,97 @@ describe('validateAttachmentFile', () => { const file = createMockFile('file.pdf', 100); const error = await validateAttachmentFile(file, mockItem); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); }); describe('FILE_CORRUPTED', () => { - it('returns FILE_CORRUPTED when validateImageForCorruption throws', async () => { + it('returns invalid result with FILE_CORRUPTED when validateImageForCorruption throws', async () => { mockFileUtils.validateImageForCorruption.mockRejectedValue(new Error('Corrupted')); const file = createMockFile('image.png', 1000); const error = await validateAttachmentFile(file); - expect(error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); + if (error.isValid) { + throw new Error('validateAttachmentFile should return an invalid result'); + } + + expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); }); }); - describe('success (returns null)', () => { - it('returns null for valid image receipt at valid size', async () => { + describe('success', () => { + it('returns valid result for valid image receipt at valid size', async () => { const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('returns null for valid receipt at exact RECEIPT_MAX_SIZE', async () => { + it('returns valid result for valid receipt at exact RECEIPT_MAX_SIZE', async () => { const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('returns null for valid PDF receipt', async () => { + it('returns valid result for valid PDF receipt', async () => { const file = createMockFile('receipt.pdf', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('returns null for valid PNG receipt', async () => { + it('returns valid result for valid PNG receipt', async () => { const file = createMockFile('receipt.png', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('returns null for valid GIF receipt', async () => { + it('returns valid result for valid GIF receipt', async () => { const file = createMockFile('receipt.gif', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('returns null for valid JPEG receipt', async () => { + it('returns valid result for valid JPEG receipt', async () => { const file = createMockFile('receipt.jpeg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('returns null for valid non-image receipt (doc)', async () => { + it('returns valid result for valid non-image receipt (doc)', async () => { const file = createMockFile('receipt.doc', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('returns null for valid non-image receipt (text)', async () => { + it('returns valid result for valid non-image receipt (text)', async () => { const file = createMockFile('receipt.text', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); const error = await validateAttachmentFile(file, undefined, true); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('returns null for valid non-receipt attachment (CSV)', async () => { + it('returns valid result for valid non-receipt attachment (CSV)', async () => { const file = createMockFile('data.csv', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); const error = await validateAttachmentFile(file); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('returns null for valid non-receipt image', async () => { + it('returns valid result for valid non-receipt image', async () => { const file = createMockFile('image.png', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE - 1); const error = await validateAttachmentFile(file); - expect(error).toBe(null); + expect(error.isValid).toBe(true); }); - it('returns null when file has getAsFile and uses converted file', async () => { + it('returns valid result when file has getAsFile and uses converted file', async () => { const blob = new Blob(['content'], {type: 'text/plain'}); const convertedFile = new File([blob], 'file.txt', {type: 'text/plain'}); const file = { @@ -266,7 +314,7 @@ describe('validateAttachmentFile', () => { const error = await validateAttachmentFile(file); - expect(error).toBe(null); + expect(error.isValid).toBe(true); expect(mockFileUtils.normalizeFileObject).toHaveBeenCalledWith(convertedFile); }); }); From 37296079b2b8cb5e0d9a154d7c107e400dc66c46 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Mar 2026 14:16:02 +0000 Subject: [PATCH 38/44] refactor: rename reset callback --- src/hooks/useFilesValidation.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 000f860183066..dfdb007c2b3b9 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -74,7 +74,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer }); }; - const resetValidationState = () => { + const reset = () => { setIsValidatingFiles(false); setIsValidatingReceipt(undefined); setIsValidatingMultipleFiles(false); @@ -98,7 +98,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer setIsErrorModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { - resetValidationState(); + reset(); }); }; @@ -146,7 +146,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer } else if (validFiles.current.length > 0) { const sortedFiles = sortFilesByOriginalOrder(validFiles.current, originalFileOrder.current); onFilesValidated(sortedFiles, dataTransferItemList.current); - resetValidationState(); + reset(); } }; @@ -258,7 +258,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer } else if (nonPdfFiles.length > 0) { const sortedFiles = sortFilesByOriginalOrder(nonPdfFiles, originalFileOrder.current); onFilesValidated(sortedFiles, dataTransferItemList.current); - resetValidationState(); + reset(); } } } @@ -323,7 +323,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer if (sortedFiles.length !== 0) { onFilesValidated(sortedFiles, dataTransferItemList.current); } - resetValidationState(); + reset(); }); } else { if (sortedFiles.length !== 0) { From 73b5ca33fda3506f138a20787c0d69dbb3b28aef Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Mar 2026 23:20:34 +0000 Subject: [PATCH 39/44] refactor: file validation logic --- src/hooks/useFilesValidation.tsx | 167 +++++++++++++++++-------------- 1 file changed, 94 insertions(+), 73 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index dfdb007c2b3b9..c1d807423add8 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -31,6 +31,8 @@ const sortFilesByOriginalOrder = (files: FileObject[], orderMap: Map (orderMap.get(a.uri ?? '') ?? 0) - (orderMap.get(b.uri ?? '') ?? 0)); }; +const isImageFile = (file: FileObject) => hasHeicOrHeifExtension(file) ?? Str.isImage(file.name ?? ''); + function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransferItems: DataTransferItem[]) => void) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -107,17 +109,6 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer setIsErrorModalVisible(true); }; - const convertHeicImageToJpegPromise = (file: FileObject): Promise => { - return new Promise((resolve, reject) => { - convertHeicImage(file, { - onSuccess: (convertedFile) => resolve(convertedFile), - onError: (nonConvertedFile) => { - reject(nonConvertedFile); - }, - }); - }); - }; - const checkIfAllValidatedAndProceed = () => { if (!validatedPDFs.current || !validFiles.current) { return; @@ -162,12 +153,34 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer originalFileOrder.current.set(file.uri ?? '', index); } - const validatedFiles = await Promise.all( + const isValidatingReceipts = validationOptions?.isValidatingReceipts ?? isValidatingReceipt; + + const pdfsToLoad: FileObject[] = []; + const validNonPdfFiles: FileObject[] = []; + + const filesToResize: FileObject[] = []; + const filesToConvert: FileObject[] = []; + await Promise.all( files.map(async (file, index) => { - const result = await validateAttachmentFile(file, items.at(index), validationOptions?.isValidatingReceipts ?? isValidatingReceipt); + const result = await validateAttachmentFile(file, items.at(index), isValidatingReceipts); if (result.isValid) { - return result.file; + if (Str.isPDF(result.file.name ?? '')) { + pdfsToLoad.push(result.file); + } else { + validNonPdfFiles.push(result.file); + } + return; + } + + if (result.error === CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE && isImageFile(file) && isValidatingReceipts) { + filesToResize.push(file); + return; + } + + if (result.error === CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE) { + filesToConvert.push(file); + return; } const errorData = { @@ -175,53 +188,60 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer fileExtension: result.error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE ? splitExtensionFromFileName(file.name ?? '').fileExtension : undefined, }; collectedErrors.current.push(errorData); - return null; }), ); - const filteredResults = validatedFiles.filter((result): result is FileObject => result !== null); - const pdfsToLoad = filteredResults.filter((file) => Str.isPDF(file.name ?? '')); - let nonPdfFiles = filteredResults.filter((file) => !Str.isPDF(file.name ?? '')); - - // Check if we need to convert images - if (nonPdfFiles.some((file) => hasHeicOrHeifExtension(file))) { + if (filesToConvert.length > 0) { setIsLoaderVisible(true); - const convertedImages = await Promise.all(nonPdfFiles.map((file) => convertHeicImageToJpegPromise(file))); + const convertedFilesToResize: FileObject[] = []; + const convertedFiles: FileObject[] = []; + await Promise.all( + filesToConvert.map( + (file) => + new Promise((resolve, reject) => { + convertHeicImage(file, { + onSuccess: (convertedFile) => { + if (isValidatingReceipts && convertedFile.size && convertedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { + convertedFilesToResize.push(convertedFile); + resolve(); + } + + if (!isValidatingReceipts && convertedFile.size && convertedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE}); + reject(); + } + + convertedFiles.push(convertedFile); + resolve(); + }, + onError: () => { + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); + reject(); + }, + }); + }), + ), + ); - for (const [index, convertedFile] of convertedImages.entries()) { - updateFileOrderMapping(nonPdfFiles.at(index), convertedFile); - } + filesToResize.push(...convertedFilesToResize); + validNonPdfFiles.push(...convertedFiles); - // Check if we need to resize images - if (convertedImages.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { - const results = await Promise.allSettled(convertedImages.map((file) => resizeImageIfNeeded(file))); - const processedFiles: FileObject[] = []; - for (const [index, result] of results.entries()) { - if (result.status === 'fulfilled') { - processedFiles.push(result.value); - updateFileOrderMapping(convertedImages.at(index), result.value); - } else { - const errorMessage = result.reason instanceof Error ? result.reason.message : undefined; - if (errorMessage === CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE) { - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE}); - } else { - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); - } - } - } - setIsLoaderVisible(false); - nonPdfFiles = processedFiles; + for (const [index, convertedFile] of convertedFiles.entries()) { + updateFileOrderMapping(filesToConvert.at(index), convertedFile); } - } else if (nonPdfFiles.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { - // No conversion needed, but check if we need to resize images + } + + if (filesToResize.length > 0) { setIsLoaderVisible(true); - const results = await Promise.allSettled(nonPdfFiles.map((file) => resizeImageIfNeeded(file))); - const processedFiles: FileObject[] = []; - for (const [index, result] of results.entries()) { + + const toResizeResults = await Promise.allSettled(filesToResize.map((file) => resizeImageIfNeeded(file))); + + for (const [index, result] of toResizeResults.entries()) { if (result.status === 'fulfilled') { - processedFiles.push(result.value); - updateFileOrderMapping(nonPdfFiles.at(index), result.value); + const value = result.value; + validNonPdfFiles.push(value); + updateFileOrderMapping(filesToResize.at(index), value); } else { const errorMessage = result.reason instanceof Error ? result.reason.message : undefined; if (errorMessage === CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE) { @@ -231,35 +251,36 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer } } } - setIsLoaderVisible(false); - nonPdfFiles = processedFiles; } + setIsLoaderVisible(false); + if (pdfsToLoad.length) { - validFiles.current = nonPdfFiles; + validFiles.current = validNonPdfFiles; setPdfFilesToRender(pdfsToLoad); - } else { - if (nonPdfFiles.length > 0) { - setValidFilesToUpload(nonPdfFiles); - } + return; + } - if (collectedErrors.current.length > 0) { - const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as ErrorObject); - setErrorQueue(uniqueErrors); - setCurrentErrorIndex(0); - const firstError = uniqueErrors.at(0); - if (firstError) { - setFileError(firstError.error); - if (firstError.fileExtension) { - setInvalidFileExtension(firstError.fileExtension); - } - setIsErrorModalVisible(true); + if (validNonPdfFiles.length > 0) { + setValidFilesToUpload(validNonPdfFiles); + } + + if (collectedErrors.current.length > 0) { + const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as ErrorObject); + setErrorQueue(uniqueErrors); + setCurrentErrorIndex(0); + const firstError = uniqueErrors.at(0); + if (firstError) { + setFileError(firstError.error); + if (firstError.fileExtension) { + setInvalidFileExtension(firstError.fileExtension); } - } else if (nonPdfFiles.length > 0) { - const sortedFiles = sortFilesByOriginalOrder(nonPdfFiles, originalFileOrder.current); - onFilesValidated(sortedFiles, dataTransferItemList.current); - reset(); + setIsErrorModalVisible(true); } + } else if (validNonPdfFiles.length > 0) { + const sortedFiles = sortFilesByOriginalOrder(validNonPdfFiles, originalFileOrder.current); + onFilesValidated(sortedFiles, dataTransferItemList.current); + reset(); } } From a1fb241f90a0aa189e4ec1cdf68d0d24f583ebfb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Mar 2026 23:34:50 +0000 Subject: [PATCH 40/44] test: fix failing test --- tests/unit/ValidateAttachmentFileTest.ts | 31 +++++++++++++++--------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts index ac8e57e72c627..ce3f393cae4fd 100644 --- a/tests/unit/ValidateAttachmentFileTest.ts +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -304,18 +304,25 @@ describe('validateAttachmentFile', () => { }); it('returns valid result when file has getAsFile and uses converted file', async () => { - const blob = new Blob(['content'], {type: 'text/plain'}); - const convertedFile = new File([blob], 'file.txt', {type: 'text/plain'}); - const file = { - name: 'file.txt', - size: 7, - getAsFile: () => convertedFile, - } as unknown as FileObject; - - const error = await validateAttachmentFile(file); - - expect(error.isValid).toBe(true); - expect(mockFileUtils.normalizeFileObject).toHaveBeenCalledWith(convertedFile); + // In Node/Jest the react-native-url-polyfill throws for createObjectURL (no BlobModule). + // Mock it so the File path that assigns file.uri can run. + const createObjectURLSpy = jest.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + try { + const blob = new Blob(['content'], {type: 'text/plain'}); + const convertedFile = new File([blob], 'file.txt', {type: 'text/plain'}); + const file = { + name: 'file.txt', + size: 7, + getAsFile: () => convertedFile, + } as unknown as FileObject; + + const error = await validateAttachmentFile(file); + + expect(error.isValid).toBe(true); + expect(mockFileUtils.normalizeFileObject).toHaveBeenCalledWith(convertedFile); + } finally { + createObjectURLSpy.mockRestore(); + } }); }); }); From ae0deea87b5d5497a5c86b84860575fb699a8553 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Mar 2026 23:43:33 +0000 Subject: [PATCH 41/44] fix: always resolve file conversion promise --- src/hooks/useFilesValidation.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index c1d807423add8..6f0e6437d0df2 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -199,7 +199,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer await Promise.all( filesToConvert.map( (file) => - new Promise((resolve, reject) => { + new Promise((resolve) => { convertHeicImage(file, { onSuccess: (convertedFile) => { if (isValidatingReceipts && convertedFile.size && convertedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { @@ -209,7 +209,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer if (!isValidatingReceipts && convertedFile.size && convertedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE}); - reject(); + resolve(); } convertedFiles.push(convertedFile); @@ -217,7 +217,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer }, onError: () => { collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); - reject(); + resolve(); }, }); }), From 8d3fd86d518d33d50283ab0785f35635d7cf53d1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Mar 2026 12:50:45 +0100 Subject: [PATCH 42/44] fix: last error not shown as "multiple files error" --- src/hooks/useFilesValidation.tsx | 102 ++++++++++++++--------------- src/libs/fileDownload/FileUtils.ts | 25 ++++--- 2 files changed, 59 insertions(+), 68 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 6f0e6437d0df2..5d0a00a150a3b 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -8,6 +8,7 @@ import PDFThumbnail from '@components/PDFThumbnail'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import {getFileValidationErrorText, hasHeicOrHeifExtension, resizeImageIfNeeded, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; +import type {FileValidationError} from '@libs/fileDownload/FileUtils'; import convertHeicImage from '@libs/fileDownload/heicConverter'; import Log from '@libs/Log'; import validateAttachmentFile from '@libs/validateAttachmentFile'; @@ -18,15 +19,15 @@ import useThemeStyles from './useThemeStyles'; const DEFAULT_IS_VALIDATING_RECEIPTS = true; -type ErrorObject = { - error: ValueOf; - fileExtension?: string; -}; - type ValidationOptions = { isValidatingReceipts?: boolean; }; +type ValidationState = { + isValidatingReceipts: boolean; + isValidatingMultipleFiles: boolean; +}; + const sortFilesByOriginalOrder = (files: FileObject[], orderMap: Map) => { return files.sort((a, b) => (orderMap.get(a.uri ?? '') ?? 0) - (orderMap.get(b.uri ?? '') ?? 0)); }; @@ -38,15 +39,14 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer const {translate} = useLocalize(); const [isValidatingFiles, setIsValidatingFiles] = useState(false); - const [isValidatingReceipt, setIsValidatingReceipt] = useState(); + const [isValidatingReceipts, setIsValidatingReceipts] = useState(); const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); - const [fileError, setFileError] = useState | null>(null); + const [fileError, setFileError] = useState(null); const [pdfFilesToRender, setPdfFilesToRender] = useState([]); const [validFilesToUpload, setValidFilesToUpload] = useState([] as FileObject[]); - const [invalidFileExtension, setInvalidFileExtension] = useState(''); - const [errorQueue, setErrorQueue] = useState([]); + const [errorQueue, setErrorQueue] = useState([]); const [currentErrorIndex, setCurrentErrorIndex] = useState(0); const {setIsLoaderVisible} = useFullScreenLoaderActions(); @@ -54,7 +54,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer const validFiles = useRef([]); const filesToValidate = useRef([]); const dataTransferItemList = useRef([]); - const collectedErrors = useRef([]); + const collectedErrors = useRef([]); const originalFileOrder = useRef>(new Map()); const updateFileOrderMapping = (oldFile: FileObject | undefined, newFile: FileObject) => { @@ -64,10 +64,10 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer } }; - const deduplicateErrors = (errors: ErrorObject[]) => { + const deduplicateErrors = (errors: FileValidationError[]) => { const uniqueErrors = new Set(); return errors.filter((error) => { - const key = `${error.error}-${error.fileExtension ?? ''}`; + const key = `${error.error}-${error.fileType ?? ''}`; if (uniqueErrors.has(key)) { return false; } @@ -78,14 +78,12 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer const reset = () => { setIsValidatingFiles(false); - setIsValidatingReceipt(undefined); - setIsValidatingMultipleFiles(false); + setIsValidatingReceipts(undefined); setIsErrorModalVisible(false); setPdfFilesToRender([]); setIsLoaderVisible(false); setValidFilesToUpload([]); setFileError(null); - setInvalidFileExtension(''); setErrorQueue([]); setCurrentErrorIndex(0); validatedPDFs.current = []; @@ -105,7 +103,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer }; const setErrorAndOpenModal = (error: ValueOf) => { - setFileError(error); + setFileError({error, isValidatingMultipleFiles}); setIsErrorModalVisible(true); }; @@ -128,10 +126,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer setCurrentErrorIndex(0); const firstError = uniqueErrors.at(0); if (firstError) { - setFileError(firstError.error); - if (firstError.fileExtension) { - setInvalidFileExtension(firstError.fileExtension); - } + setFileError(firstError); setIsErrorModalVisible(true); } } else if (validFiles.current.length > 0) { @@ -141,7 +136,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer } }; - async function validateAndResizeFiles(files: FileObject[], items: DataTransferItem[], validationOptions?: ValidationOptions) { + async function validateAndResizeFiles(files: FileObject[], items: DataTransferItem[], validationState: ValidationState) { if (files.length === 0) { return; } @@ -153,8 +148,6 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer originalFileOrder.current.set(file.uri ?? '', index); } - const isValidatingReceipts = validationOptions?.isValidatingReceipts ?? isValidatingReceipt; - const pdfsToLoad: FileObject[] = []; const validNonPdfFiles: FileObject[] = []; @@ -162,7 +155,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer const filesToConvert: FileObject[] = []; await Promise.all( files.map(async (file, index) => { - const result = await validateAttachmentFile(file, items.at(index), isValidatingReceipts); + const result = await validateAttachmentFile(file, items.at(index), validationState.isValidatingReceipts); if (result.isValid) { if (Str.isPDF(result.file.name ?? '')) { @@ -173,7 +166,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer return; } - if (result.error === CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE && isImageFile(file) && isValidatingReceipts) { + if (result.error === CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE && isImageFile(file) && validationState.isValidatingReceipts) { filesToResize.push(file); return; } @@ -185,8 +178,9 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer const errorData = { error: result.error, - fileExtension: result.error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE ? splitExtensionFromFileName(file.name ?? '').fileExtension : undefined, - }; + isValidatingMultipleFiles: validationState.isValidatingMultipleFiles, + fileType: result.error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE ? splitExtensionFromFileName(file.name ?? '').fileExtension : undefined, + } satisfies FileValidationError; collectedErrors.current.push(errorData); }), ); @@ -202,13 +196,16 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer new Promise((resolve) => { convertHeicImage(file, { onSuccess: (convertedFile) => { - if (isValidatingReceipts && convertedFile.size && convertedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { + if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { convertedFilesToResize.push(convertedFile); resolve(); } - if (!isValidatingReceipts && convertedFile.size && convertedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE}); + if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + collectedErrors.current.push({ + error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE, + isValidatingMultipleFiles: validationState.isValidatingMultipleFiles, + }); resolve(); } @@ -216,7 +213,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer resolve(); }, onError: () => { - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED, isValidatingMultipleFiles}); resolve(); }, }); @@ -245,9 +242,9 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer } else { const errorMessage = result.reason instanceof Error ? result.reason.message : undefined; if (errorMessage === CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE) { - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE}); + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE, isValidatingMultipleFiles}); } else { - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED, isValidatingMultipleFiles}); } } } @@ -266,15 +263,12 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer } if (collectedErrors.current.length > 0) { - const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as ErrorObject); + const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as FileValidationError); setErrorQueue(uniqueErrors); setCurrentErrorIndex(0); const firstError = uniqueErrors.at(0); if (firstError) { - setFileError(firstError.error); - if (firstError.fileExtension) { - setInvalidFileExtension(firstError.fileExtension); - } + setFileError(firstError); setIsErrorModalVisible(true); } } else if (validNonPdfFiles.length > 0) { @@ -292,15 +286,12 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer setIsValidatingFiles(true); - const validationOptionsWithDefaults = { - ...validationOptions, + const validationState: ValidationState = { isValidatingReceipts: validationOptions?.isValidatingReceipts ?? DEFAULT_IS_VALIDATING_RECEIPTS, + isValidatingMultipleFiles: files.length > 1, }; - setIsValidatingReceipt(validationOptionsWithDefaults.isValidatingReceipts); - - if (files.length > 1) { - setIsValidatingMultipleFiles(true); - } + setIsValidatingReceipts(validationState.isValidatingReceipts); + setIsValidatingMultipleFiles(validationState.isValidatingMultipleFiles); if (files.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { filesToValidate.current = files.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); @@ -309,14 +300,18 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer } setErrorAndOpenModal(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); } else { - validateAndResizeFiles(files, items ?? [], validationOptionsWithDefaults); + validateAndResizeFiles(files, items ?? [], validationState); } }; const onConfirmError = () => { - if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { + if (fileError?.error === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { setIsErrorModalVisible(false); - validateAndResizeFiles(filesToValidate.current, dataTransferItemList.current); + const validationState: ValidationState = { + isValidatingReceipts: isValidatingReceipts ?? false, + isValidatingMultipleFiles, + }; + validateAndResizeFiles(filesToValidate.current, dataTransferItemList.current, validationState); return; } @@ -328,8 +323,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer setIsValidatingMultipleFiles(false); } setCurrentErrorIndex(nextIndex); - setFileError(nextError.error); - setInvalidFileExtension(nextError.fileExtension ?? ''); + setFileError(nextError); return; } } @@ -337,7 +331,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer const sortedFiles = sortFilesByOriginalOrder(validFilesToUpload, originalFileOrder.current); // If we're validating attachments we need to use InteractionManager to ensure // the error modal is dismissed before opening the attachment modal - if (isValidatingReceipt === false && fileError) { + if (isValidatingReceipts === false && fileError) { setIsErrorModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { @@ -367,7 +361,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer }} onPassword={() => { validatedPDFs.current.push(file); - if (isValidatingReceipt === true) { + if (isValidatingReceipts === true) { collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE}); } else { validFiles.current.push(file); @@ -383,14 +377,14 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer )) : undefined; - const fileValidationErrorText = getFileValidationErrorText(translate, fileError, {fileType: invalidFileExtension}, {isValidatingReceipt, isValidatingMultipleFiles}); + const fileValidationErrorText = getFileValidationErrorText(translate, fileError, {isValidatingReceipt: isValidatingReceipts}); const getModalPrompt = () => { if (!fileError) { return ''; } const prompt = fileValidationErrorText.reason; - if (fileError === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE) { + if (fileError.error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE) { return ( {prompt} diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index df5886c665ca9..385f429b2f31f 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -668,21 +668,19 @@ const normalizeFileObject = async (file: FileObject): Promise => { return new File([blob], name, {type}); }; -type TranslationAdditionalData = { - maxUploadSizeInMB?: number; - fileLimit?: number; +type FileValidationError = { + error: ValueOf; + isValidatingMultipleFiles?: boolean; fileType?: string; }; type GetFileValidationErrorTextOptions = { isValidatingReceipt?: boolean; - isValidatingMultipleFiles?: boolean; }; const getFileValidationErrorText = ( translate: LocalizedTranslate, - validationError: ValueOf | null, - additionalData: TranslationAdditionalData = {}, + validationError: FileValidationError | null, options: GetFileValidationErrorTextOptions = {}, ): { title: string; @@ -696,17 +694,17 @@ const getFileValidationErrorText = ( } const maxSize = options.isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; - if (options.isValidatingMultipleFiles) { - switch (validationError) { + if (validationError.isValidatingMultipleFiles) { + switch (validationError.error) { case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: return { title: translate('attachmentPicker.someFilesCantBeUploaded'), - reason: translate('attachmentPicker.unsupportedFileType', additionalData.fileType ?? ''), + reason: translate('attachmentPicker.unsupportedFileType', validationError.fileType ?? ''), }; case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: return { title: translate('attachmentPicker.someFilesCantBeUploaded'), - reason: translate('attachmentPicker.sizeLimitExceeded', additionalData.maxUploadSizeInMB ?? maxSize / 1024 / 1024), + reason: translate('attachmentPicker.sizeLimitExceeded', maxSize / 1024 / 1024), }; case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED: return { @@ -723,7 +721,7 @@ const getFileValidationErrorText = ( } } - switch (validationError) { + switch (validationError.error) { case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: return { title: translate('attachmentPicker.wrongFileType'), @@ -732,9 +730,7 @@ const getFileValidationErrorText = ( case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: return { title: translate('attachmentPicker.attachmentTooLarge'), - reason: options.isValidatingReceipt - ? translate('attachmentPicker.sizeExceededWithLimit', additionalData.maxUploadSizeInMB ?? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / 1024 / 1024) - : translate('attachmentPicker.sizeExceeded'), + reason: options.isValidatingReceipt ? translate('attachmentPicker.sizeExceededWithLimit', maxSize / 1024 / 1024) : translate('attachmentPicker.sizeExceeded'), }; case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL: return { @@ -886,3 +882,4 @@ export { cleanFileObject, cleanFileObjectName, }; +export type {FileValidationError}; From 399478782038dd48f99568fe1e6b49a302b0e772 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Mar 2026 13:00:51 +0100 Subject: [PATCH 43/44] fix: invalid errors after converting files --- src/hooks/useFilesValidation.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 5d0a00a150a3b..576e5b2ddeabf 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -196,17 +196,19 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer new Promise((resolve) => { convertHeicImage(file, { onSuccess: (convertedFile) => { - if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { + if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { convertedFilesToResize.push(convertedFile); resolve(); + return; } - if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { collectedErrors.current.push({ error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE, isValidatingMultipleFiles: validationState.isValidatingMultipleFiles, }); resolve(); + return; } convertedFiles.push(convertedFile); From 96c29c4b70d975e730f6667fe302f1b4e7a7a707 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Mar 2026 13:04:07 +0100 Subject: [PATCH 44/44] fix: failing test --- tests/unit/FileUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index 0c3f38a41042d..1859b8fae032b 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -465,7 +465,7 @@ describe('FileUtils', () => { const mockTranslate = ((path: string) => path) as LocaleContextProps['translate']; it('should return correct error text for IMAGE_DIMENSIONS_TOO_LARGE', () => { - const result = getFileValidationErrorText(mockTranslate, CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE); + const result = getFileValidationErrorText(mockTranslate, {error: CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE}); expect(result.title).toBe('attachmentPicker.attachmentError'); expect(result.reason).toBe('attachmentPicker.imageDimensionsTooLarge');