cost-alert-window.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. /**
  2. * Cost Alert Time Window Tests
  3. *
  4. * Tests for verifying that cost-alert.ts uses proper time-utils functions
  5. * and repository functions with correct filtering (deletedAt, warmup exclusion).
  6. *
  7. * Key Differences After Fix:
  8. * | Window | Before | After |
  9. * |---------|--------------------------------|----------------------------------------|
  10. * | 5h | now - 5h | getTimeRangeForPeriod("5h") - same |
  11. * | Weekly | now - 7 days (rolling) | getTimeRangeForPeriod("weekly") - Monday |
  12. * | Monthly | Month start (no timezone) | getTimeRangeForPeriod("monthly") - TZ aware |
  13. *
  14. * Filters Added by Using sumKeyCostInTimeRange/sumProviderCostInTimeRange:
  15. * - deletedAt IS NULL
  16. * - blockedBy IS NULL OR blockedBy <> 'warmup' (EXCLUDE_WARMUP_CONDITION)
  17. */
  18. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  19. // Track mock calls
  20. const mockGetTimeRangeForPeriod = vi.fn();
  21. const mockSumKeyCostInTimeRange = vi.fn();
  22. const mockSumProviderCostInTimeRange = vi.fn();
  23. const mockDbSelect = vi.fn();
  24. const mockDbFrom = vi.fn();
  25. const mockDbWhere = vi.fn();
  26. // Mock dependencies before importing the module under test
  27. vi.mock("@/drizzle/db", () => ({
  28. db: {
  29. select: (...args: unknown[]) => {
  30. mockDbSelect(...args);
  31. return {
  32. from: (...fromArgs: unknown[]) => {
  33. mockDbFrom(...fromArgs);
  34. return {
  35. where: (...whereArgs: unknown[]) => mockDbWhere(...whereArgs),
  36. };
  37. },
  38. };
  39. },
  40. },
  41. }));
  42. vi.mock("@/lib/logger", () => ({
  43. logger: {
  44. info: vi.fn(),
  45. error: vi.fn(),
  46. debug: vi.fn(),
  47. warn: vi.fn(),
  48. },
  49. }));
  50. vi.mock("@/lib/utils/timezone", () => ({
  51. resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"),
  52. }));
  53. // Mock the time-utils module
  54. vi.mock("@/lib/rate-limit/time-utils", () => ({
  55. getTimeRangeForPeriod: (...args: unknown[]) => mockGetTimeRangeForPeriod(...args),
  56. }));
  57. // Mock the statistics repository
  58. vi.mock("@/repository/statistics", () => ({
  59. sumKeyCostInTimeRange: (...args: unknown[]) => mockSumKeyCostInTimeRange(...args),
  60. sumProviderCostInTimeRange: (...args: unknown[]) => mockSumProviderCostInTimeRange(...args),
  61. }));
  62. describe("Cost Alert Time Windows", () => {
  63. const nowMs = 1706000000000; // 2024-01-23 08:53:20 UTC (Tuesday)
  64. beforeEach(() => {
  65. vi.useFakeTimers();
  66. vi.setSystemTime(new Date(nowMs));
  67. vi.clearAllMocks();
  68. // Reset module cache to ensure fresh imports with our mocks
  69. vi.resetModules();
  70. // Default mock implementations for time ranges
  71. mockGetTimeRangeForPeriod.mockImplementation(async (period: string) => {
  72. const now = new Date(nowMs);
  73. switch (period) {
  74. case "5h":
  75. return {
  76. startTime: new Date(nowMs - 5 * 60 * 60 * 1000),
  77. endTime: now,
  78. };
  79. case "weekly":
  80. // Monday 00:00 Shanghai (2024-01-22 00:00 +08:00 = 2024-01-21 16:00 UTC)
  81. return {
  82. startTime: new Date("2024-01-21T16:00:00.000Z"),
  83. endTime: now,
  84. };
  85. case "monthly":
  86. // Month start (2024-01-01 00:00 +08:00 = 2023-12-31 16:00 UTC)
  87. return {
  88. startTime: new Date("2023-12-31T16:00:00.000Z"),
  89. endTime: now,
  90. };
  91. default:
  92. throw new Error(`Unknown period: ${period}`);
  93. }
  94. });
  95. // Default mock for cost queries
  96. mockSumKeyCostInTimeRange.mockResolvedValue(0);
  97. mockSumProviderCostInTimeRange.mockResolvedValue(0);
  98. // Default: return empty arrays for DB queries
  99. mockDbWhere.mockResolvedValue([]);
  100. });
  101. afterEach(() => {
  102. vi.useRealTimers();
  103. });
  104. describe("checkUserQuotas", () => {
  105. it("should use getTimeRangeForPeriod('5h') for 5-hour window", async () => {
  106. // Setup: Key with 5h limit
  107. mockDbWhere.mockResolvedValue([
  108. {
  109. id: 1,
  110. key: "test-key",
  111. userName: "Test User",
  112. limit5h: "10.00",
  113. limitWeek: null,
  114. limitMonth: null,
  115. },
  116. ]);
  117. mockSumKeyCostInTimeRange.mockResolvedValue(5);
  118. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  119. await generateCostAlerts(0.5);
  120. expect(mockGetTimeRangeForPeriod).toHaveBeenCalledWith("5h");
  121. });
  122. it("should use getTimeRangeForPeriod('weekly') for weekly window (natural week from Monday)", async () => {
  123. // Setup: Key with weekly limit
  124. mockDbWhere.mockResolvedValue([
  125. {
  126. id: 1,
  127. key: "test-key",
  128. userName: "Test User",
  129. limit5h: null,
  130. limitWeek: "100.00",
  131. limitMonth: null,
  132. },
  133. ]);
  134. mockSumKeyCostInTimeRange.mockResolvedValue(50);
  135. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  136. await generateCostAlerts(0.5);
  137. expect(mockGetTimeRangeForPeriod).toHaveBeenCalledWith("weekly");
  138. });
  139. it("should use getTimeRangeForPeriod('monthly') for monthly window (natural month)", async () => {
  140. // Setup: Key with monthly limit
  141. mockDbWhere.mockResolvedValue([
  142. {
  143. id: 1,
  144. key: "test-key",
  145. userName: "Test User",
  146. limit5h: null,
  147. limitWeek: null,
  148. limitMonth: "1000.00",
  149. },
  150. ]);
  151. mockSumKeyCostInTimeRange.mockResolvedValue(500);
  152. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  153. await generateCostAlerts(0.5);
  154. expect(mockGetTimeRangeForPeriod).toHaveBeenCalledWith("monthly");
  155. });
  156. it("should use sumKeyCostInTimeRange with keyId and correct time range", async () => {
  157. const expectedStart = new Date(nowMs - 5 * 60 * 60 * 1000);
  158. const expectedEnd = new Date(nowMs);
  159. mockDbWhere.mockResolvedValue([
  160. {
  161. id: 1,
  162. key: "test-key",
  163. userName: "Test User",
  164. limit5h: "10.00",
  165. limitWeek: null,
  166. limitMonth: null,
  167. },
  168. ]);
  169. mockSumKeyCostInTimeRange.mockResolvedValue(5);
  170. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  171. await generateCostAlerts(0.5);
  172. // Should call sumKeyCostInTimeRange with keyId (not key string) and time range
  173. expect(mockSumKeyCostInTimeRange).toHaveBeenCalledWith(
  174. 1, // keyId
  175. expectedStart,
  176. expectedEnd
  177. );
  178. });
  179. it("should generate alert when cost exceeds threshold", async () => {
  180. mockDbWhere.mockResolvedValue([
  181. {
  182. id: 1,
  183. key: "test-key",
  184. userName: "Test User",
  185. limit5h: "10.00",
  186. limitWeek: null,
  187. limitMonth: null,
  188. },
  189. ]);
  190. mockSumKeyCostInTimeRange.mockResolvedValue(9); // 90% of limit
  191. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  192. const alerts = await generateCostAlerts(0.8); // 80% threshold
  193. expect(alerts).toHaveLength(1);
  194. expect(alerts[0]).toMatchObject({
  195. targetType: "user",
  196. targetName: "Test User",
  197. targetId: 1,
  198. currentCost: 9,
  199. quotaLimit: 10,
  200. threshold: 0.8,
  201. period: "5小时",
  202. });
  203. });
  204. it("should NOT generate alert when cost is below threshold", async () => {
  205. mockDbWhere.mockResolvedValue([
  206. {
  207. id: 1,
  208. key: "test-key",
  209. userName: "Test User",
  210. limit5h: "10.00",
  211. limitWeek: null,
  212. limitMonth: null,
  213. },
  214. ]);
  215. mockSumKeyCostInTimeRange.mockResolvedValue(7); // 70% of limit
  216. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  217. const alerts = await generateCostAlerts(0.8); // 80% threshold
  218. expect(alerts).toHaveLength(0);
  219. });
  220. });
  221. describe("checkProviderQuotas", () => {
  222. it("should use getTimeRangeForPeriod('weekly') for provider weekly window", async () => {
  223. // First call returns empty keys, second call returns provider
  224. mockDbWhere
  225. .mockResolvedValueOnce([]) // keys query
  226. .mockResolvedValueOnce([
  227. { id: 1, name: "Test Provider", limitWeek: "100.00", limitMonth: null },
  228. ]);
  229. mockSumProviderCostInTimeRange.mockResolvedValue(50);
  230. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  231. await generateCostAlerts(0.5);
  232. expect(mockGetTimeRangeForPeriod).toHaveBeenCalledWith("weekly");
  233. });
  234. it("should use getTimeRangeForPeriod('monthly') for provider monthly window", async () => {
  235. mockDbWhere
  236. .mockResolvedValueOnce([]) // keys query
  237. .mockResolvedValueOnce([
  238. { id: 1, name: "Test Provider", limitWeek: null, limitMonth: "1000.00" },
  239. ]);
  240. mockSumProviderCostInTimeRange.mockResolvedValue(500);
  241. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  242. await generateCostAlerts(0.5);
  243. expect(mockGetTimeRangeForPeriod).toHaveBeenCalledWith("monthly");
  244. });
  245. it("should use sumProviderCostInTimeRange with correct time range", async () => {
  246. const expectedWeeklyStart = new Date("2024-01-21T16:00:00.000Z");
  247. const expectedEnd = new Date(nowMs);
  248. mockDbWhere
  249. .mockResolvedValueOnce([]) // keys query
  250. .mockResolvedValueOnce([
  251. { id: 1, name: "Test Provider", limitWeek: "100.00", limitMonth: null },
  252. ]);
  253. mockSumProviderCostInTimeRange.mockResolvedValue(50);
  254. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  255. await generateCostAlerts(0.5);
  256. expect(mockSumProviderCostInTimeRange).toHaveBeenCalledWith(
  257. 1, // providerId
  258. expectedWeeklyStart,
  259. expectedEnd
  260. );
  261. });
  262. it("should generate provider alert when cost exceeds threshold", async () => {
  263. mockDbWhere
  264. .mockResolvedValueOnce([]) // keys query
  265. .mockResolvedValueOnce([
  266. { id: 1, name: "Test Provider", limitWeek: "100.00", limitMonth: null },
  267. ]);
  268. mockSumProviderCostInTimeRange.mockResolvedValue(90); // 90% of limit
  269. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  270. const alerts = await generateCostAlerts(0.8); // 80% threshold
  271. expect(alerts).toHaveLength(1);
  272. expect(alerts[0]).toMatchObject({
  273. targetType: "provider",
  274. targetName: "Test Provider",
  275. targetId: 1,
  276. currentCost: 90,
  277. quotaLimit: 100,
  278. threshold: 0.8,
  279. period: "本周",
  280. });
  281. });
  282. });
  283. describe("Time Window Semantics", () => {
  284. it("weekly window should use natural week (Monday) not rolling 7 days", async () => {
  285. // This test verifies that weekly uses natural week boundaries
  286. // If today is Tuesday, weekly should start from Monday 00:00
  287. // NOT from 7 days ago
  288. mockDbWhere.mockResolvedValue([
  289. {
  290. id: 1,
  291. key: "test-key",
  292. userName: "Test User",
  293. limit5h: null,
  294. limitWeek: "100.00",
  295. limitMonth: null,
  296. },
  297. ]);
  298. mockSumKeyCostInTimeRange.mockResolvedValue(50);
  299. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  300. await generateCostAlerts(0.5);
  301. // Verify getTimeRangeForPeriod was called for weekly
  302. expect(mockGetTimeRangeForPeriod).toHaveBeenCalledWith("weekly");
  303. // Verify sumKeyCostInTimeRange was called
  304. expect(mockSumKeyCostInTimeRange).toHaveBeenCalled();
  305. // Extract the actual startTime passed
  306. const callArgs = mockSumKeyCostInTimeRange.mock.calls[0];
  307. const startTime = callArgs[1] as Date;
  308. // Should be Monday 00:00 Shanghai = Sunday 16:00 UTC
  309. expect(startTime.toISOString()).toBe("2024-01-21T16:00:00.000Z");
  310. });
  311. it("monthly window should use natural month (1st) with timezone awareness", async () => {
  312. mockDbWhere.mockResolvedValue([
  313. {
  314. id: 1,
  315. key: "test-key",
  316. userName: "Test User",
  317. limit5h: null,
  318. limitWeek: null,
  319. limitMonth: "1000.00",
  320. },
  321. ]);
  322. mockSumKeyCostInTimeRange.mockResolvedValue(500);
  323. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  324. await generateCostAlerts(0.5);
  325. expect(mockGetTimeRangeForPeriod).toHaveBeenCalledWith("monthly");
  326. const callArgs = mockSumKeyCostInTimeRange.mock.calls[0];
  327. const startTime = callArgs[1] as Date;
  328. // Should be Jan 1st 00:00 Shanghai = Dec 31 16:00 UTC
  329. expect(startTime.toISOString()).toBe("2023-12-31T16:00:00.000Z");
  330. });
  331. });
  332. describe("Warmup and Deleted Record Exclusion", () => {
  333. it("should use sumKeyCostInTimeRange which excludes warmup records", async () => {
  334. // This is a verification test - sumKeyCostInTimeRange already includes EXCLUDE_WARMUP_CONDITION
  335. // The old getKeyCostSince did NOT have this filter
  336. mockDbWhere.mockResolvedValue([
  337. {
  338. id: 1,
  339. key: "test-key",
  340. userName: "Test User",
  341. limit5h: "10.00",
  342. limitWeek: null,
  343. limitMonth: null,
  344. },
  345. ]);
  346. mockSumKeyCostInTimeRange.mockResolvedValue(5);
  347. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  348. await generateCostAlerts(0.5);
  349. // Verify sumKeyCostInTimeRange is called (which has EXCLUDE_WARMUP_CONDITION built-in)
  350. expect(mockSumKeyCostInTimeRange).toHaveBeenCalled();
  351. });
  352. it("should use sumProviderCostInTimeRange which excludes deleted records", async () => {
  353. // sumProviderCostInTimeRange has: isNull(messageRequest.deletedAt) filter
  354. // The old getProviderCostSince did NOT have this filter
  355. mockDbWhere
  356. .mockResolvedValueOnce([])
  357. .mockResolvedValueOnce([
  358. { id: 1, name: "Test Provider", limitWeek: "100.00", limitMonth: null },
  359. ]);
  360. mockSumProviderCostInTimeRange.mockResolvedValue(50);
  361. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  362. await generateCostAlerts(0.5);
  363. // Verify sumProviderCostInTimeRange is called (which has deletedAt IS NULL built-in)
  364. expect(mockSumProviderCostInTimeRange).toHaveBeenCalled();
  365. });
  366. });
  367. describe("Timezone Consistency", () => {
  368. it("should use system timezone for all time calculations", async () => {
  369. // getTimeRangeForPeriod internally uses resolveSystemTimezone()
  370. // This ensures all calculations are timezone-aware
  371. mockDbWhere.mockResolvedValue([
  372. {
  373. id: 1,
  374. key: "test-key",
  375. userName: "Test User",
  376. limit5h: null,
  377. limitWeek: "100.00",
  378. limitMonth: null,
  379. },
  380. ]);
  381. mockSumKeyCostInTimeRange.mockResolvedValue(50);
  382. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  383. await generateCostAlerts(0.5);
  384. // The time ranges returned by getTimeRangeForPeriod are timezone-aware
  385. // This is verified by the mock implementation which uses timezone-aware dates
  386. expect(mockGetTimeRangeForPeriod).toHaveBeenCalled();
  387. });
  388. });
  389. describe("Performance Optimization", () => {
  390. it("should pre-calculate time ranges once for all keys in checkUserQuotas", async () => {
  391. // Multiple keys with various limits
  392. mockDbWhere
  393. .mockResolvedValueOnce([
  394. {
  395. id: 1,
  396. key: "key-1",
  397. userName: "User 1",
  398. limit5h: "10.00",
  399. limitWeek: null,
  400. limitMonth: null,
  401. },
  402. {
  403. id: 2,
  404. key: "key-2",
  405. userName: "User 2",
  406. limit5h: null,
  407. limitWeek: "100.00",
  408. limitMonth: null,
  409. },
  410. {
  411. id: 3,
  412. key: "key-3",
  413. userName: "User 3",
  414. limit5h: null,
  415. limitWeek: null,
  416. limitMonth: "1000.00",
  417. },
  418. ])
  419. .mockResolvedValueOnce([]); // providers query returns empty
  420. mockSumKeyCostInTimeRange.mockResolvedValue(5);
  421. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  422. await generateCostAlerts(0.5);
  423. // getTimeRangeForPeriod should be called for user quotas (3 periods) + provider quotas (2 periods)
  424. // Total: 5 calls (5h + weekly + monthly for keys, weekly + monthly for providers)
  425. const calls = mockGetTimeRangeForPeriod.mock.calls.map((c) => c[0]);
  426. // Keys use all 3 periods, providers use weekly + monthly
  427. // So 5h should be called 1 time (keys only)
  428. // weekly should be called 2 times (keys + providers)
  429. // monthly should be called 2 times (keys + providers)
  430. expect(calls.filter((c) => c === "5h")).toHaveLength(1);
  431. expect(calls.filter((c) => c === "weekly")).toHaveLength(2);
  432. expect(calls.filter((c) => c === "monthly")).toHaveLength(2);
  433. });
  434. it("should not call getTimeRangeForPeriod per-key (optimized)", async () => {
  435. // This tests that we pre-calculate ranges once, not N times for N keys
  436. const manyKeys = Array.from({ length: 10 }, (_, i) => ({
  437. id: i + 1,
  438. key: `key-${i + 1}`,
  439. userName: `User ${i + 1}`,
  440. limit5h: "10.00",
  441. limitWeek: "100.00",
  442. limitMonth: "1000.00",
  443. }));
  444. mockDbWhere.mockResolvedValueOnce(manyKeys).mockResolvedValueOnce([]); // empty providers
  445. mockSumKeyCostInTimeRange.mockResolvedValue(5);
  446. const { generateCostAlerts } = await import("@/lib/notification/tasks/cost-alert");
  447. await generateCostAlerts(0.5);
  448. // Even with 10 keys, we should only call getTimeRangeForPeriod once per period
  449. // Not 10 times per period
  450. const calls = mockGetTimeRangeForPeriod.mock.calls.map((c) => c[0]);
  451. expect(calls.filter((c) => c === "5h")).toHaveLength(1); // 1 for keys
  452. expect(calls.filter((c) => c === "weekly")).toHaveLength(2); // 1 for keys + 1 for providers
  453. expect(calls.filter((c) => c === "monthly")).toHaveLength(2); // 1 for keys + 1 for providers
  454. });
  455. });
  456. });