diff --git a/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts b/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts new file mode 100644 index 0000000..aa9ada4 --- /dev/null +++ b/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts @@ -0,0 +1,134 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, it } 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. + */ +describe('EmailAlertAdapter', () => { + it('declares sourceType email-alert and the single email-alert portal', () => { + const adapter = new EmailAlertAdapter(); + 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\(/); + }); +});