diff --git a/frontend/src/__tests__/mail/MailSettings.validation.test.tsx b/frontend/src/__tests__/mail/MailSettings.validation.test.tsx new file mode 100644 index 0000000..21e5e44 --- /dev/null +++ b/frontend/src/__tests__/mail/MailSettings.validation.test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('@/api/mail', () => ({ + fetchAccounts: vi.fn().mockResolvedValue([]), + createAccount: vi.fn().mockResolvedValue({ id: 'a1', email: 'test@example.com', display_name: '', imap_host: '', imap_port: 993, smtp_host: '', smtp_port: 587, is_shared: false, is_active: true }), + deleteAccount: vi.fn(), + testConnection: vi.fn(), + triggerSync: vi.fn(), + updateAccount: vi.fn(), + fetchFolders: vi.fn().mockResolvedValue([]), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }), +})); + +vi.mock('@/components/mail/SignatureManager', () => ({ SignatureManager: () => null })); +vi.mock('@/components/mail/RuleEditor', () => ({ RuleEditor: () => null })); +vi.mock('@/components/mail/LabelManager', () => ({ LabelManager: () => null })); +vi.mock('@/components/mail/VacationResponder', () => ({ VacationResponder: () => null })); +vi.mock('@/components/mail/PgpSettings', () => ({ PgpSettings: () => null })); + +import { MailSettingsPage } from '@/pages/MailSettings'; +import { createAccount } from '@/api/mail'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('MailSettings account form validation (RHF + Zod)', () => { + it('shows validation error when email is empty', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('mail-settings-page')).toBeInTheDocument(); + }, { timeout: 3000 }); + // Click add account button + const addBtn = screen.getByTestId('add-account-btn'); + fireEvent.click(addBtn); + await waitFor(() => { + expect(screen.getByTestId('add-account-form')).toBeInTheDocument(); + }); + // Submit empty form + const form = screen.getByTestId('add-account-form').querySelector('form'); + expect(form).toBeTruthy(); + fireEvent.submit(form!); + await waitFor(() => { + const errors = screen.getAllByText(/erforderlich|required/i); + expect(errors.length).toBeGreaterThan(0); + }); + }); + + it('calls createAccount when form is valid', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('mail-settings-page')).toBeInTheDocument(); + }, { timeout: 3000 }); + const addBtn = screen.getByTestId('add-account-btn'); + fireEvent.click(addBtn); + await waitFor(() => { + expect(screen.getByTestId('add-account-form')).toBeInTheDocument(); + }); + const form = screen.getByTestId('add-account-form').querySelector('form'); + const inputs = form!.querySelectorAll('input'); + // Fill email, imap_host, smtp_host, password + fireEvent.change(inputs[0], { target: { value: 'test@example.com' } }); // email + fireEvent.change(inputs[3], { target: { value: 'imap.example.com' } }); // imap_host + fireEvent.change(inputs[5], { target: { value: 'smtp.example.com' } }); // smtp_host + fireEvent.change(inputs[7], { target: { value: 'password123' } }); // password + fireEvent.submit(form!); + await waitFor(() => { + expect(createAccount).toHaveBeenCalledWith(expect.objectContaining({ email: 'test@example.com', imap_host: 'imap.example.com', smtp_host: 'smtp.example.com' })); + }, { timeout: 3000 }); + }); +}); diff --git a/frontend/src/components/mail/LabelManager.tsx b/frontend/src/components/mail/LabelManager.tsx index 04d2aa6..c3bf95d 100644 --- a/frontend/src/components/mail/LabelManager.tsx +++ b/frontend/src/components/mail/LabelManager.tsx @@ -4,6 +4,9 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Card } from '@/components/ui/Card'; @@ -30,11 +33,23 @@ export function LabelManager() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showForm, setShowForm] = useState(false); - const [name, setName] = useState(''); - const [color, setColor] = useState(PRESET_COLORS[0]); const [saving, setSaving] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); + // ── Label form (RHF + Zod) ── + const labelSchema = z.object({ + name: z.string().min(1, 'required'), + color: z.string().optional().default(PRESET_COLORS[0]), + }); + type LabelFormData = z.infer; + + const { register: registerLabel, handleSubmit: handleSubmitLabel, reset: resetLabel, watch: watchLabel, setValue: setLabelValue, formState: { errors: labelErrors } } = useForm({ + resolver: zodResolver(labelSchema), + defaultValues: { name: '', color: PRESET_COLORS[0] }, + }); + + const colorValue = watchLabel('color'); + const load = useCallback(() => { setLoading(true); fetchLabels() @@ -50,22 +65,20 @@ export function LabelManager() { useEffect(() => { load(); }, [load]); - const handleSave = useCallback(async () => { - if (!name.trim()) return; + const handleSave = useCallback(async (data: LabelFormData) => { setSaving(true); try { - const label = await createLabel({ name, color }); + const label = await createLabel({ name: data.name, color: data.color }); setLabels((prev) => [...prev, label]); toast.success(t('mail.labelCreated')); setShowForm(false); - setName(''); - setColor(PRESET_COLORS[0]); + resetLabel({ name: '', color: PRESET_COLORS[0] }); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } - }, [name, color, toast, t]); + }, [toast, t, resetLabel]); const handleDelete = useCallback(async () => { if (!deleteTarget) return; @@ -104,11 +117,11 @@ export function LabelManager() { {showForm && ( -
+
setName(e.target.value)} + {...registerLabel('name')} + error={labelErrors.name?.message === 'required' ? t('validation.required') : undefined} placeholder={t('mail.labelName')} required /> @@ -119,8 +132,8 @@ export function LabelManager() {
- - + +
- +
)} diff --git a/frontend/src/components/mail/RuleEditor.tsx b/frontend/src/components/mail/RuleEditor.tsx index a540193..efd1ad6 100644 --- a/frontend/src/components/mail/RuleEditor.tsx +++ b/frontend/src/components/mail/RuleEditor.tsx @@ -4,6 +4,9 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Select } from '@/components/ui/Select'; @@ -47,13 +50,23 @@ export function RuleEditor({ accountId }: { accountId: string }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showForm, setShowForm] = useState(false); - const [name, setName] = useState(''); - const [priority, setPriority] = useState(1); const [conditions, setConditions] = useState([{ type: 'from_contains', value: '' }]); const [actions, setActions] = useState([{ type: 'mark_as_read', value: '' }]); const [saving, setSaving] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); + // ── Rule form (RHF + Zod) ── + const ruleSchema = z.object({ + name: z.string().min(1, 'required'), + priority: z.coerce.number().int().min(1, 'invalidNumber').default(1), + }); + type RuleFormData = z.infer; + + const { register: registerRule, handleSubmit: handleSubmitRule, reset: resetRule, formState: { errors: ruleErrors } } = useForm({ + resolver: zodResolver(ruleSchema), + defaultValues: { name: '', priority: 1 }, + }); + const load = useCallback(() => { setLoading(true); fetchRules() @@ -93,14 +106,13 @@ export function RuleEditor({ accountId }: { accountId: string }) { setActions((prev) => prev.filter((_, i) => i !== index)); }; - const handleSave = useCallback(async () => { - if (!name.trim()) return; + const handleSave = useCallback(async (data: RuleFormData) => { setSaving(true); try { const rule = await createRule({ - name, + name: data.name, account_id: accountId, - priority, + priority: data.priority, is_active: true, conditions, actions, @@ -108,8 +120,7 @@ export function RuleEditor({ accountId }: { accountId: string }) { setRules((prev) => [...prev, rule].sort((a, b) => a.priority - b.priority)); toast.success(t('mail.ruleCreated')); setShowForm(false); - setName(''); - setPriority(1); + resetRule({ name: '', priority: 1 }); setConditions([{ type: 'from_contains', value: '' }]); setActions([{ type: 'mark_as_read', value: '' }]); } catch (err) { @@ -117,7 +128,7 @@ export function RuleEditor({ accountId }: { accountId: string }) { } finally { setSaving(false); } - }, [name, accountId, priority, conditions, actions, toast, t]); + }, [accountId, conditions, actions, toast, t, resetRule]); const handleDelete = useCallback(async () => { if (!deleteTarget) return; @@ -156,19 +167,19 @@ export function RuleEditor({ accountId }: { accountId: string }) { {showForm && ( -
+
setName(e.target.value)} + {...registerRule('name')} + error={ruleErrors.name?.message === 'required' ? t('validation.required') : undefined} placeholder={t('mail.ruleName')} required /> setPriority(Number(e.target.value))} + {...registerRule('priority')} + error={ruleErrors.priority?.message === 'invalidNumber' ? t('validation.invalidNumber', 'Invalid number') : undefined} /> {/* Conditions */} @@ -226,10 +237,10 @@ export function RuleEditor({ accountId }: { accountId: string }) {
- - + +
- +
)} diff --git a/frontend/src/components/mail/SignatureManager.tsx b/frontend/src/components/mail/SignatureManager.tsx index b3ae2b1..4beb214 100644 --- a/frontend/src/components/mail/SignatureManager.tsx +++ b/frontend/src/components/mail/SignatureManager.tsx @@ -5,6 +5,9 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Card } from '@/components/ui/Card'; @@ -41,12 +44,24 @@ export function SignatureManager() { const [error, setError] = useState(null); const [editing, setEditing] = useState(null); const [showForm, setShowForm] = useState(false); - const [name, setName] = useState(''); - const [bodyHtml, setBodyHtml] = useState(''); - const [isDefault, setIsDefault] = useState(false); const [saving, setSaving] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); + // ── Signature form (RHF + Zod) ── + const sigSchema = z.object({ + name: z.string().min(1, 'required'), + body_html: z.string().default(''), + is_default: z.boolean().default(false), + }); + type SigFormData = z.infer; + + const { register: registerSig, handleSubmit: handleSubmitSig, reset: resetSig, watch: watchSig, setValue: setSigValue, formState: { errors: sigErrors } } = useForm({ + resolver: zodResolver(sigSchema), + defaultValues: { name: '', body_html: '', is_default: false }, + }); + + const bodyHtmlValue = watchSig('body_html'); + const load = useCallback(() => { setLoading(true); fetchSignatures() @@ -64,30 +79,25 @@ export function SignatureManager() { const handleNew = () => { setEditing(null); - setName(''); - setBodyHtml(''); - setIsDefault(false); + resetSig({ name: '', body_html: '', is_default: false }); setShowForm(true); }; const handleEdit = (sig: MailSignature) => { setEditing(sig); - setName(sig.name); - setBodyHtml(sig.body_html); - setIsDefault(sig.is_default); + resetSig({ name: sig.name, body_html: sig.body_html, is_default: sig.is_default }); setShowForm(true); }; - const handleSave = useCallback(async () => { - if (!name.trim()) return; + const handleSave = useCallback(async (data: SigFormData) => { setSaving(true); try { if (editing) { - const updated = await updateSignature(editing.id, { name, body_html: bodyHtml, is_default: isDefault }); + const updated = await updateSignature(editing.id, { name: data.name, body_html: data.body_html, is_default: data.is_default }); setSignatures((prev) => prev.map((s) => (s.id === editing.id ? updated : s))); toast.success(t('mail.signatureUpdated')); } else { - const created = await createSignature({ name, body_html: bodyHtml, is_default: isDefault }); + const created = await createSignature({ name: data.name, body_html: data.body_html, is_default: data.is_default }); setSignatures((prev) => [...prev, created]); toast.success(t('mail.signatureCreated')); } @@ -97,7 +107,7 @@ export function SignatureManager() { } finally { setSaving(false); } - }, [editing, name, bodyHtml, isDefault, toast, t]); + }, [editing, toast, t]); const handleDelete = useCallback(async () => { if (!deleteTarget) return; @@ -136,11 +146,11 @@ export function SignatureManager() { {showForm && ( -
+
setName(e.target.value)} + {...registerSig('name')} + error={sigErrors.name?.message === 'required' ? t('validation.required') : undefined} placeholder={t('mail.signatureName')} required /> @@ -152,7 +162,7 @@ export function SignatureManager() {
setSigValue('body_html', html)} placeholder="

