7691d1fd6d
- B-06: prueft die an ImapFlow uebergebenen Optionen fuer starttls und ssl-tls - B-05: prueft, dass ein octet-stream-Anhang am Dateinamen aus Content-Disposition erkannt wird - Einhaengen des Testdoppels in einen Helfer gezogen; die Umdeutung des Konstruktors steht damit nur noch an einer Stelle statt an zwoelf Gegen den heutigen Stand rot: 3 von 12 Faellen scheitern (doSTARTTLS undefined, Feld requireTLS vorhanden, Anhang nicht eingesammelt). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
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<Record<string, unknown>> = {}) {
|
|
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: '<msg-42@example.com>',
|
|
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('<p>Hallo <b>Welt</b></p>') };
|
|
}
|
|
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<typeof makeMockClient>): void {
|
|
(ImapFlow as unknown as ReturnType<typeof vi.fn>).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: '<msg-42@example.com>',
|
|
subject: 'Neue Ausschreibung verfügbar',
|
|
from: 'noreply@vergabeportal.de',
|
|
bodyHtml: '<p>Hallo <b>Welt</b></p>',
|
|
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: '<msg-7@example.com>',
|
|
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: '<msg-7@example.com>',
|
|
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([]);
|
|
});
|
|
});
|