diff --git a/frontend/src/components/tools/merge/MergeFileSorter.test.tsx b/frontend/src/components/tools/merge/MergeFileSorter.test.tsx
new file mode 100644
index 000000000..302777261
--- /dev/null
+++ b/frontend/src/components/tools/merge/MergeFileSorter.test.tsx
@@ -0,0 +1,182 @@
+import { describe, expect, test, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MantineProvider } from '@mantine/core';
+import MergeFileSorter from './MergeFileSorter';
+
+// Mock useTranslation with predictable return values
+const mockT = vi.fn((key: string) => `mock-${key}`);
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: mockT })
+}));
+
+// Wrapper component to provide Mantine context
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('MergeFileSorter', () => {
+ const mockOnSortFiles = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('should render sort options dropdown, direction toggle, and sort button', () => {
+ render(
+
+
+
+ );
+
+ // Should have a select dropdown (Mantine Select uses textbox role)
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+
+ // Should have direction toggle button
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(2); // ActionIcon + Sort Button
+
+ // Should have sort button with text
+ expect(screen.getByText('mock-merge.sortBy.sort')).toBeInTheDocument();
+ });
+
+ test('should render description text', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('mock-merge.sortBy.description')).toBeInTheDocument();
+ });
+
+ test('should have filename selected by default', () => {
+ render(
+
+
+
+ );
+
+ const select = screen.getByRole('textbox');
+ expect(select).toHaveValue('mock-merge.sortBy.filename');
+ });
+
+ test('should show ascending direction by default', () => {
+ render(
+
+
+
+ );
+
+ // Should show ascending arrow icon
+ const directionButton = screen.getAllByRole('button')[0];
+ expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
+ });
+
+ test('should toggle direction when direction button is clicked', () => {
+ render(
+
+
+
+ );
+
+ const directionButton = screen.getAllByRole('button')[0];
+
+ // Initially ascending
+ expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
+
+ // Click to toggle to descending
+ fireEvent.click(directionButton);
+ expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.descending');
+
+ // Click again to toggle back to ascending
+ fireEvent.click(directionButton);
+ expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
+ });
+
+ test('should call onSortFiles with correct parameters when sort button is clicked', () => {
+ render(
+
+
+
+ );
+
+ const sortButton = screen.getByText('mock-merge.sortBy.sort');
+ fireEvent.click(sortButton);
+
+ // Should be called with default values (filename, ascending)
+ expect(mockOnSortFiles).toHaveBeenCalledWith('filename', true);
+ });
+
+ test('should call onSortFiles with dateModified when dropdown is changed', () => {
+ render(
+
+
+
+ );
+
+ // Open the dropdown by clicking on the current selected value
+ const currentSelection = screen.getByText('mock-merge.sortBy.filename');
+ fireEvent.mouseDown(currentSelection);
+
+ // Click on the dateModified option
+ const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
+ fireEvent.click(dateModifiedOption);
+
+ const sortButton = screen.getByText('mock-merge.sortBy.sort');
+ fireEvent.click(sortButton);
+
+ expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
+ });
+
+ test('should call onSortFiles with descending direction when toggled', () => {
+ render(
+
+
+
+ );
+
+ const directionButton = screen.getAllByRole('button')[0];
+ const sortButton = screen.getByText('mock-merge.sortBy.sort');
+
+ // Toggle to descending
+ fireEvent.click(directionButton);
+
+ // Click sort
+ fireEvent.click(sortButton);
+
+ expect(mockOnSortFiles).toHaveBeenCalledWith('filename', false);
+ });
+
+ test('should handle complex user interaction sequence', () => {
+ render(
+
+
+
+ );
+
+ const directionButton = screen.getAllByRole('button')[0];
+ const sortButton = screen.getByText('mock-merge.sortBy.sort');
+
+ // 1. Change to dateModified
+ const currentSelection = screen.getByText('mock-merge.sortBy.filename');
+ fireEvent.mouseDown(currentSelection);
+ const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
+ fireEvent.click(dateModifiedOption);
+
+ // 2. Toggle to descending
+ fireEvent.click(directionButton);
+
+ // 3. Click sort
+ fireEvent.click(sortButton);
+
+ expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', false);
+
+ // 4. Toggle back to ascending
+ fireEvent.click(directionButton);
+
+ // 5. Sort again
+ fireEvent.click(sortButton);
+
+ expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
+ });
+});
diff --git a/frontend/src/components/tools/merge/MergeSettings.test.tsx b/frontend/src/components/tools/merge/MergeSettings.test.tsx
new file mode 100644
index 000000000..7989fa6d0
--- /dev/null
+++ b/frontend/src/components/tools/merge/MergeSettings.test.tsx
@@ -0,0 +1,100 @@
+import { describe, expect, test, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MantineProvider } from '@mantine/core';
+import MergeSettings from './MergeSettings';
+import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
+
+// Mock useTranslation with predictable return values
+const mockT = vi.fn((key: string) => `mock-${key}`);
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: mockT })
+}));
+
+// Wrapper component to provide Mantine context
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('MergeSettings', () => {
+ const defaultParameters: MergeParameters = {
+ removeDigitalSignature: false,
+ generateTableOfContents: false,
+ };
+
+ const mockOnParameterChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('should render both merge option checkboxes', () => {
+ render(
+
+
+
+ );
+
+ // Should render one checkbox for each parameter
+ const expectedCheckboxCount = Object.keys(defaultParameters).length;
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes).toHaveLength(expectedCheckboxCount);
+ });
+
+ test('should show correct initial checkbox states based on parameters', () => {
+ render(
+
+
+
+ );
+
+ const checkboxes = screen.getAllByRole('checkbox');
+
+ // Both checkboxes should be unchecked initially
+ checkboxes.forEach(checkbox => {
+ expect(checkbox).not.toBeChecked();
+ });
+ });
+
+ test('should call onParameterChange with correct parameters when checkboxes are clicked', () => {
+ render(
+
+
+
+ );
+
+ const checkboxes = screen.getAllByRole('checkbox');
+
+ // Click the first checkbox (removeDigitalSignature - should toggle from false to true)
+ fireEvent.click(checkboxes[0]);
+ expect(mockOnParameterChange).toHaveBeenCalledWith('removeDigitalSignature', true);
+
+ // Click the second checkbox (generateTableOfContents - should toggle from false to true)
+ fireEvent.click(checkboxes[1]);
+ expect(mockOnParameterChange).toHaveBeenCalledWith('generateTableOfContents', true);
+ });
+
+ test('should call translation function with correct keys', () => {
+ render(
+
+
+
+ );
+
+ // Verify that translation keys are being called
+ expect(mockT).toHaveBeenCalledWith('merge.removeDigitalSignature', 'Remove digital signature in the merged file?');
+ expect(mockT).toHaveBeenCalledWith('merge.generateTableOfContents', 'Generate table of contents in the merged file?');
+ });
+
+});
diff --git a/frontend/src/hooks/tools/merge/useMergeOperation.test.ts b/frontend/src/hooks/tools/merge/useMergeOperation.test.ts
new file mode 100644
index 000000000..9f23b8c6c
--- /dev/null
+++ b/frontend/src/hooks/tools/merge/useMergeOperation.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, test, vi, beforeEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useMergeOperation } from './useMergeOperation';
+import type { MergeParameters } from './useMergeParameters';
+
+// Mock the useToolOperation hook
+vi.mock('../shared/useToolOperation', () => ({
+ useToolOperation: vi.fn()
+}));
+
+// Mock the translation hook
+const mockT = vi.fn((key: string) => `translated-${key}`);
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: mockT })
+}));
+
+// Mock the error handler
+vi.mock('../../../utils/toolErrorHandler', () => ({
+ createStandardErrorHandler: vi.fn(() => 'error-handler-function')
+}));
+
+// Import the mocked function
+import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
+
+describe('useMergeOperation', () => {
+ const mockUseToolOperation = vi.mocked(useToolOperation);
+
+ const getToolConfig = (): ToolOperationConfig => mockUseToolOperation.mock.calls[0][0];
+
+ const mockToolOperationReturn: ToolOperationHook = {
+ files: [],
+ thumbnails: [],
+ downloadUrl: null,
+ downloadFilename: '',
+ isLoading: false,
+ errorMessage: null,
+ status: '',
+ isGeneratingThumbnails: false,
+ progress: null,
+ executeOperation: vi.fn(),
+ resetResults: vi.fn(),
+ clearError: vi.fn(),
+ cancelOperation: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
+ });
+
+ test('should build FormData correctly', () => {
+ renderHook(() => useMergeOperation());
+
+ const config = getToolConfig();
+ const mockFiles = [
+ new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
+ new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
+ ];
+ const parameters: MergeParameters = {
+ removeDigitalSignature: true,
+ generateTableOfContents: false
+ };
+
+ const formData = config.buildFormData(parameters, mockFiles as any /* FIX ME */);
+
+ // Verify files are appended
+ expect(formData.getAll('fileInput')).toHaveLength(2);
+ expect(formData.getAll('fileInput')[0]).toBe(mockFiles[0]);
+ expect(formData.getAll('fileInput')[1]).toBe(mockFiles[1]);
+
+ // Verify parameters are appended correctly
+ expect(formData.get('sortType')).toBe('orderProvided');
+ expect(formData.get('removeCertSign')).toBe('true');
+ expect(formData.get('generateToc')).toBe('false');
+ });
+
+ test('should handle response correctly', () => {
+ renderHook(() => useMergeOperation());
+
+ const config = getToolConfig();
+ const mockBlob = new Blob(['merged content'], { type: 'application/pdf' });
+ const mockFiles = [
+ new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
+ new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
+ ];
+
+ const result = config.responseHandler!(mockBlob, mockFiles) as File[];
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('merged_file1.pdf');
+ expect(result[0].type).toBe('application/pdf');
+ expect(result[0].size).toBe(mockBlob.size);
+ });
+
+ test('should return the hook result from useToolOperation', () => {
+ const { result } = renderHook(() => useMergeOperation());
+
+ expect(result.current).toBe(mockToolOperationReturn);
+ });
+
+ test('should use correct translation keys for error handling', () => {
+ renderHook(() => useMergeOperation());
+
+ expect(mockT).toHaveBeenCalledWith('merge.error.failed', 'An error occurred while merging the PDFs.');
+ });
+
+ test('should build FormData with different parameter combinations', () => {
+ renderHook(() => useMergeOperation());
+
+ const config = getToolConfig();
+ const mockFiles = [new File(['test'], 'test.pdf', { type: 'application/pdf' })];
+
+ // Test case 1: All options disabled
+ const params1: MergeParameters = {
+ removeDigitalSignature: false,
+ generateTableOfContents: false
+ };
+ const formData1 = config.buildFormData(params1, mockFiles as any /* FIX ME */);
+ expect(formData1.get('removeCertSign')).toBe('false');
+ expect(formData1.get('generateToc')).toBe('false');
+
+ // Test case 2: All options enabled
+ const params2: MergeParameters = {
+ removeDigitalSignature: true,
+ generateTableOfContents: true
+ };
+ const formData2 = config.buildFormData(params2, mockFiles as any /* FIX ME */);
+ expect(formData2.get('removeCertSign')).toBe('true');
+ expect(formData2.get('generateToc')).toBe('true');
+ });
+});
diff --git a/frontend/src/hooks/tools/merge/useMergeParameters.test.ts b/frontend/src/hooks/tools/merge/useMergeParameters.test.ts
new file mode 100644
index 000000000..c2ef59178
--- /dev/null
+++ b/frontend/src/hooks/tools/merge/useMergeParameters.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, test } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useMergeParameters, defaultMergeParameters } from './useMergeParameters';
+
+describe('useMergeParameters', () => {
+ test('should initialize with default parameters', () => {
+ const { result } = renderHook(() => useMergeParameters());
+
+ expect(result.current.parameters).toStrictEqual(defaultMergeParameters);
+ });
+
+ test.each([
+ { paramName: 'removeDigitalSignature' as const, value: true },
+ { paramName: 'removeDigitalSignature' as const, value: false },
+ { paramName: 'generateTableOfContents' as const, value: true },
+ { paramName: 'generateTableOfContents' as const, value: false }
+ ])('should update parameter $paramName to $value', ({ paramName, value }) => {
+ const { result } = renderHook(() => useMergeParameters());
+
+ act(() => {
+ result.current.updateParameter(paramName, value);
+ });
+
+ expect(result.current.parameters[paramName]).toBe(value);
+ });
+
+ test('should reset parameters to defaults', () => {
+ const { result } = renderHook(() => useMergeParameters());
+
+ // First, change some parameters
+ act(() => {
+ result.current.updateParameter('removeDigitalSignature', true);
+ result.current.updateParameter('generateTableOfContents', true);
+ });
+
+ expect(result.current.parameters.removeDigitalSignature).toBe(true);
+ expect(result.current.parameters.generateTableOfContents).toBe(true);
+
+ // Then reset
+ act(() => {
+ result.current.resetParameters();
+ });
+
+ expect(result.current.parameters).toStrictEqual(defaultMergeParameters);
+ });
+
+ test('should validate parameters correctly - always returns true', () => {
+ const { result } = renderHook(() => useMergeParameters());
+
+ // Default state should be valid
+ expect(result.current.validateParameters()).toBe(true);
+
+ // Change parameters and validate again
+ act(() => {
+ result.current.updateParameter('removeDigitalSignature', true);
+ result.current.updateParameter('generateTableOfContents', true);
+ });
+
+ expect(result.current.validateParameters()).toBe(true);
+
+ // Reset and validate again
+ act(() => {
+ result.current.resetParameters();
+ });
+
+ expect(result.current.validateParameters()).toBe(true);
+ });
+});