Update to use common hook code

This commit is contained in:
James 2025-08-11 15:27:01 +01:00
parent cf2250be86
commit a65721ceb7
4 changed files with 34 additions and 499 deletions

View File

@ -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);
});
});

View File

@ -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<string | null>(null);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState<string>('');
const [status, setStatus] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [thumbnails, setThumbnails] = useState<string[]>([]);
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<SanitizeParameters>({
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<File | null> => {
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<void> => {
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<void> => {
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<void> => {
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.'))
});
};

View File

@ -186,7 +186,7 @@ export const useToolOperation = <TParams = void>(
// Individual file processing - separate API call per file
const apiCallsConfig: ApiCallsConfig<TParams> = {
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
};

View File

@ -70,7 +70,7 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
return (
<ToolStepContainer>
<Stack gap="sm" p="sm" style={{ height: '80vh', overflow: 'auto' }}>
<Stack gap="sm" p="sm" style={{ height: '100vh', overflow: 'auto' }}>
{/* Files Step */}
<ToolStep
title={t('sanitize.steps.files', 'Files')}