2
0

my-usage-readonly.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";
  2. import { inArray } from "drizzle-orm";
  3. import { db } from "@/drizzle/db";
  4. import { keys, messageRequest, users } from "@/drizzle/schema";
  5. import { callActionsRoute } from "../test-utils";
  6. /**
  7. * 说明:
  8. * - /api/actions 的鉴权在 adapter 层支持 Cookie 与 Authorization: Bearer <token>
  9. * - my-usage 的业务逻辑在 action 层仍会调用 getSession()(next/headers cookies/headers)
  10. * - 测试环境下需要 mock next/headers,否则 getSession 无法读取认证信息
  11. *
  12. * 这里用一个可变的 currentAuthToken 作为“当前请求 Cookie”,并确保:
  13. * - adapter 校验用的 Cookie(callActionsRoute.authToken)
  14. * - action 读取到的 Cookie(currentAuthToken)
  15. * 两者保持一致,避免出现“adapter 通过但 action 读不到 session”的假失败。
  16. */
  17. let currentAuthToken: string | undefined;
  18. let currentAuthorization: string | undefined;
  19. vi.mock("next/headers", () => ({
  20. cookies: () => ({
  21. get: (name: string) => {
  22. if (name !== "auth-token") return undefined;
  23. return currentAuthToken ? { value: currentAuthToken } : undefined;
  24. },
  25. set: vi.fn(),
  26. delete: vi.fn(),
  27. has: (name: string) => name === "auth-token" && Boolean(currentAuthToken),
  28. }),
  29. headers: () => ({
  30. get: (name: string) => {
  31. if (name.toLowerCase() !== "authorization") return null;
  32. return currentAuthorization ?? null;
  33. },
  34. }),
  35. }));
  36. type TestKey = { id: number; userId: number; key: string; name: string };
  37. type TestUser = { id: number; name: string };
  38. async function createTestUser(name: string): Promise<TestUser> {
  39. const [row] = await db
  40. .insert(users)
  41. .values({
  42. name,
  43. })
  44. .returning({ id: users.id, name: users.name });
  45. if (!row) {
  46. throw new Error("创建测试用户失败:未返回插入结果");
  47. }
  48. return row;
  49. }
  50. async function createTestKey(params: {
  51. userId: number;
  52. key: string;
  53. name: string;
  54. canLoginWebUi: boolean;
  55. }): Promise<TestKey> {
  56. const [row] = await db
  57. .insert(keys)
  58. .values({
  59. userId: params.userId,
  60. key: params.key,
  61. name: params.name,
  62. canLoginWebUi: params.canLoginWebUi,
  63. // 为避免跨时区/临界点导致“今日”边界不稳定,这里固定使用 rolling
  64. dailyResetMode: "rolling",
  65. dailyResetTime: "00:00",
  66. })
  67. .returning({ id: keys.id, userId: keys.userId, key: keys.key, name: keys.name });
  68. if (!row) {
  69. throw new Error("创建测试 Key 失败:未返回插入结果");
  70. }
  71. return row;
  72. }
  73. async function createMessage(params: {
  74. userId: number;
  75. key: string;
  76. model: string;
  77. originalModel?: string;
  78. endpoint?: string | null;
  79. costUsd?: string | null;
  80. inputTokens?: number | null;
  81. outputTokens?: number | null;
  82. blockedBy?: string | null;
  83. createdAt: Date;
  84. }): Promise<number> {
  85. const [row] = await db
  86. .insert(messageRequest)
  87. .values({
  88. providerId: 0,
  89. userId: params.userId,
  90. key: params.key,
  91. model: params.model,
  92. originalModel: params.originalModel ?? params.model,
  93. endpoint: params.endpoint ?? "/v1/messages",
  94. costUsd: params.costUsd ?? "0",
  95. inputTokens: params.inputTokens ?? 0,
  96. outputTokens: params.outputTokens ?? 0,
  97. blockedBy: params.blockedBy ?? null,
  98. createdAt: params.createdAt,
  99. updatedAt: params.createdAt,
  100. })
  101. .returning({ id: messageRequest.id });
  102. if (!row?.id) {
  103. throw new Error("创建 message_request 失败:未返回 id");
  104. }
  105. return row.id;
  106. }
  107. describe("my-usage API:只读 Key 自助查询", () => {
  108. const createdUserIds: number[] = [];
  109. const createdKeyIds: number[] = [];
  110. const createdMessageIds: number[] = [];
  111. afterAll(async () => {
  112. // 软删除更安全:避免潜在外键约束或其他测试依赖
  113. const now = new Date();
  114. if (createdMessageIds.length > 0) {
  115. await db
  116. .update(messageRequest)
  117. .set({ deletedAt: now, updatedAt: now })
  118. .where(inArray(messageRequest.id, createdMessageIds));
  119. }
  120. if (createdKeyIds.length > 0) {
  121. await db
  122. .update(keys)
  123. .set({ deletedAt: now, updatedAt: now })
  124. .where(inArray(keys.id, createdKeyIds));
  125. }
  126. if (createdUserIds.length > 0) {
  127. await db
  128. .update(users)
  129. .set({ deletedAt: now, updatedAt: now })
  130. .where(inArray(users.id, createdUserIds));
  131. }
  132. });
  133. beforeEach(() => {
  134. currentAuthToken = undefined;
  135. currentAuthorization = undefined;
  136. });
  137. test("未携带 auth-token:my-usage 端点应返回 401", async () => {
  138. const { response, json } = await callActionsRoute({
  139. method: "POST",
  140. pathname: "/api/actions/my-usage/getMyTodayStats",
  141. body: {},
  142. });
  143. expect(response.status).toBe(401);
  144. expect(json).toMatchObject({ ok: false });
  145. });
  146. test("只读 Key:允许访问 my-usage 端点和其他 allowReadOnlyAccess 端点", async () => {
  147. const unique = `my-usage-readonly-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  148. const user = await createTestUser(`Test ${unique}`);
  149. createdUserIds.push(user.id);
  150. const readonlyKey = await createTestKey({
  151. userId: user.id,
  152. key: `test-readonly-key-${unique}`,
  153. name: `readonly-${unique}`,
  154. canLoginWebUi: false,
  155. });
  156. createdKeyIds.push(readonlyKey.id);
  157. currentAuthToken = readonlyKey.key;
  158. // 允许访问 my-usage(allowReadOnlyAccess 白名单)
  159. const meta = await callActionsRoute({
  160. method: "POST",
  161. pathname: "/api/actions/my-usage/getMyUsageMetadata",
  162. authToken: readonlyKey.key,
  163. body: {},
  164. });
  165. expect(meta.response.status).toBe(200);
  166. expect(meta.json).toMatchObject({ ok: true });
  167. const quota = await callActionsRoute({
  168. method: "POST",
  169. pathname: "/api/actions/my-usage/getMyQuota",
  170. authToken: readonlyKey.key,
  171. body: {},
  172. });
  173. expect(quota.response.status).toBe(200);
  174. expect(quota.json).toMatchObject({ ok: true });
  175. // Issue #687 fix: getUsers 和 getUsageLogs 现在也支持 allowReadOnlyAccess
  176. // 普通用户只能看到自己的数据
  177. const usersApi = await callActionsRoute({
  178. method: "POST",
  179. pathname: "/api/actions/users/getUsers",
  180. authToken: readonlyKey.key,
  181. body: {},
  182. });
  183. expect(usersApi.response.status).toBe(200);
  184. expect(usersApi.json).toMatchObject({ ok: true });
  185. // 验证只返回自己的数据
  186. const usersData = (usersApi.json as { ok: boolean; data: Array<{ id: number }> }).data;
  187. expect(usersData.length).toBe(1);
  188. expect(usersData[0].id).toBe(user.id);
  189. const usageLogsApi = await callActionsRoute({
  190. method: "POST",
  191. pathname: "/api/actions/usage-logs/getUsageLogs",
  192. authToken: readonlyKey.key,
  193. body: {},
  194. });
  195. expect(usageLogsApi.response.status).toBe(200);
  196. expect(usageLogsApi.json).toMatchObject({ ok: true });
  197. });
  198. test("Bearer-only:仅 Authorization 也应可查询 my-usage 和其他 allowReadOnlyAccess 端点", async () => {
  199. const unique = `my-usage-bearer-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  200. const user = await createTestUser(`Test ${unique}`);
  201. createdUserIds.push(user.id);
  202. const readonlyKey = await createTestKey({
  203. userId: user.id,
  204. key: `test-readonly-key-${unique}`,
  205. name: `readonly-${unique}`,
  206. canLoginWebUi: false,
  207. });
  208. createdKeyIds.push(readonlyKey.id);
  209. const now = new Date();
  210. const msgId = await createMessage({
  211. userId: user.id,
  212. key: readonlyKey.key,
  213. model: "gpt-4.1-mini",
  214. endpoint: "/v1/messages",
  215. costUsd: "0.0100",
  216. inputTokens: 10,
  217. outputTokens: 20,
  218. createdAt: new Date(now.getTime() - 60 * 1000),
  219. });
  220. createdMessageIds.push(msgId);
  221. currentAuthorization = `Bearer ${readonlyKey.key}`;
  222. const stats = await callActionsRoute({
  223. method: "POST",
  224. pathname: "/api/actions/my-usage/getMyTodayStats",
  225. headers: { Authorization: currentAuthorization },
  226. body: {},
  227. });
  228. expect(stats.response.status).toBe(200);
  229. expect(stats.json).toMatchObject({ ok: true });
  230. expect((stats.json as any).data.calls).toBe(1);
  231. // Issue #687 fix: getUsers 现在也支持 allowReadOnlyAccess
  232. const usersApi = await callActionsRoute({
  233. method: "POST",
  234. pathname: "/api/actions/users/getUsers",
  235. headers: { Authorization: currentAuthorization },
  236. body: {},
  237. });
  238. expect(usersApi.response.status).toBe(200);
  239. expect(usersApi.json).toMatchObject({ ok: true });
  240. // 验证只返回自己的数据
  241. const usersData = (usersApi.json as { ok: boolean; data: Array<{ id: number }> }).data;
  242. expect(usersData.length).toBe(1);
  243. expect(usersData[0].id).toBe(user.id);
  244. });
  245. test("今日统计:应与 message_request 数据一致,并排除 warmup 与其他 Key 数据", async () => {
  246. const unique = `my-usage-stats-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  247. const userA = await createTestUser(`Test ${unique}-A`);
  248. createdUserIds.push(userA.id);
  249. const keyA = await createTestKey({
  250. userId: userA.id,
  251. key: `test-readonly-key-A-${unique}`,
  252. name: `readonly-A-${unique}`,
  253. canLoginWebUi: false,
  254. });
  255. createdKeyIds.push(keyA.id);
  256. const userB = await createTestUser(`Test ${unique}-B`);
  257. createdUserIds.push(userB.id);
  258. const keyB = await createTestKey({
  259. userId: userB.id,
  260. key: `test-readonly-key-B-${unique}`,
  261. name: `readonly-B-${unique}`,
  262. canLoginWebUi: false,
  263. });
  264. createdKeyIds.push(keyB.id);
  265. const now = new Date();
  266. const t0 = new Date(now.getTime() - 60 * 1000);
  267. // A:两条正常计费请求 + 一条 warmup(应被排除)
  268. const a1 = await createMessage({
  269. userId: userA.id,
  270. key: keyA.key,
  271. model: "gpt-4.1",
  272. originalModel: "gpt-4.1-original",
  273. endpoint: "/v1/messages",
  274. costUsd: "0.0125",
  275. inputTokens: 100,
  276. outputTokens: 200,
  277. createdAt: t0,
  278. });
  279. const a2 = await createMessage({
  280. userId: userA.id,
  281. key: keyA.key,
  282. model: "gpt-4.1-mini",
  283. originalModel: "gpt-4.1-mini-original",
  284. endpoint: "/v1/chat/completions",
  285. costUsd: "0.0075",
  286. inputTokens: 50,
  287. outputTokens: 80,
  288. createdAt: t0,
  289. });
  290. const warmup = await createMessage({
  291. userId: userA.id,
  292. key: keyA.key,
  293. model: "gpt-4.1-mini",
  294. originalModel: "gpt-4.1-mini",
  295. endpoint: "/v1/messages",
  296. costUsd: null,
  297. inputTokens: 999,
  298. outputTokens: 999,
  299. blockedBy: "warmup",
  300. createdAt: t0,
  301. });
  302. createdMessageIds.push(a1, a2, warmup);
  303. // B:一条正常请求(不应泄漏给 A)
  304. const b1 = await createMessage({
  305. userId: userB.id,
  306. key: keyB.key,
  307. model: "gpt-4.1",
  308. originalModel: "gpt-4.1",
  309. endpoint: "/v1/messages",
  310. costUsd: "0.1000",
  311. inputTokens: 1000,
  312. outputTokens: 1000,
  313. createdAt: t0,
  314. });
  315. createdMessageIds.push(b1);
  316. currentAuthToken = keyA.key;
  317. const { response, json } = await callActionsRoute({
  318. method: "POST",
  319. pathname: "/api/actions/my-usage/getMyTodayStats",
  320. authToken: keyA.key,
  321. body: {},
  322. });
  323. expect(response.status).toBe(200);
  324. expect(json).toMatchObject({ ok: true });
  325. const data = (json as any).data as {
  326. calls: number;
  327. inputTokens: number;
  328. outputTokens: number;
  329. costUsd: number;
  330. modelBreakdown: Array<{
  331. model: string | null;
  332. billingModel: string | null;
  333. calls: number;
  334. costUsd: number;
  335. inputTokens: number;
  336. outputTokens: number;
  337. }>;
  338. billingModelSource: "original" | "redirected";
  339. };
  340. // warmup 排除后:只剩两条
  341. expect(data.calls).toBe(2);
  342. expect(data.inputTokens).toBe(150);
  343. expect(data.outputTokens).toBe(280);
  344. expect(data.costUsd).toBeCloseTo(0.02, 10);
  345. // breakdown:至少包含两个模型
  346. const breakdownByModel = new Map(data.modelBreakdown.map((row) => [row.model, row]));
  347. expect(breakdownByModel.get("gpt-4.1")?.calls).toBe(1);
  348. expect(breakdownByModel.get("gpt-4.1-mini")?.calls).toBe(1);
  349. // billingModelSource 不假设固定值,但要求 billingModel 字段与配置一致
  350. const originalModelByModel = new Map<string, string>([
  351. ["gpt-4.1", "gpt-4.1-original"],
  352. ["gpt-4.1-mini", "gpt-4.1-mini-original"],
  353. ]);
  354. for (const row of data.modelBreakdown) {
  355. if (!row.model) continue;
  356. const expectedBillingModel =
  357. data.billingModelSource === "original" ? originalModelByModel.get(row.model) : row.model;
  358. expect(row.billingModel).toBe(expectedBillingModel);
  359. }
  360. // 同时验证 usage logs:不应返回 B 的日志(不泄漏)
  361. const logs = await callActionsRoute({
  362. method: "POST",
  363. pathname: "/api/actions/my-usage/getMyUsageLogs",
  364. authToken: keyA.key,
  365. body: { page: 1, pageSize: 50 },
  366. });
  367. expect(logs.response.status).toBe(200);
  368. expect(logs.json).toMatchObject({ ok: true });
  369. const logIds = ((logs.json as any).data.logs as Array<{ id: number }>).map((l) => l.id);
  370. expect(logIds).toContain(a1);
  371. expect(logIds).toContain(a2);
  372. // warmup 行是否展示不做强约束(日志口径可见),但绝不能泄漏 B
  373. expect(logIds).not.toContain(b1);
  374. // 筛选项接口:模型与端点列表应可用
  375. const models = await callActionsRoute({
  376. method: "POST",
  377. pathname: "/api/actions/my-usage/getMyAvailableModels",
  378. authToken: keyA.key,
  379. body: {},
  380. });
  381. expect(models.response.status).toBe(200);
  382. expect((models.json as any).ok).toBe(true);
  383. expect((models.json as any).data).toEqual(expect.arrayContaining(["gpt-4.1", "gpt-4.1-mini"]));
  384. const endpoints = await callActionsRoute({
  385. method: "POST",
  386. pathname: "/api/actions/my-usage/getMyAvailableEndpoints",
  387. authToken: keyA.key,
  388. body: {},
  389. });
  390. expect(endpoints.response.status).toBe(200);
  391. expect((endpoints.json as any).ok).toBe(true);
  392. expect((endpoints.json as any).data).toEqual(
  393. expect.arrayContaining(["/v1/messages", "/v1/chat/completions"])
  394. );
  395. });
  396. test("getMyStatsSummary:未认证返回 401", async () => {
  397. const { response, json } = await callActionsRoute({
  398. method: "POST",
  399. pathname: "/api/actions/my-usage/getMyStatsSummary",
  400. body: {},
  401. });
  402. expect(response.status).toBe(401);
  403. expect(json).toMatchObject({ ok: false });
  404. });
  405. test("getMyStatsSummary:基础聚合统计,排除 warmup,区分 key/user breakdown", async () => {
  406. const unique = `stats-summary-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  407. // 创建两个用户,每个用户一个 key
  408. const userA = await createTestUser(`Test ${unique}-A`);
  409. createdUserIds.push(userA.id);
  410. const keyA = await createTestKey({
  411. userId: userA.id,
  412. key: `test-stats-key-A-${unique}`,
  413. name: `stats-A-${unique}`,
  414. canLoginWebUi: false,
  415. });
  416. createdKeyIds.push(keyA.id);
  417. // 用户 A 的第二个 key(用于测试 user breakdown 聚合多个 key)
  418. const keyA2 = await createTestKey({
  419. userId: userA.id,
  420. key: `test-stats-key-A2-${unique}`,
  421. name: `stats-A2-${unique}`,
  422. canLoginWebUi: false,
  423. });
  424. createdKeyIds.push(keyA2.id);
  425. const userB = await createTestUser(`Test ${unique}-B`);
  426. createdUserIds.push(userB.id);
  427. const keyB = await createTestKey({
  428. userId: userB.id,
  429. key: `test-stats-key-B-${unique}`,
  430. name: `stats-B-${unique}`,
  431. canLoginWebUi: false,
  432. });
  433. createdKeyIds.push(keyB.id);
  434. const now = new Date();
  435. const today = now.toISOString().split("T")[0];
  436. const t0 = new Date(now.getTime() - 60 * 1000);
  437. // Key A 的请求
  438. const a1 = await createMessage({
  439. userId: userA.id,
  440. key: keyA.key,
  441. model: "claude-3-opus",
  442. endpoint: "/v1/messages",
  443. costUsd: "0.1000",
  444. inputTokens: 500,
  445. outputTokens: 200,
  446. createdAt: t0,
  447. });
  448. const a2 = await createMessage({
  449. userId: userA.id,
  450. key: keyA.key,
  451. model: "claude-3-sonnet",
  452. endpoint: "/v1/messages",
  453. costUsd: "0.0500",
  454. inputTokens: 300,
  455. outputTokens: 100,
  456. createdAt: t0,
  457. });
  458. // Key A 的 warmup(应被排除)
  459. const warmupA = await createMessage({
  460. userId: userA.id,
  461. key: keyA.key,
  462. model: "claude-3-opus",
  463. endpoint: "/v1/messages",
  464. costUsd: "0.9999",
  465. inputTokens: 9999,
  466. outputTokens: 9999,
  467. blockedBy: "warmup",
  468. createdAt: t0,
  469. });
  470. // Key A2 的请求(同一用户的不同 key,应在 userBreakdown 中聚合)
  471. const a2_1 = await createMessage({
  472. userId: userA.id,
  473. key: keyA2.key,
  474. model: "claude-3-opus",
  475. endpoint: "/v1/messages",
  476. costUsd: "0.0800",
  477. inputTokens: 400,
  478. outputTokens: 150,
  479. createdAt: t0,
  480. });
  481. // Key B 的请求(不应泄漏给 A)
  482. const b1 = await createMessage({
  483. userId: userB.id,
  484. key: keyB.key,
  485. model: "gpt-4",
  486. endpoint: "/v1/chat/completions",
  487. costUsd: "0.5000",
  488. inputTokens: 2000,
  489. outputTokens: 1000,
  490. createdAt: t0,
  491. });
  492. createdMessageIds.push(a1, a2, warmupA, a2_1, b1);
  493. currentAuthToken = keyA.key;
  494. // 调用 getMyStatsSummary
  495. const { response, json } = await callActionsRoute({
  496. method: "POST",
  497. pathname: "/api/actions/my-usage/getMyStatsSummary",
  498. authToken: keyA.key,
  499. body: { startDate: today, endDate: today },
  500. });
  501. expect(response.status).toBe(200);
  502. expect(json).toMatchObject({ ok: true });
  503. const data = (json as any).data as {
  504. totalRequests: number;
  505. totalCost: number;
  506. totalInputTokens: number;
  507. totalOutputTokens: number;
  508. keyModelBreakdown: Array<{
  509. model: string | null;
  510. requests: number;
  511. cost: number;
  512. inputTokens: number;
  513. outputTokens: number;
  514. }>;
  515. userModelBreakdown: Array<{
  516. model: string | null;
  517. requests: number;
  518. cost: number;
  519. inputTokens: number;
  520. outputTokens: number;
  521. }>;
  522. currencyCode: string;
  523. };
  524. // 验证总计(仅 key A,排除 warmup)
  525. expect(data.totalRequests).toBe(2); // a1, a2
  526. expect(data.totalInputTokens).toBe(800); // 500 + 300
  527. expect(data.totalOutputTokens).toBe(300); // 200 + 100
  528. expect(data.totalCost).toBeCloseTo(0.15, 4); // 0.1 + 0.05
  529. // 验证 keyModelBreakdown(仅当前 key A 的数据)
  530. const keyBreakdownMap = new Map(data.keyModelBreakdown.map((r) => [r.model, r]));
  531. expect(keyBreakdownMap.get("claude-3-opus")?.requests).toBe(1);
  532. expect(keyBreakdownMap.get("claude-3-opus")?.cost).toBeCloseTo(0.1, 4);
  533. expect(keyBreakdownMap.get("claude-3-sonnet")?.requests).toBe(1);
  534. expect(keyBreakdownMap.get("claude-3-sonnet")?.cost).toBeCloseTo(0.05, 4);
  535. // warmup 不应出现(blockedBy = 'warmup')
  536. // 其他用户的模型不应出现
  537. expect(keyBreakdownMap.has("gpt-4")).toBe(false);
  538. // 验证 userModelBreakdown(用户 A 的所有 key,包括 keyA2)
  539. const userBreakdownMap = new Map(data.userModelBreakdown.map((r) => [r.model, r]));
  540. // claude-3-opus: a1 (0.1) + a2_1 (0.08) = 0.18, requests = 2
  541. expect(userBreakdownMap.get("claude-3-opus")?.requests).toBe(2);
  542. expect(userBreakdownMap.get("claude-3-opus")?.cost).toBeCloseTo(0.18, 4);
  543. // claude-3-sonnet: a2 only
  544. expect(userBreakdownMap.get("claude-3-sonnet")?.requests).toBe(1);
  545. // 其他用户的模型不应出现
  546. expect(userBreakdownMap.has("gpt-4")).toBe(false);
  547. // 验证 currencyCode 存在
  548. expect(data.currencyCode).toBeDefined();
  549. });
  550. test("getMyStatsSummary:日期范围过滤", async () => {
  551. const unique = `stats-date-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  552. const user = await createTestUser(`Test ${unique}`);
  553. createdUserIds.push(user.id);
  554. const key = await createTestKey({
  555. userId: user.id,
  556. key: `test-stats-date-key-${unique}`,
  557. name: `stats-date-${unique}`,
  558. canLoginWebUi: false,
  559. });
  560. createdKeyIds.push(key.id);
  561. const today = new Date();
  562. const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
  563. const todayStr = today.toISOString().split("T")[0];
  564. const yesterdayStr = yesterday.toISOString().split("T")[0];
  565. // 昨天的请求
  566. const m1 = await createMessage({
  567. userId: user.id,
  568. key: key.key,
  569. model: "old-model",
  570. endpoint: "/v1/messages",
  571. costUsd: "0.0100",
  572. inputTokens: 100,
  573. outputTokens: 50,
  574. createdAt: yesterday,
  575. });
  576. // 今天的请求
  577. const m2 = await createMessage({
  578. userId: user.id,
  579. key: key.key,
  580. model: "new-model",
  581. endpoint: "/v1/messages",
  582. costUsd: "0.0200",
  583. inputTokens: 200,
  584. outputTokens: 100,
  585. createdAt: today,
  586. });
  587. createdMessageIds.push(m1, m2);
  588. currentAuthToken = key.key;
  589. // 仅查询今天
  590. const todayOnly = await callActionsRoute({
  591. method: "POST",
  592. pathname: "/api/actions/my-usage/getMyStatsSummary",
  593. authToken: key.key,
  594. body: { startDate: todayStr, endDate: todayStr },
  595. });
  596. expect(todayOnly.response.status).toBe(200);
  597. const todayData = (todayOnly.json as any).data;
  598. expect(todayData.totalRequests).toBe(1);
  599. expect(todayData.keyModelBreakdown.length).toBe(1);
  600. expect(todayData.keyModelBreakdown[0].model).toBe("new-model");
  601. // 查询昨天到今天
  602. const bothDays = await callActionsRoute({
  603. method: "POST",
  604. pathname: "/api/actions/my-usage/getMyStatsSummary",
  605. authToken: key.key,
  606. body: { startDate: yesterdayStr, endDate: todayStr },
  607. });
  608. expect(bothDays.response.status).toBe(200);
  609. const bothData = (bothDays.json as any).data;
  610. expect(bothData.totalRequests).toBe(2);
  611. expect(bothData.keyModelBreakdown.length).toBe(2);
  612. });
  613. });