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([]);
});
});