test(14-03): add failing spec for email-alert generic link/subject extraction
RED phase (TDD) for Task 1: pure-function tests for extractCandidateLinks, titleFromEmail, sourceNoticeIdFor — covers HTML + plaintext bodies, footer-noise removal, link cap, and D-04 no-portal-specific-parser restraint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = `
|
||||||
|
<html><body>
|
||||||
|
<p>Neue Ausschreibung verfügbar:</p>
|
||||||
|
<a href="https://portal.example/detail/123">Zur Ausschreibung</a>
|
||||||
|
<a href="https://portal.example/detail/456">Weiterer Link</a>
|
||||||
|
</body></html>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<a href="https://portal.example/detail/1">Detail</a>
|
||||||
|
<a href="https://portal.example/unsubscribe?id=1">Abbestellen</a>
|
||||||
|
<a href="https://portal.example/abmelden">Abmelden</a>
|
||||||
|
<a href="https://portal.example/einstellungen">Einstellungen</a>
|
||||||
|
<a href="https://mail.example/preferences">Preferences</a>
|
||||||
|
`;
|
||||||
|
const links = extractCandidateLinks(html, '');
|
||||||
|
expect(links).toEqual(['https://portal.example/detail/1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes identical hrefs', () => {
|
||||||
|
const html = `
|
||||||
|
<a href="https://portal.example/detail/1">Detail</a>
|
||||||
|
<a href="https://portal.example/detail/1">Nochmal</a>
|
||||||
|
`;
|
||||||
|
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) => `<a href="https://portal.example/detail/${i}">Link ${i}</a>`,
|
||||||
|
).join('\n');
|
||||||
|
const links = extractCandidateLinks(html, '');
|
||||||
|
expect(links.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-http(s) hrefs (mailto:, relative paths, javascript:)', () => {
|
||||||
|
const html = `
|
||||||
|
<a href="mailto:info@portal.example">Kontakt</a>
|
||||||
|
<a href="/relative/path">Relativ</a>
|
||||||
|
<a href="javascript:void(0)">JS</a>
|
||||||
|
<a href="https://portal.example/detail/1">Detail</a>
|
||||||
|
`;
|
||||||
|
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\(/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user