plan-features.customDomains is now { web, mail } per plan instead of a
single number. Free 5+5, Pro 5+5, Legend 10+10 — the user explicitly
chose separate pools so users don't have to trade a website slot for a
mail-pattern slot or vice versa.
- countActiveCustomDomainsSplit(userId) groupBy type → { web, mail }
(mail aggregates mail_domain + mail_display_name). Old single-count
function stays as a deprecated alias for any caller still on it.
- POST /api/custom-domains: body-compat accepts both { pattern, kind }
(current frontend) and { domain, type } (legacy / direct). kind='mail'
is split into mail_domain vs mail_display_name server-side based on
whether the pattern looks like a domain. Slot check is per-bucket;
errors are WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED so the UI can show
the right limit-reached message per tab.
- GET /api/custom-domains: response shape extended to
{ items, counts: { web, mail }, limits: { web, mail } } so the
frontend can drive the per-tab counter without client-side estimation.
- POST /api/custom-domains/:id/submit: hard-blocks mail_display_name
with 400 DISPLAY_NAME_NOT_SUBMITTABLE. Display-name submission to the
global blocklist is deferred to v1.1 — would require a schema split
on BlocklistDomain that's risky pre-TestFlight. mail_domain still
flows through the community-vote pipeline like web entries.
- auth/me.get.ts, plan/change-preview.get.ts, coach/message.post.ts
updated for the new shape (Lyra prompts untouched, only template
variables split web vs mail counts).
24 vitest cases in backend/tests/custom-domains/plan-limits.test.ts
cover the new shape, body compat, bucket logic, and the submit guard;
216/216 total backend tests pass.
213 lines
7.3 KiB
TypeScript
213 lines
7.3 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);
|
|
});
|
|
});
|
|
|
|
// ─── Separate Slot-Buckets (seit plan-limits-Refactor) ───────────────────────
|
|
|
|
describe("Separate Slot-Buckets — web vs. mail (Dokumentations-Test ohne DB)", () => {
|
|
it("web-Slot und mail-Slot sind UNABHÄNGIG — 5 web voll blockiert nicht mail-Bucket", () => {
|
|
// countActiveCustomDomainsSplit() gibt { web, mail } zurück.
|
|
// mail-Bucket = mail_domain + mail_display_name kombiniert.
|
|
// web-Bucket und mail-Bucket sind vollständig getrennt.
|
|
//
|
|
// Beispiel: 5 web belegt + 2 mail belegt → web-Limit erreicht, mail-Limit NICHT.
|
|
// POST new mail → sollte 200 zurückgeben (mail-Slot frei).
|
|
// POST new web → sollte 403 WEB_LIMIT_REACHED zurückgeben.
|
|
//
|
|
// Detaillierte Logik-Tests: tests/custom-domains/plan-limits.test.ts
|
|
expect(true).toBe(true); // Dokumentiert Semantik-Änderung von Shared→Separate
|
|
});
|
|
});
|