rebreak-monorepo/backend/tests/mail/mail-training-utils.test.ts
chahinebrini c3de7055a5 feat(mail): Sucht-Compound-Regel + Phase-1-Training-Foundation
Task B — linguistische FP-Fix:
- mail-classifier.ts: Subject-Keyword-Loop überspringt Keyword-Score wenn
  Subject das Keyword als Sucht-Compound enthält (z.B. "glücksspiel" in
  "Glücksspielsucht" → kein +50 Score). Globale linguistische Invariante
  Deutsch — Gambling-Marketer schreiben nie "Glücksspielsucht-Bonus".
- gambling-keywords.mjs: GAMBLING_WHITELIST erweitert um Stamm-Varianten
  (wettsucht, spielsucht, suchtberatung, suchthilfe) als Fallback für
  Compounds wo keyword ≠ exakter Stamm.
- 4 neue Tests: Forum Glücksspielsucht → PASS, Hilfe bei Spielsucht → PASS,
  Wettsucht-Selbsthilfe → PASS, Glücksspiel-Bonus 100€ → BLOCK.

Task C — Phase-1-Data-Foundation:
- mail-training-utils.ts: sanitizeSubjectForTraining() (PII-Stripping via
  Regex: EMAIL/URL/NUM/Greeting/ALL-CAPS) + detectSubjectLanguage() via
  franc (iso639-3). 26 Unit-Tests.
- franc@6.2.0 installiert (~50KB ESM).
- mail.ts insertMailClassificationSample(): ruft sanitizeSubjectForTraining()
  auf, schreibt detectedLang + subjectSanitized in features-JSON
  (Interim bis Schema-Migration).
- mail-retention-cron.ts: Subject-Nullification nach 30 Tagen (täglich) +
  Sample-Purge nach 12 Monaten (monatlich). DSGVO Art. 5 Abs. 1e.

105 Tests grün (58 classifier + 26 training-utils + 11 display-name + 10 gmail).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 08:14:57 +02:00

194 lines
8.6 KiB
TypeScript

