chahinebrini c1250836a3 fix(backend): remove display-name pattern support for v1.0
User explicitly chose to drop display-name matching from v1.0 after
the UX trap surfaced — a user typing "EXTRASPIN" without a domain got
a 400 INVALID_DOMAIN back, which is a confusing dead-end. v1.1 will
ship a dedicated display-name UI; until then mail input is domain-only.

- resolveTypeAndValue returns a discriminated union — kind='mail' with
  no dot or @ now resolves to { ok: false, error: 'INVALID_MAIL_DOMAIN' }
  instead of silently turning into a mail_display_name row.
- Full-address mail input (local@domain.tld) still gets its local-part
  stripped server-side so the stored value is always a clean domain.
- Variant-B body { type: 'mail_display_name' } returns 400
  DISPLAY_NAME_NOT_SUPPORTED for direct API consumers.
- The DISPLAY_NAME_PATTERN regex is gone — the path that used it can
  no longer be reached.
- classifyMail's Layer 2.6 (the display-name substring match) is
  intentionally left in place as dead code with a v1.1 marker, so
  re-enabling later is just wiring the input field back up and feeding
  the customDisplayNames array.
- Tests rewritten: the two pre-existing display-name tests now assert
  the 400 INVALID_MAIL_DOMAIN path, plus a new positive case for the
  full-address local-part strip. 217 vitest passes, 4 pre-existing skips.

Staging DB clean — the type column hasn't been deployed yet so no
mail_display_name rows exist to backfill.
2026-05-16 02:17:50 +02:00

275 lines
12 KiB
TypeScript

