From 33841b8bc8bab27a04b158b6272ecf688aac4a76 Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Thu, 19 Mar 2026 11:45:37 +0200 Subject: [PATCH] Revert "refactor: Improve and simplify attachment and file validation" --- src/CONST/index.ts | 8 +- src/hooks/useFilesValidation.tsx | 350 ++++++++++-------- src/libs/AttachmentUtils.ts | 106 +++++- src/libs/fileDownload/FileUtils.ts | 138 ++++--- src/libs/validateAttachmentFile.ts | 91 ----- .../IOURequestStepOdometerImage/index.tsx | 3 + .../ReportAddAttachmentModalContent/index.tsx | 39 +- tests/unit/FileUtilsTest.ts | 58 ++- tests/unit/ValidateAttachmentFileTest.ts | 328 ---------------- 9 files changed, 466 insertions(+), 655 deletions(-) delete mode 100644 src/libs/validateAttachmentFile.ts delete mode 100644 tests/unit/ValidateAttachmentFileTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 77403685225b4..69e24ca5fb821 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2359,16 +2359,16 @@ const CONST = { }, FILE_VALIDATION_ERRORS: { - FILE_INVALID: 'fileInvalid', 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', - 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/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 4ed4903692eca..b55e59acc470e 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -7,11 +7,18 @@ import {useFullScreenLoaderActions} from '@components/FullScreenLoaderContext'; 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 { + getFileValidationErrorText, + hasHeicOrHeifExtension, + normalizeFileObject, + resizeImageIfNeeded, + splitExtensionFromFileName, + validateAttachment, + validateImageForCorruption, +} from '@libs/fileDownload/FileUtils'; +import type {ValidateAttachmentOptions} 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'; @@ -19,21 +26,19 @@ import useThemeStyles from './useThemeStyles'; const DEFAULT_IS_VALIDATING_RECEIPTS = true; -type ValidationOptions = { - isValidatingReceipts?: boolean; +type ErrorObject = { + error: ValueOf; + fileExtension?: string; }; -type ValidationState = { - isValidatingReceipts: boolean; - isValidatingMultipleFiles: boolean; +type ValidationOptions = { + isValidatingReceipts?: boolean; }; const sortFilesByOriginalOrder = (files: FileObject[], orderMap: Map) => { return files.sort((a, b) => (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(); @@ -43,10 +48,11 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer 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([] as FileObject[]); - const [errorQueue, setErrorQueue] = useState([]); + const [invalidFileExtension, setInvalidFileExtension] = useState(''); + const [errorQueue, setErrorQueue] = useState([]); const [currentErrorIndex, setCurrentErrorIndex] = useState(0); const {setIsLoaderVisible} = useFullScreenLoaderActions(); @@ -54,7 +60,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 +70,10 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer } }; - const deduplicateErrors = (errors: FileValidationError[]) => { + const deduplicateErrors = (errors: ErrorObject[]) => { const uniqueErrors = new Set(); return errors.filter((error) => { - const key = `${error.error}-${error.fileType ?? ''}`; + const key = `${error.error}-${error.fileExtension ?? ''}`; if (uniqueErrors.has(key)) { return false; } @@ -76,14 +82,16 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer }); }; - const reset = () => { + const resetValidationState = () => { setIsValidatingFiles(false); setIsValidatingReceipts(undefined); + setIsValidatingMultipleFiles(false); setIsErrorModalVisible(false); setPdfFilesToRender([]); setIsLoaderVisible(false); setValidFilesToUpload([]); setFileError(null); + setInvalidFileExtension(''); setErrorQueue([]); setCurrentErrorIndex(0); validatedPDFs.current = []; @@ -98,15 +106,58 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer setIsErrorModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { - reset(); + resetValidationState(); }); }; const setErrorAndOpenModal = (error: ValueOf) => { - setFileError({error, isValidatingMultipleFiles}); + setFileError(error); setIsErrorModalVisible(true); }; + const isValidFile = (originalFile: FileObject, item: DataTransferItem | undefined, validationOptions: ValidateAttachmentOptions) => { + 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, validationOptions); + 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 convertHeicToProcessableImagePromise = (file: FileObject): Promise => { + return new Promise((resolve) => { + convertHeicImage(file, { + onSuccess: (convertedFile) => resolve(convertedFile), + onError: (_error, originalFile) => { + Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name}); + resolve(originalFile); + }, + }); + }); + }; + const checkIfAllValidatedAndProceed = () => { if (!validatedPDFs.current || !validFiles.current) { return; @@ -126,17 +177,21 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer setCurrentErrorIndex(0); const firstError = uniqueErrors.at(0); if (firstError) { - setFileError(firstError); + setFileError(firstError.error); + if (firstError.fileExtension) { + setInvalidFileExtension(firstError.fileExtension); + } setIsErrorModalVisible(true); } } else if (validFiles.current.length > 0) { const sortedFiles = sortFilesByOriginalOrder(validFiles.current, originalFileOrder.current); onFilesValidated(sortedFiles, dataTransferItemList.current); - reset(); + resetValidationState(); } }; - async function validateAndResizeFiles(files: FileObject[], items: DataTransferItem[], validationState: ValidationState) { + const validateAndResizeFiles = (files: FileObject[], items: DataTransferItem[], validationOptions?: ValidationOptions) => { + // Early return for empty files if (files.length === 0) { return; } @@ -148,138 +203,110 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer originalFileOrder.current.set(file.uri ?? '', index); } - 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), validationState.isValidatingReceipts); - - if (result.isValid) { - 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) && validationState.isValidatingReceipts) { - filesToResize.push(file); - return; - } - - if (result.error === CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE) { - filesToConvert.push(file); - return; - } - - const errorData = { - error: result.error, - isValidatingMultipleFiles: validationState.isValidatingMultipleFiles, - fileType: result.error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE ? splitExtensionFromFileName(file.name ?? '').fileExtension : undefined, - } satisfies FileValidationError; - collectedErrors.current.push(errorData); - }), - ); - - if (filesToConvert.length > 0) { - setIsLoaderVisible(true); - - const convertedFilesToResize: FileObject[] = []; - const convertedFiles: FileObject[] = []; - await Promise.all( - filesToConvert.map( - (file) => - new Promise((resolve) => { - convertHeicImage(file, { - onSuccess: (convertedFile) => { - 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) { - collectedErrors.current.push({ - error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE, - isValidatingMultipleFiles: validationState.isValidatingMultipleFiles, - }); - resolve(); - return; + Promise.all( + files.map((file, index) => + isValidFile(file, items.at(index), {isValidatingMultipleFiles: files.length > 1, isValidatingReceipts: validationOptions?.isValidatingReceipts ?? isValidatingReceipts}).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 ?? '')); + + // Check if we need to convert images + if (otherFiles.some((file) => hasHeicOrHeifExtension(file))) { + setIsLoaderVisible(true); + + return Promise.all(otherFiles.map((file) => convertHeicToProcessableImagePromise(file))).then((convertedImages) => { + for (const [index, convertedFile] of convertedImages.entries()) { + updateFileOrderMapping(otherFiles.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.allSettled(convertedImages.map((file) => resizeImageIfNeeded(file))).then((results) => { + 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}); + } } - - convertedFiles.push(convertedFile); - resolve(); - }, - onError: () => { - Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name}); - convertedFiles.push(file); - resolve(); - }, + } + setIsLoaderVisible(false); + return Promise.resolve({processedFiles, pdfsToLoad}); }); - }), - ), - ); - - filesToResize.push(...convertedFilesToResize); - validNonPdfFiles.push(...convertedFiles); - - for (const [index, convertedFile] of convertedFiles.entries()) { - updateFileOrderMapping(filesToConvert.at(index), convertedFile); - } - } + } - if (filesToResize.length > 0) { - setIsLoaderVisible(true); + // No resizing needed, just return the converted images + setIsLoaderVisible(false); + return Promise.resolve({processedFiles: convertedImages, pdfsToLoad}); + }); + } - const toResizeResults = await Promise.allSettled(filesToResize.map((file) => resizeImageIfNeeded(file))); + // 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.allSettled(otherFiles.map((file) => resizeImageIfNeeded(file))).then((results) => { + const processedFiles: FileObject[] = []; + for (const [index, result] of results.entries()) { + if (result.status === 'fulfilled') { + processedFiles.push(result.value); + updateFileOrderMapping(otherFiles.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); + return Promise.resolve({processedFiles, pdfsToLoad}); + }); + } - for (const [index, result] of toResizeResults.entries()) { - if (result.status === 'fulfilled') { - const value = result.value; - validNonPdfFiles.push(value); - updateFileOrderMapping(filesToResize.at(index), value); + // 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 { - 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, isValidatingMultipleFiles}); - } else { - collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED, isValidatingMultipleFiles}); + if (processedFiles.length > 0) { + setValidFilesToUpload(processedFiles); } - } - } - } - - setIsLoaderVisible(false); - - if (pdfsToLoad.length) { - validFiles.current = validNonPdfFiles; - setPdfFilesToRender(pdfsToLoad); - return; - } - - 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 FileValidationError); - setErrorQueue(uniqueErrors); - setCurrentErrorIndex(0); - const firstError = uniqueErrors.at(0); - if (firstError) { - setFileError(firstError); - setIsErrorModalVisible(true); - } - } else if (validNonPdfFiles.length > 0) { - const sortedFiles = sortFilesByOriginalOrder(validNonPdfFiles, originalFileOrder.current); - onFilesValidated(sortedFiles, dataTransferItemList.current); - reset(); - } - } + 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 validateFiles = (files: FileObject[], items?: DataTransferItem[], validationOptions?: ValidationOptions) => { if (isValidatingFiles) { @@ -289,13 +316,15 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer setIsValidatingFiles(true); - const validationState: ValidationState = { + const validationOptionsWithDefaults = { + ...validationOptions, isValidatingReceipts: validationOptions?.isValidatingReceipts ?? DEFAULT_IS_VALIDATING_RECEIPTS, - isValidatingMultipleFiles: files.length > 1, }; - setIsValidatingReceipts(validationState.isValidatingReceipts); - setIsValidatingMultipleFiles(validationState.isValidatingMultipleFiles); + setIsValidatingReceipts(validationOptionsWithDefaults.isValidatingReceipts); + if (files.length > 1) { + setIsValidatingMultipleFiles(true); + } if (files.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { filesToValidate.current = files.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); if (items) { @@ -303,18 +332,14 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer } setErrorAndOpenModal(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); } else { - validateAndResizeFiles(files, items ?? [], validationState); + validateAndResizeFiles(files, items ?? [], validationOptionsWithDefaults); } }; const onConfirmError = () => { - if (fileError?.error === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { + if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { setIsErrorModalVisible(false); - const validationState: ValidationState = { - isValidatingReceipts: isValidatingReceipts ?? false, - isValidatingMultipleFiles, - }; - validateAndResizeFiles(filesToValidate.current, dataTransferItemList.current, validationState); + validateAndResizeFiles(filesToValidate.current, dataTransferItemList.current); return; } @@ -326,7 +351,8 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer setIsValidatingMultipleFiles(false); } setCurrentErrorIndex(nextIndex); - setFileError(nextError); + setFileError(nextError.error); + setInvalidFileExtension(nextError.fileExtension ?? ''); return; } } @@ -341,7 +367,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer if (sortedFiles.length !== 0) { onFilesValidated(sortedFiles, dataTransferItemList.current); } - reset(); + resetValidationState(); }); } else { if (sortedFiles.length !== 0) { @@ -380,14 +406,12 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer )) : undefined; - const fileValidationErrorText = getFileValidationErrorText(translate, fileError, {isValidatingReceipt: isValidatingReceipts}); - const getModalPrompt = () => { if (!fileError) { return ''; } - const prompt = fileValidationErrorText.reason; - if (fileError.error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE) { + const prompt = getFileValidationErrorText(translate, fileError, {fileType: invalidFileExtension}, isValidatingReceipts === true).reason; + if (fileError === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE || fileError === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE) { return ( {prompt} @@ -400,7 +424,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer const ErrorModal = ( { + if (!file || !isDirectoryCheck(file)) { + return Promise.resolve({isValid: false, error: 'fileDoesNotExist'}); + } + + let fileObject = file; + const fileConverted = file.getAsFile?.(); + if (fileConverted) { + fileObject = fileConverted; + } + 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; +} /** * Returns image cache file extension based from mime type @@ -8,5 +110,5 @@ function getImageCacheFileExtension(contentType: string) { return imageCacheFileTypes[contentType] ?? ''; } -// eslint-disable-next-line import/prefer-default-export -export {getImageCacheFileExtension}; +export {validateAttachmentFile, getImageCacheFileExtension}; +export type {AttachmentValidationResult}; diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 385f429b2f31f..9c6fb7417a196 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -644,9 +644,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 = async (file: FileObject): Promise => { +const normalizeFileObject = (file: FileObject): Promise => { if (file instanceof File || file instanceof Blob) { - return file; + return Promise.resolve(file); } const isAndroidNative = getPlatform() === CONST.PLATFORM.ANDROID; @@ -654,34 +654,61 @@ const normalizeFileObject = async (file: FileObject): Promise => { const isNativePlatform = isAndroidNative || isIOSNative; if (!isNativePlatform || 'size' in file) { - return file; + return Promise.resolve(file); } if (typeof file.uri !== 'string') { - return file; + return Promise.resolve(file); } - 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}); + 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); + }); }; -type FileValidationError = { - error: ValueOf; +type ValidateAttachmentOptions = { + isValidatingReceipts?: boolean; isValidatingMultipleFiles?: boolean; - fileType?: string; }; -type GetFileValidationErrorTextOptions = { - isValidatingReceipt?: boolean; +const validateAttachment = (file: FileObject, validationOptions?: ValidateAttachmentOptions) => { + const maxFileSize = validationOptions?.isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; + + if (validationOptions?.isValidatingReceipts && !isValidReceiptExtension(file)) { + return validationOptions?.isValidatingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE; + } + + // Images are exempt from file size check since they will be resized + if (!Str.isImage(file.name ?? '') && !hasHeicOrHeifExtension(file) && (file?.size ?? 0) > maxFileSize) { + return validationOptions?.isValidatingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; + } + + if (validationOptions?.isValidatingReceipts && (file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL; + } + + return ''; +}; + +type TranslationAdditionalData = { + maxUploadSizeInMB?: number; + fileLimit?: number; + fileType?: string; }; const getFileValidationErrorText = ( translate: LocalizedTranslate, - validationError: FileValidationError | null, - options: GetFileValidationErrorTextOptions = {}, + validationError: ValueOf | null, + additionalData: TranslationAdditionalData = {}, + isValidatingReceipt = false, ): { title: string; reason: string; @@ -692,51 +719,45 @@ const getFileValidationErrorText = ( reason: '', }; } - const maxSize = options.isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; - - if (validationError.isValidatingMultipleFiles) { - switch (validationError.error) { - case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: - return { - title: translate('attachmentPicker.someFilesCantBeUploaded'), - reason: translate('attachmentPicker.unsupportedFileType', validationError.fileType ?? ''), - }; - case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: - return { - title: translate('attachmentPicker.someFilesCantBeUploaded'), - reason: translate('attachmentPicker.sizeLimitExceeded', maxSize / 1024 / 1024), - }; - case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED: - return { - title: translate('attachmentPicker.attachmentError'), - reason: translate('attachmentPicker.folderNotAllowedMessage'), - }; - case CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED: - return { - title: translate('attachmentPicker.someFilesCantBeUploaded'), - reason: translate('attachmentPicker.maxFileLimitExceeded'), - }; - default: - break; - } - } - - switch (validationError.error) { + 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: return { title: translate('attachmentPicker.wrongFileType'), reason: translate('attachmentPicker.notAllowedExtension'), }; + case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE: + return { + title: translate('attachmentPicker.someFilesCantBeUploaded'), + reason: translate('attachmentPicker.unsupportedFileType', additionalData.fileType ?? ''), + }; case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: return { title: translate('attachmentPicker.attachmentTooLarge'), - reason: options.isValidatingReceipt ? translate('attachmentPicker.sizeExceededWithLimit', maxSize / 1024 / 1024) : translate('attachmentPicker.sizeExceeded'), + reason: isValidatingReceipt + ? translate('attachmentPicker.sizeExceededWithLimit', additionalData.maxUploadSizeInMB ?? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / 1024 / 1024) + : translate('attachmentPicker.sizeExceeded'), + }; + case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE: + return { + title: translate('attachmentPicker.someFilesCantBeUploaded'), + reason: translate('attachmentPicker.sizeLimitExceeded', additionalData.maxUploadSizeInMB ?? maxSize / 1024 / 1024), }; case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL: return { title: translate('attachmentPicker.attachmentTooSmall'), reason: translate('attachmentPicker.sizeNotMet'), }; + case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED: + return { + title: translate('attachmentPicker.attachmentError'), + reason: translate('attachmentPicker.folderNotAllowedMessage'), + }; + case CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED: + return { + title: translate('attachmentPicker.someFilesCantBeUploaded'), + reason: translate('attachmentPicker.maxFileLimitExceeded'), + }; case CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED: return { title: translate('attachmentPicker.attachmentError'), @@ -753,13 +774,21 @@ const getFileValidationErrorText = ( reason: translate('attachmentPicker.imageDimensionsTooLarge'), }; default: - break; + return { + title: translate('attachmentPicker.attachmentError'), + reason: translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'), + }; } +}; - return { - title: translate('attachmentPicker.attachmentError'), - reason: translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'), - }; +const getConfirmModalPrompt = (translate: LocalizedTranslate, attachmentInvalidReason: TranslationPaths | undefined) => { + if (!attachmentInvalidReason) { + return ''; + } + if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') { + return translate(attachmentInvalidReason, CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)); + } + return translate(attachmentInvalidReason); }; const MAX_CANVAS_SIZE = 4096; @@ -873,13 +902,16 @@ export { resizeImageIfNeeded, createFile, validateReceipt, + validateAttachment, normalizeFileObject, isValidReceiptExtension, getFileValidationErrorText, hasHeicOrHeifExtension, + getConfirmModalPrompt, canvasFallback, getFilesFromClipboardEvent, cleanFileObject, cleanFileObjectName, }; -export type {FileValidationError}; + +export type {ValidateAttachmentOptions}; diff --git a/src/libs/validateAttachmentFile.ts b/src/libs/validateAttachmentFile.ts deleted file mode 100644 index f46fab1eb982b..0000000000000 --- a/src/libs/validateAttachmentFile.ts +++ /dev/null @@ -1,91 +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 ValidateAttachmentValidResult = { - isValid: true; - file: FileObject; -}; - -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 {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID}; - } - - if (isValidatingReceipts && !isValidReceiptExtension(file)) { - return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE}; - } - - if (hasHeicOrHeifExtension(file)) { - 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 {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE}; - } - - if (isValidatingReceipts && file.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL}; - } - - let fileObject = file; - const fileConverted = file.getAsFile?.(); - if (fileConverted) { - fileObject = fileConverted; - } - - if (!fileObject) { - return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FILE_INVALID}; - } - - if (isDataTransferItemDirectory(item)) { - return {isValid: false, error: CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED}; - } - - const normalizedFile = await normalizeFileObject(fileObject); - try { - await validateImageForCorruption(normalizedFile); - } catch (error) { - 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 {isValid: true, file: normalizedFile}; -} - -function isDataTransferItemDirectory(item: DataTransferItem | undefined) { - if (item && item.kind === 'file' && 'webkitGetAsEntry' in item && item.webkitGetAsEntry()?.isDirectory) { - return true; - } - - return false; -} - -export default validateAttachmentFile; -export type {ValidateAttachmentResult, ValidateAttachmentValidResult, ValidateAttachmentInvalidResult}; diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx index 3c5d9a94e4ec9..430ee6f7eccb5 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -94,6 +94,9 @@ function IOURequestStepOdometerImage({ }; const {validateFiles, ErrorModal} = useFilesValidation((files: FileObject[]) => { + if (files.length === 0) { + return; + } const file = files.at(0); if (!file) { return; diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx index 31606deb2f2e1..7e6feeb49063a 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx @@ -4,10 +4,11 @@ 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'; -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'; @@ -83,27 +84,39 @@ function ReportAddAttachmentModalContent({route, navigation}: AttachmentModalScr const [validFiles, setValidFiles] = useState(fileParam); useEffect(() => { - async function validateFiles() { - if (!fileParam) { + if (!fileParam) { + return; + } + + function updateState(result: AttachmentValidationResult | AttachmentValidationResult[]) { + if (Array.isArray(result)) { + const validResults = result.filter((r) => r.isValid); + if (validResults.length === 0) { + return; + } + + const validatedFiles = validResults.map((r) => r.file); + const firstValidSource = validResults.at(0)?.source; + + setSource(firstValidSource); + setValidFiles(validatedFiles); return; } - const files = Array.isArray(fileParam) ? fileParam : [fileParam]; - const results = await Promise.all(files.map(async (file) => validateAttachmentFile(file))); - - const validResults = results.filter((r) => r.isValid); - if (validResults.length === 0) { + if (!result.isValid) { return; } - const validatedFiles = validResults.map((r) => r.file); - const firstValidSource = validResults.at(0)?.file.uri; + setSource(result.source); + setValidFiles(result.file); + } - setSource(firstValidSource); - setValidFiles(validatedFiles); + if (Array.isArray(fileParam)) { + Promise.all(fileParam.map((f) => validateAttachmentFile(f))).then(updateState); + return; } - validateFiles(); + validateAttachmentFile(fileParam).then(updateState); }, [fileParam]); const modalType = useReportAttachmentModalType(source, validFiles); diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index 1859b8fae032b..fd583d42c73f5 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -9,12 +9,18 @@ import { getFileValidationErrorText, getImageDimensionsAfterResize, splitExtensionFromFileName, + validateAttachment, } from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; jest.useFakeTimers(); jest.mock('react-native-image-size'); +const createMockFile = (name: string, size: number) => ({ + name, + size, +}); + const createFileNameFromLength = ({length, extension}: {length: number; extension?: string | undefined}): string => `${'a'.repeat(length)}${extension ? `.${extension}` : ''}`; describe('FileUtils', () => { @@ -98,6 +104,56 @@ describe('FileUtils', () => { }); }); + 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 = validateAttachment(file, {isValidatingMultipleFiles: false, isValidatingReceipts: 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 = validateAttachment(file, {isValidatingMultipleFiles: false, isValidatingReceipts: 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 = 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 = validateAttachment(file, {isValidatingMultipleFiles: true, isValidatingReceipts: false}); + 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 = validateAttachment(file, {isValidatingMultipleFiles: false, isValidatingReceipts: true}); + expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); + }); + + it('should prioritize WRONG_FILE_TYPE over FILE_TOO_LARGE for receipts', () => { + const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); + const error = validateAttachment(file, {isValidatingMultipleFiles: false, isValidatingReceipts: true}); + expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.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 = validateAttachment(file, {isValidatingMultipleFiles: true, isValidatingReceipts: true}); + expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE); + }); + + it('should return empty string for valid image receipt', () => { + const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); + const error = validateAttachment(file, {isValidatingMultipleFiles: false, isValidatingReceipts: true}); + expect(error).toBe(''); + }); + }); + describe('canvasFallback', () => { const mockCreateImageBitmap = jest.fn(); const mockCanvas = { @@ -465,7 +521,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, {error: CONST.FILE_VALIDATION_ERRORS.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'); diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts deleted file mode 100644 index ce3f393cae4fd..0000000000000 --- a/tests/unit/ValidateAttachmentFileTest.ts +++ /dev/null @@ -1,328 +0,0 @@ -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(), - }; -}); - -const mockFileUtils = FileUtils as jest.Mocked; - -const createMockFile = (name: string, size: number): FileObject => ({ - name, - size, -}); - -describe('validateAttachmentFile', () => { - beforeEach(() => { - jest.clearAllMocks(); - // Default: pass-through so async validation succeeds - mockFileUtils.normalizeFileObject.mockImplementation(async (file) => file); - mockFileUtils.validateImageForCorruption.mockResolvedValue(undefined); - }); - - describe('FILE_INVALID', () => { - it('returns invalid result with FILE_INVALID when file has no name', async () => { - const file = createMockFile('', 100); - const error = await validateAttachmentFile(file, undefined, true); - - if (error.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); - }); - - 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); - - if (error.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_INVALID); - }); - - 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); - - 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 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); - - 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 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); - - 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 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.isValid).toBe(true); - }); - }); - - describe('HEIC_OR_HEIF_IMAGE', () => { - 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); - - 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 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); - - 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 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); - - 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 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); - - 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 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.isValid).toBe(true); - }); - - 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.isValid).toBe(true); - }); - }); - - describe('FILE_TOO_SMALL', () => { - 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); - - if (error.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); - }); - - 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.isValid).toBe(true); - }); - - 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.isValid).toBe(true); - }); - }); - - describe('FOLDER_NOT_ALLOWED', () => { - it('returns invalid result with 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); - - if (error.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); - }); - - it('returns valid result when DataTransferItem 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.isValid).toBe(true); - }); - }); - - describe('FILE_CORRUPTED', () => { - 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); - - if (error.isValid) { - throw new Error('validateAttachmentFile should return an invalid result'); - } - - expect(error.error).toEqual(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); - }); - }); - - 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.isValid).toBe(true); - }); - - 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.isValid).toBe(true); - }); - - 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.isValid).toBe(true); - }); - - 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.isValid).toBe(true); - }); - - 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.isValid).toBe(true); - }); - - 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.isValid).toBe(true); - }); - - 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.isValid).toBe(true); - }); - - 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.isValid).toBe(true); - }); - - 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.isValid).toBe(true); - }); - - 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.isValid).toBe(true); - }); - - it('returns valid result when file has getAsFile and uses converted file', async () => { - // 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(); - } - }); - }); -});