2
0

my-usage-readonly.test.ts 22 KB

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