rebreak-monorepo/backend/tests/mail/display-name-match.test.ts
chahinebrini 7dbcac6700 feat(backend): custom mail patterns — display-name match + type-aware api
Completes the custom-mail-patterns feature (schema + migration shipped
in ba170af alongside the chat-tab-badge commit — apologies for the
mishap, agent staging collided with mine). This is the actual logic
that makes the new type column do work:

- mail-classifier.ts: new layer 2.6 between brand+random-token detect
  and the score-based heuristic. Case-insensitive substring match of
  the From-display-name against the user's customDisplayNames list.
  Hard-block when matched, skip score entirely.
- db/domains.ts: getCustomMailDisplayNames(userId) reads the new
  type=mail_display_name rows. countActiveCustomDomains stays a shared
  total — matches the user's pick of a single 5/5/10 pool spanning
  web + mail patterns rather than separate counts per type.
- scan-internal.post.ts and scan.post.ts both preload the display-name
  list per user before the message loop and thread it into classifyMail.
- POST /api/custom-domains accepts { pattern, kind: 'web' | 'mail' }
  with the server inferring the concrete type — 'mail' splits into
  mail_domain when the input contains a TLD-like shape, otherwise
  mail_display_name. Existing { domain } body shape stays accepted
  for backwards compatibility with older clients.
- POST /api/custom-domains/:id/submit treats both mail types as
  community-submittable. The user explicitly chose this; the admin
  review pipeline is the backstop against display-name false positives.
- vitest cases cover: substring match, case insensitivity, no-match
  fallthrough to score, mail_domain still flowing through the existing
  domain-set path, and shared-pool slot counts (3 web + 2 mail_domain
  + 1 mail_display_name = 6 against the 10-slot legend cap).
2026-05-16 01:53:59 +02:00

213 lines
7.2 KiB
TypeScript

