import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
import { renderHook } from '@testing-library/react'
import { useAutoSave } from '../useAutoSave'
import { SendConfirmationDialog } from '../send-confirmation-dialog'
import { ComposeHeader } from '../compose-header'
import { SaveStatusIndicator } from '../save-status-indicator'
import { SubjectInput } from '../subject-input'
import { ComposeLoading } from '../compose-loading'
// Mock Radix portal to render inline for all dialog tests
jest.mock('@radix-ui/react-dialog', () => {
const actual = jest.requireActual('@radix-ui/react-dialog')
return {
...actual,
Portal: ({ children }: { children: React.ReactNode }) => <>{children}>,
}
})
// ---------------------------------------------------------------------------
// useAutoSave
// ---------------------------------------------------------------------------
describe('useAutoSave', () => {
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.runOnlyPendingTimers()
jest.useRealTimers()
})
it('initializes with no unsaved changes when content matches snapshot', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ serializedContent: '', onSave, delay: 1000 })
)
// lastSavedContentRef starts as '' and serializedContent is '' — no changes
expect(result.current.hasUnsavedChanges).toBe(false)
})
it('detects unsaved changes when serializedContent differs from snapshot', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ serializedContent: 'hello world', onSave, delay: 1000 })
)
// lastSavedContentRef.current is '' but serializedContent is 'hello world'
expect(result.current.hasUnsavedChanges).toBe(true)
})
it('fires onSave after the debounce delay when content has changed', async () => {
const onSave = jest.fn().mockResolvedValue(undefined)
renderHook(() =>
useAutoSave({ serializedContent: 'changed', onSave, delay: 1000 })
)
expect(onSave).not.toHaveBeenCalled()
act(() => {
jest.advanceTimersByTime(1000)
})
expect(onSave).toHaveBeenCalledTimes(1)
expect(onSave).toHaveBeenCalledWith(false)
})
it('does not fire onSave when content matches the snapshot', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ serializedContent: '', onSave, delay: 1000 })
)
// Take a snapshot of the initial content so they match
act(() => {
result.current.snapshotContent('')
})
act(() => {
jest.advanceTimersByTime(2000)
})
expect(onSave).not.toHaveBeenCalled()
})
it('tracks that snapshotContent updates the saved-content reference', () => {
// snapshotContent stores the "known saved" baseline in a ref.
// The hasUnsavedChanges flag is derived by comparing serializedContent to
// that ref inside a useEffect. This test verifies the snapshot mechanism:
// start with no changes, take a snapshot of new content, then verify the
// hook re-evaluates when content changes relative to that snapshot.
const onSave = jest.fn().mockResolvedValue(undefined)
const { result, rerender } = renderHook(
({ content }: { content: string }) =>
useAutoSave({ serializedContent: content, onSave, delay: 1000 }),
{ initialProps: { content: '' } }
)
// Empty string matches initial ref — no unsaved changes
expect(result.current.hasUnsavedChanges).toBe(false)
// Snapshot '' as the saved state, then change content to 'edited'
act(() => {
result.current.snapshotContent('')
rerender({ content: 'edited' })
})
// 'edited' !== '' (snapshotted) — changes should now be detected
expect(result.current.hasUnsavedChanges).toBe(true)
})
it('debounces: only fires once when content changes rapidly', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { rerender } = renderHook(
({ content }: { content: string }) =>
useAutoSave({ serializedContent: content, onSave, delay: 1000 }),
{ initialProps: { content: 'v1' } }
)
rerender({ content: 'v2' })
rerender({ content: 'v3' })
act(() => {
jest.advanceTimersByTime(1000)
})
expect(onSave).toHaveBeenCalledTimes(1)
})
it('uses default delay of 30000ms when no delay is provided', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
renderHook(() =>
useAutoSave({ serializedContent: 'some content', onSave })
)
act(() => {
jest.advanceTimersByTime(29999)
})
expect(onSave).not.toHaveBeenCalled()
act(() => {
jest.advanceTimersByTime(1)
})
expect(onSave).toHaveBeenCalledTimes(1)
})
it('clears the timer on unmount to prevent calling save after cleanup', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { unmount } = renderHook(() =>
useAutoSave({ serializedContent: 'will unmount', onSave, delay: 1000 })
)
unmount()
act(() => {
jest.advanceTimersByTime(2000)
})
expect(onSave).not.toHaveBeenCalled()
})
describe('setSaving / setLastSaved', () => {
it('exposes setSaving to update saving state', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ serializedContent: '', onSave, delay: 1000 })
)
expect(result.current.saving).toBe(false)
act(() => {
result.current.setSaving(true)
})
expect(result.current.saving).toBe(true)
})
it('exposes setLastSaved to update lastSaved date', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ serializedContent: '', onSave, delay: 1000 })
)
expect(result.current.lastSaved).toBeNull()
const now = new Date()
act(() => {
result.current.setLastSaved(now)
})
expect(result.current.lastSaved).toBe(now)
})
})
describe('formatLastSaved', () => {
it('returns null when lastSaved is null', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ serializedContent: '', onSave })
)
expect(result.current.formatLastSaved()).toBeNull()
})
it('returns "just now" when saved less than 60 seconds ago', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ serializedContent: '', onSave })
)
act(() => {
result.current.setLastSaved(new Date(Date.now() - 30000))
})
expect(result.current.formatLastSaved()).toBe('just now')
})
it('returns minutes ago when saved between 1 and 60 minutes ago', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ serializedContent: '', onSave })
)
act(() => {
result.current.setLastSaved(new Date(Date.now() - 5 * 60000))
})
expect(result.current.formatLastSaved()).toBe('5m ago')
})
it('returns locale time string when saved more than 60 minutes ago', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ serializedContent: '', onSave })
)
const savedDate = new Date(Date.now() - 2 * 3600000)
act(() => {
result.current.setLastSaved(savedDate)
})
expect(result.current.formatLastSaved()).toBe(savedDate.toLocaleTimeString())
})
it('returns null for an invalid Date', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ serializedContent: '', onSave })
)
act(() => {
result.current.setLastSaved(new Date('invalid'))
})
expect(result.current.formatLastSaved()).toBeNull()
})
})
describe('beforeunload listener', () => {
it('adds a beforeunload listener when there are unsaved changes', () => {
const addSpy = jest.spyOn(window, 'addEventListener')
const onSave = jest.fn().mockResolvedValue(undefined)
renderHook(() =>
useAutoSave({ serializedContent: 'unsaved', onSave, delay: 1000 })
)
const calls = addSpy.mock.calls.filter(([event]) => event === 'beforeunload')
expect(calls.length).toBeGreaterThan(0)
addSpy.mockRestore()
})
it('prevents navigation when there are unsaved changes', () => {
const onSave = jest.fn().mockResolvedValue(undefined)
renderHook(() =>
useAutoSave({ serializedContent: 'unsaved', onSave, delay: 1000 })
)
const event = new Event('beforeunload') as BeforeUnloadEvent
Object.defineProperty(event, 'returnValue', { writable: true, value: '' })
const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
window.dispatchEvent(event)
expect(preventDefaultSpy).toHaveBeenCalled()
})
it('removes the beforeunload listener on unmount', () => {
const removeSpy = jest.spyOn(window, 'removeEventListener')
const onSave = jest.fn().mockResolvedValue(undefined)
const { unmount } = renderHook(() =>
useAutoSave({ serializedContent: 'unsaved', onSave, delay: 1000 })
)
unmount()
const calls = removeSpy.mock.calls.filter(([event]) => event === 'beforeunload')
expect(calls.length).toBeGreaterThan(0)
removeSpy.mockRestore()
})
})
})
// ---------------------------------------------------------------------------
// SendConfirmationDialog
// ---------------------------------------------------------------------------
describe('SendConfirmationDialog', () => {
const defaultProps = {
open: true,
onOpenChange: jest.fn(),
onConfirm: jest.fn(),
sending: false,
summaryItems: [
{ label: 'Recipients', value: '42 contacts' },
{ label: 'Subject', value: 'Q1 Update' },
],
}
beforeEach(() => {
jest.clearAllMocks()
})
it('renders the default title', () => {
render()
expect(screen.getByText('Send Update')).toBeInTheDocument()
})
it('renders a custom title', () => {
render()
expect(screen.getByText('Send Campaign')).toBeInTheDocument()
})
it('renders the default description', () => {
render()
expect(screen.getByText('Are you ready to send this?')).toBeInTheDocument()
})
it('renders a custom description', () => {
render(
)
expect(screen.getByText('Please confirm the send.')).toBeInTheDocument()
})
it('renders all summary item labels and values', () => {
render()
expect(screen.getByText('Recipients:')).toBeInTheDocument()
expect(screen.getByText('42 contacts')).toBeInTheDocument()
expect(screen.getByText('Subject:')).toBeInTheDocument()
expect(screen.getByText('Q1 Update')).toBeInTheDocument()
})
it('renders the default warning message', () => {
render()
expect(
screen.getByText(/Emails will be sent immediately/)
).toBeInTheDocument()
})
it('renders a custom warning message', () => {
render(
)
expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument()
})
it('does not render a warning block when warningMessage is empty string', () => {
const { container } = render(
)
expect(container.querySelector('.bg-amber-50')).not.toBeInTheDocument()
})
it('calls onConfirm when "Send Now" is clicked', () => {
render()
fireEvent.click(screen.getByText('Send Now'))
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1)
})
it('calls onOpenChange(false) when Cancel is clicked', () => {
render()
fireEvent.click(screen.getByText('Cancel'))
expect(defaultProps.onOpenChange).toHaveBeenCalledWith(false)
})
it('disables both buttons while sending', () => {
render()
const cancelBtn = screen.getByText('Cancel').closest('button')
const sendingBtn = screen.getByText('Sending...').closest('button')
expect(cancelBtn).toBeDisabled()
expect(sendingBtn).toBeDisabled()
})
it('shows "Sending..." with a spinner while sending', () => {
const { container } = render()
expect(screen.getByText('Sending...')).toBeInTheDocument()
expect(container.querySelector('.animate-spin')).toBeInTheDocument()
})
it('shows "Send Now" when not sending', () => {
render()
expect(screen.getByText('Send Now')).toBeInTheDocument()
expect(screen.queryByText('Sending...')).not.toBeInTheDocument()
})
it('renders multiple summary items correctly', () => {
const items = [
{ label: 'To', value: '100 contacts' },
{ label: 'From', value: 'hello@example.com' },
{ label: 'Schedule', value: 'Immediately' },
]
render()
expect(screen.getByText('To:')).toBeInTheDocument()
expect(screen.getByText('100 contacts')).toBeInTheDocument()
expect(screen.getByText('From:')).toBeInTheDocument()
expect(screen.getByText('hello@example.com')).toBeInTheDocument()
expect(screen.getByText('Schedule:')).toBeInTheDocument()
expect(screen.getByText('Immediately')).toBeInTheDocument()
})
it('does not render when open is false', () => {
render()
expect(screen.queryByText('Send Update')).not.toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// ComposeHeader
// ---------------------------------------------------------------------------
describe('ComposeHeader', () => {
const defaultProps = {
title: 'Monthly Newsletter',
saving: false,
lastSavedLabel: null,
hasUnsavedChanges: false,
canSend: true,
onBack: jest.fn(),
onSave: jest.fn(),
onPreview: jest.fn(),
onSend: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
it('renders the title', () => {
render()
expect(screen.getByText('Monthly Newsletter')).toBeInTheDocument()
})
it('calls onBack when the back button is clicked', () => {
render()
// ArrowLeft icon button — find by its container role since it has no label text
const buttons = screen.getAllByRole('button')
// First button is the back button (ghost icon button)
fireEvent.click(buttons[0])
expect(defaultProps.onBack).toHaveBeenCalledTimes(1)
})
it('calls onSave when Save is clicked', () => {
render()
fireEvent.click(screen.getByText('Save'))
expect(defaultProps.onSave).toHaveBeenCalledTimes(1)
})
it('calls onPreview when Preview is clicked', () => {
render()
fireEvent.click(screen.getByText('Preview'))
expect(defaultProps.onPreview).toHaveBeenCalledTimes(1)
})
it('calls onSend when Send is clicked', () => {
render()
fireEvent.click(screen.getByText('Send'))
expect(defaultProps.onSend).toHaveBeenCalledTimes(1)
})
it('disables Save button while saving', () => {
render()
const saveBtn = screen.getByText('Save').closest('button')
expect(saveBtn).toBeDisabled()
})
it('disables Send button when canSend is false', () => {
render()
const sendBtn = screen.getByText('Send').closest('button')
expect(sendBtn).toBeDisabled()
})
it('enables Send button when canSend is true', () => {
render()
const sendBtn = screen.getByText('Send').closest('button')
expect(sendBtn).not.toBeDisabled()
})
it('renders extraActions when provided', () => {
render(
Schedule}
/>
)
expect(screen.getByTestId('extra-action')).toBeInTheDocument()
})
it('renders SaveStatusIndicator with the correct props (saving state)', () => {
render()
expect(screen.getByText('Saving...')).toBeInTheDocument()
})
it('renders SaveStatusIndicator with lastSavedLabel', () => {
render()
expect(screen.getByText('Saved just now')).toBeInTheDocument()
})
it('renders SaveStatusIndicator showing unsaved changes', () => {
render()
expect(screen.getByText('Unsaved changes')).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// SaveStatusIndicator
// ---------------------------------------------------------------------------
describe('SaveStatusIndicator', () => {
it('shows "Saving..." when saving is true', () => {
render(
)
expect(screen.getByText('Saving...')).toBeInTheDocument()
})
it('shows a spinning loader icon when saving', () => {
const { container } = render(
)
expect(container.querySelector('.animate-spin')).toBeInTheDocument()
})
it('shows "Saved