Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
}
|