import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ScheduleDialog } from '../schedule-dialog' import { TemplatePicker, EmailTemplate } from '../template-picker' import { MergeFieldsMenu, MergeFieldPreview, replaceMergeFields, } from '../merge-fields' import { PreviewDialog } from '../preview-dialog' // --------------------------------------------------------------------------- // Shared mocks // --------------------------------------------------------------------------- // Render Radix portals inline so dialog content is queryable jest.mock('@radix-ui/react-dialog', () => { const actual = jest.requireActual('@radix-ui/react-dialog') return { ...actual, Portal: ({ children }: { children: React.ReactNode }) => <>{children}, } }) // Render Radix dropdown portal inline jest.mock('@radix-ui/react-dropdown-menu', () => { const actual = jest.requireActual('@radix-ui/react-dropdown-menu') return { ...actual, Portal: ({ children }: { children: React.ReactNode }) => <>{children}, } }) // Render Radix select portal inline jest.mock('@radix-ui/react-select', () => { const actual = jest.requireActual('@radix-ui/react-select') return { ...actual, Portal: ({ children }: { children: React.ReactNode }) => <>{children}, } }) // Render Radix popover portal inline jest.mock('@radix-ui/react-popover', () => { const actual = jest.requireActual('@radix-ui/react-popover') return { ...actual, Portal: ({ children }: { children: React.ReactNode }) => <>{children}, } }) // Mock useToast so we can assert toast calls without real toast state const mockToast = jest.fn() jest.mock('../../toast', () => ({ useToast: () => ({ toast: mockToast }), })) // --------------------------------------------------------------------------- // ScheduleDialog // --------------------------------------------------------------------------- describe('ScheduleDialog', () => { const defaultProps = { open: true, onOpenChange: jest.fn(), onSchedule: jest.fn().mockResolvedValue(undefined), summaryItems: [ { label: 'Subject', value: 'Q1 Update' }, { label: 'Recipients', value: '150 contacts' }, ], } beforeEach(() => { jest.clearAllMocks() jest.useFakeTimers() jest.setSystemTime(new Date('2026-01-01T08:00:00.000Z')) }) afterEach(() => { jest.runOnlyPendingTimers() jest.useRealTimers() }) it('renders the default title', () => { render() expect(screen.getByRole('heading', { name: 'Schedule Send' })).toBeInTheDocument() }) it('renders the default description', () => { render() expect(screen.getByText('Choose when to send this email')).toBeInTheDocument() }) it('renders a custom title and description', () => { render( ) expect(screen.getByRole('heading', { name: 'Schedule Campaign' })).toBeInTheDocument() expect(screen.getByText('Pick a delivery time')).toBeInTheDocument() }) it('renders summary item labels and values', () => { render() expect(screen.getByText('Subject:')).toBeInTheDocument() expect(screen.getByText('Q1 Update')).toBeInTheDocument() expect(screen.getByText('Recipients:')).toBeInTheDocument() expect(screen.getByText('150 contacts')).toBeInTheDocument() }) it('renders all four quick schedule option buttons', () => { render() expect(screen.getByRole('button', { name: 'Tomorrow 9 AM' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Tomorrow 2 PM' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Monday 9 AM' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument() }) it('renders Date and Time labels', () => { render() expect(screen.getByText('Date')).toBeInTheDocument() expect(screen.getByText('Time')).toBeInTheDocument() }) it('renders Cancel and Schedule Send buttons', () => { render() expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /schedule send/i })).toBeInTheDocument() }) it('calls onOpenChange(false) when Cancel is clicked', () => { render() fireEvent.click(screen.getByRole('button', { name: /cancel/i })) expect(defaultProps.onOpenChange).toHaveBeenCalledWith(false) }) it('Schedule Send button is enabled when default date is in the future', () => { // System time = 2026-01-01T08:00:00Z. Default date = addDays(now, 1) at 09:00 — valid. render() expect(screen.getByRole('button', { name: /schedule send/i })).not.toBeDisabled() }) it('calls onSchedule with an ISO date string when Schedule Send is clicked', async () => { render() await act(async () => { fireEvent.click(screen.getByRole('button', { name: /schedule send/i })) }) await waitFor(() => { expect(defaultProps.onSchedule).toHaveBeenCalledTimes(1) }) const calledWith: string = defaultProps.onSchedule.mock.calls[0][0] expect(typeof calledWith).toBe('string') expect(isNaN(Date.parse(calledWith))).toBe(false) }) it('calls onSaveDraft before onSchedule when hasUnsavedChanges is true', async () => { const onSaveDraft = jest.fn().mockResolvedValue(undefined) const onSchedule = jest.fn().mockResolvedValue(undefined) render( ) await act(async () => { fireEvent.click(screen.getByRole('button', { name: /schedule send/i })) }) await waitFor(() => { expect(onSaveDraft).toHaveBeenCalled() expect(onSchedule).toHaveBeenCalled() }) expect(onSaveDraft.mock.invocationCallOrder[0]).toBeLessThan( onSchedule.mock.invocationCallOrder[0] ) }) it('does not call onSaveDraft when hasUnsavedChanges is false', async () => { const onSaveDraft = jest.fn().mockResolvedValue(undefined) const onSchedule = jest.fn().mockResolvedValue(undefined) render( ) await act(async () => { fireEvent.click(screen.getByRole('button', { name: /schedule send/i })) }) await waitFor(() => expect(onSchedule).toHaveBeenCalled()) expect(onSaveDraft).not.toHaveBeenCalled() }) it('calls onPrepareRecipients before onSchedule when provided', async () => { const onPrepareRecipients = jest.fn().mockResolvedValue(undefined) const onSchedule = jest.fn().mockResolvedValue(undefined) render( ) await act(async () => { fireEvent.click(screen.getByRole('button', { name: /schedule send/i })) }) await waitFor(() => { expect(onPrepareRecipients).toHaveBeenCalled() expect(onSchedule).toHaveBeenCalled() }) expect(onPrepareRecipients.mock.invocationCallOrder[0]).toBeLessThan( onSchedule.mock.invocationCallOrder[0] ) }) it('shows a success toast and closes dialog after successful scheduling', async () => { const onOpenChange = jest.fn() render() await act(async () => { fireEvent.click(screen.getByRole('button', { name: /schedule send/i })) }) await waitFor(() => { expect(mockToast).toHaveBeenCalledWith( expect.objectContaining({ description: expect.stringContaining('Scheduled for') }) ) expect(onOpenChange).toHaveBeenCalledWith(false) }) }) it('shows a destructive toast when onSchedule throws', async () => { const onSchedule = jest.fn().mockRejectedValue(new Error('Network error')) render() await act(async () => { fireEvent.click(screen.getByRole('button', { name: /schedule send/i })) }) await waitFor(() => { expect(mockToast).toHaveBeenCalledWith( expect.objectContaining({ description: 'Network error', variant: 'destructive', }) ) }) }) it('shows scheduled preview after clicking a quick option', async () => { render() await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Next week' })) }) expect(screen.getByText(/Scheduled for:/)).toBeInTheDocument() }) it('renders custom scheduledInfoMessage in the preview box', async () => { render( ) await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Next week' })) }) expect(screen.getByText('Remember to review before send.')).toBeInTheDocument() }) it('does not render when open is false', () => { render() expect(screen.queryByRole('heading', { name: 'Schedule Send' })).not.toBeInTheDocument() }) }) // --------------------------------------------------------------------------- // TemplatePicker // --------------------------------------------------------------------------- const SAMPLE_TEMPLATES: EmailTemplate[] = [ { id: '1', name: 'Welcome Email', description: 'Onboarding welcome message', subject: 'Welcome to our platform!', blocks: null, category: 'onboarding', isDefault: true, useCount: 10, }, { id: '2', name: 'Follow Up', description: 'Post-demo follow up', subject: 'Great talking with you', blocks: null, category: 'sales', isDefault: false, useCount: 5, }, { id: '3', name: 'Newsletter', description: null, subject: 'Monthly updates', blocks: null, category: 'marketing', isDefault: false, useCount: 0, }, ] describe('TemplatePicker', () => { const defaultProps = { open: true, onOpenChange: jest.fn(), onSelectTemplate: jest.fn(), onLoadTemplates: jest.fn().mockResolvedValue(SAMPLE_TEMPLATES), } beforeEach(() => { jest.clearAllMocks() defaultProps.onLoadTemplates.mockResolvedValue(SAMPLE_TEMPLATES) }) it('shows a loading state while templates are being fetched', async () => { let resolve!: (v: EmailTemplate[]) => void const onLoadTemplates = jest.fn( () => new Promise((res) => { resolve = res }) ) render() expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument() await act(async () => { resolve([]) }) }) it('renders all templates after loading', async () => { render() await waitFor(() => { expect(screen.getByText('Welcome Email')).toBeInTheDocument() expect(screen.getByText('Follow Up')).toBeInTheDocument() expect(screen.getByText('Newsletter')).toBeInTheDocument() }) }) it('renders template descriptions when present', async () => { render() await waitFor(() => { expect(screen.getByText('Onboarding welcome message')).toBeInTheDocument() expect(screen.getByText('Post-demo follow up')).toBeInTheDocument() }) }) it('renders template subjects', async () => { render() await waitFor(() => { expect(screen.getByText(/Welcome to our platform!/)).toBeInTheDocument() }) }) it('renders a "Default" badge for default templates', async () => { render() await waitFor(() => { expect(screen.getByText('Default')).toBeInTheDocument() }) }) it('shows available template count', async () => { render() await waitFor(() => { expect(screen.getByText('3 templates available')).toBeInTheDocument() }) }) it('renders the search input', async () => { render() await waitFor(() => { expect(screen.getByPlaceholderText('Search templates...')).toBeInTheDocument() }) }) it('filters templates by name when searching', async () => { render() await waitFor(() => screen.getByPlaceholderText('Search templates...')) fireEvent.change(screen.getByPlaceholderText('Search templates...'), { target: { value: 'follow' }, }) expect(screen.getByText('Follow Up')).toBeInTheDocument() expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument() expect(screen.queryByText('Newsletter')).not.toBeInTheDocument() }) it('filters templates by subject when searching', async () => { render() await waitFor(() => screen.getByPlaceholderText('Search templates...')) fireEvent.change(screen.getByPlaceholderText('Search templates...'), { target: { value: 'monthly' }, }) expect(screen.getByText('Newsletter')).toBeInTheDocument() expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument() }) it('filters templates by description when searching', async () => { render() await waitFor(() => screen.getByPlaceholderText('Search templates...')) fireEvent.change(screen.getByPlaceholderText('Search templates...'), { target: { value: 'onboarding' }, }) expect(screen.getByText('Welcome Email')).toBeInTheDocument() expect(screen.queryByText('Follow Up')).not.toBeInTheDocument() }) it('shows "No templates match your search" when no results', async () => { render() await waitFor(() => screen.getByPlaceholderText('Search templates...')) fireEvent.change(screen.getByPlaceholderText('Search templates...'), { target: { value: 'xyzzy-no-match' }, }) expect(screen.getByText('No templates match your search')).toBeInTheDocument() }) it('updates count as search filters results', async () => { render() await waitFor(() => screen.getByPlaceholderText('Search templates...')) fireEvent.change(screen.getByPlaceholderText('Search templates...'), { target: { value: 'follow' }, }) expect(screen.getByText('1 template available')).toBeInTheDocument() }) it('calls onSelectTemplate with the clicked template and closes the dialog', async () => { const onSelectTemplate = jest.fn() const onOpenChange = jest.fn() render( ) await waitFor(() => screen.getByText('Follow Up')) fireEvent.click(screen.getByRole('option', { name: /select follow up template/i })) expect(onSelectTemplate).toHaveBeenCalledTimes(1) expect(onSelectTemplate).toHaveBeenCalledWith( expect.objectContaining({ id: '2', name: 'Follow Up' }) ) expect(onOpenChange).toHaveBeenCalledWith(false) }) it('filters by categoryFilter prop', async () => { render() await waitFor(() => { expect(screen.getByText('Follow Up')).toBeInTheDocument() expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument() expect(screen.queryByText('Newsletter')).not.toBeInTheDocument() }) }) it('shows error state when onLoadTemplates rejects', async () => { const onLoadTemplates = jest.fn().mockRejectedValue(new Error('API down')) render() await waitFor(() => { expect(screen.getByText('Failed to load templates')).toBeInTheDocument() }) }) it('shows Retry button on error and retries on click', async () => { const onLoadTemplates = jest.fn() .mockRejectedValueOnce(new Error('API down')) .mockResolvedValueOnce(SAMPLE_TEMPLATES) render() await waitFor(() => screen.getByText('Retry')) fireEvent.click(screen.getByText('Retry')) await waitFor(() => { expect(screen.getByText('Welcome Email')).toBeInTheDocument() }) }) it('shows empty state when no templates are returned', async () => { const onLoadTemplates = jest.fn().mockResolvedValue([]) render() await waitFor(() => { expect(screen.getByText('No templates available')).toBeInTheDocument() }) }) it('shows "Create Default Templates" button in empty state when onSeedTemplates is provided', async () => { const onLoadTemplates = jest.fn().mockResolvedValue([]) const onSeedTemplates = jest.fn().mockResolvedValue(undefined) render( ) await waitFor(() => { expect(screen.getByText('Create Default Templates')).toBeInTheDocument() }) }) it('normalizes templates using subjectTemplate when subject is empty', async () => { const rawTemplates = [ { id: '99', name: 'Alt Template', description: null, subject: '', blocks: null, category: null, isDefault: false, useCount: 0, subjectTemplate: 'Fallback Subject', }, ] const onLoadTemplates = jest.fn().mockResolvedValue(rawTemplates) render() await waitFor(() => { expect(screen.getByText(/Fallback Subject/)).toBeInTheDocument() }) }) it('calls onOpenChange(false) when Cancel is clicked', async () => { const onOpenChange = jest.fn() render() await waitFor(() => screen.getByText('Cancel')) fireEvent.click(screen.getByText('Cancel')) expect(onOpenChange).toHaveBeenCalledWith(false) }) it('does not render content when open is false', () => { render() expect(screen.queryByText('Choose a Template')).not.toBeInTheDocument() }) it('resets search when dialog is reopened', async () => { const { rerender } = render() await waitFor(() => screen.getByPlaceholderText('Search templates...')) fireEvent.change(screen.getByPlaceholderText('Search templates...'), { target: { value: 'follow' }, }) expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument() rerender() rerender() await waitFor(() => { expect(screen.getByText('Welcome Email')).toBeInTheDocument() }) }) }) // --------------------------------------------------------------------------- // MergeFieldsMenu // --------------------------------------------------------------------------- describe('MergeFieldsMenu', () => { beforeEach(() => { jest.clearAllMocks() }) it('renders the "Insert Merge Field" trigger button by default', () => { render() expect(screen.getByRole('button', { name: /insert merge field/i })).toBeInTheDocument() }) it('renders a compact trigger button when variant is "compact"', () => { render() expect(screen.getByText('Merge')).toBeInTheDocument() }) it('opens the dropdown menu when trigger is clicked', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /insert merge field/i })) await waitFor(() => { expect(screen.getByText('Recipient')).toBeInTheDocument() }) }) it('renders all default category labels after opening', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /insert merge field/i })) await waitFor(() => { expect(screen.getByText('Recipient')).toBeInTheDocument() // 'Organization' appears as both a category label and a field label — // assert at least one instance is present expect(screen.getAllByText('Organization').length).toBeGreaterThanOrEqual(1) expect(screen.getByText('Sender')).toBeInTheDocument() expect(screen.getByText('Date')).toBeInTheDocument() }) }) it('renders field labels from default fields after opening', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /insert merge field/i })) await waitFor(() => { expect(screen.getByText('First Name')).toBeInTheDocument() expect(screen.getByText('Last Name')).toBeInTheDocument() }) }) it('renders example values for each field', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /insert merge field/i })) await waitFor(() => { expect(screen.getByText('John')).toBeInTheDocument() expect(screen.getByText('Acme Corp')).toBeInTheDocument() }) }) it('calls onInsert with the field key when a merge field item is clicked', async () => { const user = userEvent.setup() const onInsert = jest.fn() render() await user.click(screen.getByRole('button', { name: /insert merge field/i })) await waitFor(() => screen.getByText('First Name')) await user.click(screen.getByText('First Name')) expect(onInsert).toHaveBeenCalledTimes(1) expect(onInsert).toHaveBeenCalledWith('{{recipient.firstName}}') }) it('calls onInsert with sender.name key when "Your Company" is clicked', async () => { const user = userEvent.setup() const onInsert = jest.fn() render() await user.click(screen.getByRole('button', { name: /insert merge field/i })) await waitFor(() => screen.getByText('Your Company')) await user.click(screen.getByText('Your Company')) expect(onInsert).toHaveBeenCalledWith('{{sender.name}}') }) it('renders custom category labels when provided', async () => { const user = userEvent.setup() const customFields = [ { key: '{{custom.field}}', label: 'Custom Field', example: 'Value', category: 'custom' }, ] const customCategories = [{ name: 'custom', label: 'Custom' }] render( ) await user.click(screen.getByRole('button', { name: /insert merge field/i })) await waitFor(() => { expect(screen.getByText('Custom')).toBeInTheDocument() expect(screen.getByText('Custom Field')).toBeInTheDocument() }) }) it('calls onInsert with the custom field key', async () => { const user = userEvent.setup() const onInsert = jest.fn() const customFields = [ { key: '{{custom.field}}', label: 'My Field', example: 'ex', category: 'custom' }, ] const customCategories = [{ name: 'custom', label: 'Custom' }] render( ) await user.click(screen.getByRole('button', { name: /insert merge field/i })) await waitFor(() => screen.getByText('My Field')) await user.click(screen.getByText('My Field')) expect(onInsert).toHaveBeenCalledWith('{{custom.field}}') }) }) // --------------------------------------------------------------------------- // MergeFieldPreview (pure function) // --------------------------------------------------------------------------- describe('MergeFieldPreview', () => { it('replaces known merge field tokens with default sample data', () => { const result = MergeFieldPreview({ content: 'Hello {{recipient.firstName}}' }) expect(result).toBe('Hello John') }) it('replaces multiple tokens in a single string', () => { const result = MergeFieldPreview({ content: '{{recipient.firstName}} from {{organization.name}}', }) expect(result).toBe('John from Acme Corp') }) it('overrides default sample data with provided sampleData', () => { const result = MergeFieldPreview({ content: 'Hi {{recipient.firstName}}', sampleData: { '{{recipient.firstName}}': 'Alice' }, }) expect(result).toBe('Hi Alice') }) it('returns content unchanged when no tokens match', () => { const result = MergeFieldPreview({ content: 'Hello world' }) expect(result).toBe('Hello world') }) it('handles empty string content', () => { const result = MergeFieldPreview({ content: '' }) expect(result).toBe('') }) it('replaces sender name token', () => { const result = MergeFieldPreview({ content: 'From: {{sender.name}}', sampleData: { '{{sender.name}}': 'My Biz' }, }) expect(result).toBe('From: My Biz') }) }) // --------------------------------------------------------------------------- // replaceMergeFields (pure utility) // --------------------------------------------------------------------------- describe('replaceMergeFields', () => { it('replaces recipient firstName token', () => { const result = replaceMergeFields('Hi {{recipient.firstName}}', { firstName: 'Alice' }) expect(result).toBe('Hi Alice') }) it('replaces recipient fullName using the name field when firstName/lastName absent', () => { const result = replaceMergeFields('{{recipient.fullName}}', { name: 'Bob Jones' }) expect(result).toBe('Bob Jones') }) it('derives firstName from name when not explicitly provided', () => { const result = replaceMergeFields('{{recipient.firstName}}', { name: 'Bob Jones' }) expect(result).toBe('Bob') }) it('replaces organization name token', () => { const result = replaceMergeFields('{{organization.name}}', {}, { name: 'Acme Corp' }) expect(result).toBe('Acme Corp') }) it('replaces sender tokens', () => { const result = replaceMergeFields( '{{sender.name}} / {{sender.contactName}}', {}, undefined, { name: 'My Biz', contactName: 'Jane' } ) expect(result).toBe('My Biz / Jane') }) it('replaces recipient email token', () => { const result = replaceMergeFields('{{recipient.email}}', { email: 'bob@example.com' }) expect(result).toBe('bob@example.com') }) it('replaces recipient title token', () => { const result = replaceMergeFields('{{recipient.title}}', { title: 'CTO' }) expect(result).toBe('CTO') }) it('replaces title with empty string when not provided', () => { const result = replaceMergeFields('Title: {{recipient.title}}', {}) expect(result).toBe('Title: ') }) it('returns content unchanged when there are no tokens', () => { const result = replaceMergeFields('No tokens here', { firstName: 'X' }) expect(result).toBe('No tokens here') }) it('replaces all occurrences of the same token', () => { const result = replaceMergeFields( '{{recipient.firstName}} and {{recipient.firstName}}', { firstName: 'Alice' } ) expect(result).toBe('Alice and Alice') }) it('replaces organization type token', () => { const result = replaceMergeFields('{{organization.type}}', {}, { type: 'Enterprise' }) expect(result).toBe('Enterprise') }) }) // --------------------------------------------------------------------------- // PreviewDialog // --------------------------------------------------------------------------- describe('PreviewDialog', () => { const defaultProps = { open: true, onOpenChange: jest.fn(), subject: 'Hello {{recipient.firstName}}', bodyHtml: '

