diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts index 8d59641fe..4aaed74ee 100644 --- a/frontend/src/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import axios from 'axios'; +import apiClient from '../../../services/apiClient'; import { useTranslation } from 'react-i18next'; import { ConvertParameters, defaultParameters } from './useConvertParameters'; import { createFileFromApiResponse } from '../../../utils/fileResponseUtils'; @@ -108,7 +108,7 @@ export const convertProcessor = async ( for (const file of selectedFiles) { try { const formData = buildConvertFormData(parameters, [file]); - const response = await axios.post(endpoint, formData, { responseType: 'blob' }); + const response = await apiClient.post(endpoint, formData, { responseType: 'blob' }); const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension); @@ -120,7 +120,7 @@ export const convertProcessor = async ( } else { // Batch processing for simple cases (image→PDF combine) const formData = buildConvertFormData(parameters, selectedFiles); - const response = await axios.post(endpoint, formData, { responseType: 'blob' }); + const response = await apiClient.post(endpoint, formData, { responseType: 'blob' }); const baseFilename = selectedFiles.length === 1 ? selectedFiles[0].name diff --git a/frontend/src/hooks/tools/shared/useToolApiCalls.ts b/frontend/src/hooks/tools/shared/useToolApiCalls.ts index f0a0bf704..5747ec226 100644 --- a/frontend/src/hooks/tools/shared/useToolApiCalls.ts +++ b/frontend/src/hooks/tools/shared/useToolApiCalls.ts @@ -1,5 +1,6 @@ import { useCallback, useRef } from 'react'; -import axios, { CancelTokenSource } from '../../../services/http'; +import axios, {type CancelTokenSource} from 'axios'; // Real axios for static methods (CancelToken, isCancel) +import apiClient from '../../../services/apiClient'; // Our configured instance import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor'; import { isEmptyOutput } from '../../../services/errorUtils'; import type { ProcessingProgress } from './useToolState'; @@ -42,9 +43,9 @@ export const useToolApiCalls = () => { const formData = config.buildFormData(params, file); const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; console.debug('[processFiles] POST', { endpoint, name: file.name }); - const response = await axios.post(endpoint, formData, { + const response = await apiClient.post(endpoint, formData, { responseType: 'blob', - cancelToken: cancelTokenRef.current.token, + cancelToken: cancelTokenRef.current?.token, }); console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status }); @@ -61,10 +62,10 @@ export const useToolApiCalls = () => { if (empty) { console.warn('[processFiles] Empty output treated as failure', { name: file.name }); failedFiles.push(file.name); - try { - (markFileError as any)?.((file as any).fileId); - } catch (e) { - console.debug('markFileError', e); + try { + (markFileError as any)?.((file as any).fileId); + } catch (e) { + console.debug('markFileError', e); } continue; } @@ -80,10 +81,10 @@ export const useToolApiCalls = () => { console.error('[processFiles] Failed', { name: file.name, error }); failedFiles.push(file.name); // mark errored file so UI can highlight - try { - (markFileError as any)?.((file as any).fileId); - } catch (e) { - console.debug('markFileError', e); + try { + (markFileError as any)?.((file as any).fileId); + } catch (e) { + console.debug('markFileError', e); } } } diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 2e9c67bd3..91b3c8d0c 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -1,5 +1,5 @@ import { useCallback, useRef, useEffect } from 'react'; -import axios from '../../../services/http'; +import apiClient from '../../../services/apiClient'; import { useTranslation } from 'react-i18next'; import { useFileContext } from '../../../contexts/FileContext'; import { useToolState, type ProcessingProgress } from './useToolState'; @@ -177,8 +177,8 @@ export const useToolOperation = ( for (const f of zeroByteFiles) { (fileActions.markFileError as any)((f as any).fileId); } - } catch (e) { - console.log('markFileError', e); + } catch (e) { + console.log('markFileError', e); } } const validFiles = selectedFiles.filter(file => (file as any)?.size > 0); @@ -243,7 +243,7 @@ export const useToolOperation = ( const formData = config.buildFormData(params, filesForAPI); const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; - const response = await axios.post(endpoint, formData, { responseType: 'blob' }); + const response = await apiClient.post(endpoint, formData, { responseType: 'blob' }); // Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs if (config.responseHandler) { diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts new file mode 100644 index 000000000..536c47448 --- /dev/null +++ b/frontend/src/services/apiClient.ts @@ -0,0 +1,22 @@ +// frontend/src/services/http.ts +import axios from 'axios'; +import { handleHttpError } from './httpErrorHandler'; + +// Create axios instance with default config +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || '/', // Use env var or relative path (proxied by Vite in dev) + responseType: 'json', +}); + +// ---------- Install error interceptor ---------- +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + await handleHttpError(error); // Handle error (shows toast unless suppressed) + return Promise.reject(error); + } +); + + +// ---------- Exports ---------- +export default apiClient; diff --git a/frontend/src/services/http.ts b/frontend/src/services/http.ts deleted file mode 100644 index 20a983525..000000000 --- a/frontend/src/services/http.ts +++ /dev/null @@ -1,255 +0,0 @@ -// frontend/src/services/http.ts -import axios from 'axios'; -import type { AxiosInstance } from 'axios'; -import { alert } from '../components/toast'; -import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils'; -import { showSpecialErrorToast } from './specialErrorToasts'; - -const FRIENDLY_FALLBACK = 'There was an error processing your request.'; -const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts - -function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string { - return s && s.length > max ? `${s.slice(0, max)}…` : s; -} - -function isUnhelpfulMessage(msg: string | null | undefined): boolean { - const s = (msg || '').trim(); - if (!s) return true; - // Common unhelpful payloads we see - if (s === '{}' || s === '[]') return true; - if (/^request failed/i.test(s)) return true; - if (/^network error/i.test(s)) return true; - if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc. - return false; -} - -function titleForStatus(status?: number): string { - if (!status) return 'Network error'; - if (status >= 500) return 'Server error'; - if (status >= 400) return 'Request error'; - return 'Request failed'; -} - -function extractAxiosErrorMessage(error: any): { title: string; body: string } { - if (axios.isAxiosError(error)) { - const status = error.response?.status; - const _statusText = error.response?.statusText || ''; - let parsed: any = undefined; - const raw = error.response?.data; - if (typeof raw === 'string') { - try { parsed = JSON.parse(raw); } catch { /* keep as string */ } - } else { - parsed = raw; - } - const extractIds = (): string[] | undefined => { - if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[]; - const rawText = typeof raw === 'string' ? raw : ''; - const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g); - return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined; - }; - - const body = ((): string => { - const data = parsed; - if (!data) return typeof raw === 'string' ? raw : ''; - const ids = extractIds(); - if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`; - if (data?.message) return data.message as string; - if (typeof raw === 'string') return raw; - try { return JSON.stringify(data); } catch { return ''; } - })(); - const ids = extractIds(); - const title = titleForStatus(status); - if (ids && ids.length > 0) { - return { title, body: 'Process failed due to invalid/corrupted file(s)' }; - } - if (status === 422) { - const fallbackMsg = 'Process failed due to invalid/corrupted file(s)'; - const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body; - return { title, body: bodyMsg }; - } - const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body; - return { title, body: bodyMsg }; - } - try { - const msg = (error?.message || String(error)) as string; - return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg }; - } catch (e) { - // ignore extraction errors - console.debug('extractAxiosErrorMessage', e); - return { title: 'Network error', body: FRIENDLY_FALLBACK }; - } -} - -// ---------- Axios instance creation ---------- -const __globalAny = (typeof window !== 'undefined' ? (window as any) : undefined); - -type ExtendedAxiosInstance = AxiosInstance & { - CancelToken: typeof axios.CancelToken; - isCancel: typeof axios.isCancel; -}; - -const __PREV_CLIENT: ExtendedAxiosInstance | undefined = - __globalAny?.__SPDF_HTTP_CLIENT as ExtendedAxiosInstance | undefined; - -let __createdClient: any; -if (__PREV_CLIENT) { - __createdClient = __PREV_CLIENT; -} else if (typeof (axios as any)?.create === 'function') { - try { - __createdClient = (axios as any).create(); - } catch (e) { - console.debug('createClient', e); - __createdClient = axios as any; - } -} else { - __createdClient = axios as any; -} - -const apiClient: ExtendedAxiosInstance = (__createdClient || (axios as any)) as ExtendedAxiosInstance; - -// Augment instance with axios static helpers for backwards compatibility -if (apiClient) { - try { (apiClient as any).CancelToken = (axios as any).CancelToken; } catch (e) { console.debug('setCancelToken', e); } - try { (apiClient as any).isCancel = (axios as any).isCancel; } catch (e) { console.debug('setIsCancel', e); } -} - -// ---------- Base defaults ---------- -try { - const env = (import.meta as any)?.env || {}; - apiClient.defaults.baseURL = env?.VITE_API_BASE_URL ?? '/'; - apiClient.defaults.responseType = 'json'; - // If OSS relies on cookies, uncomment: - // apiClient.defaults.withCredentials = true; - // Sensible timeout to avoid “forever hanging”: - apiClient.defaults.timeout = 20000; -} catch (e) { - console.debug('setDefaults', e); - apiClient.defaults.baseURL = apiClient.defaults.baseURL || '/'; - apiClient.defaults.responseType = apiClient.defaults.responseType || 'json'; - apiClient.defaults.timeout = apiClient.defaults.timeout || 20000; -} - -// ---------- Install a single response error interceptor (dedup + UX) ---------- -if (__globalAny?.__SPDF_HTTP_ERR_INTERCEPTOR_ID !== undefined && __PREV_CLIENT) { - try { - __PREV_CLIENT.interceptors.response.eject(__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID); - } catch (e) { - console.debug('ejectInterceptor', e); - } -} - -const __recentSpecialByEndpoint: Record = (__globalAny?.__SPDF_RECENT_SPECIAL || {}); -const __SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast - -const __INTERCEPTOR_ID__ = apiClient?.interceptors?.response?.use - ? apiClient.interceptors.response.use( - (response) => response, - async (error) => { - // Compute title/body (friendly) from the error object - const { title, body } = extractAxiosErrorMessage(error); - - // Normalize response data ONCE, reuse for both ID extraction and special-toast matching - const raw = (error?.response?.data) as any; - let normalized: unknown = raw; - try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); } - - // 1) If server sends structured file IDs for failures, also mark them errored in UI - try { - const ids = extractErrorFileIds(normalized); - if (ids && ids.length > 0) { - broadcastErroredFiles(ids); - } - } catch (e) { - console.debug('extractErrorFileIds', e); - } - - // 2) Generic-vs-special dedupe by endpoint - const url: string | undefined = error?.config?.url; - const status: number | undefined = error?.response?.status; - const now = Date.now(); - const isSpecial = - status === 422 || - status === 409 || // often actionable conflicts - /Failed files:/.test(body) || - /invalid\/corrupted file\(s\)/i.test(body); - - if (isSpecial && url) { - __recentSpecialByEndpoint[url] = now; - if (__globalAny) __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint; - } - if (!isSpecial && url) { - const last = __recentSpecialByEndpoint[url] || 0; - if (now - last < __SPECIAL_SUPPRESS_MS) { - return Promise.reject(error); - } - } - - // 3) Show specialized friendly toasts if matched; otherwise show the generic one - let rawString: string | undefined; - try { - rawString = - typeof normalized === 'string' - ? normalized - : JSON.stringify(normalized); - } catch (e) { - console.debug('extractErrorFileIds', e); - } - - const handled = showSpecialErrorToast(rawString, { status }); - if (!handled) { - const displayBody = clampText(body); - alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false }); - } - - return Promise.reject(error); - } - ) - : undefined as any; - -if (__globalAny) { - __globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID = __INTERCEPTOR_ID__; - __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint; - __globalAny.__SPDF_HTTP_CLIENT = apiClient; -} - -// ---------- Fetch helper ---------- -export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init }); - - if (!res.ok) { - let detail = ''; - try { - const ct = res.headers.get('content-type') || ''; - if (ct.includes('application/json')) { - const data = await res.json(); - detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data)); - } else { - detail = await res.text(); - } - } catch { - // ignore parse errors - } - - const title = titleForStatus(res.status); - const body = isUnhelpfulMessage(detail || res.statusText) ? FRIENDLY_FALLBACK : (detail || res.statusText); - alert({ alertType: 'error', title, body: clampText(body), expandable: true, isPersistentPopup: false }); - - // Important: match Axios semantics so callers can try/catch - throw new Error(body || res.statusText); - } - - return res; -} - -// ---------- Convenience API surface and exports ---------- -export const api = { - get: apiClient.get, - post: apiClient.post, - put: apiClient.put, - patch: apiClient.patch, - delete: apiClient.delete, - request: apiClient.request, -}; - -export default apiClient; -export type { CancelTokenSource } from 'axios'; \ No newline at end of file diff --git a/frontend/src/services/httpErrorHandler.ts b/frontend/src/services/httpErrorHandler.ts new file mode 100644 index 000000000..aaea20036 --- /dev/null +++ b/frontend/src/services/httpErrorHandler.ts @@ -0,0 +1,147 @@ +// frontend/src/services/httpErrorHandler.ts +import axios from 'axios'; +import { alert } from '../components/toast'; +import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils'; +import { showSpecialErrorToast } from './specialErrorToasts'; + +const FRIENDLY_FALLBACK = 'There was an error processing your request.'; +const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts + +function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string { + return s && s.length > max ? `${s.slice(0, max)}…` : s; +} + +function isUnhelpfulMessage(msg: string | null | undefined): boolean { + const s = (msg || '').trim(); + if (!s) return true; + // Common unhelpful payloads we see + if (s === '{}' || s === '[]') return true; + if (/^request failed/i.test(s)) return true; + if (/^network error/i.test(s)) return true; + if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc. + return false; +} + +function titleForStatus(status?: number): string { + if (!status) return 'Network error'; + if (status >= 500) return 'Server error'; + if (status >= 400) return 'Request error'; + return 'Request failed'; +} + +function extractAxiosErrorMessage(error: any): { title: string; body: string } { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const _statusText = error.response?.statusText || ''; + let parsed: any = undefined; + const raw = error.response?.data; + if (typeof raw === 'string') { + try { parsed = JSON.parse(raw); } catch { /* keep as string */ } + } else { + parsed = raw; + } + const extractIds = (): string[] | undefined => { + if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[]; + const rawText = typeof raw === 'string' ? raw : ''; + const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g); + return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined; + }; + + const body = ((): string => { + const data = parsed; + if (!data) return typeof raw === 'string' ? raw : ''; + const ids = extractIds(); + if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`; + if (data?.message) return data.message as string; + if (typeof raw === 'string') return raw; + try { return JSON.stringify(data); } catch { return ''; } + })(); + const ids = extractIds(); + const title = titleForStatus(status); + if (ids && ids.length > 0) { + return { title, body: 'Process failed due to invalid/corrupted file(s)' }; + } + if (status === 422) { + const fallbackMsg = 'Process failed due to invalid/corrupted file(s)'; + const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body; + return { title, body: bodyMsg }; + } + const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body; + return { title, body: bodyMsg }; + } + try { + const msg = (error?.message || String(error)) as string; + return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg }; + } catch (e) { + // ignore extraction errors + console.debug('extractAxiosErrorMessage', e); + return { title: 'Network error', body: FRIENDLY_FALLBACK }; + } +} + +// Module-scoped state to reduce global variable usage +const recentSpecialByEndpoint: Record = {}; +const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast + +/** + * Handles HTTP errors with toast notifications and file error broadcasting + * Returns true if the error should be suppressed (deduplicated), false otherwise + */ +export async function handleHttpError(error: any): Promise { + // Compute title/body (friendly) from the error object + const { title, body } = extractAxiosErrorMessage(error); + + // Normalize response data ONCE, reuse for both ID extraction and special-toast matching + const raw = (error?.response?.data) as any; + let normalized: unknown = raw; + try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); } + + // 1) If server sends structured file IDs for failures, also mark them errored in UI + try { + const ids = extractErrorFileIds(normalized); + if (ids && ids.length > 0) { + broadcastErroredFiles(ids); + } + } catch (e) { + console.debug('extractErrorFileIds', e); + } + + // 2) Generic-vs-special dedupe by endpoint + const url: string | undefined = error?.config?.url; + const status: number | undefined = error?.response?.status; + const now = Date.now(); + const isSpecial = + status === 422 || + status === 409 || // often actionable conflicts + /Failed files:/.test(body) || + /invalid\/corrupted file\(s\)/i.test(body); + + if (isSpecial && url) { + recentSpecialByEndpoint[url] = now; + } + if (!isSpecial && url) { + const last = recentSpecialByEndpoint[url] || 0; + if (now - last < SPECIAL_SUPPRESS_MS) { + return true; // Suppress this error (deduplicated) + } + } + + // 3) Show specialized friendly toasts if matched; otherwise show the generic one + let rawString: string | undefined; + try { + rawString = + typeof normalized === 'string' + ? normalized + : JSON.stringify(normalized); + } catch (e) { + console.debug('extractErrorFileIds', e); + } + + const handled = showSpecialErrorToast(rawString, { status }); + if (!handled) { + const displayBody = clampText(body); + alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false }); + } + + return false; // Error was handled with toast, continue normal rejection +} \ No newline at end of file diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx index 2d3e177c6..c2398dab7 100644 --- a/frontend/src/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx @@ -17,13 +17,40 @@ import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameter import { FileContextProvider } from '../../contexts/FileContext'; import { I18nextProvider } from 'react-i18next'; import i18n from '../../i18n/config'; -import axios from 'axios'; import { createTestStirlingFile } from '../utils/testFileHelpers'; import { StirlingFile } from '../../types/fileContext'; -// Mock axios -vi.mock('axios'); -const mockedAxios = vi.mocked(axios); +// Mock axios (for static methods like CancelToken, isCancel) +vi.mock('axios', () => ({ + default: { + CancelToken: { + source: vi.fn(() => ({ + token: 'mock-cancel-token', + cancel: vi.fn() + })) + }, + isCancel: vi.fn(() => false), + } +})); + +// Mock our apiClient service +vi.mock('../../services/apiClient', () => ({ + default: { + post: vi.fn(), + get: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + interceptors: { + response: { + use: vi.fn() + } + } + } +})); + +// Import the mocked apiClient +import apiClient from '../../services/apiClient'; +const mockedApiClient = vi.mocked(apiClient); // Mock only essential services that are actually called by the tests vi.mock('../../services/fileStorage', () => ({ @@ -71,8 +98,8 @@ describe('Convert Tool Integration Tests', () => { beforeEach(() => { vi.clearAllMocks(); - // Setup default axios mock - mockedAxios.post = vi.fn(); + // Setup default apiClient mock + mockedApiClient.post = vi.fn(); }); afterEach(() => { @@ -83,7 +110,7 @@ describe('Convert Tool Integration Tests', () => { test('should make correct API call for PDF to PNG conversion', async () => { const mockBlob = new Blob(['fake-image-data'], { type: 'image/png' }); - (mockedAxios.post as Mock).mockResolvedValueOnce({ + (mockedApiClient.post as Mock).mockResolvedValueOnce({ data: mockBlob, status: 200, statusText: 'OK' @@ -126,14 +153,14 @@ describe('Convert Tool Integration Tests', () => { }); // Verify axios was called with correct parameters - expect(mockedAxios.post).toHaveBeenCalledWith( + expect(mockedApiClient.post).toHaveBeenCalledWith( '/api/v1/convert/pdf/img', expect.any(FormData), { responseType: 'blob' } ); // Verify FormData contains correct parameters - const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData; + const formDataCall = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData; expect(formDataCall.get('imageFormat')).toBe('png'); expect(formDataCall.get('colorType')).toBe('color'); expect(formDataCall.get('dpi')).toBe('300'); @@ -148,7 +175,7 @@ describe('Convert Tool Integration Tests', () => { test('should handle API error responses correctly', async () => { const errorMessage = 'Invalid file format'; - (mockedAxios.post as Mock).mockRejectedValueOnce({ + (mockedApiClient.post as Mock).mockRejectedValueOnce({ response: { status: 400, data: errorMessage @@ -199,7 +226,7 @@ describe('Convert Tool Integration Tests', () => { }); test('should handle network errors gracefully', async () => { - (mockedAxios.post as Mock).mockRejectedValueOnce(new Error('Network error')); + (mockedApiClient.post as Mock).mockRejectedValueOnce(new Error('Network error')); const { result } = renderHook(() => useConvertOperation(), { wrapper: TestWrapper @@ -246,7 +273,7 @@ describe('Convert Tool Integration Tests', () => { test('should correctly map image conversion parameters to API call', async () => { const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' }); - (mockedAxios.post as Mock).mockResolvedValueOnce({ + (mockedApiClient.post as Mock).mockResolvedValueOnce({ data: mockBlob, status: 200, headers: { @@ -292,7 +319,7 @@ describe('Convert Tool Integration Tests', () => { }); // Verify integration: hook parameters → FormData → axios call → hook state - const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData; + const formDataCall = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData; expect(formDataCall.get('imageFormat')).toBe('jpg'); expect(formDataCall.get('colorType')).toBe('grayscale'); expect(formDataCall.get('dpi')).toBe('150'); @@ -307,7 +334,7 @@ describe('Convert Tool Integration Tests', () => { test('should make correct API call for PDF to CSV conversion with simplified workflow', async () => { const mockBlob = new Blob(['fake-csv-data'], { type: 'text/csv' }); - (mockedAxios.post as Mock).mockResolvedValueOnce({ + (mockedApiClient.post as Mock).mockResolvedValueOnce({ data: mockBlob, status: 200, statusText: 'OK' @@ -350,14 +377,14 @@ describe('Convert Tool Integration Tests', () => { }); // Verify correct endpoint is called - expect(mockedAxios.post).toHaveBeenCalledWith( + expect(mockedApiClient.post).toHaveBeenCalledWith( '/api/v1/convert/pdf/csv', expect.any(FormData), { responseType: 'blob' } ); // Verify FormData contains correct parameters for simplified CSV conversion - const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData; + const formDataCall = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData; expect(formDataCall.get('pageNumbers')).toBe('all'); // Always "all" for simplified workflow expect(formDataCall.get('fileInput')).toBe(testFile); @@ -406,7 +433,7 @@ describe('Convert Tool Integration Tests', () => { }); // Verify integration: utils validation prevents API call, hook shows error - expect(mockedAxios.post).not.toHaveBeenCalled(); + expect(mockedApiClient.post).not.toHaveBeenCalled(); expect(result.current.errorMessage).toContain('Unsupported conversion format'); expect(result.current.isLoading).toBe(false); expect(result.current.downloadUrl).toBe(null); @@ -417,7 +444,7 @@ describe('Convert Tool Integration Tests', () => { test('should handle multiple file uploads correctly', async () => { const mockBlob = new Blob(['zip-content'], { type: 'application/zip' }); - (mockedAxios.post as Mock).mockResolvedValueOnce({ data: mockBlob }); + (mockedApiClient.post as Mock).mockResolvedValueOnce({ data: mockBlob }); const { result } = renderHook(() => useConvertOperation(), { wrapper: TestWrapper @@ -458,7 +485,7 @@ describe('Convert Tool Integration Tests', () => { }); // Verify both files were uploaded - const calls = (mockedAxios.post as Mock).mock.calls; + const calls = (mockedApiClient.post as Mock).mock.calls; for (let i = 0; i < calls.length; i++) { const formData = calls[i][1] as FormData; @@ -506,7 +533,7 @@ describe('Convert Tool Integration Tests', () => { await result.current.executeOperation(parameters, []); }); - expect(mockedAxios.post).not.toHaveBeenCalled(); + expect(mockedApiClient.post).not.toHaveBeenCalled(); expect(result.current.errorMessage).toContain('noFileSelected'); }); }); @@ -514,7 +541,7 @@ describe('Convert Tool Integration Tests', () => { describe('Error Boundary Integration', () => { test('should handle corrupted file gracefully', async () => { - (mockedAxios.post as Mock).mockRejectedValueOnce({ + (mockedApiClient.post as Mock).mockRejectedValueOnce({ response: { status: 422, data: 'Processing failed' @@ -562,7 +589,7 @@ describe('Convert Tool Integration Tests', () => { }); test('should handle backend service unavailable', async () => { - (mockedAxios.post as Mock).mockRejectedValueOnce({ + (mockedApiClient.post as Mock).mockRejectedValueOnce({ response: { status: 503, data: 'Service unavailable' @@ -614,7 +641,7 @@ describe('Convert Tool Integration Tests', () => { test('should record operation in FileContext', async () => { const mockBlob = new Blob(['fake-data'], { type: 'image/png' }); - (mockedAxios.post as Mock).mockResolvedValueOnce({ + (mockedApiClient.post as Mock).mockResolvedValueOnce({ data: mockBlob, status: 200, headers: { @@ -667,7 +694,7 @@ describe('Convert Tool Integration Tests', () => { test('should clean up blob URLs on reset', async () => { const mockBlob = new Blob(['fake-data'], { type: 'image/png' }); - (mockedAxios.post as Mock).mockResolvedValueOnce({ + (mockedApiClient.post as Mock).mockResolvedValueOnce({ data: mockBlob, status: 200, headers: { diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx index 52826ce3f..cf28da955 100644 --- a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -11,14 +11,41 @@ import { useConvertParameters } from '../../hooks/tools/convert/useConvertParame import { FileContextProvider } from '../../contexts/FileContext'; import { I18nextProvider } from 'react-i18next'; import i18n from '../../i18n/config'; -import axios from 'axios'; import { detectFileExtension } from '../../utils/fileUtils'; import { FIT_OPTIONS } from '../../constants/convertConstants'; import { createTestStirlingFile, createTestFilesWithId } from '../utils/testFileHelpers'; -// Mock axios -vi.mock('axios'); -const mockedAxios = vi.mocked(axios); +// Mock axios (for static methods like CancelToken, isCancel) +vi.mock('axios', () => ({ + default: { + CancelToken: { + source: vi.fn(() => ({ + token: 'mock-cancel-token', + cancel: vi.fn() + })) + }, + isCancel: vi.fn(() => false), + } +})); + +// Mock our apiClient service +vi.mock('../../services/apiClient', () => ({ + default: { + post: vi.fn(), + get: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + interceptors: { + response: { + use: vi.fn() + } + } + } +})); + +// Import the mocked apiClient +import apiClient from '../../services/apiClient'; +const mockedApiClient = vi.mocked(apiClient); // Mock only essential services that are actually called by the tests vi.mock('../../services/fileStorage', () => ({ @@ -61,7 +88,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { vi.clearAllMocks(); // Mock successful API response - (mockedAxios.post as Mock).mockResolvedValue({ + (mockedApiClient.post as Mock).mockResolvedValue({ data: new Blob(['fake converted content'], { type: 'application/pdf' }) }); }); @@ -103,7 +130,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { ); }); - expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { + expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { responseType: 'blob' }); }); @@ -139,7 +166,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { ); }); - expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { + expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { responseType: 'blob' }); }); @@ -183,12 +210,12 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { ); }); - expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/img/pdf', expect.any(FormData), { + expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/img/pdf', expect.any(FormData), { responseType: 'blob' }); // Should send all files in single request - const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData; + const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData; const files = formData.getAll('fileInput'); expect(files).toHaveLength(3); }); @@ -229,7 +256,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { ); }); - expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { + expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { responseType: 'blob' }); }); @@ -269,12 +296,12 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { ); }); - expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/html/pdf', expect.any(FormData), { + expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/html/pdf', expect.any(FormData), { responseType: 'blob' }); // Should process files separately for web files - expect(mockedAxios.post).toHaveBeenCalledTimes(2); + expect(mockedApiClient.post).toHaveBeenCalledTimes(2); }); }); @@ -306,7 +333,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { ); }); - const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData; + const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData; expect(formData.get('zoom')).toBe('1.5'); }); @@ -340,7 +367,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { ); }); - const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData; + const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData; expect(formData.get('includeAttachments')).toBe('false'); expect(formData.get('maxAttachmentSizeMB')).toBe('20'); expect(formData.get('downloadHtml')).toBe('true'); @@ -374,9 +401,9 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { ); }); - const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData; + const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData; expect(formData.get('outputFormat')).toBe('pdfa'); - expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/pdf/pdfa', expect.any(FormData), { + expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/pdf/pdfa', expect.any(FormData), { responseType: 'blob' }); }); @@ -418,7 +445,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { ); }); - const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData; + const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData; expect(formData.get('fitOption')).toBe(FIT_OPTIONS.FIT_PAGE); expect(formData.get('colorType')).toBe('grayscale'); expect(formData.get('autoRotate')).toBe('false'); @@ -455,7 +482,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Should make separate API calls for each file - expect(mockedAxios.post).toHaveBeenCalledTimes(2); + expect(mockedApiClient.post).toHaveBeenCalledTimes(2); }); }); @@ -472,7 +499,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { }); // Mock one success, one failure - (mockedAxios.post as Mock) + (mockedApiClient.post as Mock) .mockResolvedValueOnce({ data: new Blob(['converted1'], { type: 'application/pdf' }) }) @@ -498,7 +525,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { await waitFor(() => { // Should have processed at least one file successfully expect(operationResult.current.files.length).toBeGreaterThan(0); - expect(mockedAxios.post).toHaveBeenCalledTimes(2); + expect(mockedApiClient.post).toHaveBeenCalledTimes(2); }); }); });