- Add can_edit_staff and can_edit_customers dangerous permissions - Move Site Builder, Services, Locations, Time Blocks, Payments to Settings permissions - Link Edit Others' Schedules and Edit Own Schedule permissions - Add permission checks to StaffViewSet (partial_update, toggle_active, verify_email) - Add permission checks to CustomerViewSet (update, partial_update, verify_email) - Fix CustomerViewSet permission key mismatch (can_access_customers) - Hide Edit/Verify buttons on Staff and Customers pages without permission - Make dangerous permissions section more visually distinct (darker red) - Fix StaffDashboard links to use correct paths (/dashboard/my-schedule) - Disable settings sub-permissions when Access Settings is unchecked 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
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: '<p>Original email body</p>',
|
|
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 }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('renders new message mode', () => {
|
|
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
|
expect(screen.getByText('New Message')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders reply mode with subject prefixed', () => {
|
|
render(<EmailComposer {...defaultProps} replyTo={mockReplyToEmail} />, {
|
|
wrapper: createWrapper(),
|
|
});
|
|
expect(screen.getByText('Reply')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue('Re: Original Subject')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders forward mode with subject prefixed', () => {
|
|
render(<EmailComposer {...defaultProps} forwardFrom={mockReplyToEmail} />, {
|
|
wrapper: createWrapper(),
|
|
});
|
|
expect(screen.getByText('Forward')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue('Fwd: Original Subject')).toBeInTheDocument();
|
|
});
|
|
|
|
it('populates recipient in reply mode', () => {
|
|
render(<EmailComposer {...defaultProps} replyTo={mockReplyToEmail} />, {
|
|
wrapper: createWrapper(),
|
|
});
|
|
expect(screen.getByDisplayValue('sender@example.com')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders minimized state', () => {
|
|
render(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
|
|
|
const bccButton = screen.getByText('Bcc');
|
|
fireEvent.click(bccButton);
|
|
|
|
expect(screen.getByPlaceholderText('bcc@example.com')).toBeInTheDocument();
|
|
});
|
|
|
|
it('updates subject field', () => {
|
|
render(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
|
|
|
expect(screen.getByText('staffEmail.send')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders save draft button', () => {
|
|
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
|
|
|
expect(screen.getByText('Save draft')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders formatting buttons', () => {
|
|
render(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
|
|
|
expect(screen.getByText('From:')).toBeInTheDocument();
|
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
});
|
|
|
|
it('populates from address with default value', async () => {
|
|
render(<EmailComposer {...defaultProps} />, { 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(<EmailComposer {...defaultProps} replyTo={emailWithReply} />, {
|
|
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(<EmailComposer {...defaultProps} forwardFrom={emailWithFwd} />, {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
expect(screen.getByDisplayValue('Fwd: Already Forwarded')).toBeInTheDocument();
|
|
});
|
|
|
|
it('includes original message in reply body', () => {
|
|
render(<EmailComposer {...defaultProps} replyTo={mockReplyToEmail} />, {
|
|
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(<EmailComposer {...defaultProps} forwardFrom={mockReplyToEmail} />, {
|
|
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(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
|
|
|
expect(screen.getByText('From:')).toBeInTheDocument();
|
|
expect(screen.getByText('To:')).toBeInTheDocument();
|
|
expect(screen.getByText('Subject:')).toBeInTheDocument();
|
|
});
|
|
});
|