Welcome to our platform!

', } beforeEach(() => { jest.clearAllMocks() }) it('renders the "Preview Email" heading', () => { render() expect(screen.getByRole('heading', { name: 'Preview Email' })).toBeInTheDocument() }) it('renders the resolved subject line with merge fields substituted', () => { render() // Default sample recipient has firstName = 'John' expect(screen.getByText('Hello John')).toBeInTheDocument() }) it('renders Desktop, Mobile, and HTML toggle tabs', () => { render() expect(screen.getByRole('tab', { name: /desktop/i })).toBeInTheDocument() expect(screen.getByRole('tab', { name: /mobile/i })).toBeInTheDocument() expect(screen.getByRole('tab', { name: /html/i })).toBeInTheDocument() }) it('defaults to desktop view with an email iframe', () => { render() expect(screen.getByTitle('Email preview')).toBeInTheDocument() }) it('switches to mobile view when Mobile tab is clicked', async () => { const user = userEvent.setup() const { container } = render() await user.click(screen.getByRole('tab', { name: /mobile/i })) const allDivs = Array.from(container.querySelectorAll('div')) const mobileContainer = allDivs.find(d => d.className.includes('max-w-[375px]')) expect(mobileContainer).toBeTruthy() }) it('switches back to desktop view when Desktop tab is clicked after mobile', async () => { const user = userEvent.setup() const { container } = render() await user.click(screen.getByRole('tab', { name: /mobile/i })) await user.click(screen.getByRole('tab', { name: /desktop/i })) const allDivs = Array.from(container.querySelectorAll('div')) const mobileContainer = allDivs.find(d => d.className.includes('max-w-[375px]')) expect(mobileContainer).toBeFalsy() expect(screen.getByTitle('Email preview')).toBeInTheDocument() }) it('switches to HTML source view when HTML tab is clicked', async () => { const user = userEvent.setup() const { container } = render() await user.click(screen.getByRole('tab', { name: /html/i })) const pre = container.querySelector('pre') expect(pre).toBeInTheDocument() expect(pre?.textContent).toContain('') }) it('renders the body HTML inside the iframe src document', async () => { const user = userEvent.setup() const { container } = render() await user.click(screen.getByRole('tab', { name: /html/i })) const pre = container.querySelector('pre') expect(pre?.textContent).toContain('Welcome to our platform!') }) it('renders the unsubscribe text in HTML source by default', async () => { const user = userEvent.setup() const { container } = render() await user.click(screen.getByRole('tab', { name: /html/i })) const pre = container.querySelector('pre') expect(pre?.textContent).toContain('Unsubscribe from these updates') }) it('omits unsubscribe block when unsubscribeText is null', async () => { const user = userEvent.setup() const { container } = render() await user.click(screen.getByRole('tab', { name: /html/i })) const pre = container.querySelector('pre') expect(pre?.textContent).not.toContain('Unsubscribe') }) it('renders custom unsubscribe text in HTML source', async () => { const user = userEvent.setup() const { container } = render() await user.click(screen.getByRole('tab', { name: /html/i })) const pre = container.querySelector('pre') expect(pre?.textContent).toContain('Opt out') }) it('renders the select trigger with the default sample recipient name', () => { render() // The select trigger shows the currently selected recipient name expect(screen.getByText('John Smith')).toBeInTheDocument() }) it('applies recipient merge data to the subject preview', () => { const recipients = [ { id: 'r1', name: 'Alice', mergeData: { '{{recipient.firstName}}': 'Alice' }, }, ] render() expect(screen.getByText('Hello Alice')).toBeInTheDocument() }) it('uses the first custom recipient label for merge field substitution', () => { const recipients = [ { id: 'r1', name: 'Alice', label: 'Alice (alice@example.com)', mergeData: { '{{recipient.firstName}}': 'Alice' }, }, { id: 'r2', name: 'Bob', label: 'Bob (bob@example.com)', mergeData: { '{{recipient.firstName}}': 'Bob' }, }, ] render() // The first recipient is selected by default — subject shows Alice's firstName expect(screen.getByText('Hello Alice')).toBeInTheDocument() }) it('calls onOpenChange(false) when the Close button is clicked', () => { const onOpenChange = jest.fn() render() // There are two "Close" text occurrences: the visible button and an sr-only span // on the dialog's X button. Click the one that is a visible button element. const closeButtons = screen.getAllByText('Close') const visibleButton = closeButtons.find( (el) => el.tagName === 'BUTTON' && !el.classList.contains('sr-only') ) expect(visibleButton).toBeTruthy() fireEvent.click(visibleButton!) expect(onOpenChange).toHaveBeenCalledWith(false) }) it('does not render when open is false', () => { render() expect(screen.queryByRole('heading', { name: 'Preview Email' })).not.toBeInTheDocument() }) it('shows "Subject" label', () => { render() expect(screen.getByText('Subject')).toBeInTheDocument() }) it('shows "Preview as" label', () => { render() expect(screen.getByText('Preview as')).toBeInTheDocument() }) })