mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-03 17:52:30 +02:00
Update to use common hook code
This commit is contained in:
parent
cf2250be86
commit
a65721ceb7
@ -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);
|
||||
});
|
||||
});
|
@ -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.'))
|
||||
});
|
||||
};
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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')}
|
||||
|
Loading…
Reference in New Issue
Block a user