import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; import { useAuthStore } from '@/stores/auth-store'; // A reply draft re-opens in compose mode, where the composer used to have // nothing to rebuild In-Reply-To / References from: every later save (and the // send) dropped the reply out of its thread. // ─── Heavy component mocks (mirrors composer-close-guard.test.tsx) ────────── vi.mock('@/components/email/rich-text-editor', () => ({ RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), })); vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); vi.mock('@/hooks/use-focus-trap', () => ({ useFocusTrap: () => ({ current: null }), })); vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), })); // ─── Store mocks ────────────────────────────────────────────────────────────── vi.mock('@/stores/auth-store', () => { const state = { client: null, identities: [], primaryIdentity: null, isAuthenticated: false, isDemoMode: false, activeAccountId: null, connectionLost: false, getClientForAccount: () => undefined, getAllConnectedClients: () => new Map(), syncIdentities: () => {}, refreshIdentities: async () => {}, }; const hook = (sel?: (s: typeof state) => unknown) => typeof sel === 'function' ? sel(state) : state; hook.getState = () => state; hook.setState = (p: Partial) => Object.assign(state, p); return { useAuthStore: hook }; }); vi.mock('@/stores/identity-store', () => { const state = { identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], defaultIdentityId: 'id-me', }; const hook = (sel?: (s: typeof state) => unknown) => typeof sel === 'function' ? sel(state) : state; hook.getState = () => state; hook.setState = (p: Partial) => Object.assign(state, p); return { useIdentityStore: hook }; }); vi.mock('@/stores/account-store', () => { const state = { accounts: [], getAccountById: () => undefined }; const hook = (sel?: (s: typeof state) => unknown) => typeof sel === 'function' ? sel(state) : state; hook.getState = () => state; hook.setState = (p: Partial) => Object.assign(state, p); return { useAccountStore: hook }; }); vi.mock('@/stores/email-store', () => { const state = { draftSaveEnabled: false, sendRawEmail: async () => ({ sent: true }), }; const hook = (sel?: (s: typeof state) => unknown) => typeof sel === 'function' ? sel(state) : state; hook.getState = () => state; hook.setState = (p: Partial) => Object.assign(state, p); return { useEmailStore: hook }; }); const updateSetting = vi.fn(); vi.mock('@/stores/settings-store', () => { const state = { timeFormat: '24h', plainTextMode: false, subAddressDelimiter: '+', autoSelectReplyIdentity: true, attachmentReminderEnabled: false, attachmentReminderKeywords: [], emptySubjectWarningEnabled: true, sendDelaySeconds: 0, signaturePosition: 'above_quote', signatureSeparatorEnabled: false, requestReadReceiptDefault: false, addTrustedSender: () => {}, trustedSendersAddressBook: null, updateSetting: (...args: unknown[]) => updateSetting(...args), }; const hook = (sel?: (s: typeof state) => unknown) => typeof sel === 'function' ? sel(state) : state; hook.getState = () => state; hook.setState = (p: Partial) => Object.assign(state, p); return { useSettingsStore: hook }; }); vi.mock('@/stores/contact-store', () => { const state = { contacts: [], getAutocomplete: async () => [], addToTrustedSendersBook: async () => {}, }; const hook = (sel?: (s: typeof state) => unknown) => typeof sel === 'function' ? sel(state) : state; hook.getState = () => state; hook.setState = (p: Partial) => Object.assign(state, p); return { useContactStore: hook }; }); vi.mock('@/stores/template-store', () => { const state = { templates: [], addTemplate: async () => {} }; const hook = (sel?: (s: typeof state) => unknown) => typeof sel === 'function' ? sel(state) : state; hook.getState = () => state; hook.setState = (p: Partial) => Object.assign(state, p); return { useTemplateStore: hook }; }); // ─── Misc dependency mocks ──────────────────────────────────────────────────── vi.mock('@/stores/toast-store', () => ({ toast: { info: () => {}, error: () => {}, success: () => {} }, })); vi.mock('@/lib/plugin-hooks', () => ({ identityHooks: { onSignatureRender: { size: 0, transform: async (value: unknown) => value }, onIdentitySelect: { size: 0, emit: async () => {} }, }, templateHooks: { onTemplateRender: { size: 0, transform: async (value: unknown) => value }, onTemplateApply: { size: 0, emit: async () => {} }, }, emailHooks: { onComposerOpen: { call: async () => [] }, onRecipientChange: { call: async () => [] }, getRecipientSuggestions: { call: async () => [] }, onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: async () => 'default' }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, }, contactHooks: { search: { call: async () => [] }, onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, }, })); vi.mock('@/lib/email-sanitization', () => ({ sanitizeSignatureHtml: (v: string) => v, sanitizeEmailHtml: (v: string) => v, parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), })); vi.mock('@/lib/email-threading', () => ({ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), })); vi.mock('@/lib/signature-utils', () => ({ appendPlainTextSignature: (body: string) => body, getPlainTextSignature: () => '', })); vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); vi.mock('@/components/email/quoted-html', () => ({ buildQuotedHtmlBlock: () => '', serializeEditorContent: () => '', })); vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); // ─── Tests ──────────────────────────────────────────────────────────────────── /** * A host that replaces the compose session - a mailto: click, say - must not * drop text the user has not sent. It hands a follow-up to requestCloseRef and * the composer runs it only once it has really closed, so "Save" and "Discard" * continue into the new session while "Cancel" leaves the draft standing. */ const REOPENED_REPLY = { to: 'bob@example.com', cc: '', bcc: '', subject: 'Re: Plans', body: '

Still typing

', showCc: false, showBcc: false, selectedIdentityId: 'id-me', subAddressTag: '', mode: 'compose' as const, draftId: 'draft-7', inReplyTo: ['parent@example.com'], references: ['root@example.com', 'parent@example.com'], }; describe('re-opened reply draft', () => { it('saves with the threading headers it was opened with', async () => { const createDraft = vi.fn().mockResolvedValue('draft-8'); useAuthStore.setState({ client: { createDraft, getEmail: vi.fn().mockResolvedValue({ id: 'draft-8', attachments: [] }), hasDelayedSend: () => false, getMaxDelayedSend: () => 0, } as never, activeAccountId: 'acct-A' as never, }); const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>; render(); fireEvent.change(screen.getByDisplayValue('Re: Plans'), { target: { value: 'Re: Plans, edited' } }); act(() => requestCloseRef.current!(vi.fn())); const dialog = await screen.findByRole('alertdialog'); fireEvent.click(within(dialog).getByText('save')); await waitFor(() => expect(createDraft).toHaveBeenCalled()); const args = createDraft.mock.calls[0]; expect(args[11]).toEqual(['parent@example.com']); expect(args[12]).toEqual(['root@example.com', 'parent@example.com']); useAuthStore.setState({ client: null, activeAccountId: null }); }); });