import { afterEach, describe, expect, it, vi } from 'vitest'; import { prepareQuickReply, type QuickReplyQuestion, type QuickReplyQuestions } from '@/lib/outgoing/quick-reply'; import { registerOutgoingFinalizer, registerPreSendCheck, resetSendPipeline } from '@/lib/outgoing/send-pipeline'; import type { Identity } from '@/lib/jmap/types'; vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); // Quick reply goes through the same checks and finalisers as the composer, // so the disclaimer and the external-recipient confirm reach it too. const html = { id: 'i', name: 'Me', email: 'me@example.com', htmlSignature: 'Me', textSignature: '' } as Identity; const text = { id: 'i', name: 'Me', email: 'me@example.com', htmlSignature: '', textSignature: 'Me' } as Identity; const input = (signatureIdentity: Identity) => ({ body: 'Thanks <3', signatureIdentity, separator: true, to: ['bob@example.com'], fromEmail: 'me@example.com', identityId: 'i', }); afterEach(() => resetSendPipeline()); describe('prepareQuickReply', () => { it('builds the text and, for an HTML signature, an escaped HTML body', async () => { const withHtml = await prepareQuickReply(input(html)); expect(withHtml?.html).toContain('
Thanks <3
'); expect(withHtml?.html).toContain('Me'); expect(withHtml?.text).toContain('Thanks <3'); const plain = await prepareQuickReply(input(text)); expect(plain?.html).toBeUndefined(); expect(plain?.text).toMatch(/Thanks <3[\s\S]*-- \nMe$/); }); it('runs the finalisers with a reply context', async () => { const contexts: unknown[] = []; registerOutgoingFinalizer('f', 10, (bodies, context) => { contexts.push(context); return { html: bodies.html, text: `${bodies.text}\nFooter` }; }); const out = await prepareQuickReply(input(text)); expect(out?.text).toMatch(/\nFooter$/); expect(contexts).toEqual([expect.objectContaining({ surface: 'quick-reply', mode: 'reply', to: ['bob@example.com'], plainText: true })]); }); it('sends nothing when a check says no', async () => { registerPreSendCheck('no', 10, () => false); expect(await prepareQuickReply(input(text))).toBeNull(); }); }); // The composer warns about placeholders and forgotten attachments and asks // "Send this message?" when that is on. Quick reply went out without any of // them, an admin-enforced send confirmation included. describe('prepareQuickReply warnings', () => { const words = [{ before: ['your'], after: [], unfinished: ['TODO'] }]; const questions = (answer: boolean, overrides: Partial = {}) => { const asked: QuickReplyQuestion[] = []; return { asked, questions: { placeholderWords: words, attachmentKeywords: ['attached', 'anbei'], confirmSend: false, ask: async (q: QuickReplyQuestion) => { asked.push(q); return answer; }, ...overrides, } satisfies QuickReplyQuestions, }; }; const reply = (body: string, q: QuickReplyQuestions) => prepareQuickReply({ ...input(text), body, questions: q }); it('asks about unfilled placeholders and stops on "back"', async () => { const { asked, questions: q } = questions(false); expect(await reply('See you at [TIME]. TODO', q)).toBeNull(); expect(asked).toEqual([{ kind: 'placeholders', found: ['[TIME]', 'TODO'] }]); }); it('asks about a forgotten attachment and stops on "back"', async () => { const { asked, questions: q } = questions(false); expect(await reply('Die Datei ist anbei.', q)).toBeNull(); expect(asked).toEqual([{ kind: 'attachment', keyword: 'anbei' }]); }); it('asks before sending when send confirmation is on, after the pre-send checks', async () => { const order: string[] = []; registerPreSendCheck('external', 10, () => { order.push('check'); return true; }); const { questions: q } = questions(false, { confirmSend: true }); const ask = async (question: QuickReplyQuestion) => { order.push(question.kind); return false; }; expect(await reply('Thanks!', { ...q, ask })).toBeNull(); expect(order).toEqual(['check', 'send']); }); it('sends when every question is answered with "send anyway"', async () => { const { asked, questions: q } = questions(true, { confirmSend: true }); const out = await reply('[TIME] works, file attached', q); expect(out?.text).toContain('[TIME] works, file attached'); expect(asked.map((a) => a.kind)).toEqual(['placeholders', 'attachment', 'send']); }); it('asks nothing for a plain reply, or when the warnings are off', async () => { const { asked, questions: q } = questions(false); expect(await reply('Thanks, see you then.', q)).not.toBeNull(); const off = questions(false, { placeholderWords: null, attachmentKeywords: null }); expect(await reply('[TIME], attached', off.questions)).not.toBeNull(); expect([...asked, ...off.asked]).toEqual([]); }); it('leaves the signature out of the placeholder check', async () => { const signed = { ...text, textSignature: 'Me\n[YOUR TITLE]' } as Identity; const { asked, questions: q } = questions(false); expect(await prepareQuickReply({ ...input(signed), body: 'Thanks!', questions: q })).not.toBeNull(); expect(asked).toEqual([]); }); });