import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ExchangeInboxProvider as ExchangeInboxProviderType } from './exchange-inbox.provider'; import type { InboxConfig } from './inbox.types'; /** * ExchangeInboxProvider.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. * * httpntlm is loaded via a raw CommonJS `require('httpntlm')` inside * exchange-inbox.provider.ts (not an ES import) — this bypasses Vitest's * `vi.mock` ESM-graph interception entirely (verified: `vi.mock('httpntlm', * ...)` has zero effect on a `require('httpntlm')` call, since Node's real * `require` executes it directly against the module cache). Instead, this * spec pre-seeds Node's own `require.cache` for the resolved httpntlm path * with a stub `{ post }` BEFORE the provider module is first loaded (in * `beforeAll`, via a dynamic `import()` — kept out of top-level module scope * so this file stays compatible with the project's commonjs tsconfig, which * rejects top-level `await`/`import.meta`), so the provider's own * `require('httpntlm')` call resolves to the stub. */ const httpntlmPath = require.resolve('httpntlm'); const httpntlmPost = vi.fn( (_opts: any, cb: (err: Error | null, res: any) => void) => { cb(new Error('httpntlmPost not configured for this test'), null); }, ); require.cache[httpntlmPath] = { id: httpntlmPath, filename: httpntlmPath, loaded: true, exports: { post: httpntlmPost }, } as any; let ExchangeInboxProvider: typeof ExchangeInboxProviderType; beforeAll(async () => { ({ ExchangeInboxProvider } = await import('./exchange-inbox.provider')); }); const BASE_CONFIG: InboxConfig = { protocol: 'exchange', host: 'https://mail.example.com/EWS/Exchange.asmx', port: 443, username: 'alerts', password: 'secret', encryption: 'ssl-tls', folder: 'INBOX', domain: 'CONTOSO', }; function soapResponse(statusCode: number, body: string) { return { statusCode, body: Buffer.from(body, 'utf-8') }; } const FIND_ITEM_RESPONSE = ` false `; function getItemHtmlResponse(): string { return ` Neue Ausschreibung verfuegbar msg-1@example.com noreply@vergabeportal.de 2026-07-20T08:00:00Z Hallo HTML Body `; } function getItemTextResponse(): string { return ` Neue Ausschreibung verfuegbar msg-1@example.com noreply@vergabeportal.de 2026-07-20T08:00:00Z Hallo Text Body `; } /** Routes the stubbed httpntlm.post by the SOAPAction header, mirroring ewsPost's per-action dispatch. */ function mockEwsFlow(getItemResponseBody: string) { httpntlmPost.mockImplementation((opts: any, cb: (err: Error | null, res: any) => void) => { const action = opts.headers.SOAPAction as string; if (action.includes('FindItem')) { cb(null, soapResponse(200, FIND_ITEM_RESPONSE)); } else if (action.includes('GetItem')) { cb(null, soapResponse(200, getItemResponseBody)); } else if (action.includes('UpdateItem')) { cb(null, soapResponse(200, '')); } else { cb(null, soapResponse(200, '')); } }); } beforeEach(() => { httpntlmPost.mockClear(); }); describe('ExchangeInboxProvider.fetchMessages', () => { it('a BodyType="HTML" GetItem response yields bodyHtml populated and bodyText empty', async () => { mockEwsFlow(getItemHtmlResponse()); const provider = new ExchangeInboxProvider(); const messages = await provider.fetchMessages(BASE_CONFIG); expect(messages).toHaveLength(1); expect(messages[0]).toMatchObject({ uid: 'AAA1', messageId: 'msg-1@example.com', subject: 'Neue Ausschreibung verfuegbar', from: 'noreply@vergabeportal.de', bodyHtml: 'Hallo HTML Body', bodyText: '', }); }); it('a BodyType="Text" GetItem response yields bodyText populated and bodyHtml null', async () => { mockEwsFlow(getItemTextResponse()); const provider = new ExchangeInboxProvider(); const messages = await provider.fetchMessages(BASE_CONFIG); expect(messages).toHaveLength(1); expect(messages[0]!.bodyHtml).toBeNull(); expect(messages[0]!.bodyText).toBe('Hallo Text Body'); }); it('marks the item read (IsRead) like fetchPdfAttachments', async () => { mockEwsFlow(getItemHtmlResponse()); const provider = new ExchangeInboxProvider(); await provider.fetchMessages(BASE_CONFIG); const updateCall = httpntlmPost.mock.calls.find( (call) => (call[0].headers.SOAPAction as string).includes('UpdateItem'), ); expect(updateCall).toBeDefined(); expect(updateCall![0].body).toContain('IsRead'); expect(updateCall![0].body).toContain('AAA1'); }); it('returns [] on non-200 FindItem response, without throwing', async () => { httpntlmPost.mockImplementation((_opts: any, cb: (err: Error | null, res: any) => void) => { cb(null, soapResponse(500, 'Internal Server Error')); }); const provider = new ExchangeInboxProvider(); await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]); }); it('returns [] when httpntlm.post errors, without throwing', async () => { httpntlmPost.mockImplementation((_opts: any, cb: (err: Error | null, res: any) => void) => { cb(new Error('NTLM handshake failed'), null); }); const provider = new ExchangeInboxProvider(); await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]); }); it('returns [] when FindItem has no items', async () => { httpntlmPost.mockImplementation((opts: any, cb: (err: Error | null, res: any) => void) => { const action = opts.headers.SOAPAction as string; if (action.includes('FindItem')) { cb(null, soapResponse(200, '')); } else { cb(null, soapResponse(200, '')); } }); const provider = new ExchangeInboxProvider(); const messages = await provider.fetchMessages(BASE_CONFIG); expect(messages).toEqual([]); }); });