Phase 6.1: ComposeModal on RHF + Zod

- Migrated ComposeModal from useState to React Hook Form + Zod
- Zod schema: to (required, email list), cc/bcc (optional, email list), subject (required), body
- Object-level superRefine for comma-separated email validation
- Error display under each field via Input error prop
- Preserved all existing functionality: attachments, signatures, templates, drafts
- Added 4 validation tests (empty to, invalid email, empty subject, valid submit)
This commit is contained in:
Agent Zero
2026-07-24 00:21:09 +02:00
parent 888e7fee3e
commit d4aa661164
2 changed files with 220 additions and 74 deletions
@@ -0,0 +1,85 @@
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', () => ({
decodeMimeHeader: (s: string) => s,
fetchTemplates: vi.fn().mockResolvedValue([]),
substituteTemplate: vi.fn().mockResolvedValue({ subject: '', body: '' }),
uploadAttachment: vi.fn(),
replaceSignatureVariables: (s: string) => s,
}));
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: () => ({ user: null, currentTenant: null }),
}));
import { ComposeModal } from '@/components/mail/ComposeModal';
function renderModal(props: Partial<React.ComponentProps<typeof ComposeModal>> = {}) {
const defaultProps = {
open: true,
mode: 'new' as const,
accountId: 'acc1',
replyToMail: null,
forwardMail: null,
signatures: [],
onSend: vi.fn().mockResolvedValue(undefined),
onClose: vi.fn(),
};
return render(<MemoryRouter><ComposeModal {...defaultProps} {...props} /></MemoryRouter>);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('ComposeModal validation (RHF + Zod)', () => {
it('shows validation error when "to" is empty on submit', async () => {
renderModal();
fireEvent.click(screen.getByTestId('compose-send'));
await waitFor(() => {
const toInput = screen.getByTestId('compose-to');
const errorEl = toInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red');
expect(errorEl).toBeTruthy();
});
});
it('shows validation error for invalid email in "to" field', async () => {
renderModal();
fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'not-an-email' } });
fireEvent.click(screen.getByTestId('compose-send'));
await waitFor(() => {
const toInput = screen.getByTestId('compose-to');
const errorEl = toInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red');
expect(errorEl).toBeTruthy();
});
});
it('shows validation error when subject is empty', async () => {
renderModal();
fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'valid@example.com' } });
fireEvent.click(screen.getByTestId('compose-send'));
await waitFor(() => {
const subjectInput = screen.getByTestId('compose-subject');
const errorEl = subjectInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red');
expect(errorEl).toBeTruthy();
});
});
it('calls onSend when form is valid', async () => {
const onSend = vi.fn().mockResolvedValue(undefined);
renderModal({ onSend });
fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'valid@example.com' } });
fireEvent.change(screen.getByTestId('compose-subject'), { target: { value: 'Test Subject' } });
fireEvent.click(screen.getByTestId('compose-send'));
await waitFor(() => {
expect(onSend).toHaveBeenCalledTimes(1);
});
});
});