/**
* Tests für mail-training-utils.ts — Phase 1 Data-Foundation.
*
* Abgedeckt:
* - sanitizeSubjectForTraining() — alle Regex-Patterns
* - detectSubjectLanguage() — franc-Integration
* - Edge-Cases: null, leer, nur Whitespace
*/
import { describe, it, expect } from "vitest";
import {
sanitizeSubjectForTraining,
detectSubjectLanguage,
} from "../../server/utils/mail-training-utils";
// ─── sanitizeSubjectForTraining() ─────────────────────────────────────────────
describe("sanitizeSubjectForTraining()", () => {
// ─── Edge-Cases ────────────────────────────────────────────────────────────
it("null → leerer String + detectedLang='und'", () => {
const result = sanitizeSubjectForTraining(null);
expect(result.subjectSanitized).toBe("");
expect(result.detectedLang).toBe("und");
});
it("leerer String → leerer String + detectedLang='und'", () => {
const result = sanitizeSubjectForTraining("");
expect(result.subjectSanitized).toBe("");
expect(result.detectedLang).toBe("und");
});
it("nur Whitespace → leerer String", () => {
const result = sanitizeSubjectForTraining(" ");
expect(result.subjectSanitized).toBe("");
});
// ─── E-Mail-Erkennung ──────────────────────────────────────────────────────
it("E-Mail-Adresse im Betreff → [EMAIL]", () => {
const result = sanitizeSubjectForTraining("Info für test@example.com");
expect(result.subjectSanitized).toBe("Info für [EMAIL]");
});
it("E-Mail mitten im Satz → [EMAIL]", () => {
const result = sanitizeSubjectForTraining("Willkommen user123@domain.de, dein Konto ist aktiv");
expect(result.subjectSanitized).toBe("Willkommen [EMAIL], dein Konto ist aktiv");
});
// ─── URL-Erkennung ─────────────────────────────────────────────────────────
it("https-URL → [URL]", () => {
const result = sanitizeSubjectForTraining("Besuche https://casino.bet/promo");
expect(result.subjectSanitized).toBe("Besuche [URL]");
});
it("http-URL → [URL]", () => {
const result = sanitizeSubjectForTraining("Klicke auf http://example.com/offer");
expect(result.subjectSanitized).toBe("Klicke auf [URL]");
});
it("www-URL → [URL]", () => {
const result = sanitizeSubjectForTraining("www.bonus.de/angebot jetzt sichern");
expect(result.subjectSanitized).toBe("[URL] jetzt sichern");
});
// ─── Zahlen-Erkennung ──────────────────────────────────────────────────────
it("5-stellige Zahl → [NUM]", () => {
const result = sanitizeSubjectForTraining("Bestellung 12345 wurde versandt");
expect(result.subjectSanitized).toBe("Bestellung [NUM] wurde versandt");
});
it("9-stellige Kundennummer → [NUM]", () => {
const result = sanitizeSubjectForTraining("Deine Kundennummer: 987654321");
expect(result.subjectSanitized).toBe("Deine Kundennummer: [NUM]");
});
it("4-stellige Zahl bleibt erhalten (Jahr, Score)", () => {
const result = sanitizeSubjectForTraining("Angebot 2024 — bis zu 1000€");
expect(result.subjectSanitized).toBe("Angebot 2024 — bis zu 1000€");
});
// ─── Greeting-Prefix ───────────────────────────────────────────────────────
it("'Hallo Max, ...' → Greeting-Prefix stripped", () => {
const result = sanitizeSubjectForTraining("Hallo Max, dein Bonus wartet");
expect(result.subjectSanitized).toBe("dein Bonus wartet");
});
it("'Hi Sarah! ...' → Greeting-Prefix stripped", () => {
const result = sanitizeSubjectForTraining("Hi Sarah! Neues Angebot für dich");
expect(result.subjectSanitized).toBe("Neues Angebot für dich");
});
it("'Dear John, ...' → Greeting-Prefix stripped", () => {
const result = sanitizeSubjectForTraining("Dear John, your account is ready");
expect(result.subjectSanitized).toBe("your account is ready");
});
it("'Cher Michel, ...' → Greeting-Prefix stripped", () => {
const result = sanitizeSubjectForTraining("Cher Michel, votre offre expire");
expect(result.subjectSanitized).toBe("votre offre expire");
});
it("kein Greeting → bleibt unverändert (kein false strip)", () => {
const result = sanitizeSubjectForTraining("Casino Bonus wartet auf dich");
expect(result.subjectSanitized).toBe("Casino Bonus wartet auf dich");
});
// ─── ALL-CAPS-Wörter ───────────────────────────────────────────────────────
it("ALL-CAPS-Wort >= 4 Zeichen → [NAME]", () => {
const result = sanitizeSubjectForTraining("Willkommen HANS, dein Konto ist bereit");
expect(result.subjectSanitized).toBe("Willkommen [NAME], dein Konto ist bereit");
});
it("ALL-CAPS-Wort < 4 Zeichen bleibt (z.B. 'SMS', 'VIP')", () => {
const result = sanitizeSubjectForTraining("Dein VIP-Bonus wartet");
// "VIP" ist 3 Zeichen → bleibt erhalten
expect(result.subjectSanitized).toBe("Dein VIP-Bonus wartet");
});
// ─── Whitespace-Normalisierung ─────────────────────────────────────────────
it("mehrfache Leerzeichen nach Sanitization → normalisiert", () => {
// Wenn Greeting entfernt wird können führende Leerzeichen entstehen
const result = sanitizeSubjectForTraining("Hallo Test, dein Angebot");
expect(result.subjectSanitized).not.toMatch(/\s{2,}/);
});
// ─── Kombinierte Patterns ──────────────────────────────────────────────────
it("Kombination: URL + Zahl + E-Mail alle ersetzt", () => {
const result = sanitizeSubjectForTraining(
"Tracking 123456789: Paket von user@shop.de — Link: https://track.shop.de/abc"
);
expect(result.subjectSanitized).toContain("[NUM]");
expect(result.subjectSanitized).toContain("[EMAIL]");
expect(result.subjectSanitized).toContain("[URL]");
expect(result.subjectSanitized).not.toMatch(/\d{5,}/);
expect(result.subjectSanitized).not.toMatch(/[a-zA-Z0-9._%+\-]+@/);
});
it("realistisches Gambling-Mail bleibt erkennbar nach Sanitization", () => {
const result = sanitizeSubjectForTraining(
"Dein Glücksspiel-Bonus 100€ wartet — Tracking: 987654321"
);
// Gambling-Signal "Glücksspiel-Bonus" bleibt erhalten
expect(result.subjectSanitized).toContain("Glücksspiel-Bonus");
// Tracking-ID wird ersetzt
expect(result.subjectSanitized).toContain("[NUM]");
expect(result.subjectSanitized).not.toMatch(/\d{5,}/);
});
});
// ─── detectSubjectLanguage() ──────────────────────────────────────────────────
describe("detectSubjectLanguage()", () => {
it("null → 'und'", () => {
expect(detectSubjectLanguage(null)).toBe("und");
});
it("leerer String → 'und'", () => {
expect(detectSubjectLanguage("")).toBe("und");
});
it("Deutsch erkannt (deu)", () => {
// Längerer Text für bessere Spracherkennung
const lang = detectSubjectLanguage(
"Dein exklusives Angebot wartet auf dich — jetzt einlösen und profitieren"
);
expect(lang).toBe("deu");
});
it("Englisch-artiger Text → nicht 'und' (franc erkennt eine Sprache)", () => {
// franc kann je nach Text-Sample eng oder sco (Scots) zurückgeben für englische Texte —
// beide sind akzeptabel. Wichtig: kein "und" (= unbekannt).
const lang = detectSubjectLanguage(
"Your exclusive offer is waiting — claim your bonus now and start winning today"
);
expect(lang).not.toBe("und");
expect(typeof lang).toBe("string");
});
it("Zu kurzer Text → 'und' (franc-Limitation bei < ~20 Zeichen)", () => {
// Sehr kurze Texte liefern "und" oder falsche Erkennungen — erwartetes Verhalten.
// Wir prüfen nur dass ein String zurückkommt (kein throw).
const lang = detectSubjectLanguage("Hi");
expect(typeof lang).toBe("string");
expect(lang.length).toBeGreaterThan(0);
});
});