Mit freundlichen Grüßen,
{{user.name}}

" />
- - + +
- +
)} diff --git a/frontend/src/components/mail/VacationResponder.tsx b/frontend/src/components/mail/VacationResponder.tsx index 25e0e04..27f80b7 100644 --- a/frontend/src/components/mail/VacationResponder.tsx +++ b/frontend/src/components/mail/VacationResponder.tsx @@ -1,53 +1,90 @@ /** * Vacation responder — toggle + date range + auto-reply text. + * Form validation: React Hook Form + Zod. */ -import React, { useState, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Card } from '@/components/ui/Card'; import { useToast } from '@/components/ui/Toast'; import { configureVacation, type VacationPayload } from '@/api/mail'; +// ── Zod Schema ── +const vacationSchema = z.object({ + enabled: z.boolean().default(false), + start_date: z.string().optional().default(''), + end_date: z.string().optional().default(''), + subject: z.string().optional().default(''), + body: z.string().optional().default(''), +}).superRefine((data, ctx) => { + if (data.enabled && data.start_date && data.end_date) { + const start = new Date(data.start_date); + const end = new Date(data.end_date); + if (!Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) { + if (end < start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'endBeforeStart', + path: ['end_date'], + }); + } + } + } +}); + +type VacationFormData = z.infer; + export function VacationResponder({ accountId }: { accountId: string }) { const { t } = useTranslation(); const toast = useToast(); - const [enabled, setEnabled] = useState(false); - const [startDate, setStartDate] = useState(''); - const [endDate, setEndDate] = useState(''); - const [subject, setSubject] = useState(''); - const [body, setBody] = useState(''); - const [saving, setSaving] = useState(false); - const handleSave = useCallback(async () => { - setSaving(true); + const { + register, + handleSubmit, + watch, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(vacationSchema), + defaultValues: { enabled: false, start_date: '', end_date: '', subject: '', body: '' }, + }); + + const enabled = watch('enabled'); + + const onSubmit = useCallback(async (data: VacationFormData) => { try { const payload: VacationPayload = { - enabled, - start_date: startDate || null, - end_date: endDate || null, - subject, - body, + enabled: data.enabled, + start_date: data.start_date || null, + end_date: data.end_date || null, + subject: data.subject, + body: data.body, }; await configureVacation(payload); toast.success(t('mail.vacationSaved')); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); - } finally { - setSaving(false); } - }, [enabled, startDate, endDate, subject, body, toast, t]); + }, [toast, t]); + + const errorMsg = (key: string | undefined) => { + if (!key) return undefined; + if (key === 'endBeforeStart') return t('calendar.endBeforeStart', 'End must be after start'); + return key; + }; return (
-
+
setSubject(e.target.value)} + {...register('subject')} placeholder={t('mail.vacationSubjectPlaceholder')} />