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