/**
* Tests: Custom-Domain Plan-Limits (separate web/mail buckets)
*
* Testet:
* - getPlanLimits returnt strukturiertes { web, mail } Objekt
* - countActiveCustomDomainsSplit returnt korrekte Split-Counts (Unit ohne DB)
* - POST-Body-Compat: { pattern, kind: 'mail' } mit Domain-Pattern → mail_domain
* - POST-Body-Compat: { pattern, kind: 'mail' } mit Display-Name-Pattern → 400 INVALID_MAIL_DOMAIN (v1.0)
* - POST-Body-Compat: { domain, type: 'mail_display_name' } → 400 DISPLAY_NAME_NOT_SUPPORTED (v1.0)
* - POST-Body-Compat: volle Adresse (local@domain.tld) → local-part gestripped, gespeichert als mail_domain
* - Submit eines mail_domain → erlaubt (gleich wie web)
*
* DSGVO: keine PII. Synthetic Brand-Namen und Test-Domains.
*/
import { describe, it, expect } from "vitest";
import { getPlanLimits, PLAN_LIMITS } from "../../server/utils/plan-features";
// ─── Plan-Limits Shape ────────────────────────────────────────────────────────
describe("getPlanLimits — customDomains ist strukturiertes Objekt", () => {
it("Free: customDomains = { web: 5, mail: 5 }", () => {
const limits = getPlanLimits("free");
expect(limits.customDomains).toEqual({ web: 5, mail: 5 });
});
it("Pro: customDomains = { web: 5, mail: 5 }", () => {
const limits = getPlanLimits("pro");
expect(limits.customDomains).toEqual({ web: 5, mail: 5 });
});
it("Legend: customDomains = { web: 10, mail: 10 }", () => {
const limits = getPlanLimits("legend");
expect(limits.customDomains).toEqual({ web: 10, mail: 10 });
});
it("Legacy 'premium' → Legend limits", () => {
const limits = getPlanLimits("premium");
expect(limits.customDomains).toEqual({ web: 10, mail: 10 });
});
it("Legacy 'standard' → Pro limits", () => {
const limits = getPlanLimits("standard");
expect(limits.customDomains).toEqual({ web: 5, mail: 5 });
});
it("PLAN_LIMITS.free.customDomains hat keine 'number'-Shape mehr", () => {
const val = PLAN_LIMITS.free.customDomains;
expect(typeof val).not.toBe("number");
expect(typeof val).toBe("object");
expect(typeof val.web).toBe("number");
expect(typeof val.mail).toBe("number");
});
});
// ─── Body-Compat-Mapping ──────────────────────────────────────────────────────
/**
* Extrahiert die resolveTypeAndValue-Logik als reiner Unit-Test ohne Server-Overhead.
* Spiegelt exakt die Implementierung in index.post.ts wider (v1.0 — kein Display-Name).
*
* Returns { ok: true, type, value } oder { ok: false, error } — analog zum Endpoint.
*/
type TestResolveResult =
| { ok: true; type: string; value: string }
| { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" };
function resolveTypeAndValueForTest(body: any): TestResolveResult {
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
if (body?.kind !== undefined || body?.pattern !== undefined) {
const kind = (body?.kind as string)?.trim() ?? "web";
const pattern = (body?.pattern as string)?.trim() ?? "";
if (kind === "web") {
return { ok: true, type: "web", value: pattern };
}
if (kind === "mail") {
// Defensiv: volle Adresse → local-part strippen
let normalized = pattern;
if (normalized.includes("@")) {
const atIdx = normalized.lastIndexOf("@");
normalized = normalized.slice(atIdx + 1);
}
normalized = normalized.toLowerCase().replace(/^https?:\/\//, "").trim();
if (normalized.includes(".") && DOMAIN_RE.test(normalized)) {
return { ok: true, type: "mail_domain", value: normalized };
}
// Display-Name-Input: nicht unterstützt in v1.0
return { ok: false, error: "INVALID_MAIL_DOMAIN" };
}
return { ok: true, type: kind, value: pattern };
}
const rawType = (body?.type as string)?.trim() ?? "web";
const value = (body?.domain as string)?.trim() ?? "";
// v1.0: mail_display_name wird nicht akzeptiert
if (rawType === "mail_display_name") {
return { ok: false, error: "DISPLAY_NAME_NOT_SUPPORTED" };
}
return { ok: true, type: rawType, value };
}
describe("Body-Compat-Mapping — kind='mail' Pattern-Analyse (v1.0: nur Domain-Input)", () => {
it("{ kind: 'web', pattern: 'casino.de' } → type='web'", () => {
const r = resolveTypeAndValueForTest({ kind: "web", pattern: "casino.de" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.type).toBe("web");
expect(r.value).toBe("casino.de");
}
});
it("{ kind: 'mail', pattern: 'only4-subscribers.com' } → mail_domain (enthält Punkt + TLD)", () => {
const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "only4-subscribers.com" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.type).toBe("mail_domain");
expect(r.value).toBe("only4-subscribers.com");
}
});
it("{ kind: 'mail', pattern: 'casino-relay.de' } → mail_domain", () => {
const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "casino-relay.de" });
expect(r.ok).toBe(true);
if (r.ok) expect(r.type).toBe("mail_domain");
});
// v1.0: Display-Name-Input → 400 INVALID_MAIL_DOMAIN (kein mail_display_name mehr)
it("{ kind: 'mail', pattern: 'EXTRASPIN' } → 400 INVALID_MAIL_DOMAIN (kein Punkt, Display-Name)", () => {
const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "EXTRASPIN" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe("INVALID_MAIL_DOMAIN");
});
it("{ kind: 'mail', pattern: 'Casino Bonus' } → 400 INVALID_MAIL_DOMAIN (Leerzeichen, kein TLD)", () => {
const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "Casino Bonus" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe("INVALID_MAIL_DOMAIN");
});
// Defensiv: volle Adresse → local-part gestripped, als mail_domain gespeichert
it("{ kind: 'mail', pattern: 'communications@only4-subscribers.com' } → mail_domain mit 'only4-subscribers.com'", () => {
const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "communications@only4-subscribers.com" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.type).toBe("mail_domain");
expect(r.value).toBe("only4-subscribers.com");
}
});
it("Variante B { domain, type: 'mail_domain' } passiert unverändert durch", () => {
const r = resolveTypeAndValueForTest({ domain: "spin.casino.com", type: "mail_domain" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.type).toBe("mail_domain");
expect(r.value).toBe("spin.casino.com");
}
});
// v1.0: Variante B mit mail_display_name → 400 DISPLAY_NAME_NOT_SUPPORTED
it("Variante B { domain, type: 'mail_display_name' } → 400 DISPLAY_NAME_NOT_SUPPORTED (v1.0)", () => {
const r = resolveTypeAndValueForTest({ domain: "EXTRASPIN", type: "mail_display_name" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe("DISPLAY_NAME_NOT_SUPPORTED");
});
});
// ─── Slot-Bucket-Logik ────────────────────────────────────────────────────────
describe("Slot-Bucket-Logik — welcher Bucket pro Type", () => {
it("type='web' → bucket='web'", () => {
const bucket = "web" === "web" ? "web" : "mail";
expect(bucket).toBe("web");
});
it("type='mail_domain' → bucket='mail'", () => {
const type = "mail_domain";
const bucket: "web" | "mail" = type === "web" ? "web" : "mail";
expect(bucket).toBe("mail");
});
it("type='mail_display_name' → bucket='mail'", () => {
const type = "mail_display_name";
const bucket: "web" | "mail" = type === "web" ? "web" : "mail";
expect(bucket).toBe("mail");
});
it("5 mail_domain + 0 mail_display_name = 5 mail-Slots belegt (kein Platz für 6tes bei Free)", () => {
const freeMailLimit = PLAN_LIMITS.free.customDomains.mail; // 5
const mailCount = 5; // 5 mail_domain belegt
expect(mailCount >= freeMailLimit).toBe(true);
});
it("5 mail belegt + neues web → kein MAIL_LIMIT_REACHED (web-Slot separat)", () => {
const freeWebLimit = PLAN_LIMITS.free.customDomains.web; // 5
const freeMailLimit = PLAN_LIMITS.free.customDomains.mail; // 5
const mailCount = 5;
const webCount = 0;
// Mail voll, aber web-Slot noch frei
expect(webCount >= freeWebLimit).toBe(false); // web nicht voll
expect(mailCount >= freeMailLimit).toBe(true); // mail voll
});
it("5 web + 5 mail belegt → beide Buckets voll (Free-Plan exhausted)", () => {
const limits = getPlanLimits("free");
const webCount = 5;
const mailCount = 5;
expect(webCount >= limits.customDomains.web).toBe(true);
expect(mailCount >= limits.customDomains.mail).toBe(true);
});
it("legacy mail_display_name-Rows (falls vorhanden) zählen weiterhin in mail-Bucket", () => {
// v1.0: mail_display_name kann nicht mehr über POST /api/custom-domains erstellt werden.
// Etwaige Legacy-Rows (aus alten Tests oder manuellem Insert) zählen dennoch korrekt
// im mail-Bucket via countActiveCustomDomainsSplit — da sie type='mail_display_name' haben.
// DB-Typ bleibt im Schema erhalten für triviale v1.1-Reaktivierung.
const mailDomainCount = 3;
const mailDisplayNameCount = 2; // Legacy-Rows (nicht mehr per API anlegbar in v1.0)
const totalMail = mailDomainCount + mailDisplayNameCount;
const freeMailLimit = PLAN_LIMITS.free.customDomains.mail;
expect(totalMail >= freeMailLimit).toBe(true);
});
});
// ─── countActiveCustomDomainsSplit Dokumentation ──────────────────────────────
describe("countActiveCustomDomainsSplit — Slot-Counting-Semantik (Dokumentationstest ohne DB)", () => {
it("grupiert type='web' in web-Bucket, 'mail_domain'+'mail_display_name' in mail-Bucket", () => {
// Diese Funktion nutzt Prisma groupBy — echter DB-Test läuft auf Hetzner.
// Hier dokumentieren wir die erwartete Semantik:
//
// Input: 3 rows web + 2 rows mail_domain + 1 row mail_display_name
// Expected: { web: 3, mail: 3 }
//
// Der mail-Bucket summiert BEIDE mail-Types.
const expectedWeb = 3;
const expectedMail = 3; // 2 mail_domain + 1 mail_display_name
expect(expectedWeb + expectedMail).toBe(6); // Gesamtanzahl korrekt
expect(typeof expectedWeb).toBe("number");
expect(typeof expectedMail).toBe("number");
});
});
// ─── Submit-Guard mail_display_name ──────────────────────────────────────────
describe("Submit-Guard — DISPLAY_NAME_NOT_SUBMITTABLE", () => {
it("type='mail_display_name' darf nicht submitted werden — guard in submit.post.ts (v1.0)", () => {
// v1.0: mail_display_name-Rows können nicht mehr per POST /api/custom-domains angelegt werden.
// Falls Legacy-Rows in der DB existieren: submit.post.ts wirft 400 DISPLAY_NAME_NOT_SUBMITTABLE.
// Spiegelt die Guard-Logik in submit.post.ts wider:
// if (existing.type === 'mail_display_name') → 400 DISPLAY_NAME_NOT_SUBMITTABLE
const type = "mail_display_name";
const isSubmittable = type !== "mail_display_name";
expect(isSubmittable).toBe(false);
});
it("type='mail_domain' ist submittable (gleich wie web)", () => {
const type = "mail_domain";
const isSubmittable = type !== "mail_display_name";
expect(isSubmittable).toBe(true);
});
it("type='web' ist submittable", () => {
const type = "web";
const isSubmittable = type !== "mail_display_name";
expect(isSubmittable).toBe(true);
});
});