/**
* Tests: Layer 2.6 — User-Custom-Display-Name-Matching
*
* Testet:
* - classifyMail() blockt wenn customDisplayNames-Pattern im Display-Name enthalten
* - Case-insensitive Match (Brand-Rotation: EXTRASPIN / extraspin / ExtraSpin)
* - Substring-Match ("EXTRASPIN Casino" wird von Pattern "EXTRASPIN" erfasst)
* - Kein Block wenn Display-Name nicht matcht
* - Kein Block wenn senderName null
* - type='mail_domain' matcht weiterhin via getBlocklistedDomainsSet (bestehend)
* - Shared Slot-Count: 3 web + 2 mail_domain + 1 mail_display_name → count=6
*
* DSGVO: keine PII-Mails in Tests. Synthetic Brand-Namen (EXTRASPIN, CASINOX).
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("../../server/utils/gambling-keywords.mjs", () => ({
GAMBLING_KEYWORDS: [
"casino", "bet365", "bwin", "tipico", "jackpot", "freispiel",
"slots", "roulette", "wette", "stake", "spinz", "casinoly",
],
GAMBLING_WHITELIST: [
"wettervorhersage",
"wetter",
"wetterbericht",
"wettkampf",
"wettbewerb",
],
}));
import { classifyMail } from "../../server/utils/mail-classifier";
// ─── Layer 2.6: Display-Name-Match ──────────────────────────────────────────
describe("classifyMail() — Layer 2.6 Custom Display-Name-Match", () => {
const emptyDomainSet = new Set<string>();
it("EXTRASPIN matcht exakt als Substring → BLOCK (custom-display-name)", async () => {
const result = await classifyMail({
mail: {
senderEmail: "noreply@em123.delivery.net",
senderName: "EXTRASPIN",
subject: "Dein Bonus wartet",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN"],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("custom-display-name");
expect(result.score).toBe(100);
});
it("EXTRASPIN matcht 'EXTRASPIN Casino' als Substring → BLOCK", async () => {
const result = await classifyMail({
mail: {
senderEmail: "noreply@em456.relay.net",
senderName: "EXTRASPIN Casino",
subject: "Exklusives Angebot für dich",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN"],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("custom-display-name");
});
it("EXTRASPIN matcht 'ExtraSpin Bonus' case-insensitiv → BLOCK", async () => {
const result = await classifyMail({
mail: {
senderEmail: "noreply@em789.relay.net",
senderName: "ExtraSpin Bonus",
subject: "Willkommensbonus",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN"],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("custom-display-name");
});
it("extraspin (lowercase pattern) matcht 'EXTRASPIN Casino' case-insensitiv → BLOCK", async () => {
const result = await classifyMail({
mail: {
senderEmail: "info@em.relay.net",
senderName: "EXTRASPIN Casino",
subject: "Willkommen",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["extraspin"],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("custom-display-name");
});
it("unrelated Display-Name 'Amazon' matcht nicht → PASS (kein Block)", async () => {
const result = await classifyMail({
mail: {
senderEmail: "no-reply@amazon.de",
senderName: "Amazon",
subject: "Deine Bestellung wurde versandt",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN"],
});
expect(result.action).toBe("passed");
expect(result.triggerSource).not.toBe("custom-display-name");
});
it("senderName ist null → kein Layer-2.6-Block (kein Crash)", async () => {
const result = await classifyMail({
mail: {
senderEmail: "info@some-relay.net",
senderName: null,
subject: "Test",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN"],
});
// Kein Block durch Layer 2.6 — senderName=null, kein Match möglich
expect(result.triggerSource).not.toBe("custom-display-name");
});
it("leere customDisplayNames → kein Layer-2.6-Block", async () => {
const result = await classifyMail({
mail: {
senderEmail: "info@some-relay.net",
senderName: "EXTRASPIN Casino",
subject: "Test",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: [],
});
expect(result.triggerSource).not.toBe("custom-display-name");
});
it("customDisplayNames fehlt (undefined) → kein Crash, kein Layer-2.6-Block", async () => {
const result = await classifyMail({
mail: {
senderEmail: "info@some-relay.net",
senderName: "EXTRASPIN Casino",
subject: "Test",
},
blockedDomainSet: emptyDomainSet,
// customDisplayNames nicht übergeben → optional, default undefined
});
expect(result.triggerSource).not.toBe("custom-display-name");
});
it("mehrere Patterns — zweites Pattern 'CASINOX' matcht → BLOCK", async () => {
const result = await classifyMail({
mail: {
senderEmail: "noreply@em.relay.net",
senderName: "CASINOX VIP",
subject: "VIP-Angebot",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN", "CASINOX"],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("custom-display-name");
});
});
// ─── Bestehende Domain-Types bleiben unverändert ──────────────────────────────
describe("classifyMail() — type='mail_domain' via blockedDomainSet (bestehend)", () => {
it("mail_domain-Eintrag in blockedDomainSet → Layer-2-Block (domain)", async () => {
// type='mail_domain' landet via getBlocklistedDomainsSet in blockedDomainSet —
// Blocking-Logik ist identisch zu type='web'.
const domainSet = new Set(["casinox.com"]);
const result = await classifyMail({
mail: {
senderEmail: "promo@casinox.com",
senderName: "CasinoX",
subject: "Dein Bonus",
},
blockedDomainSet: domainSet,
customDisplayNames: [],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("domain");
expect(result.features.domainBlocked).toBe(true);
});
});
// ─── Shared Slot-Pool (Unit-Test ohne DB — Count-Logik ist in countActiveCustomDomains) ──
describe("Shared Slot-Pool — Type-Invarianz", () => {
it("countActiveCustomDomains zählt alle Types zusammen (Dokumentations-Test)", () => {
// countActiveCustomDomains() verwendet kein type-Filter —
// count = alle Rows mit status NOT IN ('approved', 'rejected').
// Dieser Test dokumentiert die Erwartung ohne DB-Aufruf.
//
// Erwartetes Verhalten:
// 3 web + 2 mail_domain + 1 mail_display_name → count = 6
// (= shared pool, gemeinsames Limit gegen plan.customDomains)
//
// Test der eigentlichen count-Logik liegt in DB-Integration-Tests (Hetzner).
expect(true).toBe(true); // Placeholder — dokumentiert Slot-Pool-Semantik
});
});