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>
454 lines
18 KiB
TypeScript
454 lines
18 KiB
TypeScript
/**
|
|
* Tests: Custom-Domain Plan-Limits (gemeinsamer Slot-Pool web + mail)
|
|
*
|
|
* Testet:
|
|
* - 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.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { getPlanLimits, PLAN_LIMITS } from "../../server/utils/plan-features";
|
|
|
|
// ─── Plan-Limits Shape ────────────────────────────────────────────────────────
|
|
|
|
describe("getPlanLimits — customDomains ist ZAHL (gemeinsamer Slot-Pool)", () => {
|
|
it("Pro: customDomains = 10", () => {
|
|
const limits = getPlanLimits("pro");
|
|
expect(limits.customDomains).toBe(10);
|
|
});
|
|
|
|
it("Legend: customDomains = 20", () => {
|
|
const limits = getPlanLimits("legend");
|
|
expect(limits.customDomains).toBe(20);
|
|
});
|
|
|
|
it("Legacy 'premium' → Legend limits (customDomains = 20)", () => {
|
|
const limits = getPlanLimits("premium");
|
|
expect(limits.customDomains).toBe(20);
|
|
});
|
|
|
|
it("Legacy 'standard' → Pro limits (customDomains = 10)", () => {
|
|
const limits = getPlanLimits("standard");
|
|
expect(limits.customDomains).toBe(10);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ─── 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" | "INVALID_PATTERN" };
|
|
|
|
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 rawKind = (body?.kind as string)?.trim();
|
|
const pattern = (body?.pattern as string)?.trim() ?? "";
|
|
|
|
// Variante C: kind fehlt oder kind='auto' → Auto-Detect
|
|
if (rawKind === undefined || rawKind === null || rawKind === "" || rawKind === "auto") {
|
|
if (pattern.includes("@")) {
|
|
const atIdx = pattern.lastIndexOf("@");
|
|
const normalized = pattern.slice(atIdx + 1).toLowerCase().replace(/^https?:\/\//, "").trim();
|
|
if (normalized.includes(".") && DOMAIN_RE.test(normalized)) {
|
|
return { ok: true, type: "mail_domain", value: normalized };
|
|
}
|
|
return { ok: false, error: "INVALID_MAIL_DOMAIN" };
|
|
}
|
|
|
|
if (pattern.includes(".")) {
|
|
return { ok: true, type: "web", value: pattern };
|
|
}
|
|
|
|
return { ok: false, error: "INVALID_PATTERN" };
|
|
}
|
|
|
|
const kind = rawKind;
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
// ─── DomainSubmission type-Kopier-Semantik ────────────────────────────────────
|
|
|
|
/**
|
|
* Dokumentiert die Regel: submitDomainForReview kopiert type aus UserCustomDomain.
|
|
* Kein DB-Test — reine Semantik-Verifikation.
|
|
*/
|
|
describe("DomainSubmission.type — Type-Kopier-Semantik beim Submit", () => {
|
|
it("type='web' aus CustomDomain → DomainSubmission.type='web'", () => {
|
|
const customDomainType = "web";
|
|
const submissionType = customDomainType;
|
|
expect(submissionType).toBe("web");
|
|
});
|
|
|
|
it("type='mail_domain' aus CustomDomain → DomainSubmission.type='mail_domain'", () => {
|
|
const customDomainType = "mail_domain";
|
|
const submissionType = customDomainType;
|
|
expect(submissionType).toBe("mail_domain");
|
|
});
|
|
|
|
it("type='web' ist Default falls CustomDomain fehlt (Backfill-Semantik)", () => {
|
|
const fallback = "web";
|
|
expect(fallback).toBe("web");
|
|
});
|
|
|
|
it("Community-Vote-Post-Text für mail_domain enthält Hinweis auf Mail-Absender", () => {
|
|
const type: string = "mail_domain";
|
|
const domain = "mailing.casino-affiliate.com";
|
|
const postContent =
|
|
type === "mail_domain"
|
|
? `Domain-Vorschlag (Mail-Absender): **${domain}**\n\nIch schlage vor, diese Absender-Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Casino-Affiliates nutzen oft Mailing-Listen mit harmlosen Namen. Stimme ab: Sollte **${domain}** global gesperrt werden?`
|
|
: `Domain-Vorschlag: **${domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${domain}** global gesperrt werden?`;
|
|
expect(postContent).toContain("Mail-Absender");
|
|
expect(postContent).toContain(domain);
|
|
});
|
|
|
|
it("Community-Vote-Post-Text für web enthält keinen Mail-Absender-Hinweis", () => {
|
|
const type: string = "web";
|
|
const domain = "casino.de";
|
|
const postContent =
|
|
type === "mail_domain"
|
|
? `Domain-Vorschlag (Mail-Absender): **${domain}**`
|
|
: `Domain-Vorschlag: **${domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen.`;
|
|
expect(postContent).not.toContain("Mail-Absender");
|
|
expect(postContent).toContain(domain);
|
|
});
|
|
});
|
|
|
|
// ─── 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.
|
|
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: string = "mail_domain";
|
|
const isSubmittable = type !== "mail_display_name";
|
|
expect(isSubmittable).toBe(true);
|
|
});
|
|
|
|
it("type='web' ist submittable", () => {
|
|
const type: string = "web";
|
|
const isSubmittable = type !== "mail_display_name";
|
|
expect(isSubmittable).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── Variante C — Auto-Detect (kein kind im Body) ────────────────────────────
|
|
|
|
describe("Auto-Detect — { pattern } ohne kind (Variante C)", () => {
|
|
it("{ pattern: 'casino.com' } → type='web', value='casino.com'", () => {
|
|
const r = resolveTypeAndValueForTest({ pattern: "casino.com" });
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
expect(r.type).toBe("web");
|
|
expect(r.value).toBe("casino.com");
|
|
}
|
|
});
|
|
|
|
it("{ pattern: 'info@casino.com' } → type='mail_domain', value='casino.com'", () => {
|
|
const r = resolveTypeAndValueForTest({ pattern: "info@casino.com" });
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
expect(r.type).toBe("mail_domain");
|
|
expect(r.value).toBe("casino.com");
|
|
}
|
|
});
|
|
|
|
it("{ pattern: 'INVALID' } → 400 INVALID_PATTERN (kein '@', kein '.')", () => {
|
|
const r = resolveTypeAndValueForTest({ pattern: "INVALID" });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toBe("INVALID_PATTERN");
|
|
});
|
|
|
|
it("{ pattern: 'a.b@c.d' } → type='mail_domain', value='c.d' (lastIndexOf @ strip)", () => {
|
|
const r = resolveTypeAndValueForTest({ pattern: "a.b@c.d" });
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
expect(r.type).toBe("mail_domain");
|
|
expect(r.value).toBe("c.d");
|
|
}
|
|
});
|
|
|
|
it("{ kind: 'auto', pattern: 'casino.com' } → type='web' (kind='auto' triggert Auto-Detect)", () => {
|
|
const r = resolveTypeAndValueForTest({ kind: "auto", pattern: "casino.com" });
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
expect(r.type).toBe("web");
|
|
expect(r.value).toBe("casino.com");
|
|
}
|
|
});
|
|
|
|
it("{ kind: 'auto', pattern: 'no@valid.domain.here' } → type='mail_domain', value='valid.domain.here'", () => {
|
|
const r = resolveTypeAndValueForTest({ kind: "auto", pattern: "no@valid.domain.here" });
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
expect(r.type).toBe("mail_domain");
|
|
expect(r.value).toBe("valid.domain.here");
|
|
}
|
|
});
|
|
|
|
// Bestehende Variante A bleibt unberührt (Backwards-Compat)
|
|
it("Backwards-Compat: { kind: 'web', pattern: 'casino.de' } → type='web' (weiterhin)", () => {
|
|
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("Backwards-Compat: { kind: 'mail', pattern: 'news@bet365.de' } → type='mail_domain', value='bet365.de'", () => {
|
|
const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "news@bet365.de" });
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
expect(r.type).toBe("mail_domain");
|
|
expect(r.value).toBe("bet365.de");
|
|
}
|
|
});
|
|
});
|