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);
+ });
+});