rolling-window-5h.test.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. /**
  2. * 5h Rolling Window Tests
  3. *
  4. * TDD: RED phase - tests to verify 5h quota uses true sliding window
  5. *
  6. * Expected behavior:
  7. * - 5h window = current time - 5 hours (rolling, not fixed reset time)
  8. * - Entries older than 5h should be excluded automatically
  9. * - No "reset time" concept for 5h window
  10. * - Error messages should NOT show a fixed reset time, but indicate rolling window
  11. */
  12. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  13. // Mock resolveSystemTimezone before importing modules
  14. vi.mock("@/lib/utils/timezone", () => ({
  15. resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"),
  16. }));
  17. const pipelineCommands: Array<unknown[]> = [];
  18. const pipeline = {
  19. zadd: vi.fn((...args: unknown[]) => {
  20. pipelineCommands.push(["zadd", ...args]);
  21. return pipeline;
  22. }),
  23. expire: vi.fn((...args: unknown[]) => {
  24. pipelineCommands.push(["expire", ...args]);
  25. return pipeline;
  26. }),
  27. exec: vi.fn(async () => {
  28. pipelineCommands.push(["exec"]);
  29. return [];
  30. }),
  31. incrbyfloat: vi.fn(() => pipeline),
  32. zremrangebyscore: vi.fn(() => pipeline),
  33. zcard: vi.fn(() => pipeline),
  34. };
  35. const redisClient = {
  36. status: "ready",
  37. eval: vi.fn(async () => "0"),
  38. exists: vi.fn(async () => 1),
  39. get: vi.fn(async () => null),
  40. set: vi.fn(async () => "OK"),
  41. setex: vi.fn(async () => "OK"),
  42. pipeline: vi.fn(() => pipeline),
  43. };
  44. vi.mock("@/lib/redis", () => ({
  45. getRedisClient: () => redisClient,
  46. }));
  47. const statisticsMock = {
  48. // total cost
  49. sumKeyTotalCost: vi.fn(async () => 0),
  50. sumUserTotalCost: vi.fn(async () => 0),
  51. sumProviderTotalCost: vi.fn(async () => 0),
  52. // fixed-window sums
  53. sumKeyCostInTimeRange: vi.fn(async () => 0),
  54. sumProviderCostInTimeRange: vi.fn(async () => 0),
  55. sumUserCostInTimeRange: vi.fn(async () => 0),
  56. // rolling-window entries
  57. findKeyCostEntriesInTimeRange: vi.fn(async () => []),
  58. findProviderCostEntriesInTimeRange: vi.fn(async () => []),
  59. findUserCostEntriesInTimeRange: vi.fn(async () => []),
  60. };
  61. vi.mock("@/repository/statistics", () => statisticsMock);
  62. describe("RateLimitService - 5h rolling window behavior", () => {
  63. const baseTime = 1700000000000; // Base timestamp
  64. beforeEach(() => {
  65. pipelineCommands.length = 0;
  66. vi.resetAllMocks();
  67. vi.useFakeTimers();
  68. vi.setSystemTime(new Date(baseTime));
  69. });
  70. afterEach(() => {
  71. vi.useRealTimers();
  72. });
  73. describe("Scenario 1: Basic rolling window - entries expire after 5h", () => {
  74. it("T0: consume $10, window should be $10", async () => {
  75. const { RateLimitService } = await import("@/lib/rate-limit");
  76. // trackCost calls eval twice (key + provider)
  77. redisClient.eval.mockResolvedValueOnce("10"); // TRACK key
  78. redisClient.eval.mockResolvedValueOnce("10"); // TRACK provider
  79. await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 1, createdAtMs: baseTime });
  80. // getCurrentCost calls eval once, then exists
  81. redisClient.eval.mockResolvedValueOnce("10"); // GET query
  82. redisClient.exists.mockResolvedValueOnce(1); // key exists
  83. const current = await RateLimitService.getCurrentCost(1, "key", "5h");
  84. expect(current).toBe(10);
  85. });
  86. it("T1 (3h later): consume $20, window should be $30", async () => {
  87. const { RateLimitService } = await import("@/lib/rate-limit");
  88. // T0: Track $10 (2 evals: key + provider)
  89. redisClient.eval.mockResolvedValueOnce("10");
  90. redisClient.eval.mockResolvedValueOnce("10");
  91. await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 1, createdAtMs: baseTime });
  92. // T1: Move to 3h later
  93. const t1 = baseTime + 3 * 60 * 60 * 1000;
  94. vi.setSystemTime(new Date(t1));
  95. // Track $20 (2 evals: key + provider)
  96. redisClient.eval.mockResolvedValueOnce("20");
  97. redisClient.eval.mockResolvedValueOnce("20");
  98. await RateLimitService.trackCost(1, 2, "sess", 20, { requestId: 2, createdAtMs: t1 });
  99. // getCurrentCost: eval returns sum
  100. redisClient.eval.mockResolvedValueOnce("30");
  101. redisClient.exists.mockResolvedValueOnce(1);
  102. const current = await RateLimitService.getCurrentCost(1, "key", "5h");
  103. expect(current).toBe(30);
  104. });
  105. it("T2 (6h later): query cost, should only include T1 ($20) as T0 expired", async () => {
  106. const { RateLimitService } = await import("@/lib/rate-limit");
  107. // T0: Track $10 (2 evals)
  108. redisClient.eval.mockResolvedValueOnce("10");
  109. redisClient.eval.mockResolvedValueOnce("10");
  110. await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 1, createdAtMs: baseTime });
  111. // T1: 3h later, track $20 (2 evals)
  112. const t1 = baseTime + 3 * 60 * 60 * 1000;
  113. vi.setSystemTime(new Date(t1));
  114. redisClient.eval.mockResolvedValueOnce("30");
  115. redisClient.eval.mockResolvedValueOnce("30");
  116. await RateLimitService.trackCost(1, 2, "sess", 20, { requestId: 2, createdAtMs: t1 });
  117. // T2: 6h after T0 (3h after T1)
  118. const t2 = baseTime + 6 * 60 * 60 * 1000;
  119. vi.setSystemTime(new Date(t2));
  120. // Lua script should clean T0 and return only T1
  121. redisClient.eval.mockResolvedValueOnce("20");
  122. redisClient.exists.mockResolvedValueOnce(1);
  123. const current = await RateLimitService.getCurrentCost(1, "key", "5h");
  124. expect(current).toBe(20);
  125. // Verify Lua script was called with correct window calculation
  126. const evalCall = redisClient.eval.mock.calls[redisClient.eval.mock.calls.length - 1];
  127. expect(evalCall[3]).toBe(t2.toString()); // now
  128. expect(evalCall[4]).toBe((5 * 60 * 60 * 1000).toString()); // 5h window
  129. });
  130. });
  131. describe("Scenario 2: Window boundary - 4h59m vs 5h01m", () => {
  132. it("T0: consume $5, T1 (4h59m later): consume $10, window = $15", async () => {
  133. const { RateLimitService } = await import("@/lib/rate-limit");
  134. // T0: Track $5 (2 evals)
  135. redisClient.eval.mockResolvedValueOnce("5");
  136. redisClient.eval.mockResolvedValueOnce("5");
  137. await RateLimitService.trackCost(1, 2, "sess", 5, { requestId: 1, createdAtMs: baseTime });
  138. // T1: 4h59m later (still within 5h)
  139. const t1 = baseTime + (4 * 60 + 59) * 60 * 1000;
  140. vi.setSystemTime(new Date(t1));
  141. redisClient.eval.mockResolvedValueOnce("15");
  142. redisClient.eval.mockResolvedValueOnce("15");
  143. await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 2, createdAtMs: t1 });
  144. // Both entries should be in window
  145. redisClient.eval.mockResolvedValueOnce("15");
  146. redisClient.exists.mockResolvedValueOnce(1);
  147. const current = await RateLimitService.getCurrentCost(1, "key", "5h");
  148. expect(current).toBe(15);
  149. });
  150. it("T2 (5h01m after T0): query, window = $10 (T0 expired)", async () => {
  151. const { RateLimitService } = await import("@/lib/rate-limit");
  152. // T0: Track $5 (2 evals)
  153. redisClient.eval.mockResolvedValueOnce("5");
  154. redisClient.eval.mockResolvedValueOnce("5");
  155. await RateLimitService.trackCost(1, 2, "sess", 5, { requestId: 1, createdAtMs: baseTime });
  156. // T1: 4h59m later (2 evals)
  157. const t1 = baseTime + (4 * 60 + 59) * 60 * 1000;
  158. vi.setSystemTime(new Date(t1));
  159. redisClient.eval.mockResolvedValueOnce("15");
  160. redisClient.eval.mockResolvedValueOnce("15");
  161. await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 2, createdAtMs: t1 });
  162. // T2: 5h01m after T0
  163. const t2 = baseTime + (5 * 60 + 1) * 60 * 1000;
  164. vi.setSystemTime(new Date(t2));
  165. // T0 should be cleaned, only T1 remains
  166. redisClient.eval.mockResolvedValueOnce("10");
  167. redisClient.exists.mockResolvedValueOnce(1);
  168. const current = await RateLimitService.getCurrentCost(1, "key", "5h");
  169. expect(current).toBe(10);
  170. });
  171. });
  172. describe("Scenario 3: Multiple entries rolling out", () => {
  173. it("should correctly calculate window with multiple entries at different times", async () => {
  174. const { RateLimitService } = await import("@/lib/rate-limit");
  175. // T0: $10 (2 evals)
  176. redisClient.eval.mockResolvedValueOnce("10");
  177. redisClient.eval.mockResolvedValueOnce("10");
  178. await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 1, createdAtMs: baseTime });
  179. // T1: 1h later, $20 (2 evals)
  180. const t1 = baseTime + 1 * 60 * 60 * 1000;
  181. vi.setSystemTime(new Date(t1));
  182. redisClient.eval.mockResolvedValueOnce("30");
  183. redisClient.eval.mockResolvedValueOnce("30");
  184. await RateLimitService.trackCost(1, 2, "sess", 20, { requestId: 2, createdAtMs: t1 });
  185. // T2: 2h later, $15 (2 evals)
  186. const t2 = baseTime + 2 * 60 * 60 * 1000;
  187. vi.setSystemTime(new Date(t2));
  188. redisClient.eval.mockResolvedValueOnce("45");
  189. redisClient.eval.mockResolvedValueOnce("45");
  190. await RateLimitService.trackCost(1, 2, "sess", 15, { requestId: 3, createdAtMs: t2 });
  191. // T3: 3h after T0, $25 (2 evals)
  192. const t3 = baseTime + 3 * 60 * 60 * 1000;
  193. vi.setSystemTime(new Date(t3));
  194. redisClient.eval.mockResolvedValueOnce("70");
  195. redisClient.eval.mockResolvedValueOnce("70");
  196. await RateLimitService.trackCost(1, 2, "sess", 25, { requestId: 4, createdAtMs: t3 });
  197. // At T3: all 4 entries within window = $70
  198. redisClient.eval.mockResolvedValueOnce("70");
  199. redisClient.exists.mockResolvedValueOnce(1);
  200. const currentT3 = await RateLimitService.getCurrentCost(1, "key", "5h");
  201. expect(currentT3).toBe(70);
  202. // T4: 6h after T0
  203. const t4 = baseTime + 6 * 60 * 60 * 1000;
  204. vi.setSystemTime(new Date(t4));
  205. // T0 and T1 expired, only T2 and T3 remain = $40
  206. redisClient.eval.mockResolvedValueOnce("40");
  207. redisClient.exists.mockResolvedValueOnce(1);
  208. const currentT4 = await RateLimitService.getCurrentCost(1, "key", "5h");
  209. expect(currentT4).toBe(40);
  210. });
  211. });
  212. describe("Scenario 4: Limit check with rolling window", () => {
  213. it("should reject request when rolling window exceeds limit", async () => {
  214. const { RateLimitService } = await import("@/lib/rate-limit");
  215. // T0: consume $40 (2 evals for trackCost)
  216. redisClient.eval.mockResolvedValueOnce("40");
  217. redisClient.eval.mockResolvedValueOnce("40");
  218. await RateLimitService.trackCost(1, 2, "sess", 40, { requestId: 1, createdAtMs: baseTime });
  219. // Check limit (5h = $50) - checkCostLimits calls eval
  220. redisClient.eval.mockResolvedValueOnce("40");
  221. redisClient.exists.mockResolvedValueOnce(1);
  222. const checkT0 = await RateLimitService.checkCostLimits(1, "key", {
  223. limit_5h_usd: 50,
  224. limit_daily_usd: null,
  225. limit_weekly_usd: null,
  226. limit_monthly_usd: null,
  227. });
  228. expect(checkT0.allowed).toBe(true);
  229. // T1: 3h later, try to consume $20 (would make window $60 > $50)
  230. const t1 = baseTime + 3 * 60 * 60 * 1000;
  231. vi.setSystemTime(new Date(t1));
  232. // checkCostLimits: eval returns current = $40
  233. redisClient.eval.mockResolvedValueOnce("40");
  234. redisClient.exists.mockResolvedValueOnce(1);
  235. const checkT1 = await RateLimitService.checkCostLimits(1, "key", {
  236. limit_5h_usd: 50,
  237. limit_daily_usd: null,
  238. limit_weekly_usd: null,
  239. limit_monthly_usd: null,
  240. });
  241. // Current is $40, limit is $50, should still be allowed
  242. expect(checkT1.allowed).toBe(true);
  243. // After adding $20, would be $60 - trackCost (2 evals)
  244. redisClient.eval.mockResolvedValueOnce("60");
  245. redisClient.eval.mockResolvedValueOnce("60");
  246. await RateLimitService.trackCost(1, 2, "sess", 20, { requestId: 2, createdAtMs: t1 });
  247. // Verify window now shows $60
  248. redisClient.eval.mockResolvedValueOnce("60");
  249. redisClient.exists.mockResolvedValueOnce(1);
  250. const currentT1 = await RateLimitService.getCurrentCost(1, "key", "5h");
  251. expect(currentT1).toBe(60);
  252. // T2: 6h after T0, T0's $40 expires, window = $20
  253. const t2 = baseTime + 6 * 60 * 60 * 1000;
  254. vi.setSystemTime(new Date(t2));
  255. redisClient.eval.mockResolvedValueOnce("20");
  256. redisClient.exists.mockResolvedValueOnce(1);
  257. const checkT2 = await RateLimitService.checkCostLimits(1, "key", {
  258. limit_5h_usd: 50,
  259. limit_daily_usd: null,
  260. limit_weekly_usd: null,
  261. limit_monthly_usd: null,
  262. });
  263. expect(checkT2.allowed).toBe(true);
  264. });
  265. });
  266. describe("Scenario 5: Cross-day rolling window", () => {
  267. it("should handle entries across day boundary correctly", async () => {
  268. const { RateLimitService } = await import("@/lib/rate-limit");
  269. // Day1 22:00 UTC
  270. const day1_22h = new Date("2024-01-15T22:00:00.000Z").getTime();
  271. vi.setSystemTime(new Date(day1_22h));
  272. // Track $10 (2 evals)
  273. redisClient.eval.mockResolvedValueOnce("10");
  274. redisClient.eval.mockResolvedValueOnce("10");
  275. await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 1, createdAtMs: day1_22h });
  276. // Day2 01:00 UTC (3h later, crossed midnight)
  277. const day2_01h = new Date("2024-01-16T01:00:00.000Z").getTime();
  278. vi.setSystemTime(new Date(day2_01h));
  279. // Track $20 (2 evals)
  280. redisClient.eval.mockResolvedValueOnce("30");
  281. redisClient.eval.mockResolvedValueOnce("30");
  282. await RateLimitService.trackCost(1, 2, "sess", 20, { requestId: 2, createdAtMs: day2_01h });
  283. // Both entries in window = $30
  284. redisClient.eval.mockResolvedValueOnce("30");
  285. redisClient.exists.mockResolvedValueOnce(1);
  286. const current01h = await RateLimitService.getCurrentCost(1, "key", "5h");
  287. expect(current01h).toBe(30);
  288. // Day2 04:00 UTC (6h after day1_22h)
  289. const day2_04h = new Date("2024-01-16T04:00:00.000Z").getTime();
  290. vi.setSystemTime(new Date(day2_04h));
  291. // First entry expired, only second remains = $20
  292. redisClient.eval.mockResolvedValueOnce("20");
  293. redisClient.exists.mockResolvedValueOnce(1);
  294. const current04h = await RateLimitService.getCurrentCost(1, "key", "5h");
  295. expect(current04h).toBe(20);
  296. });
  297. });
  298. describe("Verify no fixed reset time exists for 5h window", () => {
  299. it("should not have any fixed reset time concept", async () => {
  300. const { getResetInfo } = await import("@/lib/rate-limit/time-utils");
  301. const info = await getResetInfo("5h");
  302. // 5h window is rolling type, no resetAt timestamp
  303. expect(info.type).toBe("rolling");
  304. expect(info.period).toBe("5 小时");
  305. expect(info.resetAt).toBeUndefined();
  306. });
  307. it("should always calculate window as (now - 5h) to now", async () => {
  308. const { getTimeRangeForPeriod } = await import("@/lib/rate-limit/time-utils");
  309. const now1 = new Date("2024-01-15T10:00:00.000Z").getTime();
  310. vi.setSystemTime(new Date(now1));
  311. const range1 = await getTimeRangeForPeriod("5h");
  312. expect(range1.endTime.getTime()).toBe(now1);
  313. expect(range1.startTime.getTime()).toBe(now1 - 5 * 60 * 60 * 1000);
  314. // Different time
  315. const now2 = new Date("2024-01-16T15:30:00.000Z").getTime();
  316. vi.setSystemTime(new Date(now2));
  317. const range2 = await getTimeRangeForPeriod("5h");
  318. expect(range2.endTime.getTime()).toBe(now2);
  319. expect(range2.startTime.getTime()).toBe(now2 - 5 * 60 * 60 * 1000);
  320. });
  321. });
  322. describe("Provider 5h rolling window", () => {
  323. it("should work identically for provider entities", async () => {
  324. const { RateLimitService } = await import("@/lib/rate-limit");
  325. // T0: provider consumes $15 (2 evals)
  326. redisClient.eval.mockResolvedValueOnce("15");
  327. redisClient.eval.mockResolvedValueOnce("15");
  328. await RateLimitService.trackCost(1, 2, "sess", 15, { requestId: 1, createdAtMs: baseTime });
  329. // T1: 4h later, consume $25 (2 evals)
  330. const t1 = baseTime + 4 * 60 * 60 * 1000;
  331. vi.setSystemTime(new Date(t1));
  332. redisClient.eval.mockResolvedValueOnce("40");
  333. redisClient.eval.mockResolvedValueOnce("40");
  334. await RateLimitService.trackCost(1, 2, "sess", 25, { requestId: 2, createdAtMs: t1 });
  335. // Window = $40
  336. redisClient.eval.mockResolvedValueOnce("40");
  337. redisClient.exists.mockResolvedValueOnce(1);
  338. const currentT1 = await RateLimitService.getCurrentCost(2, "provider", "5h");
  339. expect(currentT1).toBe(40);
  340. // T2: 6h after T0
  341. const t2 = baseTime + 6 * 60 * 60 * 1000;
  342. vi.setSystemTime(new Date(t2));
  343. // Only T1 remains = $25
  344. redisClient.eval.mockResolvedValueOnce("25");
  345. redisClient.exists.mockResolvedValueOnce(1);
  346. const currentT2 = await RateLimitService.getCurrentCost(2, "provider", "5h");
  347. expect(currentT2).toBe(25);
  348. });
  349. });
  350. describe("Cache miss and DB recovery", () => {
  351. it("should restore from DB entries with correct time range on cache miss", async () => {
  352. const { RateLimitService } = await import("@/lib/rate-limit");
  353. // Simulate cache miss: eval returns 0 and key doesn't exist
  354. redisClient.eval.mockResolvedValueOnce("0");
  355. redisClient.exists.mockResolvedValueOnce(0);
  356. // Mock DB entries within 5h window
  357. const now = baseTime + 3 * 60 * 60 * 1000; // 3h later
  358. vi.setSystemTime(new Date(now));
  359. statisticsMock.findKeyCostEntriesInTimeRange.mockResolvedValueOnce([
  360. { id: 1, createdAt: new Date(baseTime), costUsd: 10 },
  361. { id: 2, createdAt: new Date(baseTime + 1 * 60 * 60 * 1000), costUsd: 20 },
  362. { id: 3, createdAt: new Date(baseTime + 2 * 60 * 60 * 1000), costUsd: 15 },
  363. ]);
  364. const current = await RateLimitService.getCurrentCost(1, "key", "5h");
  365. // Should sum all entries = $45
  366. expect(current).toBeCloseTo(45, 10);
  367. // Verify DB was called with correct time range (now - 5h to now)
  368. expect(statisticsMock.findKeyCostEntriesInTimeRange).toHaveBeenCalledWith(
  369. 1,
  370. expect.objectContaining({
  371. getTime: expect.any(Function),
  372. }),
  373. expect.objectContaining({
  374. getTime: expect.any(Function),
  375. })
  376. );
  377. const [, startTime, endTime] = statisticsMock.findKeyCostEntriesInTimeRange.mock.calls[0];
  378. expect(endTime.getTime()).toBe(now);
  379. expect(startTime.getTime()).toBe(now - 5 * 60 * 60 * 1000);
  380. });
  381. });
  382. });
  383. /**
  384. * Tests for error message and resetTime when 5h limit is exceeded
  385. *
  386. * Key expectation: 5h rolling window should NOT have a fixed "reset time"
  387. * The current implementation incorrectly calculates resetTime as Date.now() + 5h
  388. * which implies "start counting from when limit is hit"
  389. *
  390. * Expected behavior for rolling window:
  391. * - resetTime concept doesn't apply to rolling windows
  392. * - Should indicate "rolling 5h window" in the message
  393. * - Earliest entry expiry time might be useful to show when some budget will free up
  394. */
  395. describe("5h limit exceeded - error message and resetTime", () => {
  396. const baseTime = 1700000000000;
  397. beforeEach(() => {
  398. vi.useFakeTimers();
  399. vi.setSystemTime(new Date(baseTime));
  400. });
  401. afterEach(() => {
  402. vi.useRealTimers();
  403. });
  404. describe("resetTime semantics for rolling window", () => {
  405. it("5h window getResetInfo should return rolling type without resetAt", async () => {
  406. const { getResetInfo } = await import("@/lib/rate-limit/time-utils");
  407. const info = await getResetInfo("5h");
  408. // Rolling windows have no fixed reset time
  409. expect(info.type).toBe("rolling");
  410. expect(info.resetAt).toBeUndefined();
  411. expect(info.period).toBe("5 小时");
  412. });
  413. it("5h rolling window should NOT use (now + 5h) as reset time", async () => {
  414. // This test documents the expected behavior:
  415. // For rolling windows, the "reset time" concept is misleading
  416. // Because usage gradually rolls out as entries age past 5h
  417. //
  418. // WRONG: resetTime = now + 5h (implies "start counting from trigger")
  419. // RIGHT: No fixed reset time, or show when earliest entry expires
  420. const { getResetInfo } = await import("@/lib/rate-limit/time-utils");
  421. const t1 = baseTime;
  422. vi.setSystemTime(new Date(t1));
  423. const info1 = await getResetInfo("5h");
  424. // Move forward 3 hours
  425. const t2 = baseTime + 3 * 60 * 60 * 1000;
  426. vi.setSystemTime(new Date(t2));
  427. const info2 = await getResetInfo("5h");
  428. // Both should indicate rolling type, no specific resetAt
  429. expect(info1.type).toBe("rolling");
  430. expect(info2.type).toBe("rolling");
  431. expect(info1.resetAt).toBeUndefined();
  432. expect(info2.resetAt).toBeUndefined();
  433. });
  434. it("time range should always be (now - 5h, now), not anchored to trigger time", async () => {
  435. const { getTimeRangeForPeriod } = await import("@/lib/rate-limit/time-utils");
  436. // T1: Check time range
  437. const t1 = baseTime;
  438. vi.setSystemTime(new Date(t1));
  439. const range1 = await getTimeRangeForPeriod("5h");
  440. expect(range1.startTime.getTime()).toBe(t1 - 5 * 60 * 60 * 1000);
  441. expect(range1.endTime.getTime()).toBe(t1);
  442. // T2: 3 hours later, time range should shift
  443. const t2 = baseTime + 3 * 60 * 60 * 1000;
  444. vi.setSystemTime(new Date(t2));
  445. const range2 = await getTimeRangeForPeriod("5h");
  446. expect(range2.startTime.getTime()).toBe(t2 - 5 * 60 * 60 * 1000);
  447. expect(range2.endTime.getTime()).toBe(t2);
  448. // The window should have shifted, not stayed anchored
  449. expect(range2.startTime.getTime()).toBe(range1.startTime.getTime() + 3 * 60 * 60 * 1000);
  450. });
  451. });
  452. describe("error message content verification", () => {
  453. it("error message should indicate rolling window nature", async () => {
  454. // For rolling windows, the message should NOT say "Resets at <specific time>"
  455. // Instead, it should convey that this is a rolling 5-hour window
  456. //
  457. // Example of problematic message:
  458. // "5-hour cost limit exceeded. Resets at 2024-01-15T15:00:00Z"
  459. // (This implies you wait until 15:00 and then everything resets)
  460. //
  461. // Better message:
  462. // "5-hour rolling window cost limit exceeded. Usage is calculated over the past 5 hours."
  463. // or
  464. // "5-hour cost limit exceeded. Oldest usage will roll off in X hours."
  465. const { getResetInfo } = await import("@/lib/rate-limit/time-utils");
  466. const info = await getResetInfo("5h");
  467. // The info should clearly indicate this is a rolling window
  468. expect(info.type).toBe("rolling");
  469. // And provide the period description
  470. expect(info.period).toBeDefined();
  471. });
  472. });
  473. describe("comparison with daily fixed window", () => {
  474. it("daily fixed window SHOULD have a specific reset time", async () => {
  475. const { getResetInfo } = await import("@/lib/rate-limit/time-utils");
  476. const info = await getResetInfo("daily", "18:00");
  477. // Daily fixed windows have a specific reset time
  478. expect(info.type).toBe("custom");
  479. expect(info.resetAt).toBeDefined();
  480. expect(info.resetAt).toBeInstanceOf(Date);
  481. });
  482. it("daily rolling window should NOT have a specific reset time", async () => {
  483. const { getResetInfoWithMode } = await import("@/lib/rate-limit/time-utils");
  484. const info = await getResetInfoWithMode("daily", "18:00", "rolling");
  485. // Daily rolling also has no fixed reset
  486. expect(info.type).toBe("rolling");
  487. expect(info.resetAt).toBeUndefined();
  488. expect(info.period).toBe("24 小时");
  489. });
  490. });
  491. describe("weekly and monthly windows for comparison", () => {
  492. it("weekly window should have natural reset time (next Monday)", async () => {
  493. const { getResetInfo } = await import("@/lib/rate-limit/time-utils");
  494. const info = await getResetInfo("weekly");
  495. expect(info.type).toBe("natural");
  496. expect(info.resetAt).toBeDefined();
  497. });
  498. it("monthly window should have natural reset time (1st of next month)", async () => {
  499. const { getResetInfo } = await import("@/lib/rate-limit/time-utils");
  500. const info = await getResetInfo("monthly");
  501. expect(info.type).toBe("natural");
  502. expect(info.resetAt).toBeDefined();
  503. });
  504. });
  505. });
  506. /**
  507. * Integration test: verify the full flow from limit check to error message
  508. *
  509. * This test verifies that when a 5h limit is exceeded:
  510. * 1. The check correctly identifies the limit is exceeded
  511. * 2. The error response contains appropriate information about the rolling window
  512. * 3. The resetTime in the error is semantically correct for a rolling window
  513. */
  514. describe("5h limit exceeded - full flow integration", () => {
  515. const baseTime = 1700000000000;
  516. beforeEach(() => {
  517. vi.useFakeTimers();
  518. vi.setSystemTime(new Date(baseTime));
  519. });
  520. afterEach(() => {
  521. vi.useRealTimers();
  522. });
  523. it("checkCostLimits should return appropriate failure info for 5h exceeded", async () => {
  524. const { RateLimitService } = await import("@/lib/rate-limit");
  525. // Mock current usage: $60 (exceeds $50 limit)
  526. redisClient.eval.mockResolvedValueOnce("60");
  527. redisClient.exists.mockResolvedValueOnce(1);
  528. const result = await RateLimitService.checkCostLimits(1, "key", {
  529. limit_5h_usd: 50, // Limit: $50
  530. limit_daily_usd: null,
  531. limit_weekly_usd: null,
  532. limit_monthly_usd: null,
  533. });
  534. expect(result.allowed).toBe(false);
  535. // The reason should indicate the limit was exceeded
  536. expect(result.reason).toContain("5小时");
  537. expect(result.reason).toContain("60");
  538. expect(result.reason).toContain("50");
  539. });
  540. });