完成回答、直达校正、输出审核和开流前加载仍留在路由里,使 diff 停在 1500 行以内。 Co-Authored-By: Grok (agent loop)
218 lines
7.7 KiB
TypeScript
218 lines
7.7 KiB
TypeScript
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<string, number[]>();
|
||
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<string, number[]>();
|
||
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<string, number[]>();
|
||
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<string, number[]>();
|
||
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<string, number[]>();
|
||
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<string, number[]>();
|
||
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<string, number[]>();
|
||
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"));
|
||
});
|