providers-usage.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. /**
  2. * Provider Limit Usage Actions Tests
  3. *
  4. * Verifies that getProviderLimitUsage and getProviderLimitUsageBatch
  5. * use DB direct sums (sumProviderCostInTimeRange) instead of Redis-first reads.
  6. *
  7. * Test scenarios:
  8. * 1. getProviderLimitUsage uses sumProviderCostInTimeRange for all periods
  9. * 2. getProviderLimitUsageBatch uses parallel DB queries for all providers
  10. * 3. Correct time ranges are computed for 5h/daily/weekly/monthly
  11. * 4. dailyResetMode is respected for daily window calculation
  12. */
  13. import { beforeEach, describe, expect, it, vi } from "vitest";
  14. // Mock dependencies
  15. const getSessionMock = vi.fn();
  16. const findProviderByIdMock = vi.fn();
  17. const sumProviderCostInTimeRangeMock = vi.fn();
  18. const getProviderSessionCountMock = vi.fn();
  19. const getProviderSessionCountBatchMock = vi.fn();
  20. const getTimeRangeForPeriodMock = vi.fn();
  21. const getTimeRangeForPeriodWithModeMock = vi.fn();
  22. const getResetInfoMock = vi.fn();
  23. const getResetInfoWithModeMock = vi.fn();
  24. vi.mock("@/lib/auth", () => ({
  25. getSession: () => getSessionMock(),
  26. }));
  27. vi.mock("@/repository/provider", () => ({
  28. findProviderById: (id: number) => findProviderByIdMock(id),
  29. findAllProvidersFresh: vi.fn(async () => []),
  30. getProviderStatistics: vi.fn(async () => []),
  31. }));
  32. vi.mock("@/repository/statistics", () => ({
  33. sumProviderCostInTimeRange: (providerId: number, startTime: Date, endTime: Date) =>
  34. sumProviderCostInTimeRangeMock(providerId, startTime, endTime),
  35. }));
  36. vi.mock("@/lib/session-tracker", () => ({
  37. SessionTracker: {
  38. getProviderSessionCount: (providerId: number) => getProviderSessionCountMock(providerId),
  39. getProviderSessionCountBatch: (providerIds: number[]) =>
  40. getProviderSessionCountBatchMock(providerIds),
  41. },
  42. }));
  43. vi.mock("@/lib/rate-limit/time-utils", () => ({
  44. getTimeRangeForPeriod: (period: string, resetTime?: string) =>
  45. getTimeRangeForPeriodMock(period, resetTime),
  46. getTimeRangeForPeriodWithMode: (period: string, resetTime?: string, mode?: string) =>
  47. getTimeRangeForPeriodWithModeMock(period, resetTime, mode),
  48. getResetInfo: (period: string, resetTime?: string) => getResetInfoMock(period, resetTime),
  49. getResetInfoWithMode: (period: string, resetTime?: string, mode?: string) =>
  50. getResetInfoWithModeMock(period, resetTime, mode),
  51. }));
  52. // Mock logger
  53. vi.mock("@/lib/logger", () => ({
  54. logger: {
  55. trace: vi.fn(),
  56. debug: vi.fn(),
  57. info: vi.fn(),
  58. warn: vi.fn(),
  59. error: vi.fn(),
  60. },
  61. }));
  62. // Mock next/cache
  63. vi.mock("next/cache", () => ({
  64. revalidatePath: vi.fn(),
  65. }));
  66. // Mock rate-limit service - should NOT be called after refactor
  67. const getCurrentCostMock = vi.fn();
  68. const getCurrentCostBatchMock = vi.fn();
  69. vi.mock("@/lib/rate-limit", () => ({
  70. RateLimitService: {
  71. getCurrentCost: (...args: unknown[]) => getCurrentCostMock(...args),
  72. getCurrentCostBatch: (...args: unknown[]) => getCurrentCostBatchMock(...args),
  73. },
  74. }));
  75. describe("getProviderLimitUsage", () => {
  76. const nowMs = 1700000000000; // Fixed timestamp for testing
  77. const mockProvider = {
  78. id: 1,
  79. name: "Test Provider",
  80. dailyResetTime: "18:00",
  81. dailyResetMode: "fixed" as const,
  82. limit5hUsd: 10,
  83. limitDailyUsd: 50,
  84. limitWeeklyUsd: 200,
  85. limitMonthlyUsd: 500,
  86. limitConcurrentSessions: 5,
  87. };
  88. beforeEach(() => {
  89. vi.clearAllMocks();
  90. vi.useFakeTimers();
  91. vi.setSystemTime(new Date(nowMs));
  92. // Default: admin session
  93. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  94. // Default provider lookup
  95. findProviderByIdMock.mockResolvedValue(mockProvider);
  96. // Default session count
  97. getProviderSessionCountMock.mockResolvedValue(2);
  98. // Default time ranges
  99. const range5h = {
  100. startTime: new Date(nowMs - 5 * 60 * 60 * 1000),
  101. endTime: new Date(nowMs),
  102. };
  103. const rangeDaily = {
  104. startTime: new Date(nowMs - 24 * 60 * 60 * 1000),
  105. endTime: new Date(nowMs),
  106. };
  107. const rangeWeekly = {
  108. startTime: new Date(nowMs - 7 * 24 * 60 * 60 * 1000),
  109. endTime: new Date(nowMs),
  110. };
  111. const rangeMonthly = {
  112. startTime: new Date(nowMs - 30 * 24 * 60 * 60 * 1000),
  113. endTime: new Date(nowMs),
  114. };
  115. getTimeRangeForPeriodMock.mockImplementation((period: string) => {
  116. switch (period) {
  117. case "5h":
  118. return Promise.resolve(range5h);
  119. case "weekly":
  120. return Promise.resolve(rangeWeekly);
  121. case "monthly":
  122. return Promise.resolve(rangeMonthly);
  123. default:
  124. return Promise.resolve(rangeDaily);
  125. }
  126. });
  127. getTimeRangeForPeriodWithModeMock.mockResolvedValue(rangeDaily);
  128. // Default reset info
  129. getResetInfoMock.mockImplementation((period: string) => {
  130. if (period === "5h") {
  131. return Promise.resolve({ type: "rolling", period: "5 小时" });
  132. }
  133. return Promise.resolve({
  134. type: "natural",
  135. resetAt: new Date(nowMs + 24 * 60 * 60 * 1000),
  136. });
  137. });
  138. getResetInfoWithModeMock.mockResolvedValue({
  139. type: "custom",
  140. resetAt: new Date(nowMs + 6 * 60 * 60 * 1000),
  141. });
  142. // Default DB costs
  143. sumProviderCostInTimeRangeMock.mockResolvedValue(5.5);
  144. });
  145. afterEach(() => {
  146. vi.useRealTimers();
  147. });
  148. it("should use sumProviderCostInTimeRange for all periods instead of RateLimitService", async () => {
  149. const { getProviderLimitUsage } = await import("@/actions/providers");
  150. const result = await getProviderLimitUsage(1);
  151. expect(result.ok).toBe(true);
  152. // Verify DB function was called for all 4 periods
  153. expect(sumProviderCostInTimeRangeMock).toHaveBeenCalledTimes(4);
  154. // Verify RateLimitService.getCurrentCost was NOT called
  155. expect(getCurrentCostMock).not.toHaveBeenCalled();
  156. });
  157. it("should call getTimeRangeForPeriod for 5h/weekly/monthly", async () => {
  158. const { getProviderLimitUsage } = await import("@/actions/providers");
  159. await getProviderLimitUsage(1);
  160. // 5h should use getTimeRangeForPeriod (note: second arg is optional resetTime, defaults to undefined)
  161. expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("5h", undefined);
  162. expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("weekly", undefined);
  163. expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("monthly", undefined);
  164. });
  165. it("should call getTimeRangeForPeriodWithMode for daily with provider config", async () => {
  166. const { getProviderLimitUsage } = await import("@/actions/providers");
  167. await getProviderLimitUsage(1);
  168. // daily should use getTimeRangeForPeriodWithMode with provider's reset config
  169. expect(getTimeRangeForPeriodWithModeMock).toHaveBeenCalledWith(
  170. "daily",
  171. "18:00", // provider.dailyResetTime
  172. "fixed" // provider.dailyResetMode
  173. );
  174. });
  175. it("should respect rolling mode for daily when provider uses rolling", async () => {
  176. findProviderByIdMock.mockResolvedValue({
  177. ...mockProvider,
  178. dailyResetMode: "rolling",
  179. });
  180. const { getProviderLimitUsage } = await import("@/actions/providers");
  181. await getProviderLimitUsage(1);
  182. expect(getTimeRangeForPeriodWithModeMock).toHaveBeenCalledWith("daily", "18:00", "rolling");
  183. });
  184. it("should pass correct time ranges to sumProviderCostInTimeRange", async () => {
  185. const range5h = {
  186. startTime: new Date(nowMs - 5 * 60 * 60 * 1000),
  187. endTime: new Date(nowMs),
  188. };
  189. getTimeRangeForPeriodMock.mockImplementation((period: string) => {
  190. if (period === "5h") return Promise.resolve(range5h);
  191. return Promise.resolve({
  192. startTime: new Date(nowMs - 24 * 60 * 60 * 1000),
  193. endTime: new Date(nowMs),
  194. });
  195. });
  196. const { getProviderLimitUsage } = await import("@/actions/providers");
  197. await getProviderLimitUsage(1);
  198. // Check that 5h call received correct time range
  199. expect(sumProviderCostInTimeRangeMock).toHaveBeenCalledWith(
  200. 1,
  201. range5h.startTime,
  202. range5h.endTime
  203. );
  204. });
  205. it("should return correct structure with DB-sourced costs", async () => {
  206. sumProviderCostInTimeRangeMock
  207. .mockResolvedValueOnce(1.5) // 5h
  208. .mockResolvedValueOnce(10.0) // daily
  209. .mockResolvedValueOnce(45.0) // weekly
  210. .mockResolvedValueOnce(120.0); // monthly
  211. const { getProviderLimitUsage } = await import("@/actions/providers");
  212. const result = await getProviderLimitUsage(1);
  213. expect(result.ok).toBe(true);
  214. if (result.ok) {
  215. expect(result.data.cost5h.current).toBe(1.5);
  216. expect(result.data.costDaily.current).toBe(10.0);
  217. expect(result.data.costWeekly.current).toBe(45.0);
  218. expect(result.data.costMonthly.current).toBe(120.0);
  219. }
  220. });
  221. it("should return error for non-admin user", async () => {
  222. getSessionMock.mockResolvedValue({ user: { role: "user" } });
  223. const { getProviderLimitUsage } = await import("@/actions/providers");
  224. const result = await getProviderLimitUsage(1);
  225. expect(result.ok).toBe(false);
  226. expect(sumProviderCostInTimeRangeMock).not.toHaveBeenCalled();
  227. });
  228. it("should return error for non-existent provider", async () => {
  229. findProviderByIdMock.mockResolvedValue(null);
  230. const { getProviderLimitUsage } = await import("@/actions/providers");
  231. const result = await getProviderLimitUsage(999);
  232. expect(result.ok).toBe(false);
  233. expect(sumProviderCostInTimeRangeMock).not.toHaveBeenCalled();
  234. });
  235. });
  236. describe("getProviderLimitUsageBatch", () => {
  237. const nowMs = 1700000000000;
  238. const mockProviders = [
  239. {
  240. id: 1,
  241. dailyResetTime: "00:00",
  242. dailyResetMode: "fixed" as const,
  243. limit5hUsd: 10,
  244. limitDailyUsd: 50,
  245. limitWeeklyUsd: 200,
  246. limitMonthlyUsd: 500,
  247. limitConcurrentSessions: 5,
  248. },
  249. {
  250. id: 2,
  251. dailyResetTime: "18:00",
  252. dailyResetMode: "rolling" as const,
  253. limit5hUsd: 20,
  254. limitDailyUsd: 100,
  255. limitWeeklyUsd: 400,
  256. limitMonthlyUsd: 1000,
  257. limitConcurrentSessions: 10,
  258. },
  259. ];
  260. beforeEach(() => {
  261. vi.clearAllMocks();
  262. vi.useFakeTimers();
  263. vi.setSystemTime(new Date(nowMs));
  264. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  265. // Mock batch session counts
  266. getProviderSessionCountBatchMock.mockResolvedValue(
  267. new Map([
  268. [1, 2],
  269. [2, 5],
  270. ])
  271. );
  272. // Default time ranges
  273. const range5h = {
  274. startTime: new Date(nowMs - 5 * 60 * 60 * 1000),
  275. endTime: new Date(nowMs),
  276. };
  277. const rangeDaily = {
  278. startTime: new Date(nowMs - 24 * 60 * 60 * 1000),
  279. endTime: new Date(nowMs),
  280. };
  281. const rangeWeekly = {
  282. startTime: new Date(nowMs - 7 * 24 * 60 * 60 * 1000),
  283. endTime: new Date(nowMs),
  284. };
  285. const rangeMonthly = {
  286. startTime: new Date(nowMs - 30 * 24 * 60 * 60 * 1000),
  287. endTime: new Date(nowMs),
  288. };
  289. getTimeRangeForPeriodMock.mockImplementation((period: string) => {
  290. switch (period) {
  291. case "5h":
  292. return Promise.resolve(range5h);
  293. case "weekly":
  294. return Promise.resolve(rangeWeekly);
  295. case "monthly":
  296. return Promise.resolve(rangeMonthly);
  297. default:
  298. return Promise.resolve(rangeDaily);
  299. }
  300. });
  301. getTimeRangeForPeriodWithModeMock.mockResolvedValue(rangeDaily);
  302. getResetInfoMock.mockImplementation((period: string) => {
  303. if (period === "5h") {
  304. return Promise.resolve({ type: "rolling", period: "5 小时" });
  305. }
  306. return Promise.resolve({
  307. type: "natural",
  308. resetAt: new Date(nowMs + 24 * 60 * 60 * 1000),
  309. });
  310. });
  311. getResetInfoWithModeMock.mockResolvedValue({
  312. type: "custom",
  313. resetAt: new Date(nowMs + 6 * 60 * 60 * 1000),
  314. });
  315. sumProviderCostInTimeRangeMock.mockResolvedValue(5.5);
  316. });
  317. afterEach(() => {
  318. vi.useRealTimers();
  319. });
  320. it("should use sumProviderCostInTimeRange for all providers instead of RateLimitService batch", async () => {
  321. const { getProviderLimitUsageBatch } = await import("@/actions/providers");
  322. await getProviderLimitUsageBatch(mockProviders);
  323. // 2 providers * 4 periods = 8 calls
  324. expect(sumProviderCostInTimeRangeMock).toHaveBeenCalledTimes(8);
  325. // Verify RateLimitService.getCurrentCostBatch was NOT called
  326. expect(getCurrentCostBatchMock).not.toHaveBeenCalled();
  327. });
  328. it("should compute time ranges per provider for daily with their specific resetMode", async () => {
  329. const { getProviderLimitUsageBatch } = await import("@/actions/providers");
  330. await getProviderLimitUsageBatch(mockProviders);
  331. // Provider 1: fixed mode
  332. expect(getTimeRangeForPeriodWithModeMock).toHaveBeenCalledWith("daily", "00:00", "fixed");
  333. // Provider 2: rolling mode
  334. expect(getTimeRangeForPeriodWithModeMock).toHaveBeenCalledWith("daily", "18:00", "rolling");
  335. });
  336. it("should return empty map for empty providers array", async () => {
  337. const { getProviderLimitUsageBatch } = await import("@/actions/providers");
  338. const result = await getProviderLimitUsageBatch([]);
  339. expect(result.size).toBe(0);
  340. expect(sumProviderCostInTimeRangeMock).not.toHaveBeenCalled();
  341. });
  342. it("should return empty map for non-admin user", async () => {
  343. getSessionMock.mockResolvedValue({ user: { role: "user" } });
  344. const { getProviderLimitUsageBatch } = await import("@/actions/providers");
  345. const result = await getProviderLimitUsageBatch(mockProviders);
  346. expect(result.size).toBe(0);
  347. expect(sumProviderCostInTimeRangeMock).not.toHaveBeenCalled();
  348. });
  349. it("should return correct costs from DB for each provider", async () => {
  350. // Mock different costs for different calls
  351. // Provider 1: 5h=1, daily=10, weekly=40, monthly=100
  352. // Provider 2: 5h=2, daily=20, weekly=80, monthly=200
  353. sumProviderCostInTimeRangeMock
  354. .mockResolvedValueOnce(1) // P1 5h
  355. .mockResolvedValueOnce(10) // P1 daily
  356. .mockResolvedValueOnce(40) // P1 weekly
  357. .mockResolvedValueOnce(100) // P1 monthly
  358. .mockResolvedValueOnce(2) // P2 5h
  359. .mockResolvedValueOnce(20) // P2 daily
  360. .mockResolvedValueOnce(80) // P2 weekly
  361. .mockResolvedValueOnce(200); // P2 monthly
  362. const { getProviderLimitUsageBatch } = await import("@/actions/providers");
  363. const result = await getProviderLimitUsageBatch(mockProviders);
  364. expect(result.size).toBe(2);
  365. const p1Data = result.get(1);
  366. expect(p1Data?.cost5h.current).toBe(1);
  367. expect(p1Data?.costDaily.current).toBe(10);
  368. expect(p1Data?.costWeekly.current).toBe(40);
  369. expect(p1Data?.costMonthly.current).toBe(100);
  370. const p2Data = result.get(2);
  371. expect(p2Data?.cost5h.current).toBe(2);
  372. expect(p2Data?.costDaily.current).toBe(20);
  373. expect(p2Data?.costWeekly.current).toBe(80);
  374. expect(p2Data?.costMonthly.current).toBe(200);
  375. });
  376. it("should still use SessionTracker for concurrent session counts", async () => {
  377. const { getProviderLimitUsageBatch } = await import("@/actions/providers");
  378. await getProviderLimitUsageBatch(mockProviders);
  379. expect(getProviderSessionCountBatchMock).toHaveBeenCalledWith([1, 2]);
  380. });
  381. });