diff --git a/apps/web/src/components/settings/security/two-factor-setup/useTwoFactorSetup.test.ts b/apps/web/src/components/settings/security/two-factor-setup/useTwoFactorSetup.test.ts new file mode 100644 index 000000000..589951ba0 --- /dev/null +++ b/apps/web/src/components/settings/security/two-factor-setup/useTwoFactorSetup.test.ts @@ -0,0 +1,122 @@ +/** + * useTwoFactorSetup regression tests + * Covers Bug #3: toast calls must use .success() not direct toast() + * + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// Mock toast - verify .success() is called, not bare toast() +const mockToastSuccess = vi.fn(); +const mockToastError = vi.fn(); +vi.mock('@/utils/toast', () => ({ + default: { + success: (...args: unknown[]) => mockToastSuccess(...args), + error: (...args: unknown[]) => mockToastError(...args), + loading: vi.fn(), + }, +})); + +// Mock 2FA service +vi.mock('@/services/2fa-service', () => ({ + twoFactorService: { + setup: vi.fn().mockResolvedValue({ + secret: 'JBSWY3DPEHPK3PXP', + qr_code_url: 'otpauth://totp/Veza:user@example.com?secret=JBSWY3DPEHPK3PXP', + recovery_codes: ['code1', 'code2', 'code3'], + }), + verify: vi.fn().mockResolvedValue({}), + }, +})); + +// Mock clipboard +Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, +}); + +// Mock URL.createObjectURL +global.URL.createObjectURL = vi.fn(() => 'blob:mock'); + +describe('useTwoFactorSetup', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Bug #3 regression: toast() direct calls should be toast.success() + it('[Bug #3] copyCodes should call toast.success, not bare toast()', async () => { + const { useTwoFactorSetup } = await import('./useTwoFactorSetup'); + const onBack = vi.fn(); + const onComplete = vi.fn(); + + const container = document.createElement('div'); + document.body.appendChild(container); + const { result } = renderHook(() => useTwoFactorSetup(onBack, onComplete), { container }); + + // Move to step 2 (totp) to trigger setup data fetch + act(() => { + result.current.goToStep2Totp(); + }); + + // Wait for setup data to load + await vi.waitFor(() => { + expect(result.current.setupData).not.toBeNull(); + }); + + // Copy codes + act(() => { + result.current.copyCodes(); + }); + + expect(mockToastSuccess).toHaveBeenCalledWith('Backup codes copied to clipboard'); + // Ensure bare toast() was NOT called (it would throw TypeError) + }); + + it('[Bug #3] downloadCodes should call toast.success, not bare toast()', async () => { + const { useTwoFactorSetup } = await import('./useTwoFactorSetup'); + const onBack = vi.fn(); + const onComplete = vi.fn(); + + const container = document.createElement('div'); + document.body.appendChild(container); + const { result } = renderHook(() => useTwoFactorSetup(onBack, onComplete), { container }); + + act(() => { + result.current.goToStep2Totp(); + }); + + await vi.waitFor(() => { + expect(result.current.setupData).not.toBeNull(); + }); + + act(() => { + result.current.downloadCodes(); + }); + + expect(mockToastSuccess).toHaveBeenCalledWith('Backup codes downloaded'); + + document.body.removeChild(container); + }); + + it('[Bug #3] handleSmsUnavailable should call toast.success', async () => { + const { useTwoFactorSetup } = await import('./useTwoFactorSetup'); + + const container = document.createElement('div'); + document.body.appendChild(container); + + const { result } = renderHook( + () => useTwoFactorSetup(vi.fn(), vi.fn()), + { container }, + ); + + act(() => { + result.current.handleSmsUnavailable(); + }); + + expect(mockToastSuccess).toHaveBeenCalledWith( + 'SMS method not yet available in this region', + ); + + document.body.removeChild(container); + }); +}); diff --git a/apps/web/src/components/ui/checkbox.test.tsx b/apps/web/src/components/ui/checkbox.test.tsx index 79b274735..bbb3293fb 100644 --- a/apps/web/src/components/ui/checkbox.test.tsx +++ b/apps/web/src/components/ui/checkbox.test.tsx @@ -79,4 +79,34 @@ describe('Checkbox Component', () => { const label = screen.getByRole('checkbox').closest('label'); expect(label).toHaveClass('custom-checkbox'); }); + + // === Bug #11 Regression: no hardcoded "Checkbox" aria-label === + + it('[Bug #11] should not have hardcoded "Checkbox" aria-label when no label provided', () => { + render(); + + const checkbox = screen.getByRole('checkbox'); + const ariaLabel = checkbox.getAttribute('aria-label'); + // Should NOT be the hardcoded string "Checkbox" + expect(ariaLabel).not.toBe('Checkbox'); + }); + + it('[Bug #11] should use explicit aria-label when provided', () => { + render(); + + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Enable notifications'); + }); + + it('[Bug #11] should get accessible name from associated label via htmlFor', () => { + render( + <> + + + , + ); + + const checkbox = screen.getByRole('checkbox', { name: /email notifications/i }); + expect(checkbox).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/ui/radio-group.test.tsx b/apps/web/src/components/ui/radio-group.test.tsx index 18864c442..ccb353c0a 100644 --- a/apps/web/src/components/ui/radio-group.test.tsx +++ b/apps/web/src/components/ui/radio-group.test.tsx @@ -105,4 +105,78 @@ describe('RadioGroup Component', () => { const radioGroup = container.querySelector('.custom-radio-group'); expect(radioGroup).toBeInTheDocument(); }); + + // === Bug #6 Regression: mutual exclusion with wrapped items === + + it('[Bug #6] enforces mutual exclusion when items are wrapped in divs', () => { + const onChange = vi.fn(); + render( + +
+ + +
+
+ + +
+
+ + +
+
, + ); + + // Use input elements to check checked state (labels + inputs both have role="radio") + const inputs = document.querySelectorAll('input[type="radio"]'); + expect(inputs).toHaveLength(3); + expect(inputs[0]).toBeChecked(); // light = checked + expect(inputs[1]).not.toBeChecked(); // dark = unchecked + expect(inputs[2]).not.toBeChecked(); // auto = unchecked + + // Click the dark radio label + const labels = document.querySelectorAll('label[role="radio"]'); + fireEvent.click(labels[1]); + expect(onChange).toHaveBeenCalledWith('dark'); + }); + + it('[Bug #6] never has two radios checked simultaneously after value change', () => { + const { rerender } = render( + +
+
+
, + ); + + let inputs = document.querySelectorAll('input[type="radio"]'); + expect(inputs[0]).not.toBeChecked(); + expect(inputs[1]).toBeChecked(); + + rerender( + +
+
+
, + ); + + inputs = document.querySelectorAll('input[type="radio"]'); + expect(inputs[0]).toBeChecked(); + expect(inputs[1]).not.toBeChecked(); + }); + + it('[Bug #6] assigns shared name attribute to all radio inputs', () => { + render( + +
+
+
, + ); + + const inputs = document.querySelectorAll('input[type="radio"]'); + expect(inputs).toHaveLength(2); + const name0 = inputs[0].getAttribute('name'); + const name1 = inputs[1].getAttribute('name'); + expect(name0).toBeTruthy(); + expect(name0).toBe(name1); + }); }); diff --git a/apps/web/src/features/settings/components/PreferenceSettings.test.tsx b/apps/web/src/features/settings/components/PreferenceSettings.test.tsx index 05e17e834..8f7008c5f 100644 --- a/apps/web/src/features/settings/components/PreferenceSettings.test.tsx +++ b/apps/web/src/features/settings/components/PreferenceSettings.test.tsx @@ -59,6 +59,7 @@ vi.mock('@/hooks/useTranslation', () => ({ const translations: Record = { 'settings.language.language': 'Language', 'settings.language.title': 'Language and Region', + 'settings.preferences.timezone': 'Time Zone', 'settings.language.description': 'Choose your preferred language', 'settings.appearance.theme': 'Theme', 'settings.appearance.light': 'Light', @@ -96,7 +97,8 @@ describe('PreferenceSettings Component', () => { ); expect(screen.getByText('Language')).toBeInTheDocument(); - expect(screen.getByText('Language and Region')).toBeInTheDocument(); + // Bug #18: label was "Language and Region", now correctly "Time Zone" + expect(screen.getByText('Time Zone')).toBeInTheDocument(); expect(screen.getByText('Theme')).toBeInTheDocument(); expect(screen.getByTestId('radio-group')).toBeInTheDocument(); }); diff --git a/apps/web/src/features/settings/components/account-settings/useAccountSettings.test.ts b/apps/web/src/features/settings/components/account-settings/useAccountSettings.test.ts new file mode 100644 index 000000000..e66ee3bdd --- /dev/null +++ b/apps/web/src/features/settings/components/account-settings/useAccountSettings.test.ts @@ -0,0 +1,213 @@ +/** + * useAccountSettings regression tests + * Covers Bug #1: Password change API call + * Covers Bug #9: Delete account DELETE validation + * Covers Bug #17: Password error persists on input change + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAccountSettings } from './useAccountSettings'; + +// Mock dependencies +vi.mock('@/features/auth/store/authStore', () => ({ + useAuthStore: () => ({ logout: vi.fn() }), +})); + +vi.mock('@/services/api/client', () => ({ + apiClient: { + put: vi.fn(), + delete: vi.fn(), + post: vi.fn(), + }, +})); + +vi.mock('@/utils/apiErrorHandler', () => ({ + parseApiError: (e: any) => ({ message: e.message || 'Unknown error', code: 'ERROR' }), +})); + +vi.mock('@/hooks/useToast', () => ({ + useToast: () => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }), +})); + +describe('useAccountSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Bug #17 regression: password error should clear on input change + describe('[Bug #17] Password error clearing', () => { + it('should clear password error when current password changes', async () => { + const { result } = renderHook(() => useAccountSettings()); + + // Simulate setting an error + await act(async () => { + result.current.setNewPassword('short'); + result.current.setConfirmPassword('short'); + }); + + // Trigger validation error by submitting with short password + await act(async () => { + const fakeEvent = { preventDefault: vi.fn() } as any; + await result.current.handleChangePassword(fakeEvent); + }); + + expect(result.current.passwordError).toBeTruthy(); + + // Now change current password - error should clear + act(() => { + result.current.setCurrentPassword('something'); + }); + + expect(result.current.passwordError).toBe(''); + }); + + it('should clear password error when new password changes', async () => { + const { result } = renderHook(() => useAccountSettings()); + + // Set mismatched passwords and trigger error + await act(async () => { + result.current.setNewPassword('NewPassword123!'); + result.current.setConfirmPassword('DifferentPass123!'); + }); + + await act(async () => { + const fakeEvent = { preventDefault: vi.fn() } as any; + await result.current.handleChangePassword(fakeEvent); + }); + + expect(result.current.passwordError).toBe('New passwords do not match'); + + // Fix the password - error should clear + act(() => { + result.current.setNewPassword('FixedPassword123!'); + }); + + expect(result.current.passwordError).toBe(''); + }); + + it('should clear password error when confirm password changes', async () => { + const { result } = renderHook(() => useAccountSettings()); + + await act(async () => { + result.current.setNewPassword('NewPassword123!'); + result.current.setConfirmPassword('DifferentPass123!'); + }); + + await act(async () => { + const fakeEvent = { preventDefault: vi.fn() } as any; + await result.current.handleChangePassword(fakeEvent); + }); + + expect(result.current.passwordError).toBeTruthy(); + + act(() => { + result.current.setConfirmPassword('NewPassword123!'); + }); + + expect(result.current.passwordError).toBe(''); + }); + }); + + // Bug #9 regression: DELETE text validation + describe('[Bug #9] Delete account validation', () => { + it('should set validation error when DELETE not typed', async () => { + const { result } = renderHook(() => useAccountSettings()); + + act(() => { + result.current.setDeleteConfirmText('wrong'); + }); + + await act(async () => { + await result.current.handleDeleteAccount(); + }); + + expect(result.current.deleteValidationError).toBe('Please type DELETE to confirm'); + }); + + it('should not set validation error when DELETE is typed correctly', async () => { + const { apiClient } = await import('@/services/api/client'); + vi.mocked(apiClient.delete).mockResolvedValue({ data: {} }); + + const { result } = renderHook(() => useAccountSettings()); + + act(() => { + result.current.setDeleteConfirmText('DELETE'); + result.current.setDeletePassword('password123'); + }); + + await act(async () => { + await result.current.handleDeleteAccount(); + }); + + expect(result.current.deleteValidationError).toBeNull(); + }); + }); + + // Password validation + describe('Password validation', () => { + it('should reject passwords shorter than 12 characters', async () => { + const { result } = renderHook(() => useAccountSettings()); + + await act(async () => { + result.current.setCurrentPassword('old'); + result.current.setNewPassword('short'); + result.current.setConfirmPassword('short'); + }); + + await act(async () => { + const fakeEvent = { preventDefault: vi.fn() } as any; + await result.current.handleChangePassword(fakeEvent); + }); + + expect(result.current.passwordError).toBe( + 'Password must be at least 12 characters long', + ); + }); + + it('should reject mismatched passwords', async () => { + const { result } = renderHook(() => useAccountSettings()); + + await act(async () => { + result.current.setCurrentPassword('OldPassword123!'); + result.current.setNewPassword('NewPassword123!'); + result.current.setConfirmPassword('DifferentPass123!'); + }); + + await act(async () => { + const fakeEvent = { preventDefault: vi.fn() } as any; + await result.current.handleChangePassword(fakeEvent); + }); + + expect(result.current.passwordError).toBe('New passwords do not match'); + }); + + // Bug #1 regression: password change should call correct endpoint + it('[Bug #1] should call PUT /users/me/password', async () => { + const { apiClient } = await import('@/services/api/client'); + vi.mocked(apiClient.put).mockResolvedValue({ data: {} }); + + const { result } = renderHook(() => useAccountSettings()); + + await act(async () => { + result.current.setCurrentPassword('OldPassword123!'); + result.current.setNewPassword('NewSecurePass123!'); + result.current.setConfirmPassword('NewSecurePass123!'); + }); + + await act(async () => { + const fakeEvent = { preventDefault: vi.fn() } as any; + await result.current.handleChangePassword(fakeEvent); + }); + + expect(apiClient.put).toHaveBeenCalledWith('/users/me/password', { + current_password: 'OldPassword123!', + new_password: 'NewSecurePass123!', + }); + }); + }); +}); diff --git a/apps/web/src/features/settings/schemas/settingsSchema.test.ts b/apps/web/src/features/settings/schemas/settingsSchema.test.ts new file mode 100644 index 000000000..7c43a088a --- /dev/null +++ b/apps/web/src/features/settings/schemas/settingsSchema.test.ts @@ -0,0 +1,123 @@ +/** + * settingsSchema regression tests + * Covers Bug #5: Schema validation must accept playback field + */ +import { describe, it, expect } from 'vitest'; +import { settingsSchema } from './settingsSchema'; + +describe('settingsSchema', () => { + const validSettings = { + notifications: { + email_notifications: true, + push_notifications: true, + browser_notifications: true, + email_on_follow: true, + email_on_like: true, + email_on_comment: true, + email_on_message: true, + email_on_mention: true, + email_marketing: false, + }, + privacy: { + allow_search_indexing: true, + show_activity: true, + }, + content: { + explicit_content: false, + autoplay: true, + }, + preferences: { + language: 'en' as const, + timezone: 'UTC', + theme: 'auto' as const, + }, + }; + + it('should validate a complete settings object', () => { + const result = settingsSchema.safeParse(validSettings); + expect(result.success).toBe(true); + }); + + // Bug #5 regression: playback field must be accepted + it('[Bug #5] should accept settings with playback field', () => { + const withPlayback = { + ...validSettings, + playback: { + quality: 'high' as const, + volume: 0.8, + crossfade: 3, + autoplay: true, + }, + }; + const result = settingsSchema.safeParse(withPlayback); + expect(result.success).toBe(true); + }); + + it('[Bug #5] should accept settings without playback (optional)', () => { + const result = settingsSchema.safeParse(validSettings); + expect(result.success).toBe(true); + }); + + it('[Bug #5] should validate playback quality enum', () => { + const withBadQuality = { + ...validSettings, + playback: { + quality: 'ultra-hd', + volume: 0.8, + crossfade: 3, + autoplay: true, + }, + }; + const result = settingsSchema.safeParse(withBadQuality); + expect(result.success).toBe(false); + }); + + it('[Bug #5] should validate playback volume range (0-1)', () => { + const withBadVolume = { + ...validSettings, + playback: { + quality: 'high' as const, + volume: 1.5, + crossfade: 3, + autoplay: true, + }, + }; + const result = settingsSchema.safeParse(withBadVolume); + expect(result.success).toBe(false); + }); + + it('[Bug #5] should validate playback crossfade range (0-12)', () => { + const withBadCrossfade = { + ...validSettings, + playback: { + quality: 'high' as const, + volume: 0.8, + crossfade: 20, + autoplay: true, + }, + }; + const result = settingsSchema.safeParse(withBadCrossfade); + expect(result.success).toBe(false); + }); + + it('should reject invalid theme values', () => { + const withBadTheme = { + ...validSettings, + preferences: { ...validSettings.preferences, theme: 'neon' }, + }; + const result = settingsSchema.safeParse(withBadTheme); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errors[0].message).toContain('Thème invalide'); + } + }); + + it('should reject invalid language values', () => { + const withBadLang = { + ...validSettings, + preferences: { ...validSettings.preferences, language: 'xx' }, + }; + const result = settingsSchema.safeParse(withBadLang); + expect(result.success).toBe(false); + }); +});