Files
syndarix/frontend/tests/lib/forms/hooks/useValidationErrorHandler.test.tsx
Felipe Cardoso 3c6b14d2bf refactor(forms): extract reusable form utilities and components
- Add getFirstValidationError utility for nested FieldErrors extraction
- Add mergeWithDefaults utilities (deepMergeWithDefaults, type guards)
- Add useValidationErrorHandler hook for toast + tab navigation
- Add FormSelect component with Controller integration
- Add FormTextarea component with register integration
- Refactor AgentTypeForm to use new utilities
- Remove verbose debug logging (now handled by hook)
- Add comprehensive tests (53 new tests, 100 total)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 13:50:36 +01:00

213 lines
5.8 KiB
TypeScript

/**
* Tests for useValidationErrorHandler hook
*/
import { renderHook } from '@testing-library/react';
import { toast } from 'sonner';
import { useValidationErrorHandler } from '@/lib/forms/hooks/useValidationErrorHandler';
import type { FieldErrors } from 'react-hook-form';
// Mock sonner toast
jest.mock('sonner', () => ({
toast: {
error: jest.fn(),
},
}));
// Mock console.error to track debug logging
const originalConsoleError = console.error;
let consoleErrorMock: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorMock.mockRestore();
console.error = originalConsoleError;
});
describe('useValidationErrorHandler', () => {
describe('basic functionality', () => {
it('shows toast with first error message', () => {
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
const errors: FieldErrors = {
name: { message: 'Name is required', type: 'required' },
};
result.current.onValidationError(errors);
expect(toast.error).toHaveBeenCalledWith('Please fix form errors', {
description: 'name: Name is required',
});
});
it('uses custom toast title when provided', () => {
const { result } = renderHook(() =>
useValidationErrorHandler({
toastTitle: 'Validation Failed',
debug: false,
})
);
const errors: FieldErrors = {
email: { message: 'Invalid email', type: 'pattern' },
};
result.current.onValidationError(errors);
expect(toast.error).toHaveBeenCalledWith('Validation Failed', {
description: 'email: Invalid email',
});
});
it('does nothing when no errors', () => {
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
result.current.onValidationError({});
expect(toast.error).not.toHaveBeenCalled();
});
});
describe('nested errors', () => {
it('handles nested field errors', () => {
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
const errors: FieldErrors = {
model_params: {
temperature: { message: 'Temperature must be between 0 and 2', type: 'max' },
},
};
result.current.onValidationError(errors);
expect(toast.error).toHaveBeenCalledWith('Please fix form errors', {
description: 'model_params.temperature: Temperature must be between 0 and 2',
});
});
});
describe('tab navigation', () => {
it('navigates to correct tab when mapping provided', () => {
const setActiveTab = jest.fn();
const tabMapping = {
name: 'basic',
model_params: 'model',
};
const { result } = renderHook(() =>
useValidationErrorHandler({
tabMapping,
setActiveTab,
debug: false,
})
);
const errors: FieldErrors = {
model_params: {
temperature: { message: 'Invalid', type: 'type' },
},
};
result.current.onValidationError(errors);
expect(setActiveTab).toHaveBeenCalledWith('model');
});
it('does not navigate if field not in mapping', () => {
const setActiveTab = jest.fn();
const tabMapping = {
name: 'basic',
};
const { result } = renderHook(() =>
useValidationErrorHandler({
tabMapping,
setActiveTab,
debug: false,
})
);
const errors: FieldErrors = {
unknown_field: { message: 'Error', type: 'validation' },
};
result.current.onValidationError(errors);
expect(setActiveTab).not.toHaveBeenCalled();
});
it('does not crash when setActiveTab not provided', () => {
const tabMapping = { name: 'basic' };
const { result } = renderHook(() =>
useValidationErrorHandler({
tabMapping,
// setActiveTab not provided
debug: false,
})
);
const errors: FieldErrors = {
name: { message: 'Required', type: 'required' },
};
expect(() => result.current.onValidationError(errors)).not.toThrow();
});
});
describe('debug logging', () => {
it('logs errors when debug is true', () => {
const { result } = renderHook(() => useValidationErrorHandler({ debug: true }));
const errors: FieldErrors = {
name: { message: 'Required', type: 'required' },
};
result.current.onValidationError(errors);
expect(consoleErrorMock).toHaveBeenCalledWith('[Form Validation] Errors:', errors);
});
it('does not log errors when debug is false', () => {
const { result } = renderHook(() => useValidationErrorHandler({ debug: false }));
const errors: FieldErrors = {
name: { message: 'Required', type: 'required' },
};
result.current.onValidationError(errors);
expect(consoleErrorMock).not.toHaveBeenCalled();
});
});
describe('memoization', () => {
it('returns stable callback reference', () => {
const { result, rerender } = renderHook(() => useValidationErrorHandler({ debug: false }));
const firstCallback = result.current.onValidationError;
rerender();
const secondCallback = result.current.onValidationError;
expect(firstCallback).toBe(secondCallback);
});
it('returns new callback when options change', () => {
const { result, rerender } = renderHook(
({ title }) => useValidationErrorHandler({ toastTitle: title, debug: false }),
{ initialProps: { title: 'Error A' } }
);
const firstCallback = result.current.onValidationError;
rerender({ title: 'Error B' });
const secondCallback = result.current.onValidationError;
expect(firstCallback).not.toBe(secondCallback);
});
});
});