/** * Daily AI-Usage Cap (src/lib/ai/usage.ts) — rein logische Tests. * * Kein Netzwerk, keine Datenbank: geprüft wird ausschließlich der in-memory * 24h-Fenster-Limiter (Pre-Check + Record-nach-Extraktion). Die Scan-Route * selbst wird hier NICHT aufgerufen (CSRF-/DB-/Extraction-Abhängigkeit) — die * pure Limiter-Logik ist die vertragliche Einheit. * * Determinismus: Alle Fenster-Aussagen laufen über das injizierbare `now`, * echte Uhrzeit wird in keinem Test verwendet. Jeder Test nutzt eigene Keys, * damit die modul-globale Map keine Suite-übergreifenden Nebeneffekte hat. */ import { describe, test, expect, runAllTests } from "../e2e/runner"; import { DAILY_SCAN_LIMIT, DAILY_GUEST_SCAN_LIMIT, DAILY_PRO_SCAN_LIMIT, HOURLY_SCAN_LIMIT, checkDailyUsage, dailyScanLimitFor, recordUsage, usageKeyForUser, usageKeyForGuest, } from "../../src/lib/ai/usage"; const HOUR = 60 * 60 * 1000; const DAY = 24 * HOUR; /** Eindeutiger Key pro Testaufruf — isoliert Fenster über die Testgrenzen hinweg. */ let keySeq = 0; const nextKey = (prefix = "usage") => `${prefix}_${Date.now()}_${keySeq++}`; // --------------------------------------------------------------------------- // 1. Pre-Check-Semantik (checkDailyUsage) // --------------------------------------------------------------------------- describe("checkDailyUsage — Pre-Check-Semantik", () => { test("C-1: ohne Verbrauch erlaubt, used=0, remaining=Limit", () => { const key = nextKey(); const r = checkDailyUsage(key, DAILY_SCAN_LIMIT); expect(r.allowed).toBe(true); expect(r.used).toBe(0); expect(r.remaining).toBe(DAILY_SCAN_LIMIT); // resetsAt liegt in der Zukunft (neues Fenster würde erst beim Record starten) expect(r.resetsAt).toBeGreaterThan(Date.now()); }); test("C-2: erlaubt bis zum Limit; Limit erreicht → blocked (Limit+1-Einheit)", () => { const key = nextKey(); for (let i = 1; i <= DAILY_SCAN_LIMIT; i++) { recordUsage(key, 1); } const atLimit = checkDailyUsage(key, DAILY_SCAN_LIMIT); expect(atLimit.used).toBe(DAILY_SCAN_LIMIT); expect(atLimit.remaining).toBe(0); expect(atLimit.allowed).toBe(false); // Und eine Einheit davor war noch erlaubt: const key2 = nextKey(); for (let i = 1; i <= DAILY_SCAN_LIMIT - 1; i++) { recordUsage(key2, 1); } expect(checkDailyUsage(key2, DAILY_SCAN_LIMIT).allowed).toBe(true); expect(checkDailyUsage(key2, DAILY_SCAN_LIMIT).remaining).toBe(1); }); test("C-3: allowed hängt am explizit übergebenen Limit", () => { const key = nextKey(); for (let i = 0; i < DAILY_GUEST_SCAN_LIMIT; i++) { recordUsage(key, 1); } // Gegen das User-Limit (30) noch frei, gegen das Guest-Limit (10) blockiert: expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(true); expect(checkDailyUsage(key, DAILY_GUEST_SCAN_LIMIT).allowed).toBe(false); }); test("C-3b: Pro-Cap (150) erlaubt weiter, wo Free (30) schon blockiert", () => { const key = nextKey(); for (let i = 0; i < DAILY_SCAN_LIMIT; i++) { recordUsage(key, 1); } expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(false); expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).allowed).toBe(true); expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).remaining).toBe( DAILY_PRO_SCAN_LIMIT - DAILY_SCAN_LIMIT ); for (let i = DAILY_SCAN_LIMIT; i < DAILY_PRO_SCAN_LIMIT; i++) { recordUsage(key, 1); } expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).allowed).toBe(false); expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).used).toBe(DAILY_PRO_SCAN_LIMIT); }); test("C-4: ohne explizites Limit gilt der schlüssel-abgeleitete Default", () => { expect(checkDailyUsage(usageKeyForUser("u_a")).remaining).toBe(DAILY_SCAN_LIMIT); expect(checkDailyUsage(usageKeyForGuest("guest_a")).remaining).toBe(DAILY_GUEST_SCAN_LIMIT); }); test("C-5: leerer Key oder ungültiges Limit throttelt nie (Misconfiguration-Sicherheit)", () => { const r = checkDailyUsage("", DAILY_SCAN_LIMIT); expect(r.allowed).toBe(true); const r2 = checkDailyUsage("some_key", 0); expect(r2.allowed).toBe(true); }); }); // --------------------------------------------------------------------------- // 2. Record-Semantik (recordUsage) // --------------------------------------------------------------------------- describe("recordUsage — Zähler & Fensterstart", () => { test("R-1: erhöht den Zähler, used/remaining konsistent", () => { const key = nextKey(); const r1 = recordUsage(key, 1); expect(r1.used).toBe(1); expect(r1.remaining).toBe(DAILY_SCAN_LIMIT - 1); const r2 = recordUsage(key, 5); expect(r2.used).toBe(6); expect(r2.remaining).toBe(DAILY_SCAN_LIMIT - 6); }); test("R-2: remaining wird bei 0 geklemmt", () => { const key = nextKey(); const r = recordUsage(key, DAILY_SCAN_LIMIT + 50); expect(r.used).toBe(DAILY_SCAN_LIMIT + 50); expect(r.remaining).toBe(0); }); test("R-3: Multi-Unit-Record zählt Seiten (20-Seiten-PDF = 20 Einheiten)", () => { const key = nextKey(); const r = recordUsage(key, 20); expect(r.used).toBe(20); expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).used).toBe(20); }); test("R-4: ungültige/negative/0 Einheiten werden defensiv zu 1 normalisiert", () => { const key = nextKey(); expect(recordUsage(key, 2.5).used).toBe(3); // aufgerundet expect(recordUsage(key, -3).used).toBe(4); // negativ → 1 expect(recordUsage(key, 0).used).toBe(5); expect(recordUsage(key, NaN).used).toBe(6); }); test("R-5: erster Record startet das 24h-Fenster (resetsAt = now + 24h)", () => { const key = nextKey(); const t0 = new Date("2026-03-01T12:00:00.000Z"); recordUsage(key, 1, t0); const check = checkDailyUsage(key, DAILY_SCAN_LIMIT, new Date(t0.getTime() + 1000)); expect(check.resetsAt).toBe(t0.getTime() + DAY); }); }); // --------------------------------------------------------------------------- // 3. Fenster-Reset (injizierbares `now`) // --------------------------------------------------------------------------- describe("Fenster-Reset über injiziertes `now`", () => { test("W-1: nach 24h+1ms ist das Limit wieder frei", () => { const key = nextKey(); const day1 = new Date("2026-01-01T00:00:00.000Z"); for (let i = 0; i < DAILY_SCAN_LIMIT; i++) { recordUsage(key, 1, day1); } // Am Tag 1 aufgebraucht: expect(checkDailyUsage(key, DAILY_SCAN_LIMIT, day1).allowed).toBe(false); // Kurz vor Ablauf weiterhin blockiert: const almostDay2 = new Date(day1.getTime() + DAY - 1000); expect(checkDailyUsage(key, DAILY_SCAN_LIMIT, almostDay2).allowed).toBe(false); // 24h + 1ms später: frisches Fenster: const day2 = new Date(day1.getTime() + DAY + 1); const fresh = checkDailyUsage(key, DAILY_SCAN_LIMIT, day2); expect(fresh.allowed).toBe(true); expect(fresh.used).toBe(0); expect(fresh.remaining).toBe(DAILY_SCAN_LIMIT); // Record im neuen Fenster startet es neu: const rec = recordUsage(key, 3, day2); expect(rec.used).toBe(3); expect(rec.remaining).toBe(DAILY_SCAN_LIMIT - 3); }); test("W-2: checkDailyUsage mutiert nicht — mehrere Reads liefern gleiche Werte", () => { const key = nextKey(); const t = new Date("2026-02-02T08:00:00.000Z"); recordUsage(key, 4, t); const a = checkDailyUsage(key, DAILY_SCAN_LIMIT, t); const b = checkDailyUsage(key, DAILY_SCAN_LIMIT, t); expect(b).toEqual(a); expect(a.used).toBe(4); }); }); // --------------------------------------------------------------------------- // 4. Key-Isolation & Key-Format // --------------------------------------------------------------------------- describe("Key-Isolation & Key-Format", () => { test("K-1: User- und Guest-Key sind unabhängige Fenster", () => { const userKey = usageKeyForUser("user-42"); const guestKey = usageKeyForGuest("guest_bucket_1"); for (let i = 0; i < DAILY_SCAN_LIMIT; i++) { recordUsage(userKey, 1); } expect(checkDailyUsage(userKey, DAILY_SCAN_LIMIT).allowed).toBe(false); // Guest unberührt: expect(checkDailyUsage(guestKey, DAILY_GUEST_SCAN_LIMIT).allowed).toBe(true); expect(checkDailyUsage(guestKey, DAILY_GUEST_SCAN_LIMIT).used).toBe(0); // Und umgekehrt: for (let i = 0; i < DAILY_GUEST_SCAN_LIMIT; i++) { recordUsage(guestKey, 1); } expect(checkDailyUsage(guestKey, DAILY_GUEST_SCAN_LIMIT).allowed).toBe(false); expect(checkDailyUsage(userKey, DAILY_SCAN_LIMIT).used).toBe(DAILY_SCAN_LIMIT); }); test("K-2: usageKeyForUser/usageKeyForGuest liefern die dokumentierten Formate", () => { expect(usageKeyForUser("u_42")).toBe("ai:user:u_42"); expect(usageKeyForGuest("guest_abc")).toBe("ai:guest:guest_abc"); // Kein Prefix-Collision-Risiko zwischen beiden Räumen: expect(usageKeyForUser("guest_x")).toBe("ai:user:guest_x"); expect(usageKeyForGuest("u_1")).toBe("ai:guest:u_1"); }); }); // --------------------------------------------------------------------------- // 5. Overshoot-Caveat (Seiten = AI-Calls) & Konfiguration // --------------------------------------------------------------------------- describe("Overshoot & Konfiguration", () => { test("O-1: Pre-Check erlaubt, Multi-Seiten-Record sprengt das Budget — dokumentierter Overshoot", () => { const key = nextKey(); for (let i = 0; i < 5; i++) { recordUsage(key, 1); } // Pre-Check vor dem Upload: noch 25 frei → erlaubt. expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(true); // 10-Seiten-PDF wird korrekt gebucht: expect(recordUsage(key, 10).used).toBe(15); // 20-Seiten-PDF: Pre-Check hätte erlaubt (15 frei), Record überschreitet das // Budget — das ist der dokumentierte, durch Konkurrenz begrenzte Overshoot. const overshoot = recordUsage(key, 20); expect(overshoot.used).toBe(35); expect(overshoot.remaining).toBe(0); expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(false); }); test("KONF-1: Limits sind positiv; Guest < Free < Pro; HOURLY dokumentiert", () => { expect(DAILY_SCAN_LIMIT).toBe(30); expect(DAILY_GUEST_SCAN_LIMIT).toBe(10); expect(DAILY_PRO_SCAN_LIMIT).toBe(150); expect(DAILY_GUEST_SCAN_LIMIT).toBeLessThan(DAILY_SCAN_LIMIT); expect(DAILY_SCAN_LIMIT).toBeLessThan(DAILY_PRO_SCAN_LIMIT); expect(HOURLY_SCAN_LIMIT).toBeGreaterThanOrEqual(DAILY_SCAN_LIMIT); }); test("KONF-2: dailyScanLimitFor — Free hat kein Daily-Cap, Pro hat 150", () => { expect(dailyScanLimitFor({ isPro: false })).toBeNull(); expect(dailyScanLimitFor({ isPro: true })).toBe(DAILY_PRO_SCAN_LIMIT); }); }); if (typeof require !== "undefined" && require.main === module) { runAllTests() .then((ok) => process.exit(ok ? 0 : 1)) .catch((err) => { console.error("Fatal runner crash:", err); process.exit(1); }); }