feat(14-01): add fetchMessages() to InboxProvider + both implementations
- Add InboxMessage type (subject + html/text body) and fetchMessages() to the InboxProvider interface, ImapProvider, and ExchangeInboxProvider - IMAP: findBodyParts() walks the MIME tree for first text/html + text/plain parts, reusing the connect/lock/search/fetchAll skeleton; marks \Seen - EWS: new getItemBodySoap() requests item:Body, extracts BodyType via the existing extractAttr/extractAll helpers, marks IsRead via markReadSoap - Net-new spec coverage (imap.provider.spec.ts, exchange-inbox.provider.spec.ts) mocking ImapFlow and httpntlm.post (via require.cache stub, since httpntlm is loaded with a raw require() that vi.mock cannot intercept) - fetchPdfAttachments untouched in both providers (D-02); full API suite (301 tests) + tsc --noEmit stay green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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('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,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => 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();
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => 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();
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => 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')),
|
||||
});
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => 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')),
|
||||
});
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => 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([]) });
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => 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' },
|
||||
},
|
||||
]),
|
||||
});
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => 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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user