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.
225 lines
9.3 KiB
TypeScript
225 lines
9.3 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 → mail_display_name
|
|
* - Submit eines mail_display_name → 400 DISPLAY_NAME_NOT_SUBMITTABLE
|
|
* - 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.
|
|
*/
|
|
function resolveTypeAndValueForTest(body: any): { type: string; value: string } {
|
|
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 { type: "web", value: pattern };
|
|
}
|
|
|
|
if (kind === "mail") {
|
|
const lower = pattern.toLowerCase().replace(/^https?:\/\//, "");
|
|
if (lower.includes(".") && DOMAIN_RE.test(lower)) {
|
|
return { type: "mail_domain", value: lower };
|
|
}
|
|
return { type: "mail_display_name", value: pattern };
|
|
}
|
|
|
|
return { type: kind, value: pattern };
|
|
}
|
|
|
|
const rawType = (body?.type as string)?.trim() ?? "web";
|
|
const value = (body?.domain as string)?.trim() ?? "";
|
|
return { type: rawType, value };
|
|
}
|
|
|
|
describe("Body-Compat-Mapping — kind='mail' Pattern-Analyse", () => {
|
|
it("{ kind: 'web', pattern: 'casino.de' } → type='web'", () => {
|
|
const r = resolveTypeAndValueForTest({ kind: "web", pattern: "casino.de" });
|
|
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.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.type).toBe("mail_domain");
|
|
});
|
|
|
|
it("{ kind: 'mail', pattern: 'EXTRASPIN' } → mail_display_name (kein Punkt)", () => {
|
|
const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "EXTRASPIN" });
|
|
expect(r.type).toBe("mail_display_name");
|
|
expect(r.value).toBe("EXTRASPIN");
|
|
});
|
|
|
|
it("{ kind: 'mail', pattern: 'Casino Bonus' } → mail_display_name (Leerzeichen, kein TLD)", () => {
|
|
const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "Casino Bonus" });
|
|
expect(r.type).toBe("mail_display_name");
|
|
});
|
|
|
|
it("Variante B { domain, type } passiert unverändert durch", () => {
|
|
const r = resolveTypeAndValueForTest({ domain: "spin.casino.com", type: "mail_domain" });
|
|
expect(r.type).toBe("mail_domain");
|
|
expect(r.value).toBe("spin.casino.com");
|
|
});
|
|
|
|
it("Variante B { domain, type: 'mail_display_name' } passiert unverändert", () => {
|
|
const r = resolveTypeAndValueForTest({ domain: "EXTRASPIN", type: "mail_display_name" });
|
|
expect(r.type).toBe("mail_display_name");
|
|
expect(r.value).toBe("EXTRASPIN");
|
|
});
|
|
});
|
|
|
|
// ─── 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("mix mail_domain + mail_display_name zählt gemeinsam in mail-Bucket", () => {
|
|
// 3 mail_domain + 2 mail_display_name = 5 mail total → Limit erreicht (Free)
|
|
const mailDomainCount = 3;
|
|
const mailDisplayNameCount = 2;
|
|
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 (v1.0 constraint)", () => {
|
|
// 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);
|
|
});
|
|
});
|