leaderboard-route.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. const mocks = vi.hoisted(() => ({
  3. getSession: vi.fn(),
  4. getLeaderboardWithCache: vi.fn(),
  5. getSystemSettings: vi.fn(),
  6. formatCurrency: vi.fn(),
  7. }));
  8. vi.mock("@/lib/auth", () => ({
  9. getSession: mocks.getSession,
  10. }));
  11. vi.mock("@/repository/system-config", () => ({
  12. getSystemSettings: mocks.getSystemSettings,
  13. }));
  14. vi.mock("@/lib/utils", () => ({
  15. formatCurrency: mocks.formatCurrency,
  16. }));
  17. vi.mock("@/lib/redis", () => ({
  18. getLeaderboardWithCache: mocks.getLeaderboardWithCache,
  19. }));
  20. describe("GET /api/leaderboard", () => {
  21. beforeEach(() => {
  22. vi.clearAllMocks();
  23. mocks.formatCurrency.mockImplementation((val: number) => String(val));
  24. mocks.getSystemSettings.mockResolvedValue({
  25. currencyDisplay: "USD",
  26. allowGlobalUsageView: true,
  27. });
  28. mocks.getLeaderboardWithCache.mockResolvedValue([]);
  29. });
  30. it("returns 401 when session is missing", async () => {
  31. mocks.getSession.mockResolvedValue(null);
  32. const { GET } = await import("@/app/api/leaderboard/route");
  33. const url = "http://localhost/api/leaderboard";
  34. const response = await GET({ nextUrl: new URL(url) } as any);
  35. expect(response.status).toBe(401);
  36. });
  37. it("parses and trims userTags/userGroups and caps at 20 items", async () => {
  38. mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
  39. const tags = Array.from({ length: 25 }, (_, i) => ` t${i} `).join(",");
  40. const groups = " a, ,b ,c, ";
  41. const { GET } = await import("@/app/api/leaderboard/route");
  42. const url = `http://localhost/api/leaderboard?scope=user&period=daily&userTags=${encodeURIComponent(
  43. tags
  44. )}&userGroups=${encodeURIComponent(groups)}`;
  45. const response = await GET({ nextUrl: new URL(url) } as any);
  46. expect(response.status).toBe(200);
  47. expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1);
  48. const callArgs = mocks.getLeaderboardWithCache.mock.calls[0];
  49. const options = callArgs[4];
  50. expect(options.userTags).toHaveLength(20);
  51. expect(options.userTags?.[0]).toBe("t0");
  52. expect(options.userGroups).toEqual(["a", "b", "c"]);
  53. });
  54. it("applies userTags/userGroups to userCacheHitRate scope too", async () => {
  55. mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
  56. const { GET } = await import("@/app/api/leaderboard/route");
  57. const url =
  58. "http://localhost/api/leaderboard?scope=userCacheHitRate&period=daily&userTags=vip, beta &userGroups=g1, g2";
  59. const response = await GET({ nextUrl: new URL(url) } as any);
  60. expect(response.status).toBe(200);
  61. expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1);
  62. const options = mocks.getLeaderboardWithCache.mock.calls[0][4];
  63. expect(options.userTags).toEqual(["vip", "beta"]);
  64. expect(options.userGroups).toEqual(["g1", "g2"]);
  65. });
  66. it("does not apply userTags/userGroups when scope is not user", async () => {
  67. mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
  68. const { GET } = await import("@/app/api/leaderboard/route");
  69. const url =
  70. "http://localhost/api/leaderboard?scope=provider&period=daily&userTags=a&userGroups=b";
  71. const response = await GET({ nextUrl: new URL(url) } as any);
  72. expect(response.status).toBe(200);
  73. expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1);
  74. const callArgs = mocks.getLeaderboardWithCache.mock.calls[0];
  75. const options = callArgs[4];
  76. expect(options.userTags).toBeUndefined();
  77. expect(options.userGroups).toBeUndefined();
  78. });
  79. describe("additive provider fields", () => {
  80. it("includes avgCostPerRequest and avgCostPerMillionTokens in provider scope response", async () => {
  81. mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
  82. mocks.getLeaderboardWithCache.mockResolvedValue([
  83. {
  84. providerId: 1,
  85. providerName: "test-provider",
  86. totalRequests: 100,
  87. totalCost: 5.0,
  88. totalTokens: 500000,
  89. successRate: 0.95,
  90. avgTtfbMs: 200,
  91. avgTokensPerSecond: 50,
  92. avgCostPerRequest: 0.05,
  93. avgCostPerMillionTokens: 10.0,
  94. },
  95. ]);
  96. const { GET } = await import("@/app/api/leaderboard/route");
  97. const url = "http://localhost/api/leaderboard?scope=provider&period=daily";
  98. const response = await GET({ nextUrl: new URL(url) } as any);
  99. const body = await response.json();
  100. expect(response.status).toBe(200);
  101. expect(body).toHaveLength(1);
  102. const entry = body[0];
  103. // Additive fields must be present
  104. expect(entry).toHaveProperty("avgCostPerRequest", 0.05);
  105. expect(entry).toHaveProperty("avgCostPerMillionTokens", 10.0);
  106. // Formatted variants should exist
  107. expect(entry).toHaveProperty("avgCostPerRequestFormatted");
  108. expect(entry).toHaveProperty("avgCostPerMillionTokensFormatted");
  109. // Existing fields must still be present
  110. expect(entry).toHaveProperty("totalCostFormatted");
  111. expect(entry).toHaveProperty("providerId", 1);
  112. expect(entry).toHaveProperty("providerName", "test-provider");
  113. });
  114. it("formats null avgCost fields without error", async () => {
  115. mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
  116. mocks.getLeaderboardWithCache.mockResolvedValue([
  117. {
  118. providerId: 2,
  119. providerName: "zero-provider",
  120. totalRequests: 0,
  121. totalCost: 0,
  122. totalTokens: 0,
  123. successRate: 0,
  124. avgTtfbMs: 0,
  125. avgTokensPerSecond: 0,
  126. avgCostPerRequest: null,
  127. avgCostPerMillionTokens: null,
  128. },
  129. ]);
  130. const { GET } = await import("@/app/api/leaderboard/route");
  131. const url = "http://localhost/api/leaderboard?scope=provider&period=daily";
  132. const response = await GET({ nextUrl: new URL(url) } as any);
  133. const body = await response.json();
  134. expect(response.status).toBe(200);
  135. const entry = body[0];
  136. expect(entry.avgCostPerRequest).toBeNull();
  137. expect(entry.avgCostPerMillionTokens).toBeNull();
  138. });
  139. it("includes modelStats in providerCacheHitRate scope response", async () => {
  140. mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
  141. mocks.getLeaderboardWithCache.mockResolvedValue([
  142. {
  143. providerId: 1,
  144. providerName: "cache-provider",
  145. totalRequests: 50,
  146. cacheReadTokens: 10000,
  147. totalCost: 2.5,
  148. cacheCreationCost: 1.0,
  149. totalInputTokens: 20000,
  150. totalTokens: 20000,
  151. cacheHitRate: 0.5,
  152. modelStats: [
  153. {
  154. model: "claude-3-opus",
  155. totalRequests: 30,
  156. cacheReadTokens: 8000,
  157. totalInputTokens: 15000,
  158. cacheHitRate: 0.53,
  159. },
  160. {
  161. model: "claude-3-sonnet",
  162. totalRequests: 20,
  163. cacheReadTokens: 2000,
  164. totalInputTokens: 5000,
  165. cacheHitRate: 0.4,
  166. },
  167. ],
  168. },
  169. ]);
  170. const { GET } = await import("@/app/api/leaderboard/route");
  171. const url = "http://localhost/api/leaderboard?scope=providerCacheHitRate&period=daily";
  172. const response = await GET({ nextUrl: new URL(url) } as any);
  173. const body = await response.json();
  174. expect(response.status).toBe(200);
  175. expect(body).toHaveLength(1);
  176. const entry = body[0];
  177. expect(entry).toHaveProperty("modelStats");
  178. expect(entry.modelStats).toHaveLength(2);
  179. expect(entry.modelStats[0]).toHaveProperty("model", "claude-3-opus");
  180. expect(entry.modelStats[0]).toHaveProperty("cacheHitRate", 0.53);
  181. });
  182. it("includes modelStats in userCacheHitRate scope response", async () => {
  183. mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
  184. mocks.getLeaderboardWithCache.mockResolvedValue([
  185. {
  186. userId: 7,
  187. userName: "cache-user",
  188. totalRequests: 50,
  189. cacheReadTokens: 10000,
  190. totalCost: 2.5,
  191. cacheCreationCost: 1.0,
  192. totalInputTokens: 20000,
  193. totalTokens: 20000,
  194. cacheHitRate: 0.5,
  195. modelStats: [
  196. {
  197. model: "claude-3-opus",
  198. totalRequests: 30,
  199. cacheReadTokens: 8000,
  200. totalInputTokens: 15000,
  201. cacheHitRate: 0.53,
  202. },
  203. {
  204. model: null,
  205. totalRequests: 20,
  206. cacheReadTokens: 2000,
  207. totalInputTokens: 5000,
  208. cacheHitRate: 0.4,
  209. },
  210. ],
  211. },
  212. ]);
  213. const { GET } = await import("@/app/api/leaderboard/route");
  214. const url = "http://localhost/api/leaderboard?scope=userCacheHitRate&period=daily";
  215. const response = await GET({ nextUrl: new URL(url) } as any);
  216. const body = await response.json();
  217. expect(response.status).toBe(200);
  218. expect(body).toHaveLength(1);
  219. expect(body[0]).toHaveProperty("modelStats");
  220. expect(body[0].modelStats).toHaveLength(2);
  221. expect(body[0].modelStats[0]).toHaveProperty("model", "claude-3-opus");
  222. expect(body[0].modelStats[1]).toHaveProperty("model", null);
  223. });
  224. it("passes includeModelStats to cache and formats provider modelStats entries", async () => {
  225. mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
  226. mocks.getLeaderboardWithCache.mockResolvedValue([
  227. {
  228. providerId: 1,
  229. providerName: "test-provider",
  230. totalRequests: 10,
  231. totalCost: 1.5,
  232. totalTokens: 1000,
  233. successRate: 1,
  234. avgTtfbMs: 100,
  235. avgTokensPerSecond: 20,
  236. avgCostPerRequest: 0.15,
  237. avgCostPerMillionTokens: 1500,
  238. modelStats: [
  239. {
  240. model: "model-a",
  241. totalRequests: 6,
  242. totalCost: 1.0,
  243. totalTokens: 600,
  244. successRate: 1,
  245. avgTtfbMs: 110,
  246. avgTokensPerSecond: 25,
  247. avgCostPerRequest: 0.1667,
  248. avgCostPerMillionTokens: 1666.7,
  249. },
  250. ],
  251. },
  252. ]);
  253. const { GET } = await import("@/app/api/leaderboard/route");
  254. const url =
  255. "http://localhost/api/leaderboard?scope=provider&period=daily&includeModelStats=1";
  256. const response = await GET({ nextUrl: new URL(url) } as any);
  257. const body = await response.json();
  258. expect(response.status).toBe(200);
  259. expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1);
  260. const callArgs = mocks.getLeaderboardWithCache.mock.calls[0];
  261. const options = callArgs[4];
  262. expect(options.includeModelStats).toBe(true);
  263. expect(body).toHaveLength(1);
  264. const entry = body[0];
  265. expect(entry).toHaveProperty("modelStats");
  266. expect(entry.modelStats).toHaveLength(1);
  267. expect(entry.modelStats[0]).toHaveProperty("totalCostFormatted");
  268. expect(entry.modelStats[0]).toHaveProperty("avgCostPerRequestFormatted");
  269. expect(entry.modelStats[0]).toHaveProperty("avgCostPerMillionTokensFormatted");
  270. });
  271. it("returns empty modelStats array when includeModelStats is requested but provider has no model data", async () => {
  272. mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
  273. mocks.getLeaderboardWithCache.mockResolvedValue([
  274. {
  275. providerId: 1,
  276. providerName: "empty-models-provider",
  277. totalRequests: 10,
  278. totalCost: 1.0,
  279. totalTokens: 1000,
  280. successRate: 1,
  281. avgTtfbMs: 100,
  282. avgTokensPerSecond: 20,
  283. avgCostPerRequest: 0.1,
  284. avgCostPerMillionTokens: 1000,
  285. modelStats: [],
  286. },
  287. ]);
  288. const { GET } = await import("@/app/api/leaderboard/route");
  289. const url =
  290. "http://localhost/api/leaderboard?scope=provider&period=daily&includeModelStats=1";
  291. const response = await GET({ nextUrl: new URL(url) } as any);
  292. const body = await response.json();
  293. expect(response.status).toBe(200);
  294. expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1);
  295. const callArgs = mocks.getLeaderboardWithCache.mock.calls[0];
  296. const options = callArgs[4];
  297. expect(options.includeModelStats).toBe(true);
  298. expect(body).toHaveLength(1);
  299. expect(body[0]).toHaveProperty("modelStats");
  300. expect(Array.isArray(body[0].modelStats)).toBe(true);
  301. expect(body[0].modelStats).toHaveLength(0);
  302. });
  303. });
  304. describe("user scope includeUserModelStats", () => {
  305. it("admin + includeUserModelStats=1 returns 200 with correct cache call and private headers", async () => {
  306. mocks.getSession.mockResolvedValue({ user: { id: 1, name: "admin", role: "admin" } });
  307. mocks.getLeaderboardWithCache.mockResolvedValue([
  308. {
  309. userId: 1,
  310. userName: "user-a",
  311. totalRequests: 100,
  312. totalCost: 5.0,
  313. totalTokens: 1000,
  314. modelStats: [
  315. { model: "claude-3-opus", totalRequests: 60, totalCost: 3.0, totalTokens: 600 },
  316. { model: null, totalRequests: 40, totalCost: 2.0, totalTokens: 400 },
  317. ],
  318. },
  319. ]);
  320. const { GET } = await import("@/app/api/leaderboard/route");
  321. const url =
  322. "http://localhost/api/leaderboard?scope=user&period=daily&includeUserModelStats=1";
  323. const response = await GET({ nextUrl: new URL(url) } as any);
  324. const body = await response.json();
  325. expect(response.status).toBe(200);
  326. expect(response.headers.get("Cache-Control")).toBe("private, no-store");
  327. const options = mocks.getLeaderboardWithCache.mock.calls[0][4];
  328. expect(options.includeModelStats).toBe(true);
  329. expect(body[0].modelStats).toHaveLength(2);
  330. expect(body[0].modelStats[0]).toHaveProperty("totalCostFormatted");
  331. expect(body[0].modelStats[1].model).toBeNull();
  332. });
  333. it("non-admin + includeUserModelStats=1 returns 403", async () => {
  334. mocks.getSession.mockResolvedValue({ user: { id: 2, name: "user", role: "user" } });
  335. const { GET } = await import("@/app/api/leaderboard/route");
  336. const url =
  337. "http://localhost/api/leaderboard?scope=user&period=daily&includeUserModelStats=1";
  338. const response = await GET({ nextUrl: new URL(url) } as any);
  339. expect(response.status).toBe(403);
  340. const body = await response.json();
  341. expect(body.error).toBe("INCLUDE_USER_MODEL_STATS_ADMIN_REQUIRED");
  342. });
  343. it("non-admin with allowGlobalUsageView + includeUserModelStats=1 returns 403", async () => {
  344. mocks.getSession.mockResolvedValue({ user: { id: 2, name: "user", role: "user" } });
  345. mocks.getSystemSettings.mockResolvedValue({
  346. currencyDisplay: "USD",
  347. allowGlobalUsageView: true,
  348. });
  349. const { GET } = await import("@/app/api/leaderboard/route");
  350. const url =
  351. "http://localhost/api/leaderboard?scope=user&period=daily&includeUserModelStats=1";
  352. const response = await GET({ nextUrl: new URL(url) } as any);
  353. expect(response.status).toBe(403);
  354. const body = await response.json();
  355. expect(body.error).toBe("INCLUDE_USER_MODEL_STATS_ADMIN_REQUIRED");
  356. });
  357. it("admin + userCacheHitRate + includeUserModelStats=1 forwards includeModelStats to cache", async () => {
  358. mocks.getSession.mockResolvedValue({ user: { id: 1, name: "admin", role: "admin" } });
  359. mocks.getLeaderboardWithCache.mockResolvedValue([
  360. {
  361. userId: 1,
  362. userName: "cache-user",
  363. totalRequests: 20,
  364. cacheReadTokens: 500,
  365. totalCost: 1.5,
  366. cacheCreationCost: 0.4,
  367. totalInputTokens: 1000,
  368. totalTokens: 1000,
  369. cacheHitRate: 0.5,
  370. modelStats: [
  371. {
  372. model: "claude-sonnet",
  373. totalRequests: 20,
  374. cacheReadTokens: 500,
  375. totalInputTokens: 1000,
  376. cacheHitRate: 0.5,
  377. },
  378. ],
  379. },
  380. ]);
  381. const { GET } = await import("@/app/api/leaderboard/route");
  382. const url =
  383. "http://localhost/api/leaderboard?scope=userCacheHitRate&period=daily&includeUserModelStats=1";
  384. const response = await GET({ nextUrl: new URL(url) } as any);
  385. const body = await response.json();
  386. expect(response.status).toBe(200);
  387. expect(response.headers.get("Cache-Control")).toBe("private, no-store");
  388. expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1);
  389. expect(mocks.getLeaderboardWithCache.mock.calls[0][4].includeModelStats).toBe(true);
  390. expect(body[0].modelStats).toHaveLength(1);
  391. expect(body[0].modelStats[0]).not.toHaveProperty("totalCostFormatted");
  392. });
  393. });
  394. });