import assert from "node:assert/strict"; import test from "node:test"; import { readFileSync } from "node:fs"; import { consumeRequestRateLimit, consumeUserRateLimitSet, requestRateLimits, resetRequestRateLimitMaintenanceForTests, userRateLimitDeniedResponse, userRateLimitMessages, } from "../src/lib/request-rate-limit.ts"; test("a user is allowed up to the window limit and then must wait", () => { const store = new Map(); const first = consumeRequestRateLimit({ key: "search:user-1", limit: 2, windowMs: 60_000, now: 1_000, store, }); const second = consumeRequestRateLimit({ key: "search:user-1", limit: 2, windowMs: 60_000, now: 2_000, store, }); const blocked = consumeRequestRateLimit({ key: "search:user-1", limit: 2, windowMs: 60_000, now: 3_000, store, }); const otherUser = consumeRequestRateLimit({ key: "search:user-2", limit: 2, windowMs: 60_000, now: 3_000, store, }); const afterWindow = consumeRequestRateLimit({ key: "search:user-1", limit: 2, windowMs: 60_000, now: 61_500, store, }); assert.deepEqual(first, { ok: true }); assert.deepEqual(second, { ok: true }); assert.deepEqual(blocked, { ok: false, retryAfterSeconds: 58 }); assert.deepEqual(otherUser, { ok: true }); assert.deepEqual(afterWindow, { ok: true }); }); test("keys past the longest seen window are swept, and a one-hour key survives 61 seconds", () => { resetRequestRateLimitMaintenanceForTests(); const shortWindow = new Map(); consumeRequestRateLimit({ key: "old", limit: 5, windowMs: 60_000, now: 0, store: shortWindow, }); for (let index = 0; index < 998; index += 1) { consumeRequestRateLimit({ key: `pad:${index}`, limit: 5, windowMs: 60_000, now: 0, store: shortWindow, }); } consumeRequestRateLimit({ key: "trigger", limit: 5, windowMs: 60_000, now: 61_000, store: shortWindow, }); assert.equal(shortWindow.has("old"), false); assert.equal(shortWindow.get("old"), undefined); assert.equal(shortWindow.has("trigger"), true); assert.equal(shortWindow.size, 1); resetRequestRateLimitMaintenanceForTests(); const mixed = new Map(); consumeRequestRateLimit({ key: "synastry:person", limit: 8, windowMs: 60 * 60_000, now: 0, store: mixed, }); consumeRequestRateLimit({ key: "ephemeris:person", limit: 30, windowMs: 60_000, now: 0, store: mixed, }); for (let index = 0; index < 998; index += 1) { consumeRequestRateLimit({ key: `later:${index}`, limit: 5, windowMs: 60_000, now: 61_000, store: mixed, }); } assert.equal(mixed.has("synastry:person"), true); assert.equal(mixed.has("ephemeris:person"), true); resetRequestRateLimitMaintenanceForTests(); }); test("consult allows 10 per minute and refuses the 11th without charging the hour bucket further", () => { const store = new Map(); for (let index = 0; index < 10; index += 1) { const decision = consumeUserRateLimitSet( ["consultMinute", "consultHour"], "person", 1_000 + index, store, ); assert.deepEqual(decision, { ok: true }); } const denied = consumeUserRateLimitSet(["consultMinute", "consultHour"], "person", 2_000, store); assert.equal(denied.ok, false); if (!denied.ok) assert.equal(denied.retryAfterSeconds, 59); assert.equal(store.get("consultMinute:person")?.length, 10); assert.equal(store.get("consultHour:person")?.length, 10); assert.equal(requestRateLimits.consultMinute.limit, 10); assert.equal(requestRateLimits.consultHour.limit, 120); }); test("the hour bucket can refuse while the minute bucket still has room, and neither bucket records the miss", () => { const store = new Map(); for (let index = 0; index < 120; index += 1) { consumeRequestRateLimit({ key: "consultHour:person", limit: 120, windowMs: 60 * 60_000, now: index * 1_000, store, }); } const denied = consumeUserRateLimitSet(["consultMinute", "consultHour"], "person", 130_000, store); assert.equal(denied.ok, false); assert.equal(store.has("consultMinute:person"), false); assert.equal(store.get("consultHour:person")?.length, 120); }); test("rectification turns allow 5 per minute and 40 per hour, reports allow 3 per hour", () => { const store = new Map(); for (let index = 0; index < 5; index += 1) { assert.deepEqual( consumeUserRateLimitSet(["rectificationTurnMinute", "rectificationTurnHour"], "person", 5_000 + index, store), { ok: true }, ); } const denied = consumeUserRateLimitSet( ["rectificationTurnMinute", "rectificationTurnHour"], "person", 5_100, store, ); assert.equal(denied.ok, false); assert.equal(store.get("rectificationTurnMinute:person")?.length, 5); assert.equal(requestRateLimits.rectificationTurnMinute.limit, 5); assert.equal(requestRateLimits.rectificationTurnHour.limit, 40); assert.equal(requestRateLimits.reportGenerateHour.limit, 3); assert.equal(requestRateLimits.reportGenerateHour.windowMs, 60 * 60_000); const reports = new Map(); for (let index = 0; index < 3; index += 1) { assert.deepEqual( consumeUserRateLimitSet(["reportGenerateHour"], "person", 8_000 + index, reports), { ok: true }, ); } const reportDenied = consumeUserRateLimitSet(["reportGenerateHour"], "person", 9_000, reports); assert.equal(reportDenied.ok, false); assert.equal(reports.get("reportGenerateHour:person")?.length, 3); }); test("a denied response carries 429, rate_limited, and Retry-After", () => { const denied = userRateLimitDeniedResponse(12, userRateLimitMessages.consult); assert.equal(denied.status, 429); assert.equal(denied.headers["Retry-After"], "12"); assert.deepEqual(denied.body, { error: "这一会儿问得有点密,先停一下。", code: "rate_limited", retryAfterSeconds: 12, }); }); test("consult, rectification turns, and report creation check the limit before they reserve", () => { const consult = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); const consultSettlement = readFileSync(new URL("../src/lib/consult/settlement.ts", import.meta.url), "utf8"); const rectification = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const caseOpen = readFileSync(new URL("../src/app/api/rectification/cases/open/route.ts", import.meta.url), "utf8"); const reports = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8"); const reportCore = readFileSync(new URL("../src/lib/personal-report-route-core.ts", import.meta.url), "utf8"); // 原值: route.ts 里 consumeUserRateLimitSet( 早于 reserve_consultation_usage // 新值: route.ts 里限额调用早于 consultSettlement.reserveCredit;RPC 名在 settlement.ts // 原因: 预留回调体搬进结算工厂,限额仍在开流前,顺序不跨文件拼接 assert.ok(consult.indexOf("consumeUserRateLimitSet(") < consult.indexOf("consultSettlement.reserveCredit")); assert.match(consultSettlement, /rpc\("reserve_consultation_usage"/); assert.match(consult, /userRateLimitDeniedResponse\(/); assert.ok(rectification.indexOf("consumeUserRateLimitSet(") < rectification.indexOf("respondWithRectificationAgentTurn(")); assert.equal(caseOpen.includes("consumeUserRateLimitSet"), false); assert.match(reports, /consumeUserRateLimitSet\(\["reportGenerateHour"\]/); assert.ok(reportCore.indexOf("if (deps.consumeReportRateLimit)") < reportCore.indexOf("deps.billing.reserve")); });