import { readFileSync } from 'fs'; import { join } from 'path'; import { describe, expect, it, vi } from 'vitest'; import { EmailAlertAdapter, extractCandidateLinks, sourceNoticeIdFor, titleFromEmail, } from './email-alert.adapter'; /** * email-alert.adapter.spec — Task 1 (test-first, TDD) proof for the generic * link/subject extraction (D-04): pure functions only, no I/O, mirroring * cosinex.adapter.spec.ts's pure-function spec style. Task 2 adds * fetchTenders() fan-out coverage (mocked PrismaService/CalendarCryptoService/ * inbox providers — no live DB/network I/O). */ /** Fake CalendarCryptoService — deterministic reversible encode, not real AES. */ function makeFakeCrypto() { return { encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`), decrypt: vi.fn((stored: string) => { if (!stored.startsWith('enc:')) throw new Error('Invalid encrypted value format'); return Buffer.from(stored.slice(4), 'base64').toString('utf8'); }), }; } function makeAdapter(overrides: { configs?: any[]; imapFetchMessages?: ReturnType; exchangeFetchMessages?: ReturnType; } = {}) { const crypto = makeFakeCrypto(); const prisma = { tenderEmailConfig: { findMany: vi.fn(async () => overrides.configs ?? []), }, }; const imapProvider = { fetchMessages: overrides.imapFetchMessages ?? vi.fn(async () => []) }; const exchangeProvider = { fetchMessages: overrides.exchangeFetchMessages ?? vi.fn(async () => []), }; const adapter = new EmailAlertAdapter( prisma as any, crypto as any, imapProvider as any, exchangeProvider as any, ); return { adapter, prisma, crypto, imapProvider, exchangeProvider }; } describe('EmailAlertAdapter', () => { it('declares sourceType email-alert and the single email-alert portal', () => { const { adapter } = makeAdapter(); expect(adapter.sourceType).toBe('email-alert'); expect(adapter.portals).toEqual(['email-alert']); }); describe('extractCandidateLinks (pure)', () => { it('extracts absolute http(s) hrefs from an HTML body via cheerio', () => { const html = `

Neue Ausschreibung verfügbar:

Zur Ausschreibung Weiterer Link `; const links = extractCandidateLinks(html, ''); expect(links).toEqual([ 'https://portal.example/detail/123', 'https://portal.example/detail/456', ]); }); it('falls back to a plaintext URL regex when bodyHtml is null', () => { const text = 'Neue Ausschreibung: https://portal.example/detail/789 — bitte prüfen.'; const links = extractCandidateLinks(null, text); expect(links).toEqual(['https://portal.example/detail/789']); }); it('drops footer-noise links (unsubscribe/abmelden/einstellungen/preferences)', () => { const html = ` Detail Abbestellen Abmelden Einstellungen Preferences `; const links = extractCandidateLinks(html, ''); expect(links).toEqual(['https://portal.example/detail/1']); }); it('dedupes identical hrefs', () => { const html = ` Detail Nochmal `; const links = extractCandidateLinks(html, ''); expect(links).toEqual(['https://portal.example/detail/1']); }); it('caps extracted links at MAX_LINKS_PER_EMAIL (link-spam safety valve)', () => { const html = Array.from( { length: 10 }, (_, i) => `Link ${i}`, ).join('\n'); const links = extractCandidateLinks(html, ''); expect(links.length).toBe(5); }); it('ignores non-http(s) hrefs (mailto:, relative paths, javascript:)', () => { const html = ` Kontakt Relativ JS Detail `; const links = extractCandidateLinks(html, ''); expect(links).toEqual(['https://portal.example/detail/1']); }); it('returns [] for an empty HTML body and empty plaintext', () => { expect(extractCandidateLinks('', '')).toEqual([]); expect(extractCandidateLinks(null, '')).toEqual([]); }); }); describe('titleFromEmail (pure)', () => { it('returns the trimmed subject when present', () => { expect(titleFromEmail(' Neue Ausschreibung Nr. 123 ', 'Body text')).toBe( 'Neue Ausschreibung Nr. 123', ); }); it('falls back to the first non-empty body line when subject is blank', () => { const text = '\n\n Erste inhaltliche Zeile \nZweite Zeile'; expect(titleFromEmail('', text)).toBe('Erste inhaltliche Zeile'); expect(titleFromEmail(' ', text)).toBe('Erste inhaltliche Zeile'); }); it('falls back to "Unbenannte Ausschreibung" when subject and body are both empty', () => { expect(titleFromEmail('', '')).toBe('Unbenannte Ausschreibung'); expect(titleFromEmail('', '\n\n \n')).toBe('Unbenannte Ausschreibung'); }); }); describe('sourceNoticeIdFor (pure)', () => { it('returns a stable 40-char hex id for the same link', () => { const link = 'https://portal.example/detail/123'; const id1 = sourceNoticeIdFor(link); const id2 = sourceNoticeIdFor(link); expect(id1).toBe(id2); expect(id1).toMatch(/^[a-f0-9]{40}$/); }); it('returns different ids for different links', () => { const id1 = sourceNoticeIdFor('https://portal.example/detail/1'); const id2 = sourceNoticeIdFor('https://portal.example/detail/2'); expect(id1).not.toBe(id2); }); }); it('never branches on a specific sender email/domain (D-04 restraint — no portal-specific parser)', () => { const source = readFileSync(join(__dirname, 'email-alert.adapter.ts'), 'utf8'); // No hardcoded sender-domain/portal-name conditional logic — generic // extraction only, mirroring the RESEARCH.md Anti-Pattern guard. expect(source).not.toMatch(/senderDomain|from\.includes\(|sender\.includes\(/); }); describe('fetchTenders (per-tenant fan-out, Task 2, INGEST-05/D-13)', () => { const MSG = { uid: 1, messageId: '', subject: 'Neue Ausschreibung Nr. 42', from: 'alerts@portal.example', date: new Date('2026-07-23T09:00:00.000Z'), bodyHtml: 'Details', bodyText: '', }; it('only queries isActive TenderEmailConfig rows (cross-tenant read, deliberate)', async () => { const { adapter, prisma } = makeAdapter({ configs: [] }); await adapter.fetchTenders('2026-07-23'); expect(prisma.tenderEmailConfig.findMany).toHaveBeenCalledWith({ where: { isActive: true }, }); }); it('tags each extracted record with ownerTenantId = the configuring tenant (D-13)', async () => { const imapFetchMessages = vi.fn(async () => [MSG]); const { adapter } = makeAdapter({ configs: [ { tenantId: 'tenant-a', protocol: 'imap', host: 'imap.example.test', port: 993, encryption: 'ssl-tls', folder: 'INBOX', senderFilter: null, domain: null, encryptedInboxCreds: null, }, ], imapFetchMessages, }); const records = await adapter.fetchTenders('2026-07-23'); expect(records).toHaveLength(1); expect(records[0]?.ownerTenantId).toBe('tenant-a'); expect(records[0]?.sourceType).toBe('email-alert'); expect(records[0]?.sourcePortal).toBe('email-alert'); expect(records[0]?.sourceUrl).toBe('https://portal.example/detail/42'); }); it('routes protocol=exchange configs to the ExchangeInboxProvider, imap to ImapProvider', async () => { const imapFetchMessages = vi.fn(async () => []); const exchangeFetchMessages = vi.fn(async () => [MSG]); const { adapter } = makeAdapter({ configs: [ { tenantId: 'tenant-ews', protocol: 'exchange', host: 'https://mail.example.test/EWS/Exchange.asmx', port: null, encryption: 'ssl-tls', folder: 'INBOX', senderFilter: null, domain: 'CONTOSO', encryptedInboxCreds: null, }, ], imapFetchMessages, exchangeFetchMessages, }); const records = await adapter.fetchTenders('2026-07-23'); expect(imapFetchMessages).not.toHaveBeenCalled(); expect(exchangeFetchMessages).toHaveBeenCalledTimes(1); expect(records).toHaveLength(1); expect(records[0]?.ownerTenantId).toBe('tenant-ews'); }); it('decrypts encryptedInboxCreds and passes username/password into InboxConfig', async () => { const crypto = makeFakeCrypto(); const encrypted = crypto.encrypt( JSON.stringify({ username: 'alerts@example.test', password: 'secret' }), ); const imapFetchMessages = vi.fn(async () => []); const prisma = { tenderEmailConfig: { findMany: vi.fn(async () => [ { tenantId: 'tenant-a', protocol: 'imap', host: 'imap.example.test', port: 993, encryption: 'ssl-tls', folder: 'INBOX', senderFilter: null, domain: null, encryptedInboxCreds: encrypted, }, ]), }, }; const adapter = new EmailAlertAdapter( prisma as any, crypto as any, { fetchMessages: imapFetchMessages } as any, { fetchMessages: vi.fn(async () => []) } as any, ); await adapter.fetchTenders('2026-07-23'); expect(imapFetchMessages).toHaveBeenCalledWith( expect.objectContaining({ username: 'alerts@example.test', password: 'secret' }), ); }); it('catch-per-tenant: one mocked mailbox throws, others still return records tagged with their own ownerTenantId', async () => { const throwingFetch = vi.fn(async () => { throw new Error('connection refused'); }); const workingFetch = vi.fn(async () => [MSG]); const crypto = makeFakeCrypto(); const prisma = { tenderEmailConfig: { findMany: vi.fn(async () => [ { tenantId: 'tenant-broken', protocol: 'imap', host: 'imap.broken.test', port: 993, encryption: 'ssl-tls', folder: 'INBOX', senderFilter: null, domain: null, encryptedInboxCreds: null, }, { tenantId: 'tenant-ok', protocol: 'imap', host: 'imap.ok.test', port: 993, encryption: 'ssl-tls', folder: 'INBOX', senderFilter: null, domain: null, encryptedInboxCreds: null, }, ]), }, }; // First mailbox throws, second succeeds — same imapProvider instance, // called twice (once per tenant), mockImplementationOnce per call. const imapProvider = { fetchMessages: vi .fn() .mockImplementationOnce(throwingFetch) .mockImplementationOnce(workingFetch), }; const adapter = new EmailAlertAdapter( prisma as any, crypto as any, imapProvider as any, { fetchMessages: vi.fn(async () => []) } as any, ); const records = await adapter.fetchTenders('2026-07-23'); expect(imapProvider.fetchMessages).toHaveBeenCalledTimes(2); expect(records).toHaveLength(1); expect(records[0]?.ownerTenantId).toBe('tenant-ok'); }); it('returns [] when there are no active configs', async () => { const { adapter } = makeAdapter({ configs: [] }); const records = await adapter.fetchTenders('2026-07-23'); expect(records).toEqual([]); }); it('produces one RawTenderRecord per extracted candidate link (multiple links in one message)', async () => { const multiLinkMsg = { ...MSG, bodyHtml: 'AB', }; const { adapter } = makeAdapter({ configs: [ { tenantId: 'tenant-a', protocol: 'imap', host: 'imap.example.test', port: 993, encryption: 'ssl-tls', folder: 'INBOX', senderFilter: null, domain: null, encryptedInboxCreds: null, }, ], imapFetchMessages: vi.fn(async () => [multiLinkMsg]), }); const records = await adapter.fetchTenders('2026-07-23'); expect(records).toHaveLength(2); expect(records.every((r) => r.ownerTenantId === 'tenant-a')).toBe(true); }); }); });