import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import EmailComposer from '../EmailComposer';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { StaffEmail } from '../../../types';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
// Mock react-hot-toast
vi.mock('react-hot-toast', () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Mock hooks
vi.mock('../../../hooks/useStaffEmail', () => ({
useCreateDraft: vi.fn(() => ({
mutateAsync: vi.fn(),
isPending: false,
})),
useUpdateDraft: vi.fn(() => ({
mutateAsync: vi.fn(),
isPending: false,
})),
useSendEmail: vi.fn(() => ({
mutateAsync: vi.fn(),
isPending: false,
})),
useUploadAttachment: vi.fn(() => ({
mutateAsync: vi.fn(),
})),
useContactSearch: vi.fn(() => ({
data: [],
})),
useUserEmailAddresses: vi.fn(() => ({
data: [
{
id: 1,
email_address: 'test@example.com',
display_name: 'Test User',
},
],
})),
}));
describe('EmailComposer', () => {
const defaultProps = {
onClose: vi.fn(),
onSent: vi.fn(),
};
const mockReplyToEmail: StaffEmail = {
id: 1,
owner: 1,
emailAddress: 1,
folder: 1,
fromAddress: 'sender@example.com',
fromName: 'Sender Name',
toAddresses: ['test@example.com'],
ccAddresses: [],
bccAddresses: [],
subject: 'Original Subject',
snippet: 'Email snippet',
bodyText: 'Original email body',
bodyHtml: '
Original email body
',
messageId: 'message-123',
inReplyTo: null,
references: '',
status: 'SENT',
threadId: 'thread-123',
emailDate: '2025-01-15T10:00:00Z',
isRead: true,
isStarred: false,
isImportant: false,
isAnswered: false,
isPermanentlyDeleted: false,
deletedAt: null,
hasAttachments: false,
attachmentCount: 0,
attachments: [],
labels: [],
createdAt: '2025-01-15T10:00:00Z',
updatedAt: '2025-01-15T10:00:00Z',
};
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
{children}
);
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders new message mode', () => {
render(, { wrapper: createWrapper() });
expect(screen.getByText('New Message')).toBeInTheDocument();
});
it('renders reply mode with subject prefixed', () => {
render(, {
wrapper: createWrapper(),
});
expect(screen.getByText('Reply')).toBeInTheDocument();
expect(screen.getByDisplayValue('Re: Original Subject')).toBeInTheDocument();
});
it('renders forward mode with subject prefixed', () => {
render(, {
wrapper: createWrapper(),
});
expect(screen.getByText('Forward')).toBeInTheDocument();
expect(screen.getByDisplayValue('Fwd: Original Subject')).toBeInTheDocument();
});
it('populates recipient in reply mode', () => {
render(, {
wrapper: createWrapper(),
});
expect(screen.getByDisplayValue('sender@example.com')).toBeInTheDocument();
});
it('renders minimized state', () => {
render(, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const minimizeButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-minimize-2')
);
if (minimizeButton) {
fireEvent.click(minimizeButton);
}
expect(screen.getByText('New Message')).toBeInTheDocument();
});
it('expands from minimized state', () => {
render(, { wrapper: createWrapper() });
// Minimize
let buttons = screen.getAllByRole('button');
const minimizeButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-minimize-2')
);
if (minimizeButton) {
fireEvent.click(minimizeButton);
}
// Maximize
buttons = screen.getAllByRole('button');
const maximizeButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-maximize-2')
);
if (maximizeButton) {
fireEvent.click(maximizeButton);
}
expect(screen.getByPlaceholderText('recipient@example.com')).toBeInTheDocument();
});
it('calls onClose when close button clicked', () => {
render(, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const closeButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-x')
);
if (closeButton) {
fireEvent.click(closeButton);
}
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('shows Cc field when Cc button clicked', () => {
render(, { wrapper: createWrapper() });
const ccButton = screen.getByText('Cc');
fireEvent.click(ccButton);
expect(screen.getByPlaceholderText('cc@example.com')).toBeInTheDocument();
});
it('shows Bcc field when Bcc button clicked', () => {
render(, { wrapper: createWrapper() });
const bccButton = screen.getByText('Bcc');
fireEvent.click(bccButton);
expect(screen.getByPlaceholderText('bcc@example.com')).toBeInTheDocument();
});
it('updates subject field', () => {
render(, { wrapper: createWrapper() });
const subjectInput = screen.getByPlaceholderText('Email subject');
fireEvent.change(subjectInput, { target: { value: 'Test Subject' } });
expect(subjectInput).toHaveValue('Test Subject');
});
it('updates body field', () => {
render(, { wrapper: createWrapper() });
const bodyTextarea = screen.getByPlaceholderText('Write your message...');
fireEvent.change(bodyTextarea, { target: { value: 'Test email body' } });
expect(bodyTextarea).toHaveValue('Test email body');
});
it('updates to field', () => {
render(, { wrapper: createWrapper() });
const toInput = screen.getByPlaceholderText('recipient@example.com');
fireEvent.change(toInput, { target: { value: 'test@example.com' } });
expect(toInput).toHaveValue('test@example.com');
});
it('updates cc field when shown', () => {
render(, { wrapper: createWrapper() });
fireEvent.click(screen.getByText('Cc'));
const ccInput = screen.getByPlaceholderText('cc@example.com');
fireEvent.change(ccInput, { target: { value: 'cc@example.com' } });
expect(ccInput).toHaveValue('cc@example.com');
});
it('updates bcc field when shown', () => {
render(, { wrapper: createWrapper() });
fireEvent.click(screen.getByText('Bcc'));
const bccInput = screen.getByPlaceholderText('bcc@example.com');
fireEvent.change(bccInput, { target: { value: 'bcc@example.com' } });
expect(bccInput).toHaveValue('bcc@example.com');
});
it('renders send button', () => {
render(, { wrapper: createWrapper() });
expect(screen.getByText('staffEmail.send')).toBeInTheDocument();
});
it('renders save draft button', () => {
render(, { wrapper: createWrapper() });
expect(screen.getByText('Save draft')).toBeInTheDocument();
});
it('renders formatting buttons', () => {
render(, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const boldButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-bold')
);
const italicButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-italic')
);
const underlineButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-underline')
);
expect(boldButton).toBeInTheDocument();
expect(italicButton).toBeInTheDocument();
expect(underlineButton).toBeInTheDocument();
});
it('renders attachment button', () => {
render(, { wrapper: createWrapper() });
// Attachment input is wrapped in a label, not a button
const paperclipIcons = document.querySelectorAll('.lucide-paperclip');
expect(paperclipIcons.length).toBeGreaterThan(0);
});
it('renders discard button', () => {
render(, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const discardButton = buttons.find((btn) =>
btn.querySelector('svg')?.classList.contains('lucide-trash-2')
);
expect(discardButton).toBeInTheDocument();
});
it('renders from address selector', () => {
render(, { wrapper: createWrapper() });
expect(screen.getByText('From:')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('populates from address with default value', async () => {
render(, { wrapper: createWrapper() });
await waitFor(() => {
const select = screen.getByRole('combobox');
expect(select).toHaveValue('1');
});
});
it('does not add Re: prefix if subject already has it', () => {
const emailWithReply = {
...mockReplyToEmail,
subject: 'Re: Already Replied',
};
render(, {
wrapper: createWrapper(),
});
expect(screen.getByDisplayValue('Re: Already Replied')).toBeInTheDocument();
});
it('does not add Fwd: prefix if subject already has it', () => {
const emailWithFwd = {
...mockReplyToEmail,
subject: 'Fwd: Already Forwarded',
};
render(, {
wrapper: createWrapper(),
});
expect(screen.getByDisplayValue('Fwd: Already Forwarded')).toBeInTheDocument();
});
it('includes original message in reply body', () => {
render(, {
wrapper: createWrapper(),
});
const bodyTextarea = screen.getByPlaceholderText('Write your message...');
const value = (bodyTextarea as HTMLTextAreaElement).value;
expect(value).toContain('Original email body');
});
it('includes original message in forward body', () => {
render(, {
wrapper: createWrapper(),
});
const bodyTextarea = screen.getByPlaceholderText('Write your message...');
const value = (bodyTextarea as HTMLTextAreaElement).value;
expect(value).toContain('Original email body');
});
it('renders with all header fields', () => {
render(, { wrapper: createWrapper() });
expect(screen.getByText('From:')).toBeInTheDocument();
expect(screen.getByText('To:')).toBeInTheDocument();
expect(screen.getByText('Subject:')).toBeInTheDocument();
});
});