diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f2d66c6656c65..251ebe3d90f51 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2325,16 +2325,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 fc3506f970088..6f0e6437d0df2 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -7,18 +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, - normalizeFileObject, - resizeImageIfNeeded, - splitExtensionFromFileName, - validateAttachment, - validateImageForCorruption, -} from '@libs/fileDownload/FileUtils'; -import type {ValidateAttachmentOptions} 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'; @@ -39,12 +31,14 @@ 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(); const [isValidatingFiles, setIsValidatingFiles] = useState(false); - const [isValidatingReceipts, setIsValidatingReceipts] = useState(); + const [isValidatingReceipt, setIsValidatingReceipt] = useState(); const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); @@ -82,9 +76,9 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer }); }; - const resetValidationState = () => { + const reset = () => { setIsValidatingFiles(false); - setIsValidatingReceipts(undefined); + setIsValidatingReceipt(undefined); setIsValidatingMultipleFiles(false); setIsErrorModalVisible(false); setPdfFilesToRender([]); @@ -106,7 +100,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer setIsErrorModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { - resetValidationState(); + reset(); }); }; @@ -115,48 +109,6 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer 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 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; @@ -185,12 +137,11 @@ 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(); } }; - const validateAndResizeFiles = (files: FileObject[], items: DataTransferItem[], validationOptions?: ValidationOptions) => { - // Early return for empty files + async function validateAndResizeFiles(files: FileObject[], items: DataTransferItem[], validationOptions?: ValidationOptions) { if (files.length === 0) { return; } @@ -202,110 +153,136 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer originalFileOrder.current.set(file.uri ?? '', index); } - 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) => convertHeicImageToJpegPromise(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}); - } - } - } - setIsLoaderVisible(false); - return Promise.resolve({processedFiles, pdfsToLoad}); - }); - } + const isValidatingReceipts = validationOptions?.isValidatingReceipts ?? isValidatingReceipt; - // No resizing needed, just return the converted images - setIsLoaderVisible(false); - return Promise.resolve({processedFiles: convertedImages, pdfsToLoad}); - }); + 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), isValidatingReceipts); + + if (result.isValid) { + if (Str.isPDF(result.file.name ?? '')) { + pdfsToLoad.push(result.file); + } else { + validNonPdfFiles.push(result.file); + } + 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); - 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}); - }); + if (result.error === CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE && isImageFile(file) && isValidatingReceipts) { + filesToResize.push(file); + return; } - // 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); + if (result.error === CONST.FILE_VALIDATION_ERRORS.HEIC_OR_HEIF_IMAGE) { + filesToConvert.push(file); + return; + } + + const errorData = { + error: result.error, + fileExtension: result.error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE ? splitExtensionFromFileName(file.name ?? '').fileExtension : undefined, + }; + 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 (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}); + resolve(); + } + + convertedFiles.push(convertedFile); + resolve(); + }, + onError: () => { + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); + resolve(); + }, + }); + }), + ), + ); + + filesToResize.push(...convertedFilesToResize); + validNonPdfFiles.push(...convertedFiles); + + for (const [index, convertedFile] of convertedFiles.entries()) { + updateFileOrderMapping(filesToConvert.at(index), convertedFile); + } + } + + if (filesToResize.length > 0) { + setIsLoaderVisible(true); + + const toResizeResults = await Promise.allSettled(filesToResize.map((file) => resizeImageIfNeeded(file))); + + for (const [index, result] of toResizeResults.entries()) { + if (result.status === 'fulfilled') { + const value = result.value; + validNonPdfFiles.push(value); + updateFileOrderMapping(filesToResize.at(index), value); } else { - if (processedFiles.length > 0) { - setValidFilesToUpload(processedFiles); + 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}); } + } + } + } - 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(); - } + 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 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 (validNonPdfFiles.length > 0) { + const sortedFiles = sortFilesByOriginalOrder(validNonPdfFiles, originalFileOrder.current); + onFilesValidated(sortedFiles, dataTransferItemList.current); + reset(); + } + } const validateFiles = (files: FileObject[], items?: DataTransferItem[], validationOptions?: ValidationOptions) => { if (isValidatingFiles) { @@ -319,11 +296,12 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer ...validationOptions, isValidatingReceipts: validationOptions?.isValidatingReceipts ?? DEFAULT_IS_VALIDATING_RECEIPTS, }; - setIsValidatingReceipts(validationOptionsWithDefaults.isValidatingReceipts); + setIsValidatingReceipt(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) { @@ -359,14 +337,14 @@ 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 (isValidatingReceipts === false && fileError) { + if (isValidatingReceipt === false && fileError) { setIsErrorModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { if (sortedFiles.length !== 0) { onFilesValidated(sortedFiles, dataTransferItemList.current); } - resetValidationState(); + reset(); }); } else { if (sortedFiles.length !== 0) { @@ -389,7 +367,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer }} onPassword={() => { validatedPDFs.current.push(file); - if (isValidatingReceipts === true) { + if (isValidatingReceipt === true) { collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE}); } else { validFiles.current.push(file); @@ -405,12 +383,14 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer )) : undefined; + const fileValidationErrorText = getFileValidationErrorText(translate, fileError, {fileType: invalidFileExtension}, {isValidatingReceipt, isValidatingMultipleFiles}); + const getModalPrompt = () => { if (!fileError) { return ''; } - 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) { + const prompt = fileValidationErrorText.reason; + if (fileError === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE) { return ( {prompt} @@ -423,7 +403,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; -} - -export default validateAttachmentFile; -export type {AttachmentValidationResult}; diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index d8ba834cf292f..d2aba69705807 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 = (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; @@ -654,48 +654,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); - }); -}; - -type ValidateAttachmentOptions = { - isValidatingReceipts?: boolean; - isValidatingMultipleFiles?: 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 ''; + 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 = { @@ -704,11 +674,16 @@ type TranslationAdditionalData = { fileType?: string; }; +type GetFileValidationErrorTextOptions = { + isValidatingReceipt?: boolean; + isValidatingMultipleFiles?: boolean; +}; + const getFileValidationErrorText = ( translate: LocalizedTranslate, validationError: ValueOf | null, additionalData: TranslationAdditionalData = {}, - isValidatingReceipt = false, + options: GetFileValidationErrorTextOptions = {}, ): { title: string; reason: string; @@ -719,49 +694,57 @@ const getFileValidationErrorText = ( reason: '', }; } - const maxSize = isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; + const maxSize = options.isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; + + if (options.isValidatingMultipleFiles) { + switch (validationError) { + case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: + return { + title: translate('attachmentPicker.someFilesCantBeUploaded'), + reason: translate('attachmentPicker.unsupportedFileType', additionalData.fileType ?? ''), + }; + 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.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) { 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: isValidatingReceipt + reason: options.isValidatingReceipt ? translate('attachmentPicker.sizeExceededWithLimit', { maxUploadSizeInMB: 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', { - maxUploadSizeInMB: 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'), @@ -778,21 +761,13 @@ const getFileValidationErrorText = ( reason: translate('attachmentPicker.imageDimensionsTooLarge'), }; default: - return { - title: translate('attachmentPicker.attachmentError'), - reason: translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'), - }; + break; } -}; -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); + return { + title: translate('attachmentPicker.attachmentError'), + reason: translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'), + }; }; const MAX_CANVAS_SIZE = 4096; @@ -906,16 +881,12 @@ export { resizeImageIfNeeded, createFile, validateReceipt, - validateAttachment, normalizeFileObject, isValidReceiptExtension, getFileValidationErrorText, hasHeicOrHeifExtension, - getConfirmModalPrompt, canvasFallback, getFilesFromClipboardEvent, cleanFileObject, cleanFileObjectName, }; - -export type {ValidateAttachmentOptions}; diff --git a/src/libs/validateAttachmentFile.ts b/src/libs/validateAttachmentFile.ts new file mode 100644 index 0000000000000..f46fab1eb982b --- /dev/null +++ b/src/libs/validateAttachmentFile.ts @@ -0,0 +1,91 @@ +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 430ee6f7eccb5..3c5d9a94e4ec9 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -94,9 +94,6 @@ 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 6f667e47fc4f6..31606deb2f2e1 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx @@ -4,11 +4,10 @@ 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'; @@ -84,39 +83,27 @@ function ReportAddAttachmentModalContent({route, navigation}: AttachmentModalScr const [validFiles, setValidFiles] = useState(fileParam); useEffect(() => { - 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); + async function validateFiles() { + if (!fileParam) { return; } - if (!result.isValid) { + 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) { return; } - setSource(result.source); - setValidFiles(result.file); - } + const validatedFiles = validResults.map((r) => r.file); + const firstValidSource = validResults.at(0)?.file.uri; - if (Array.isArray(fileParam)) { - Promise.all(fileParam.map((f) => validateAttachmentFile(f))).then(updateState); - return; + setSource(firstValidSource); + setValidFiles(validatedFiles); } - validateAttachmentFile(fileParam).then(updateState); + validateFiles(); }, [fileParam]); const modalType = useReportAttachmentModalType(source, validFiles); diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index fd583d42c73f5..0c3f38a41042d 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -9,18 +9,12 @@ 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', () => { @@ -104,56 +98,6 @@ 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 = { diff --git a/tests/unit/ValidateAttachmentFileTest.ts b/tests/unit/ValidateAttachmentFileTest.ts new file mode 100644 index 0000000000000..ce3f393cae4fd --- /dev/null +++ b/tests/unit/ValidateAttachmentFileTest.ts @@ -0,0 +1,328 @@ +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(); + } + }); + }); +});