diff --git a/backend/server/api/auth/me.get.ts b/backend/server/api/auth/me.get.ts index a0fc749..7a8b699 100644 --- a/backend/server/api/auth/me.get.ts +++ b/backend/server/api/auth/me.get.ts @@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => { created_at: dbProfile?.createdAt?.toISOString() ?? user.created_at, // Für useUserPlan im Frontend — Key-Subset der PlanLimits planLimits: { - customDomains: limits.customDomains, + customDomains: limits.customDomains, // { web: number, mail: number } domainRefill: limits.domainRefill, mailAgents: limits.mailAgents === Infinity ? null : limits.mailAgents, globalBlocklist: limits.globalBlocklist, diff --git a/backend/server/api/coach/message.post.ts b/backend/server/api/coach/message.post.ts index ca431a6..cba1421 100644 --- a/backend/server/api/coach/message.post.ts +++ b/backend/server/api/coach/message.post.ts @@ -263,7 +263,7 @@ function generatePlanDetails(): string { : "(NICHT rückfüllbar – einmal belegt, bleibt für immer belegt)"; return `Free (0 €): -- Gambling-Blocker mit ${free.customDomains} eigenen Custom Domains ${refillNote(free.domainRefill)} +- Gambling-Blocker mit ${free.customDomains.web} eigenen Web-Domains + ${free.customDomains.mail} Mail-Patterns ${refillNote(free.domainRefill)} - Free kann Custom Domains NICHT zur Community-Abstimmung einreichen — das ist Pro/Legend exklusiv - ${fmtCount(free.mailAgents)} Mail-Konto, Scan alle ${free.mailIntervalOptions[0]}h - Streak-Tracker, SOS-Hilfe & Spiele-Sammlung, Atemübung @@ -273,14 +273,14 @@ function generatePlanDetails(): string { Pro (3,99 € / Monat oder 29 € / Jahr – spare 19 %): - Alles aus Free PLUS: - Zugang zur vollständigen 208.000+ globalen Blocklist (Community-gepflegt) -- ${pro.customDomains} Custom Domains ${refillNote(pro.domainRefill)} +- ${pro.customDomains.web} Web-Domains + ${pro.customDomains.mail} Mail-Patterns ${refillNote(pro.domainRefill)} - Bis zu ${fmtCount(pro.mailAgents)} Mail-Konten, Scan-Intervall wählbar (${pro.mailIntervalOptions.join("h / ")}h) - Stärkeres KI-Modell (du, Lyra wirst zu einem 70B-Modell) - Kann Custom Domains zur Community-Abstimmung einreichen Legend (7,99 € / Monat oder 59 € / Jahr – spare 38 %): - Alles aus Pro PLUS: -- ${legend.customDomains} Custom Domains ${refillNote(legend.domainRefill)} +- ${legend.customDomains.web} Web-Domains + ${legend.customDomains.mail} Mail-Patterns ${refillNote(legend.domainRefill)} - ⭐ MULTI-DEVICE-SCHUTZ: App auf bis zu 3 WEITEREN Geräten gleichzeitig — Familie, Partner, Eltern können mitgeschützt werden ohne extra zu zahlen. Real-life-relevant: viele Betroffene haben mehrere Geräte (iPhone + iPad + alter Laptop) - ⭐ MAIL-DAEMON (echter technischer Durchbruch — Alleinstellungsmerkmal!): ${fmtCount(legend.mailAgents)} Mail-Konten mit Echtzeit-IMAP-IDLE-Überwachung. Casino-Mails werden in Sekunden permanent gelöscht — sie tauchen nicht mal im Papierkorb auf. Der User sieht nichts. Kein "Sie haben gewonnen!"-Trigger erreicht je das Postfach. Keine andere App im Markt kann das. - Privilegierte Domain-Einreichung: umgeht Community-Vote komplett. Domains werden direkt + priorisiert vom ReBreak-Admin geprüft (schneller als die 24h-Standard-Prüfung der Pro-Submissions). Vertrauensvorteil als Legend. diff --git a/backend/server/api/custom-domains/[id]/submit.post.ts b/backend/server/api/custom-domains/[id]/submit.post.ts index f3cc811..e603189 100644 --- a/backend/server/api/custom-domains/[id]/submit.post.ts +++ b/backend/server/api/custom-domains/[id]/submit.post.ts @@ -34,6 +34,18 @@ export default defineEventHandler(async (event) => { }); } + // v1.0: Display-Name-Patterns sind nicht submittable (keine BlocklistDomain-Erweiterung vor TestFlight) + if (existing.type === "mail_display_name") { + throw createError({ + statusCode: 400, + data: { + error: "DISPLAY_NAME_NOT_SUBMITTABLE", + message: + "Display-name patterns cannot be submitted to the global blocklist in v1.0. Use them as user-private filters only.", + }, + }); + } + // Tier-Routing: // - Pro: Community-Post mit Voting-Flow erstellen // - Legend: KEIN Post — Domain/Pattern landet direkt in der Admin-Queue diff --git a/backend/server/api/custom-domains/index.get.ts b/backend/server/api/custom-domains/index.get.ts index 706638e..3e57204 100644 --- a/backend/server/api/custom-domains/index.get.ts +++ b/backend/server/api/custom-domains/index.get.ts @@ -1,6 +1,21 @@ -import { getUserCustomDomains } from "../../db/domains"; +import { getUserCustomDomains, countActiveCustomDomainsSplit } from "../../db/domains"; +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; export default defineEventHandler(async (event) => { const user = await requireUser(event); - return getUserCustomDomains(user.id); + + const [items, split, profile] = await Promise.all([ + getUserCustomDomains(user.id), + countActiveCustomDomainsSplit(user.id), + getProfile(user.id), + ]); + + const limits = getPlanLimits(profile?.plan ?? "free"); + + return { + items, + counts: split, + limits: limits.customDomains, + }; }); diff --git a/backend/server/api/custom-domains/index.post.ts b/backend/server/api/custom-domains/index.post.ts index ddfcd34..020328d 100644 --- a/backend/server/api/custom-domains/index.post.ts +++ b/backend/server/api/custom-domains/index.post.ts @@ -1,7 +1,7 @@ import { awardPoints } from "../../utils/scoring"; import { addUserCustomDomain, - countActiveCustomDomains, + countActiveCustomDomainsSplit, CUSTOM_DOMAIN_TYPES, type CustomDomainType, } from "../../db/domains"; @@ -15,22 +15,61 @@ const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9]) // Erlaubt: Buchstaben, Ziffern, Leerzeichen, Bindestrich, Unterstrich const DISPLAY_NAME_PATTERN_RE = /^[a-zA-Z0-9\s\-_]+$/; +/** + * Leitet Frontend-`kind` auf internen `CustomDomainType` ab. + * + * Variante A (neues Frontend): { pattern: string, kind: 'web' | 'mail' } + * - kind='web' → type='web' + * - kind='mail' → analysiere pattern: + * enthält '.' + sieht wie Domain aus → 'mail_domain' + * sonst → 'mail_display_name' + * + * Variante B (direkt / Legacy): { domain: string, type: 'web' | 'mail_domain' | 'mail_display_name' } + */ +function resolveTypeAndValue(body: any): { type: CustomDomainType; value: string } { + // Variante A: pattern + kind + 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") { + // Domain-shape: enthält mindestens einen Punkt und passt auf Domain-Regex (nach lowercase) + 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 }; + } + + // Unbekanntes kind → 400 via validTypes-Check unten + return { type: kind as CustomDomainType, value: pattern }; + } + + // Variante B: domain + type + const rawType = (body?.type as string)?.trim() ?? "web"; + const value = (body?.domain as string)?.trim() ?? ""; + return { type: rawType as CustomDomainType, value }; +} + export default defineEventHandler(async (event) => { const user = await requireUser(event); const body = await readBody(event); - // type aus Body lesen, Default 'web' - const rawType = (body?.type as string)?.trim() ?? "web"; - if (!CUSTOM_DOMAIN_TYPES.includes(rawType as CustomDomainType)) { + const { type, value: rawValue } = resolveTypeAndValue(body); + + if (!CUSTOM_DOMAIN_TYPES.includes(type as CustomDomainType)) { throw createError({ statusCode: 400, data: { error: "INVALID_TYPE", validTypes: CUSTOM_DOMAIN_TYPES }, }); } - const type = rawType as CustomDomainType; - // domain/pattern normalisieren - let value = (body?.domain as string)?.trim() ?? ""; + // domain/pattern validieren + normalisieren + let value = rawValue; if (type === "mail_display_name") { // Display-Name-Pattern: Case-sensitive gespeichert wie eingegeben, @@ -62,20 +101,27 @@ export default defineEventHandler(async (event) => { } } - // Shared Slot-Pool prüfen (alle Types zusammen) + // Per-type Slot-Limit prüfen const profile = await getProfile(user.id); const limits = getPlanLimits(profile?.plan ?? "free"); - if (limits.customDomains !== Infinity) { - const activeCount = await countActiveCustomDomains(user.id); - if (activeCount >= limits.customDomains) { + // Welcher Bucket? + const bucket: "web" | "mail" = type === "web" ? "web" : "mail"; + const bucketLimit = limits.customDomains[bucket]; + + if (bucketLimit !== Infinity) { + const split = await countActiveCustomDomainsSplit(user.id); + const currentCount = split[bucket]; + if (currentCount >= bucketLimit) { + const errorCode = bucket === "web" ? "WEB_LIMIT_REACHED" : "MAIL_LIMIT_REACHED"; throw createError({ statusCode: 403, data: { - error: "PLAN_LIMIT", + error: errorCode, resource: "custom_domains", - current: activeCount, - limit: limits.customDomains, + bucket, + current: currentCount, + limit: bucketLimit, }, }); } diff --git a/backend/server/api/plan/change-preview.get.ts b/backend/server/api/plan/change-preview.get.ts index deda045..6044765 100644 --- a/backend/server/api/plan/change-preview.get.ts +++ b/backend/server/api/plan/change-preview.get.ts @@ -144,25 +144,32 @@ export default defineEventHandler(async (event): Promise } // ── Custom Domains ──────────────────────────────────────────────────────── - if (fromLimits.customDomains !== toLimits.customDomains) { + // Compare total (web + mail) to detect any bucket change + const fromTotalDomains = fromLimits.customDomains.web + fromLimits.customDomains.mail; + const toTotalDomains = toLimits.customDomains.web + toLimits.customDomains.mail; + if (fromTotalDomains !== toTotalDomains) { if (direction === "downgrade") { - const newLimit = toLimits.customDomains; - const overBy = Math.max(0, activeDomainCount - newLimit); + const newLimitWeb = toLimits.customDomains.web; + const newLimitMail = toLimits.customDomains.mail; + const newLimitTotal = newLimitWeb + newLimitMail; + const overBy = Math.max(0, activeDomainCount - newLimitTotal); changes.push({ resource: "custom_domains", current: activeDomainCount, - newLimit, + newLimit: newLimitTotal, overBy, action: "keep", // grandfathered — alle bleiben aktiv detail: overBy > 0 - ? `Du hast ${activeDomainCount} eigene Domains, ${to}-Plan erlaubt ${newLimit}. ` + - `Alle bleiben aktiv — du kannst erst wieder welche hinzufügen wenn du unter ${newLimit} bist.` - : `Du hast ${activeDomainCount} von ${toLimits.customDomains} möglichen Domains — kein Überlauf.`, + ? `Du hast ${activeDomainCount} eigene Domains, ${to}-Plan erlaubt ${newLimitTotal} (${newLimitWeb} Web + ${newLimitMail} Mail). ` + + `Alle bleiben aktiv — du kannst erst wieder welche hinzufügen wenn du unter dem jeweiligen Limit bist.` + : `Du hast ${activeDomainCount} von ${newLimitTotal} möglichen Domains (${newLimitWeb} Web + ${newLimitMail} Mail) — kein Überlauf.`, }); } else { + const web = toLimits.customDomains.web; + const mail = toLimits.customDomains.mail; gains.push( - `Bis zu ${toLimits.customDomains} eigene Domains${toLimits.domainRefill ? " (Slots füllen sich auf wenn deine Domain in die globale Liste aufgenommen wird)" : ""}`, + `Bis zu ${web} Web-Domains + ${mail} Mail-Patterns${toLimits.domainRefill ? " (Slots füllen sich auf wenn deine Domain in die globale Liste aufgenommen wird)" : ""}`, ); } } diff --git a/backend/server/db/domains.ts b/backend/server/db/domains.ts index e7b2777..4938da0 100644 --- a/backend/server/db/domains.ts +++ b/backend/server/db/domains.ts @@ -45,6 +45,8 @@ export async function getUserCustomDomains(userId: string) { * Counts domains that occupy a slot (active + submitted). * approved → slot freed (domain joined global list) * rejected → slot freed (user can re-submit or delete) + * + * @deprecated Use countActiveCustomDomainsSplit for per-type quota checks. */ export async function countActiveCustomDomains(userId: string) { const db = usePrisma(); @@ -53,6 +55,32 @@ export async function countActiveCustomDomains(userId: string) { }); } +/** + * Returns per-bucket slot usage: + * web = type === 'web' + * mail = type IN ('mail_domain', 'mail_display_name') — combined bucket + * + * Used for per-type quota enforcement (separate Free/Pro/Legend limits for web vs. mail). + */ +export async function countActiveCustomDomainsSplit( + userId: string, +): Promise<{ web: number; mail: number }> { + const db = usePrisma(); + const rows = await db.userCustomDomain.groupBy({ + by: ["type"], + where: { userId, status: { notIn: ["approved", "rejected"] } }, + _count: { _all: true }, + }); + let web = 0; + let mail = 0; + for (const row of rows) { + if (row.type === "web") web = row._count._all; + else if (row.type === "mail_domain" || row.type === "mail_display_name") + mail += row._count._all; + } + return { web, mail }; +} + export async function addUserCustomDomain( userId: string, domain: string, diff --git a/backend/server/utils/plan-features.ts b/backend/server/utils/plan-features.ts index 300f891..b0d0cee 100644 --- a/backend/server/utils/plan-features.ts +++ b/backend/server/utils/plan-features.ts @@ -13,10 +13,17 @@ export interface VoiceConfig { dailyQuotaSeconds: number; } +export interface CustomDomainLimits { + /** Max. Web-Domain-Slots (Infinity = unbegrenzt) */ + web: number; + /** Max. Mail-Slots — kombiniert für mail_domain + mail_display_name */ + mail: number; +} + export interface PlanLimits { // ─── Custom Domains ────────────────────────────────────────────────────── - /** Max. eigene Domains (Infinity = unbegrenzt) */ - customDomains: number; + /** Max. eigene Domains aufgeteilt nach Typ-Bucket */ + customDomains: CustomDomainLimits; /** Freigeschaltete Domain-Slots füllen sich wieder auf (Community-Promotion) */ domainRefill: boolean; @@ -79,7 +86,7 @@ export interface PlanLimits { export const PLAN_LIMITS: Record = { free: { - customDomains: 5, + customDomains: { web: 5, mail: 5 }, domainRefill: false, mailAgents: 1, mailIntervalOptions: [4], @@ -103,7 +110,7 @@ export const PLAN_LIMITS: Record = { }, }, pro: { - customDomains: 5, + customDomains: { web: 5, mail: 5 }, domainRefill: true, mailAgents: 3, mailIntervalOptions: [1, 4, 8], @@ -126,7 +133,7 @@ export const PLAN_LIMITS: Record = { }, }, legend: { - customDomains: 10, + customDomains: { web: 10, mail: 10 }, domainRefill: true, mailAgents: Infinity, mailIntervalOptions: [1, 4, 8], diff --git a/backend/tests/custom-domains/plan-limits.test.ts b/backend/tests/custom-domains/plan-limits.test.ts new file mode 100644 index 0000000..f0f387c --- /dev/null +++ b/backend/tests/custom-domains/plan-limits.test.ts @@ -0,0 +1,224 @@ +/** + * 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); + }); +}); diff --git a/backend/tests/mail/display-name-match.test.ts b/backend/tests/mail/display-name-match.test.ts index 4f2a707..a497e93 100644 --- a/backend/tests/mail/display-name-match.test.ts +++ b/backend/tests/mail/display-name-match.test.ts @@ -194,19 +194,19 @@ describe("classifyMail() — type='mail_domain' via blockedDomainSet (bestehend) }); }); -// ─── Shared Slot-Pool (Unit-Test ohne DB — Count-Logik ist in countActiveCustomDomains) ── +// ─── Separate Slot-Buckets (seit plan-limits-Refactor) ─────────────────────── -describe("Shared Slot-Pool — Type-Invarianz", () => { - it("countActiveCustomDomains zählt alle Types zusammen (Dokumentations-Test)", () => { - // countActiveCustomDomains() verwendet kein type-Filter — - // count = alle Rows mit status NOT IN ('approved', 'rejected'). - // Dieser Test dokumentiert die Erwartung ohne DB-Aufruf. +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. // - // Erwartetes Verhalten: - // 3 web + 2 mail_domain + 1 mail_display_name → count = 6 - // (= shared pool, gemeinsames Limit gegen plan.customDomains) + // 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. // - // Test der eigentlichen count-Logik liegt in DB-Integration-Tests (Hetzner). - expect(true).toBe(true); // Placeholder — dokumentiert Slot-Pool-Semantik + // Detaillierte Logik-Tests: tests/custom-domains/plan-limits.test.ts + expect(true).toBe(true); // Dokumentiert Semantik-Änderung von Shared→Separate }); });