diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts deleted file mode 100644 index cac27cf99..000000000 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { useSanitizeOperation } from './useSanitizeOperation'; - -// Mock useTranslation -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, fallback?: string, options?: any) => { - if (key === 'sanitize.error' && options?.error) { - return `Sanitization failed: ${options.error}`; - } - if (key === 'error.noFilesSelected') { - return 'No files selected'; - } - if (key === 'sanitize.error.generic') { - return 'Sanitization failed'; - } - return fallback || key; - } - }) -})); - -// Mock FileContext -vi.mock('../../../contexts/FileContext', () => ({ - useFileContext: () => ({ - recordOperation: vi.fn(), - markOperationApplied: vi.fn(), - markOperationFailed: vi.fn(), - addFiles: vi.fn() - }) -})); - -// Mock fetch -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - -// Mock URL.createObjectURL and revokeObjectURL -const mockCreateObjectURL = vi.fn(() => 'mock-blob-url'); -const mockRevokeObjectURL = vi.fn(); -vi.stubGlobal('URL', { - createObjectURL: mockCreateObjectURL, - revokeObjectURL: mockRevokeObjectURL -}); - -describe('useSanitizeOperation', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - test('should initialize with default state', () => { - const { result } = renderHook(() => useSanitizeOperation()); - - expect(result.current.isLoading).toBe(false); - expect(result.current.errorMessage).toBe(null); - expect(result.current.downloadUrl).toBe(null); - expect(result.current.status).toBe(null); - }); - - test('should execute sanitization operation successfully', async () => { - const mockBlob = new Blob(['test'], { type: 'application/pdf' }); - const mockResponse = { - ok: true, - blob: () => Promise.resolve(mockBlob) - }; - mockFetch.mockResolvedValueOnce(mockResponse); - - const { result } = renderHook(() => useSanitizeOperation()); - - const parameters = { - removeJavaScript: true, - removeEmbeddedFiles: false, - removeXMPMetadata: true, - removeMetadata: false, - removeLinks: false, - removeFonts: false - }; - - const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); - - await act(async () => { - await result.current.executeOperation(parameters, [testFile]); - }); - - expect(mockFetch).toHaveBeenCalledWith('/api/v1/security/sanitize-pdf', { - method: 'POST', - body: expect.any(FormData) - }); - - expect(result.current.isLoading).toBe(false); - expect(result.current.downloadUrl).toBe('mock-blob-url'); - expect(result.current.status).toBe('Sanitization completed successfully'); - expect(result.current.errorMessage).toBe(null); - }); - - test('should handle API errors correctly', async () => { - const mockResponse = { - ok: false, - text: () => Promise.resolve('Server error') - }; - mockFetch.mockResolvedValueOnce(mockResponse); - - const { result } = renderHook(() => useSanitizeOperation()); - - const parameters = { - removeJavaScript: true, - removeEmbeddedFiles: true, - removeXMPMetadata: false, - removeMetadata: false, - removeLinks: false, - removeFonts: false - }; - - const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); - - await act(async () => { - await expect(result.current.executeOperation(parameters, [testFile])) - .rejects.toThrow('Failed to sanitize all files: test.pdf'); - }); - - expect(result.current.isLoading).toBe(false); - expect(result.current.errorMessage).toBe('Failed to sanitize all files: test.pdf'); - expect(result.current.downloadUrl).toBe(null); - expect(result.current.status).toBe(null); - }); - - test('should handle no files selected error', async () => { - const { result } = renderHook(() => useSanitizeOperation()); - - const parameters = { - removeJavaScript: true, - removeEmbeddedFiles: true, - removeXMPMetadata: false, - removeMetadata: false, - removeLinks: false, - removeFonts: false - }; - - await act(async () => { - await expect(result.current.executeOperation(parameters, [])) - .rejects.toThrow('No files selected'); - }); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - test('should send correct form data to API', async () => { - const mockBlob = new Blob(['test'], { type: 'application/pdf' }); - const mockResponse = { - ok: true, - blob: () => Promise.resolve(mockBlob) - }; - mockFetch.mockResolvedValueOnce(mockResponse); - - const { result } = renderHook(() => useSanitizeOperation()); - - const parameters = { - removeJavaScript: true, - removeEmbeddedFiles: false, - removeXMPMetadata: true, - removeMetadata: false, - removeLinks: true, - removeFonts: false - }; - - const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); - - await act(async () => { - await result.current.executeOperation(parameters, [testFile]); - }); - - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('/api/v1/security/sanitize-pdf'); - expect(options.method).toBe('POST'); - - const formData = options.body as FormData; - expect(formData.get('removeJavaScript')).toBe('true'); - expect(formData.get('removeEmbeddedFiles')).toBe('false'); - expect(formData.get('removeXMPMetadata')).toBe('true'); - expect(formData.get('removeMetadata')).toBe('false'); - expect(formData.get('removeLinks')).toBe('true'); - expect(formData.get('removeFonts')).toBe('false'); - expect(formData.get('fileInput')).toBe(testFile); - }); - - test('should reset results correctly', () => { - const { result } = renderHook(() => useSanitizeOperation()); - - act(() => { - result.current.resetResults(); - }); - - expect(result.current.downloadUrl).toBe(null); - expect(result.current.errorMessage).toBe(null); - expect(result.current.status).toBe(null); - expect(mockRevokeObjectURL).not.toHaveBeenCalled(); // No URL to revoke initially - }); - - test('should clear error message', async () => { - // Mock a failed API response - const mockResponse = { - ok: false, - text: () => Promise.resolve('API Error') - }; - mockFetch.mockResolvedValueOnce(mockResponse); - - const { result } = renderHook(() => useSanitizeOperation()); - - const parameters = { - removeJavaScript: true, - removeEmbeddedFiles: true, - removeXMPMetadata: false, - removeMetadata: false, - removeLinks: false, - removeFonts: false - }; - - const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); - - // Trigger an API error - await act(async () => { - await expect(result.current.executeOperation(parameters, [testFile])) - .rejects.toThrow('Failed to sanitize all files: test.pdf'); - }); - - expect(result.current.errorMessage).toBe('Failed to sanitize all files: test.pdf'); - - act(() => { - result.current.clearError(); - }); - - expect(result.current.errorMessage).toBe(null); - }); -}); diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts index dab8ff04d..6f77dd47c 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts @@ -1,268 +1,40 @@ -import { useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { useFileContext } from '../../../contexts/FileContext'; -import { FileOperation } from '../../../types/fileContext'; -import { generateThumbnailForFile } from '../../../utils/thumbnailUtils'; -import { zipFileService } from '../../../services/zipFileService'; +import { useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { SanitizeParameters } from './useSanitizeParameters'; +const buildFormData = (parameters: SanitizeParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + + // Add parameters + formData.append('removeJavaScript', parameters.removeJavaScript.toString()); + formData.append('removeEmbeddedFiles', parameters.removeEmbeddedFiles.toString()); + formData.append('removeXMPMetadata', parameters.removeXMPMetadata.toString()); + formData.append('removeMetadata', parameters.removeMetadata.toString()); + formData.append('removeLinks', parameters.removeLinks.toString()); + formData.append('removeFonts', parameters.removeFonts.toString()); + + return formData; +}; + export const useSanitizeOperation = () => { const { t } = useTranslation(); - const { - recordOperation, - markOperationApplied, - markOperationFailed, - addFiles - } = useFileContext(); - const [isLoading, setIsLoading] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const [downloadUrl, setDownloadUrl] = useState(null); - const [downloadFilename, setDownloadFilename] = useState(''); - const [status, setStatus] = useState(null); - const [files, setFiles] = useState([]); - const [thumbnails, setThumbnails] = useState([]); - const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false); - - const createOperation = useCallback(( - parameters: SanitizeParameters, - selectedFiles: File[] - ): { operation: FileOperation; operationId: string; fileId: string } => { - const operationId = `sanitize-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const fileId = selectedFiles[0].name; - - const operation: FileOperation = { - id: operationId, - type: 'sanitize', - timestamp: Date.now(), - fileIds: selectedFiles.map(f => f.name), - status: 'pending', - metadata: { - originalFileName: selectedFiles[0].name, - parameters: { - removeJavaScript: parameters.removeJavaScript, - removeEmbeddedFiles: parameters.removeEmbeddedFiles, - removeXMPMetadata: parameters.removeXMPMetadata, - removeMetadata: parameters.removeMetadata, - removeLinks: parameters.removeLinks, - removeFonts: parameters.removeFonts, - }, - fileSize: selectedFiles[0].size + return useToolOperation({ + operationType: 'sanitize', + endpoint: '/api/v1/security/sanitize-pdf', + buildFormData, + filePrefix: t('sanitize.filenamePrefix', 'sanitized') + '_', + multiFileEndpoint: false, // Individual API calls per file + validateParams: (params) => { + // At least one sanitization option must be selected + const hasAnyOption = Object.values(params).some(value => value === true); + if (!hasAnyOption) { + return { valid: false, errors: [t('sanitize.validation.atLeastOne', 'At least one sanitization option must be selected')] }; } - }; - - return { operation, operationId, fileId }; - }, []); - - const buildFormData = useCallback((parameters: SanitizeParameters, file: File): FormData => { - const formData = new FormData(); - formData.append('fileInput', file); - - // Add parameters - formData.append('removeJavaScript', parameters.removeJavaScript.toString()); - formData.append('removeEmbeddedFiles', parameters.removeEmbeddedFiles.toString()); - formData.append('removeXMPMetadata', parameters.removeXMPMetadata.toString()); - formData.append('removeMetadata', parameters.removeMetadata.toString()); - formData.append('removeLinks', parameters.removeLinks.toString()); - formData.append('removeFonts', parameters.removeFonts.toString()); - - return formData; - }, []); - - const generateSanitizedFileName = (originalFileName: string): string => { - const baseName = originalFileName.replace(/\.[^/.]+$/, ''); - const prefix = t('sanitize.filenamePrefix', 'sanitized'); - return `${prefix}_${baseName}.pdf`; - }; - - const sanitizeFile = useCallback(async ( - file: File, - parameters: SanitizeParameters, - operationId: string, - fileId: string - ): Promise => { - try { - const formData = buildFormData(parameters, file); - - const response = await fetch('/api/v1/security/sanitize-pdf', { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text(); - markOperationFailed(fileId, operationId, errorText); - console.error(`Error sanitizing file ${file.name}:`, errorText); - return null; - } - - const blob = await response.blob(); - const sanitizedFileName = generateSanitizedFileName(file.name); - const sanitizedFile = new File([blob], sanitizedFileName, { type: blob.type }); - - markOperationApplied(fileId, operationId); - return sanitizedFile; - } catch (error) { - console.error(`Error sanitizing file ${file.name}:`, error); - markOperationFailed(fileId, operationId, error instanceof Error ? error.message : 'Unknown error'); - return null; - } - }, [buildFormData, markOperationApplied, markOperationFailed]); - - const createDownloadInfo = useCallback(async (results: File[]): Promise => { - if (results.length === 1) { - const url = window.URL.createObjectURL(results[0]); - setDownloadUrl(url); - setDownloadFilename(results[0].name); - } else { - const zipFilename = `${t('sanitize.filenamePrefix', 'sanitized')}_files.zip`; - const { zipFile } = await zipFileService.createZipFromFiles(results, zipFilename); - const url = window.URL.createObjectURL(zipFile); - setDownloadUrl(url); - setDownloadFilename(zipFilename); - } - }, [t]); - - const generateThumbnailsForResults = useCallback(async (results: File[]): Promise => { - const thumbnails = await Promise.all( - results.map(async (file) => { - try { - const thumbnail = await generateThumbnailForFile(file); - return thumbnail || ''; - } catch (error) { - console.warn(`Failed to generate thumbnail for ${file.name}:`, error); - return ''; - } - }) - ); - - setThumbnails(thumbnails); - }, []); - - const processResults = useCallback(async (results: File[]): Promise => { - setFiles(results); - setIsGeneratingThumbnails(true); - - // Add sanitized files to FileContext for future use - await addFiles(results); - - // Create download info - single file or ZIP - await createDownloadInfo(results); - - // Generate thumbnails - await generateThumbnailsForResults(results); - - setIsGeneratingThumbnails(false); - setStatus(results.length === 1 - ? t('sanitize.completed', 'Sanitization completed successfully') - : t('sanitize.completedMultiple', 'Sanitized {{count}} files successfully', { count: results.length }) - ); - }, [addFiles, createDownloadInfo, generateThumbnailsForResults, t]); - - const executeOperation = useCallback(async ( - parameters: SanitizeParameters, - selectedFiles: File[], - ) => { - if (selectedFiles.length === 0) { - throw new Error(t('error.noFilesSelected', 'No files selected')); - } - - setIsLoading(true); - setErrorMessage(null); - setStatus(selectedFiles.length === 1 - ? t('sanitize.processing', 'Sanitizing PDF...') - : t('sanitize.processingMultiple', 'Sanitizing {{count}} PDFs...', { count: selectedFiles.length }) - ); - - const results: File[] = []; - const failedFiles: string[] = []; - - try { - // Process each file separately - for (let i = 0; i < selectedFiles.length; i++) { - const file = selectedFiles[i]; - const { operation, operationId, fileId } = createOperation(parameters, [file]); - recordOperation(fileId, operation); - - setStatus(selectedFiles.length === 1 - ? t('sanitize.processing', 'Sanitizing PDF...') - : t('sanitize.processingFile', 'Processing file {{current}} of {{total}}: {{filename}}', { - current: i + 1, - total: selectedFiles.length, - filename: file.name - }) - ); - - const sanitizedFile = await sanitizeFile(file, parameters, operationId, fileId); - - if (sanitizedFile) { - results.push(sanitizedFile); - } else { - failedFiles.push(file.name); - } - } - - if (failedFiles.length > 0 && results.length === 0) { - throw new Error(`Failed to sanitize all files: ${failedFiles.join(', ')}`); - } - - if (failedFiles.length > 0) { - setStatus(`Sanitized ${results.length}/${selectedFiles.length} files. Failed: ${failedFiles.join(', ')}`); - } - - if (results.length > 0) { - await processResults(results); - } else { - setErrorMessage(t('sanitize.errorAllFilesFailed', 'All files failed to sanitize')); - } - } catch (error) { - console.error('Error in sanitization operation:', error); - const message = error instanceof Error ? error.message : t('sanitize.error.generic', 'Sanitization failed'); - setErrorMessage(message); - setStatus(null); - throw error; - } finally { - setIsLoading(false); - } - }, [t, createOperation, recordOperation, sanitizeFile, processResults]); - - const resetResults = useCallback(() => { - if (downloadUrl) { - URL.revokeObjectURL(downloadUrl); - } - setFiles([]); - setThumbnails([]); - setIsGeneratingThumbnails(false); - setDownloadUrl(null); - setDownloadFilename(''); - setErrorMessage(null); - setStatus(null); - }, [downloadUrl]); - - const clearError = useCallback(() => { - setErrorMessage(null); - }, []); - - // Cleanup blob URLs on unmount to prevent memory leaks - useEffect(() => { - return () => { - if (downloadUrl) { - URL.revokeObjectURL(downloadUrl); - } - }; - }, [downloadUrl]); - - return { - isLoading, - errorMessage, - downloadUrl, - downloadFilename, - status, - files, - thumbnails, - isGeneratingThumbnails, - executeOperation, - resetResults, - clearError, - }; + return { valid: true }; + }, + getErrorMessage: createStandardErrorHandler(t('sanitize.error.failed', 'An error occurred while sanitizing the PDF.')) + }); }; diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index cebdc5a23..da409cbfd 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -186,7 +186,7 @@ export const useToolOperation = ( // Individual file processing - separate API call per file const apiCallsConfig: ApiCallsConfig = { endpoint: config.endpoint, - buildFormData: (file: File, params: TParams) => (config.buildFormData as any /* FIX ME */)(file, params), + buildFormData: (file: File, params: TParams) => (config.buildFormData as (params: TParams, file: File) => FormData /* FIX ME */)(params, file), filePrefix: config.filePrefix, responseHandler: config.responseHandler }; diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index 8eea5568a..6a4e9ac49 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -70,7 +70,7 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { return ( - + {/* Files Step */}