diff --git a/backend/tests/custom-domains/plan-limits.test.ts b/backend/tests/custom-domains/plan-limits.test.ts index b787a5b..ffcae3a 100644 --- a/backend/tests/custom-domains/plan-limits.test.ts +++ b/backend/tests/custom-domains/plan-limits.test.ts @@ -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); });