test(custom-domains): plan-limits-Test auf gemeinsamen Slot-Pool umgeschrieben

Der Test prüfte die alte Per-Bucket-Logik (web/mail getrennt) + Free-Tier.
Angepasst an den 10/20-Pool-Refactor: customDomains als Zahl, Pro 10 /
Legend 20, gemeinsamer web+mail-Pool, LIMIT_REACHED, GET liefert
{ items, count, limit }. Free-Tier-Fälle entfernt. 45/45 grün.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-22 18:42:31 +02:00
parent 834e6efffc
commit 38a74dd1ad

View File

@ -1,14 +1,18 @@
/**
* Tests: Custom-Domain Plan-Limits (separate web/mail buckets)
* Tests: Custom-Domain Plan-Limits (gemeinsamer Slot-Pool web + mail)
*
* Testet:
* - getPlanLimits returnt strukturiertes { web, mail } Objekt
* - countActiveCustomDomainsSplit returnt korrekte Split-Counts (Unit ohne DB)
* - getPlanLimits returnt customDomains als ZAHL (kein { web, mail } Objekt mehr)
* - Pro = 10, Legend = 20 ein gemeinsamer Pool
* - Legacy-Plan-Namen: premium Legend, standard/free/etc. Pro
* - PLAN_LIMITS hat keinen 'free'-Eintrag mehr (nur pro + legend)
* - Slot-Limit-Prüfung: currentCount >= limit LIMIT_REACHED (kein Bucket-Split)
* - 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)
* - GET /api/custom-domains gibt { items, count, limit } zurück (Zahlen, kein Bucket-Split)
*
* DSGVO: keine PII. Synthetic Brand-Namen und Test-Domains.
*/
@ -17,38 +21,150 @@ 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 }", () => {
describe("getPlanLimits — customDomains ist ZAHL (gemeinsamer Slot-Pool)", () => {
it("Pro: customDomains = 10", () => {
const limits = getPlanLimits("pro");
expect(limits.customDomains).toEqual({ web: 5, mail: 5 });
expect(limits.customDomains).toBe(10);
});
it("Legend: customDomains = { web: 10, mail: 10 }", () => {
it("Legend: customDomains = 20", () => {
const limits = getPlanLimits("legend");
expect(limits.customDomains).toEqual({ web: 10, mail: 10 });
expect(limits.customDomains).toBe(20);
});
it("Legacy 'premium' → Legend limits", () => {
it("Legacy 'premium' → Legend limits (customDomains = 20)", () => {
const limits = getPlanLimits("premium");
expect(limits.customDomains).toEqual({ web: 10, mail: 10 });
expect(limits.customDomains).toBe(20);
});
it("Legacy 'standard' → Pro limits", () => {
it("Legacy 'standard' → Pro limits (customDomains = 10)", () => {
const limits = getPlanLimits("standard");
expect(limits.customDomains).toEqual({ web: 5, mail: 5 });
expect(limits.customDomains).toBe(10);
});
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");
it("Legacy 'free' → Pro limits (customDomains = 10, kein free-Tier mehr)", () => {
const limits = getPlanLimits("free");
expect(limits.customDomains).toBe(10);
});
it("Unbekannter Plan-String → Pro limits (sicherer Fallback)", () => {
const limits = getPlanLimits("whatever");
expect(limits.customDomains).toBe(10);
});
it("customDomains ist typeof 'number' — kein Objekt mehr", () => {
expect(typeof getPlanLimits("pro").customDomains).toBe("number");
expect(typeof getPlanLimits("legend").customDomains).toBe("number");
});
it("PLAN_LIMITS hat keinen 'free'-Eintrag", () => {
expect("free" in PLAN_LIMITS).toBe(false);
});
it("PLAN_LIMITS.pro.customDomains = 10", () => {
expect(PLAN_LIMITS.pro.customDomains).toBe(10);
});
it("PLAN_LIMITS.legend.customDomains = 20", () => {
expect(PLAN_LIMITS.legend.customDomains).toBe(20);
});
});
// ─── Slot-Pool-Logik ──────────────────────────────────────────────────────────
describe("Slot-Pool-Logik — EIN gemeinsamer Pool, kein Bucket-Split", () => {
it("10 domains (web + mail gemischt) bei Pro → voll (currentCount >= limit)", () => {
const limit = getPlanLimits("pro").customDomains; // 10
const currentCount = 10; // z.B. 7 web + 3 mail_domain
expect(currentCount >= limit).toBe(true);
});
it("9 domains bei Pro → noch ein Slot frei", () => {
const limit = getPlanLimits("pro").customDomains; // 10
const currentCount = 9;
expect(currentCount >= limit).toBe(false);
});
it("20 domains bei Legend → voll", () => {
const limit = getPlanLimits("legend").customDomains; // 20
const currentCount = 20;
expect(currentCount >= limit).toBe(true);
});
it("19 domains bei Legend → noch ein Slot frei", () => {
const limit = getPlanLimits("legend").customDomains; // 20
const currentCount = 19;
expect(currentCount >= limit).toBe(false);
});
it("web + mail_domain summieren sich im gleichen Pool — kein separater Bucket", () => {
const limit = getPlanLimits("pro").customDomains; // 10
const webCount = 7;
const mailCount = 3;
const totalCount = webCount + mailCount; // wie countActiveCustomDomains liefert
expect(totalCount >= limit).toBe(true);
});
it("7 web + 2 mail → 9 gesamt, noch Slot frei bei Pro", () => {
const limit = getPlanLimits("pro").customDomains; // 10
const totalCount = 7 + 2; // 9
expect(totalCount >= limit).toBe(false);
});
it("LIMIT_REACHED-Fehlercode (kein WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED mehr)", () => {
// Spiegelt POST-Handler: bei currentCount >= limit → error: 'LIMIT_REACHED'
const errorCode = "LIMIT_REACHED";
expect(errorCode).not.toBe("WEB_LIMIT_REACHED");
expect(errorCode).not.toBe("MAIL_LIMIT_REACHED");
expect(errorCode).toBe("LIMIT_REACHED");
});
it("LIMIT_REACHED-Response hat { error, resource, current, limit } — kein 'bucket'-Feld", () => {
// Spiegelt die data-Shape aus index.post.ts bei Limit-Überschreitung
const limitData: {
error: string;
resource: string;
current: number;
limit: number;
bucket?: string;
} = {
error: "LIMIT_REACHED",
resource: "custom_domains",
current: 10,
limit: 10,
};
expect(limitData.bucket).toBeUndefined();
expect(limitData.resource).toBe("custom_domains");
expect(typeof limitData.current).toBe("number");
expect(typeof limitData.limit).toBe("number");
});
});
// ─── GET /api/custom-domains Response-Shape ───────────────────────────────────
describe("GET /api/custom-domains — Response-Shape { items, count, limit }", () => {
it("Response hat count (Zahl) statt counts:{web,mail}", () => {
// Spiegelt index.get.ts: return { items, count, limit }
const mockResponse: { items: unknown[]; count: number; limit: number } = {
items: [],
count: 3,
limit: 10,
};
expect(typeof mockResponse.count).toBe("number");
expect(typeof mockResponse.limit).toBe("number");
// Kein splits mehr
expect("counts" in mockResponse).toBe(false);
expect("limits" in mockResponse).toBe(false);
});
it("limit aus getPlanLimits('pro').customDomains = 10 — was GET zurückgibt", () => {
const limit = getPlanLimits("pro").customDomains;
expect(limit).toBe(10);
});
it("limit aus getPlanLimits('legend').customDomains = 20 — was GET zurückgibt", () => {
const limit = getPlanLimits("legend").customDomains;
expect(limit).toBe(20);
});
});
@ -191,82 +307,6 @@ describe("Body-Compat-Mapping — kind='mail' Pattern-Analyse (v1.0: nur Domain-
});
});
// ─── 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");
});
});
// ─── DomainSubmission type-Kopier-Semantik ────────────────────────────────────
/**
@ -275,9 +315,8 @@ describe("countActiveCustomDomainsSplit — Slot-Counting-Semantik (Dokumentatio
*/
describe("DomainSubmission.type — Type-Kopier-Semantik beim Submit", () => {
it("type='web' aus CustomDomain → DomainSubmission.type='web'", () => {
// Spiegelt die Logik in submitDomainForReview: domain.type wird in Submission kopiert
const customDomainType = "web";
const submissionType = customDomainType; // direkte Zuweisung im DB-Layer
const submissionType = customDomainType;
expect(submissionType).toBe("web");
});
@ -288,14 +327,12 @@ describe("DomainSubmission.type — Type-Kopier-Semantik beim Submit", () => {
});
it("type='web' ist Default falls CustomDomain fehlt (Backfill-Semantik)", () => {
// Backfill-Migration: Rows ohne JOIN-Match behalten DEFAULT 'web'
const fallback = "web";
expect(fallback).toBe("web");
});
it("Community-Vote-Post-Text für mail_domain enthält Hinweis auf Mail-Absender", () => {
// Spiegelt die Logik in submit.post.ts — type-aware Post-Content
const type = "mail_domain";
const type: string = "mail_domain";
const domain = "mailing.casino-affiliate.com";
const postContent =
type === "mail_domain"
@ -306,7 +343,7 @@ describe("DomainSubmission.type — Type-Kopier-Semantik beim Submit", () => {
});
it("Community-Vote-Post-Text für web enthält keinen Mail-Absender-Hinweis", () => {
const type = "web";
const type: string = "web";
const domain = "casino.de";
const postContent =
type === "mail_domain"
@ -323,21 +360,19 @@ 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 type: string = "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 type: string = "mail_domain";
const isSubmittable = type !== "mail_display_name";
expect(isSubmittable).toBe(true);
});
it("type='web' ist submittable", () => {
const type = "web";
const type: string = "web";
const isSubmittable = type !== "mail_display_name";
expect(isSubmittable).toBe(true);
});