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,207 @@
|
||||
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 = `<?xml version="1.0"?>
|
||||
<s:Envelope><s:Body>
|
||||
<m:FindItemResponseMessage ResponseClass="Success">
|
||||
<m:RootFolder>
|
||||
<t:Items>
|
||||
<t:Message>
|
||||
<t:ItemId Id="AAA1" ChangeKey="CK1"/>
|
||||
<t:HasAttachments>false</t:HasAttachments>
|
||||
</t:Message>
|
||||
</t:Items>
|
||||
</m:RootFolder>
|
||||
</m:FindItemResponseMessage>
|
||||
</s:Body></s:Envelope>`;
|
||||
|
||||
function getItemHtmlResponse(): string {
|
||||
return `<?xml version="1.0"?>
|
||||
<s:Envelope><s:Body>
|
||||
<m:GetItemResponseMessage ResponseClass="Success">
|
||||
<m:Items>
|
||||
<t:Message>
|
||||
<t:ItemId Id="AAA1" ChangeKey="CK1"/>
|
||||
<t:Subject>Neue Ausschreibung verfuegbar</t:Subject>
|
||||
<t:InternetMessageId>msg-1@example.com</t:InternetMessageId>
|
||||
<t:From><t:Mailbox><t:EmailAddress>noreply@vergabeportal.de</t:EmailAddress></t:Mailbox></t:From>
|
||||
<t:DateTimeReceived>2026-07-20T08:00:00Z</t:DateTimeReceived>
|
||||
<t:Body BodyType="HTML">Hallo HTML Body</t:Body>
|
||||
</t:Message>
|
||||
</m:Items>
|
||||
</m:GetItemResponseMessage>
|
||||
</s:Body></s:Envelope>`;
|
||||
}
|
||||
|
||||
function getItemTextResponse(): string {
|
||||
return `<?xml version="1.0"?>
|
||||
<s:Envelope><s:Body>
|
||||
<m:GetItemResponseMessage ResponseClass="Success">
|
||||
<m:Items>
|
||||
<t:Message>
|
||||
<t:ItemId Id="AAA1" ChangeKey="CK1"/>
|
||||
<t:Subject>Neue Ausschreibung verfuegbar</t:Subject>
|
||||
<t:InternetMessageId>msg-1@example.com</t:InternetMessageId>
|
||||
<t:From><t:Mailbox><t:EmailAddress>noreply@vergabeportal.de</t:EmailAddress></t:Mailbox></t:From>
|
||||
<t:DateTimeReceived>2026-07-20T08:00:00Z</t:DateTimeReceived>
|
||||
<t:Body BodyType="Text">Hallo Text Body</t:Body>
|
||||
</t:Message>
|
||||
</m:Items>
|
||||
</m:GetItemResponseMessage>
|
||||
</s:Body></s:Envelope>`;
|
||||
}
|
||||
|
||||
/** 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, '<s:Envelope><s:Body/></s:Envelope>'));
|
||||
} else {
|
||||
cb(null, soapResponse(200, '<s:Envelope><s:Body/></s:Envelope>'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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, '<s:Envelope><s:Body><m:FindItemResponseMessage/></s:Body></s:Envelope>'));
|
||||
} else {
|
||||
cb(null, soapResponse(200, '<s:Envelope><s:Body/></s:Envelope>'));
|
||||
}
|
||||
});
|
||||
const provider = new ExchangeInboxProvider();
|
||||
|
||||
const messages = await provider.fetchMessages(BASE_CONFIG);
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user