import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ImapFlow } from 'imapflow'; import { ImapProvider } from './imap.provider'; import type { InboxConfig } from './inbox.types'; /** * ImapProvider.fetchMessages spec (Plan 14-01, Task 2, TDD). * * Net-new coverage for the additive fetchMessages() method (D-02) — the seam * the Plan 14-03 EmailAlertAdapter consumes. fetchPdfAttachments is NOT * touched or retested here (regression coverage lives in the DKV suite). * * ImapFlow is mocked entirely — no real IMAP connection. Mirrors the mock * style used in tender-mail.service.spec.ts (module-level vi.mock + * mockImplementation returning a stub client). */ vi.mock('imapflow', () => ({ ImapFlow: vi.fn(), })); function makeReadable(text: string): NodeJS.ReadableStream { const { Readable } = require('node:stream') as typeof import('stream'); return Readable.from([Buffer.from(text, 'utf8')]); } const BASE_CONFIG: InboxConfig = { protocol: 'imap', host: 'imap.example.com', port: 993, username: 'alerts@example.com', password: 'secret', encryption: 'ssl-tls', folder: 'INBOX', }; /** Body structure with a text/html part (id '1') and a text/plain part (id '2'). */ const MULTIPART_BODY_STRUCTURE = { type: 'multipart/alternative', childNodes: [ { type: 'text/plain', part: '2' }, { type: 'text/html', part: '1' }, ], }; function makeMockClient(overrides: Partial> = {}) { return { connect: vi.fn().mockResolvedValue(undefined), logout: vi.fn().mockResolvedValue(undefined), getMailboxLock: vi.fn().mockResolvedValue({ release: vi.fn() }), search: vi.fn().mockResolvedValue([42]), fetchAll: vi.fn().mockResolvedValue([ { uid: 42, envelope: { messageId: '', subject: 'Neue Ausschreibung verfügbar', from: [{ address: 'noreply@vergabeportal.de' }], date: new Date('2026-07-20T08:00:00Z'), }, bodyStructure: MULTIPART_BODY_STRUCTURE, }, ]), download: vi.fn(async (_uid: string, partId: string) => { if (partId === '1') { return { content: makeReadable('

Hallo Welt

') }; } return { content: makeReadable('Hallo Welt (Text)') }; }), messageFlagsAdd: vi.fn().mockResolvedValue(undefined), ...overrides, }; } /** * Haengt ein Testdoppel als ImapFlow-Klient ein. * * Der Modul-Mock oben ersetzt den Konstruktor durch `vi.fn()`; diese Funktion * ist die einzige Stelle im Test, die das ausnutzt. Vorher stand dieselbe * Umdeutung in jedem einzelnen Fall. */ function useMockClient(client: ReturnType): void { (ImapFlow as unknown as ReturnType).mockImplementation(() => client); } beforeEach(() => { vi.clearAllMocks(); }); describe('ImapProvider.fetchMessages', () => { it('returns one InboxMessage with bodyHtml from the html part and bodyText from the plain part', async () => { const client = makeMockClient(); useMockClient(client); const provider = new ImapProvider(); const messages = await provider.fetchMessages(BASE_CONFIG); expect(messages).toHaveLength(1); expect(messages[0]).toMatchObject({ uid: 42, messageId: '', subject: 'Neue Ausschreibung verfügbar', from: 'noreply@vergabeportal.de', bodyHtml: '

Hallo Welt

', bodyText: 'Hallo Welt (Text)', }); }); it('marks each processed message \\Seen (idempotency for re-polls)', async () => { const client = makeMockClient(); useMockClient(client); const provider = new ImapProvider(); await provider.fetchMessages(BASE_CONFIG); expect(client.messageFlagsAdd).toHaveBeenCalledWith('42', ['\\Seen'], { uid: true }); }); it('honors the same UNSEEN + optional senderFilter search as fetchPdfAttachments', async () => { const client = makeMockClient(); useMockClient(client); const provider = new ImapProvider(); await provider.fetchMessages({ ...BASE_CONFIG, senderFilter: 'vergabeportal.de' }); expect(client.search).toHaveBeenCalledWith( { seen: false, from: 'vergabeportal.de' }, { uid: true }, ); }); it('returns [] on connect error without throwing', async () => { const client = makeMockClient({ connect: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')), }); useMockClient(client); const provider = new ImapProvider(); await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]); }); it('returns [] on search error without throwing', async () => { const client = makeMockClient({ search: vi.fn().mockRejectedValue(new Error('search boom')), }); useMockClient(client); const provider = new ImapProvider(); await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]); expect(client.logout).toHaveBeenCalled(); }); it('returns [] when there are no unread messages', async () => { const client = makeMockClient({ search: vi.fn().mockResolvedValue([]) }); useMockClient(client); const provider = new ImapProvider(); const messages = await provider.fetchMessages(BASE_CONFIG); expect(messages).toEqual([]); }); it('sets bodyHtml null and bodyText empty when the message has neither part', async () => { const client = makeMockClient({ fetchAll: vi.fn().mockResolvedValue([ { uid: 7, envelope: { messageId: '', subject: 'Nur Betreff', from: [{ address: 'a@b.de' }], date: new Date('2026-07-20T08:00:00Z'), }, bodyStructure: { type: 'text/x-unknown' }, }, ]), }); useMockClient(client); const provider = new ImapProvider(); const messages = await provider.fetchMessages(BASE_CONFIG); expect(messages).toHaveLength(1); expect(messages[0]!.bodyHtml).toBeNull(); expect(messages[0]!.bodyText).toBe(''); }); }); /** * Befund B-06 (gemeldet in 260921-m34): buildClient() uebergab `requireTLS`, * eine Option, die imapflow 1.4.3 gar nicht kennt — weder in `ImapFlowOptions` * (lib/imap-flow.d.ts) noch im Laufzeitcode (lib/imap-flow.js). Sie wurde still * verworfen, ein STARTTLS-Zwang entstand durch sie nie. Die richtige Option * heisst `doSTARTTLS` (imap-flow.d.ts:81). * * Geprueft wird hier ausschliesslich, was an `new ImapFlow(...)` uebergeben * wird — keine echte Verbindung. */ describe('ImapProvider — Transportverschluesselung (B-06)', () => { /** Optionen des zuletzt erzeugten ImapFlow-Klienten. */ function lastClientOptions() { const calls = vi.mocked(ImapFlow).mock.calls; return calls[calls.length - 1]?.[0]; } it('erzwingt STARTTLS, wenn die Verschluesselung auf starttls steht', async () => { const client = makeMockClient(); useMockClient(client); const provider = new ImapProvider(); await provider.testConnection({ ...BASE_CONFIG, port: 143, encryption: 'starttls' }); const options = lastClientOptions(); expect(options?.secure).toBe(false); expect(options?.doSTARTTLS).toBe(true); expect(options).not.toHaveProperty('requireTLS'); }); it('setzt doSTARTTLS nicht auf true, wenn die Verschluesselung auf ssl-tls steht', async () => { const client = makeMockClient(); useMockClient(client); const provider = new ImapProvider(); await provider.testConnection({ ...BASE_CONFIG, encryption: 'ssl-tls' }); const options = lastClientOptions(); // imapflow wirft bei secure=true zusammen mit doSTARTTLS=true // ("Misconfiguration", imap-flow.js:1201) — diese Kombination darf nie entstehen. expect(options?.secure).toBe(true); expect(options?.doSTARTTLS).not.toBe(true); expect(options).not.toHaveProperty('requireTLS'); }); }); /** * Befund B-05 (gemeldet in 260921-m34): der Dateiname aus Content-Disposition * wurde als `disposition.parameters.filename` gelesen. imapflow deklariert * `disposition` aber als Zeichenkette (imap-flow.d.ts:448) und legt die * Parameter in ein eigenes Feld `dispositionParameters` (:450, gefuellt in * tools.js:887 mit kleingeschriebenen Schluesseln). Der alte Ausdruck war zur * Laufzeit immer undefined — Anhaenge, die als application/octet-stream * ankommen (typisch fuer Outlook), wurden darueber nie erkannt. */ describe('ImapProvider.fetchPdfAttachments — Dateiname aus Content-Disposition (B-05)', () => { function makeAttachmentClient(bodyStructure: unknown) { return makeMockClient({ search: vi.fn().mockResolvedValue([7]), fetchAll: vi.fn().mockResolvedValue([ { uid: 7, envelope: { messageId: '', subject: 'Rechnung', from: [{ address: 'rechnung@dkv.de' }], date: new Date('2026-07-20T08:00:00Z'), }, bodyStructure, }, ]), download: vi.fn(async () => ({ content: makeReadable('%PDF-1.4 inhalt') })), }); } it('erkennt einen application/octet-stream-Anhang am Dateinamen aus dispositionParameters', async () => { const client = makeAttachmentClient({ type: 'multipart/mixed', childNodes: [ { type: 'text/plain', part: '1' }, { type: 'application/octet-stream', part: '2', disposition: 'attachment', dispositionParameters: { filename: 'Rechnung-4711.PDF' }, }, ], }); useMockClient(client); const provider = new ImapProvider(); const emails = await provider.fetchPdfAttachments(BASE_CONFIG); expect(emails).toHaveLength(1); expect(emails[0]?.attachments ?? []).toHaveLength(1); expect(emails[0]?.attachments?.[0]?.contentType).toBe('application/pdf'); }); it('erkennt einen application/octet-stream-Anhang weiterhin am Namen aus Content-Type', async () => { const client = makeAttachmentClient({ type: 'application/octet-stream', part: '1', parameters: { name: 'Rechnung-4711.pdf' }, }); useMockClient(client); const provider = new ImapProvider(); const emails = await provider.fetchPdfAttachments(BASE_CONFIG); expect(emails).toHaveLength(1); expect(emails[0]?.attachments ?? []).toHaveLength(1); }); it('sammelt einen application/octet-stream-Anhang ohne .pdf-Dateinamen nicht ein', async () => { const client = makeAttachmentClient({ type: 'application/octet-stream', part: '1', disposition: 'attachment', dispositionParameters: { filename: 'Lieferschein.docx' }, }); useMockClient(client); const provider = new ImapProvider(); const emails = await provider.fetchPdfAttachments(BASE_CONFIG); expect(emails).toEqual([]); }); });