admin-user-insights.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. const mockGetSession = vi.hoisted(() => vi.fn());
  3. const mockFindUserById = vi.hoisted(() => vi.fn());
  4. const mockGetStatisticsWithCache = vi.hoisted(() => vi.fn());
  5. const mockGetUserOverviewMetrics = vi.hoisted(() => vi.fn());
  6. const mockGetUserModelBreakdown = vi.hoisted(() => vi.fn());
  7. const mockGetUserProviderBreakdown = vi.hoisted(() => vi.fn());
  8. const mockGetSystemSettings = vi.hoisted(() => vi.fn());
  9. vi.mock("@/lib/auth", () => ({
  10. getSession: mockGetSession,
  11. }));
  12. vi.mock("@/repository/user", () => ({
  13. findUserById: mockFindUserById,
  14. }));
  15. vi.mock("@/lib/redis/statistics-cache", () => ({
  16. getStatisticsWithCache: mockGetStatisticsWithCache,
  17. }));
  18. vi.mock("@/repository/admin-user-insights", () => ({
  19. getUserOverviewMetrics: mockGetUserOverviewMetrics,
  20. getUserModelBreakdown: mockGetUserModelBreakdown,
  21. getUserProviderBreakdown: mockGetUserProviderBreakdown,
  22. }));
  23. vi.mock("@/repository/system-config", () => ({
  24. getSystemSettings: mockGetSystemSettings,
  25. }));
  26. function createAdminSession() {
  27. return {
  28. user: { id: 1, name: "Admin", role: "admin" },
  29. key: { id: 1, key: "sk-admin" },
  30. };
  31. }
  32. function createUserSession() {
  33. return {
  34. user: { id: 2, name: "User", role: "user" },
  35. key: { id: 2, key: "sk-user" },
  36. };
  37. }
  38. function createMockUser() {
  39. return {
  40. id: 10,
  41. name: "Target User",
  42. description: "",
  43. role: "user" as const,
  44. rpm: null,
  45. dailyQuota: null,
  46. providerGroup: "default",
  47. isEnabled: true,
  48. expiresAt: null,
  49. dailyResetMode: "fixed" as const,
  50. dailyResetTime: "00:00",
  51. createdAt: new Date(),
  52. updatedAt: new Date(),
  53. };
  54. }
  55. function createMockOverview() {
  56. return {
  57. requestCount: 50,
  58. totalCost: 5.5,
  59. avgResponseTime: 200,
  60. errorRate: 2.0,
  61. };
  62. }
  63. function createMockSettings() {
  64. return {
  65. id: 1,
  66. siteTitle: "Claude Code Hub",
  67. allowGlobalUsageView: false,
  68. currencyDisplay: "USD",
  69. billingModelSource: "original",
  70. timezone: null,
  71. enableAutoCleanup: false,
  72. cleanupRetentionDays: 30,
  73. cleanupSchedule: "0 2 * * *",
  74. cleanupBatchSize: 10000,
  75. enableClientVersionCheck: false,
  76. verboseProviderError: false,
  77. enableHttp2: false,
  78. interceptAnthropicWarmupRequests: false,
  79. enableThinkingSignatureRectifier: true,
  80. enableThinkingBudgetRectifier: true,
  81. enableBillingHeaderRectifier: true,
  82. enableCodexSessionIdCompletion: true,
  83. enableClaudeMetadataUserIdInjection: true,
  84. enableResponseFixer: true,
  85. responseFixerConfig: {
  86. fixTruncatedJson: true,
  87. fixSseFormat: true,
  88. fixEncoding: true,
  89. maxJsonDepth: 50,
  90. maxFixSize: 1048576,
  91. },
  92. createdAt: new Date(),
  93. updatedAt: new Date(),
  94. };
  95. }
  96. function createMockBreakdown() {
  97. return [
  98. {
  99. model: "claude-sonnet-4-20250514",
  100. requests: 30,
  101. cost: 3.5,
  102. inputTokens: 10000,
  103. outputTokens: 5000,
  104. cacheCreationTokens: 2000,
  105. cacheReadTokens: 8000,
  106. },
  107. {
  108. model: "claude-opus-4-20250514",
  109. requests: 20,
  110. cost: 2.0,
  111. inputTokens: 8000,
  112. outputTokens: 3000,
  113. cacheCreationTokens: 1000,
  114. cacheReadTokens: 5000,
  115. },
  116. ];
  117. }
  118. describe("getUserInsightsOverview", () => {
  119. beforeEach(() => {
  120. vi.clearAllMocks();
  121. });
  122. it("returns unauthorized for non-admin", async () => {
  123. mockGetSession.mockResolvedValueOnce(createUserSession());
  124. const { getUserInsightsOverview } = await import("@/actions/admin-user-insights");
  125. const result = await getUserInsightsOverview(10);
  126. expect(result.ok).toBe(false);
  127. if (!result.ok) {
  128. expect(result.error).toBe("Unauthorized");
  129. }
  130. expect(mockFindUserById).not.toHaveBeenCalled();
  131. });
  132. it("returns unauthorized when not logged in", async () => {
  133. mockGetSession.mockResolvedValueOnce(null);
  134. const { getUserInsightsOverview } = await import("@/actions/admin-user-insights");
  135. const result = await getUserInsightsOverview(10);
  136. expect(result.ok).toBe(false);
  137. if (!result.ok) {
  138. expect(result.error).toBe("Unauthorized");
  139. }
  140. });
  141. it("returns error for non-existent user", async () => {
  142. mockGetSession.mockResolvedValueOnce(createAdminSession());
  143. mockFindUserById.mockResolvedValueOnce(null);
  144. const { getUserInsightsOverview } = await import("@/actions/admin-user-insights");
  145. const result = await getUserInsightsOverview(999);
  146. expect(result.ok).toBe(false);
  147. if (!result.ok) {
  148. expect(result.error).toBe("User not found");
  149. }
  150. expect(mockFindUserById).toHaveBeenCalledWith(999);
  151. });
  152. it("returns overview data for valid admin request", async () => {
  153. const user = createMockUser();
  154. const overview = createMockOverview();
  155. const settings = createMockSettings();
  156. mockGetSession.mockResolvedValueOnce(createAdminSession());
  157. mockFindUserById.mockResolvedValueOnce(user);
  158. mockGetUserOverviewMetrics.mockResolvedValueOnce(overview);
  159. mockGetSystemSettings.mockResolvedValueOnce(settings);
  160. const { getUserInsightsOverview } = await import("@/actions/admin-user-insights");
  161. const result = await getUserInsightsOverview(10, "2026-03-01", "2026-03-09");
  162. expect(result.ok).toBe(true);
  163. if (result.ok) {
  164. expect(result.data.user).toEqual(user);
  165. expect(result.data.overview).toEqual(overview);
  166. expect(result.data.currencyCode).toBe("USD");
  167. }
  168. expect(mockFindUserById).toHaveBeenCalledWith(10);
  169. expect(mockGetUserOverviewMetrics).toHaveBeenCalledWith(10, "2026-03-01", "2026-03-09");
  170. });
  171. it("rejects invalid startDate format", async () => {
  172. mockGetSession.mockResolvedValueOnce(createAdminSession());
  173. const { getUserInsightsOverview } = await import("@/actions/admin-user-insights");
  174. const result = await getUserInsightsOverview(10, "not-a-date", "2026-03-09");
  175. expect(result.ok).toBe(false);
  176. if (!result.ok) {
  177. expect(result.error).toContain("startDate");
  178. }
  179. expect(mockGetUserOverviewMetrics).not.toHaveBeenCalled();
  180. });
  181. it("rejects invalid endDate format", async () => {
  182. mockGetSession.mockResolvedValueOnce(createAdminSession());
  183. const { getUserInsightsOverview } = await import("@/actions/admin-user-insights");
  184. const result = await getUserInsightsOverview(10, "2026-03-01", "03/09/2026");
  185. expect(result.ok).toBe(false);
  186. if (!result.ok) {
  187. expect(result.error).toContain("endDate");
  188. }
  189. expect(mockGetUserOverviewMetrics).not.toHaveBeenCalled();
  190. });
  191. it("rejects startDate after endDate", async () => {
  192. mockGetSession.mockResolvedValueOnce(createAdminSession());
  193. const { getUserInsightsOverview } = await import("@/actions/admin-user-insights");
  194. const result = await getUserInsightsOverview(10, "2026-03-09", "2026-03-01");
  195. expect(result.ok).toBe(false);
  196. if (!result.ok) {
  197. expect(result.error).toContain("startDate must not be after endDate");
  198. }
  199. expect(mockGetUserOverviewMetrics).not.toHaveBeenCalled();
  200. });
  201. });
  202. describe("getUserInsightsKeyTrend", () => {
  203. beforeEach(() => {
  204. vi.clearAllMocks();
  205. });
  206. it("returns unauthorized for non-admin", async () => {
  207. mockGetSession.mockResolvedValueOnce(createUserSession());
  208. const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights");
  209. const result = await getUserInsightsKeyTrend(10, "today");
  210. expect(result.ok).toBe(false);
  211. if (!result.ok) {
  212. expect(result.error).toBe("Unauthorized");
  213. }
  214. expect(mockGetStatisticsWithCache).not.toHaveBeenCalled();
  215. });
  216. it("validates timeRange parameter", async () => {
  217. mockGetSession.mockResolvedValueOnce(createAdminSession());
  218. const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights");
  219. const result = await getUserInsightsKeyTrend(10, "invalidRange");
  220. expect(result.ok).toBe(false);
  221. if (!result.ok) {
  222. expect(result.error).toContain("Invalid timeRange");
  223. }
  224. expect(mockGetStatisticsWithCache).not.toHaveBeenCalled();
  225. });
  226. it("returns trend data for valid request", async () => {
  227. const mockStats = [
  228. { key_id: 1, key_name: "sk-key-1", date: "2026-03-09", api_calls: 10, total_cost: 1.5 },
  229. { key_id: 2, key_name: "sk-key-2", date: "2026-03-08", api_calls: 15, total_cost: 2.0 },
  230. ];
  231. mockGetSession.mockResolvedValueOnce(createAdminSession());
  232. mockGetStatisticsWithCache.mockResolvedValueOnce(mockStats);
  233. const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights");
  234. const result = await getUserInsightsKeyTrend(10, "7days");
  235. expect(result.ok).toBe(true);
  236. if (result.ok) {
  237. expect(result.data).toHaveLength(2);
  238. expect(result.data[0].date).toBe("2026-03-09");
  239. expect(result.data[0].key_id).toBe(1);
  240. expect(result.data[0].key_name).toBe("sk-key-1");
  241. expect(result.data[0].api_calls).toBe(10);
  242. expect(result.data[1].date).toBe("2026-03-08");
  243. }
  244. expect(mockGetStatisticsWithCache).toHaveBeenCalledWith("7days", "keys", 10);
  245. });
  246. it("normalizes Date objects to ISO strings", async () => {
  247. const mockStats = [
  248. {
  249. key_id: 1,
  250. key_name: "sk-key-1",
  251. date: new Date("2026-03-09T12:00:00Z"),
  252. api_calls: 10,
  253. total_cost: 1.5,
  254. },
  255. ];
  256. mockGetSession.mockResolvedValueOnce(createAdminSession());
  257. mockGetStatisticsWithCache.mockResolvedValueOnce(mockStats);
  258. const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights");
  259. const result = await getUserInsightsKeyTrend(10, "today");
  260. expect(result.ok).toBe(true);
  261. if (result.ok) {
  262. expect(typeof result.data[0].date).toBe("string");
  263. expect(result.data[0].date).toContain("2026-03-09");
  264. }
  265. });
  266. it("accepts all valid timeRange values", async () => {
  267. const validRanges = ["today", "7days", "30days", "thisMonth"];
  268. for (const range of validRanges) {
  269. vi.clearAllMocks();
  270. mockGetSession.mockResolvedValueOnce(createAdminSession());
  271. mockGetStatisticsWithCache.mockResolvedValueOnce([]);
  272. const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights");
  273. const result = await getUserInsightsKeyTrend(10, range);
  274. expect(result.ok).toBe(true);
  275. }
  276. });
  277. });
  278. describe("getUserInsightsModelBreakdown", () => {
  279. beforeEach(() => {
  280. vi.clearAllMocks();
  281. });
  282. it("returns unauthorized for non-admin", async () => {
  283. mockGetSession.mockResolvedValueOnce(createUserSession());
  284. const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights");
  285. const result = await getUserInsightsModelBreakdown(10);
  286. expect(result.ok).toBe(false);
  287. if (!result.ok) {
  288. expect(result.error).toBe("Unauthorized");
  289. }
  290. expect(mockGetUserModelBreakdown).not.toHaveBeenCalled();
  291. });
  292. it("returns breakdown data for valid request", async () => {
  293. const breakdown = createMockBreakdown();
  294. const settings = createMockSettings();
  295. mockGetSession.mockResolvedValueOnce(createAdminSession());
  296. mockGetUserModelBreakdown.mockResolvedValueOnce(breakdown);
  297. mockGetSystemSettings.mockResolvedValueOnce(settings);
  298. const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights");
  299. const result = await getUserInsightsModelBreakdown(10);
  300. expect(result.ok).toBe(true);
  301. if (result.ok) {
  302. expect(result.data.breakdown).toEqual(breakdown);
  303. expect(result.data.currencyCode).toBe("USD");
  304. }
  305. expect(mockGetUserModelBreakdown).toHaveBeenCalledWith(10, undefined, undefined, undefined);
  306. });
  307. it("passes date range to getUserModelBreakdown", async () => {
  308. const breakdown = createMockBreakdown();
  309. const settings = createMockSettings();
  310. mockGetSession.mockResolvedValueOnce(createAdminSession());
  311. mockGetUserModelBreakdown.mockResolvedValueOnce(breakdown);
  312. mockGetSystemSettings.mockResolvedValueOnce(settings);
  313. const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights");
  314. const result = await getUserInsightsModelBreakdown(10, "2026-03-01", "2026-03-09");
  315. expect(result.ok).toBe(true);
  316. expect(mockGetUserModelBreakdown).toHaveBeenCalledWith(
  317. 10,
  318. "2026-03-01",
  319. "2026-03-09",
  320. undefined
  321. );
  322. });
  323. it("passes filter params to getUserModelBreakdown", async () => {
  324. const breakdown = createMockBreakdown();
  325. const settings = createMockSettings();
  326. mockGetSession.mockResolvedValueOnce(createAdminSession());
  327. mockGetUserModelBreakdown.mockResolvedValueOnce(breakdown);
  328. mockGetSystemSettings.mockResolvedValueOnce(settings);
  329. const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights");
  330. const filters = { keyId: 5, providerId: 3 };
  331. const result = await getUserInsightsModelBreakdown(10, "2026-03-01", "2026-03-09", filters);
  332. expect(result.ok).toBe(true);
  333. expect(mockGetUserModelBreakdown).toHaveBeenCalledWith(10, "2026-03-01", "2026-03-09", filters);
  334. });
  335. it("rejects invalid startDate format", async () => {
  336. mockGetSession.mockResolvedValueOnce(createAdminSession());
  337. const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights");
  338. const result = await getUserInsightsModelBreakdown(10, "not-a-date");
  339. expect(result.ok).toBe(false);
  340. if (!result.ok) {
  341. expect(result.error).toContain("startDate");
  342. }
  343. expect(mockGetUserModelBreakdown).not.toHaveBeenCalled();
  344. });
  345. it("rejects invalid endDate format", async () => {
  346. mockGetSession.mockResolvedValueOnce(createAdminSession());
  347. const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights");
  348. const result = await getUserInsightsModelBreakdown(10, "2026-03-01", "03/09/2026");
  349. expect(result.ok).toBe(false);
  350. if (!result.ok) {
  351. expect(result.error).toContain("endDate");
  352. }
  353. expect(mockGetUserModelBreakdown).not.toHaveBeenCalled();
  354. });
  355. it("rejects startDate after endDate", async () => {
  356. mockGetSession.mockResolvedValueOnce(createAdminSession());
  357. const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights");
  358. const result = await getUserInsightsModelBreakdown(10, "2026-03-09", "2026-03-01");
  359. expect(result.ok).toBe(false);
  360. if (!result.ok) {
  361. expect(result.error).toContain("startDate must not be after endDate");
  362. }
  363. expect(mockGetUserModelBreakdown).not.toHaveBeenCalled();
  364. });
  365. });
  366. function createMockProviderBreakdown() {
  367. return [
  368. {
  369. providerId: 1,
  370. providerName: "Provider A",
  371. requests: 40,
  372. cost: 4.0,
  373. inputTokens: 12000,
  374. outputTokens: 6000,
  375. cacheCreationTokens: 2500,
  376. cacheReadTokens: 9000,
  377. },
  378. {
  379. providerId: 2,
  380. providerName: "Provider B",
  381. requests: 10,
  382. cost: 1.5,
  383. inputTokens: 6000,
  384. outputTokens: 2000,
  385. cacheCreationTokens: 500,
  386. cacheReadTokens: 4000,
  387. },
  388. ];
  389. }
  390. describe("getUserInsightsProviderBreakdown", () => {
  391. beforeEach(() => {
  392. vi.clearAllMocks();
  393. });
  394. it("returns unauthorized for non-admin", async () => {
  395. mockGetSession.mockResolvedValueOnce(createUserSession());
  396. const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights");
  397. const result = await getUserInsightsProviderBreakdown(10);
  398. expect(result.ok).toBe(false);
  399. if (!result.ok) {
  400. expect(result.error).toBe("Unauthorized");
  401. }
  402. expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled();
  403. });
  404. it("returns unauthorized when not logged in", async () => {
  405. mockGetSession.mockResolvedValueOnce(null);
  406. const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights");
  407. const result = await getUserInsightsProviderBreakdown(10);
  408. expect(result.ok).toBe(false);
  409. if (!result.ok) {
  410. expect(result.error).toBe("Unauthorized");
  411. }
  412. });
  413. it("returns breakdown data for valid request", async () => {
  414. const breakdown = createMockProviderBreakdown();
  415. const settings = createMockSettings();
  416. mockGetSession.mockResolvedValueOnce(createAdminSession());
  417. mockGetUserProviderBreakdown.mockResolvedValueOnce(breakdown);
  418. mockGetSystemSettings.mockResolvedValueOnce(settings);
  419. const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights");
  420. const result = await getUserInsightsProviderBreakdown(10);
  421. expect(result.ok).toBe(true);
  422. if (result.ok) {
  423. expect(result.data.breakdown).toEqual(breakdown);
  424. expect(result.data.breakdown[0].providerName).toBe("Provider A");
  425. expect(result.data.currencyCode).toBe("USD");
  426. }
  427. expect(mockGetUserProviderBreakdown).toHaveBeenCalledWith(10, undefined, undefined, undefined);
  428. });
  429. it("passes date range to getUserProviderBreakdown", async () => {
  430. const breakdown = createMockProviderBreakdown();
  431. const settings = createMockSettings();
  432. mockGetSession.mockResolvedValueOnce(createAdminSession());
  433. mockGetUserProviderBreakdown.mockResolvedValueOnce(breakdown);
  434. mockGetSystemSettings.mockResolvedValueOnce(settings);
  435. const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights");
  436. const result = await getUserInsightsProviderBreakdown(10, "2026-03-01", "2026-03-09");
  437. expect(result.ok).toBe(true);
  438. expect(mockGetUserProviderBreakdown).toHaveBeenCalledWith(
  439. 10,
  440. "2026-03-01",
  441. "2026-03-09",
  442. undefined
  443. );
  444. });
  445. it("passes filter params to getUserProviderBreakdown", async () => {
  446. const breakdown = createMockProviderBreakdown();
  447. const settings = createMockSettings();
  448. mockGetSession.mockResolvedValueOnce(createAdminSession());
  449. mockGetUserProviderBreakdown.mockResolvedValueOnce(breakdown);
  450. mockGetSystemSettings.mockResolvedValueOnce(settings);
  451. const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights");
  452. const filters = { keyId: 5, model: "claude-sonnet-4-20250514" };
  453. const result = await getUserInsightsProviderBreakdown(10, "2026-03-01", "2026-03-09", filters);
  454. expect(result.ok).toBe(true);
  455. expect(mockGetUserProviderBreakdown).toHaveBeenCalledWith(
  456. 10,
  457. "2026-03-01",
  458. "2026-03-09",
  459. filters
  460. );
  461. });
  462. it("rejects invalid startDate format", async () => {
  463. mockGetSession.mockResolvedValueOnce(createAdminSession());
  464. const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights");
  465. const result = await getUserInsightsProviderBreakdown(10, "not-a-date");
  466. expect(result.ok).toBe(false);
  467. if (!result.ok) {
  468. expect(result.error).toContain("startDate");
  469. }
  470. expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled();
  471. });
  472. it("rejects invalid endDate format", async () => {
  473. mockGetSession.mockResolvedValueOnce(createAdminSession());
  474. const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights");
  475. const result = await getUserInsightsProviderBreakdown(10, "2026-03-01", "03/09/2026");
  476. expect(result.ok).toBe(false);
  477. if (!result.ok) {
  478. expect(result.error).toContain("endDate");
  479. }
  480. expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled();
  481. });
  482. it("rejects startDate after endDate", async () => {
  483. mockGetSession.mockResolvedValueOnce(createAdminSession());
  484. const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights");
  485. const result = await getUserInsightsProviderBreakdown(10, "2026-03-09", "2026-03-01");
  486. expect(result.ok).toBe(false);
  487. if (!result.ok) {
  488. expect(result.error).toContain("startDate must not be after endDate");
  489. }
  490. expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled();
  491. });
  492. });