my-usage-readonly.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684
  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 端点,但禁止访问其他 WebUI API", 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. // 禁止访问需要 WebUI 权限的 actions(默认 validateKey 会拒绝 canLoginWebUi=false 的 key)
  176. const usersApi = await callActionsRoute({
  177. method: "POST",
  178. pathname: "/api/actions/users/getUsers",
  179. authToken: readonlyKey.key,
  180. body: {},
  181. });
  182. expect(usersApi.response.status).toBe(401);
  183. expect(usersApi.json).toMatchObject({ ok: false });
  184. const usageLogsApi = await callActionsRoute({
  185. method: "POST",
  186. pathname: "/api/actions/usage-logs/getUsageLogs",
  187. authToken: readonlyKey.key,
  188. body: {},
  189. });
  190. expect(usageLogsApi.response.status).toBe(401);
  191. expect(usageLogsApi.json).toMatchObject({ ok: false });
  192. });
  193. test("Bearer-only:仅 Authorization 也应可查询 my-usage,但仍禁止访问 WebUI API", async () => {
  194. const unique = `my-usage-bearer-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  195. const user = await createTestUser(`Test ${unique}`);
  196. createdUserIds.push(user.id);
  197. const readonlyKey = await createTestKey({
  198. userId: user.id,
  199. key: `test-readonly-key-${unique}`,
  200. name: `readonly-${unique}`,
  201. canLoginWebUi: false,
  202. });
  203. createdKeyIds.push(readonlyKey.id);
  204. const now = new Date();
  205. const msgId = await createMessage({
  206. userId: user.id,
  207. key: readonlyKey.key,
  208. model: "gpt-4.1-mini",
  209. endpoint: "/v1/messages",
  210. costUsd: "0.0100",
  211. inputTokens: 10,
  212. outputTokens: 20,
  213. createdAt: new Date(now.getTime() - 60 * 1000),
  214. });
  215. createdMessageIds.push(msgId);
  216. currentAuthorization = `Bearer ${readonlyKey.key}`;
  217. const stats = await callActionsRoute({
  218. method: "POST",
  219. pathname: "/api/actions/my-usage/getMyTodayStats",
  220. headers: { Authorization: currentAuthorization },
  221. body: {},
  222. });
  223. expect(stats.response.status).toBe(200);
  224. expect(stats.json).toMatchObject({ ok: true });
  225. expect((stats.json as any).data.calls).toBe(1);
  226. const usersApi = await callActionsRoute({
  227. method: "POST",
  228. pathname: "/api/actions/users/getUsers",
  229. headers: { Authorization: currentAuthorization },
  230. body: {},
  231. });
  232. expect(usersApi.response.status).toBe(401);
  233. expect(usersApi.json).toMatchObject({ ok: false });
  234. });
  235. test("今日统计:应与 message_request 数据一致,并排除 warmup 与其他 Key 数据", async () => {
  236. const unique = `my-usage-stats-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  237. const userA = await createTestUser(`Test ${unique}-A`);
  238. createdUserIds.push(userA.id);
  239. const keyA = await createTestKey({
  240. userId: userA.id,
  241. key: `test-readonly-key-A-${unique}`,
  242. name: `readonly-A-${unique}`,
  243. canLoginWebUi: false,
  244. });
  245. createdKeyIds.push(keyA.id);
  246. const userB = await createTestUser(`Test ${unique}-B`);
  247. createdUserIds.push(userB.id);
  248. const keyB = await createTestKey({
  249. userId: userB.id,
  250. key: `test-readonly-key-B-${unique}`,
  251. name: `readonly-B-${unique}`,
  252. canLoginWebUi: false,
  253. });
  254. createdKeyIds.push(keyB.id);
  255. const now = new Date();
  256. const t0 = new Date(now.getTime() - 60 * 1000);
  257. // A:两条正常计费请求 + 一条 warmup(应被排除)
  258. const a1 = await createMessage({
  259. userId: userA.id,
  260. key: keyA.key,
  261. model: "gpt-4.1",
  262. originalModel: "gpt-4.1-original",
  263. endpoint: "/v1/messages",
  264. costUsd: "0.0125",
  265. inputTokens: 100,
  266. outputTokens: 200,
  267. createdAt: t0,
  268. });
  269. const a2 = await createMessage({
  270. userId: userA.id,
  271. key: keyA.key,
  272. model: "gpt-4.1-mini",
  273. originalModel: "gpt-4.1-mini-original",
  274. endpoint: "/v1/chat/completions",
  275. costUsd: "0.0075",
  276. inputTokens: 50,
  277. outputTokens: 80,
  278. createdAt: t0,
  279. });
  280. const warmup = await createMessage({
  281. userId: userA.id,
  282. key: keyA.key,
  283. model: "gpt-4.1-mini",
  284. originalModel: "gpt-4.1-mini",
  285. endpoint: "/v1/messages",
  286. costUsd: null,
  287. inputTokens: 999,
  288. outputTokens: 999,
  289. blockedBy: "warmup",
  290. createdAt: t0,
  291. });
  292. createdMessageIds.push(a1, a2, warmup);
  293. // B:一条正常请求(不应泄漏给 A)
  294. const b1 = await createMessage({
  295. userId: userB.id,
  296. key: keyB.key,
  297. model: "gpt-4.1",
  298. originalModel: "gpt-4.1",
  299. endpoint: "/v1/messages",
  300. costUsd: "0.1000",
  301. inputTokens: 1000,
  302. outputTokens: 1000,
  303. createdAt: t0,
  304. });
  305. createdMessageIds.push(b1);
  306. currentAuthToken = keyA.key;
  307. const { response, json } = await callActionsRoute({
  308. method: "POST",
  309. pathname: "/api/actions/my-usage/getMyTodayStats",
  310. authToken: keyA.key,
  311. body: {},
  312. });
  313. expect(response.status).toBe(200);
  314. expect(json).toMatchObject({ ok: true });
  315. const data = (json as any).data as {
  316. calls: number;
  317. inputTokens: number;
  318. outputTokens: number;
  319. costUsd: number;
  320. modelBreakdown: Array<{
  321. model: string | null;
  322. billingModel: string | null;
  323. calls: number;
  324. costUsd: number;
  325. inputTokens: number;
  326. outputTokens: number;
  327. }>;
  328. billingModelSource: "original" | "redirected";
  329. };
  330. // warmup 排除后:只剩两条
  331. expect(data.calls).toBe(2);
  332. expect(data.inputTokens).toBe(150);
  333. expect(data.outputTokens).toBe(280);
  334. expect(data.costUsd).toBeCloseTo(0.02, 10);
  335. // breakdown:至少包含两个模型
  336. const breakdownByModel = new Map(data.modelBreakdown.map((row) => [row.model, row]));
  337. expect(breakdownByModel.get("gpt-4.1")?.calls).toBe(1);
  338. expect(breakdownByModel.get("gpt-4.1-mini")?.calls).toBe(1);
  339. // billingModelSource 不假设固定值,但要求 billingModel 字段与配置一致
  340. const originalModelByModel = new Map<string, string>([
  341. ["gpt-4.1", "gpt-4.1-original"],
  342. ["gpt-4.1-mini", "gpt-4.1-mini-original"],
  343. ]);
  344. for (const row of data.modelBreakdown) {
  345. if (!row.model) continue;
  346. const expectedBillingModel =
  347. data.billingModelSource === "original" ? originalModelByModel.get(row.model) : row.model;
  348. expect(row.billingModel).toBe(expectedBillingModel);
  349. }
  350. // 同时验证 usage logs:不应返回 B 的日志(不泄漏)
  351. const logs = await callActionsRoute({
  352. method: "POST",
  353. pathname: "/api/actions/my-usage/getMyUsageLogs",
  354. authToken: keyA.key,
  355. body: { page: 1, pageSize: 50 },
  356. });
  357. expect(logs.response.status).toBe(200);
  358. expect(logs.json).toMatchObject({ ok: true });
  359. const logIds = ((logs.json as any).data.logs as Array<{ id: number }>).map((l) => l.id);
  360. expect(logIds).toContain(a1);
  361. expect(logIds).toContain(a2);
  362. // warmup 行是否展示不做强约束(日志口径可见),但绝不能泄漏 B
  363. expect(logIds).not.toContain(b1);
  364. // 筛选项接口:模型与端点列表应可用
  365. const models = await callActionsRoute({
  366. method: "POST",
  367. pathname: "/api/actions/my-usage/getMyAvailableModels",
  368. authToken: keyA.key,
  369. body: {},
  370. });
  371. expect(models.response.status).toBe(200);
  372. expect((models.json as any).ok).toBe(true);
  373. expect((models.json as any).data).toEqual(expect.arrayContaining(["gpt-4.1", "gpt-4.1-mini"]));
  374. const endpoints = await callActionsRoute({
  375. method: "POST",
  376. pathname: "/api/actions/my-usage/getMyAvailableEndpoints",
  377. authToken: keyA.key,
  378. body: {},
  379. });
  380. expect(endpoints.response.status).toBe(200);
  381. expect((endpoints.json as any).ok).toBe(true);
  382. expect((endpoints.json as any).data).toEqual(
  383. expect.arrayContaining(["/v1/messages", "/v1/chat/completions"])
  384. );
  385. });
  386. test("getMyStatsSummary:未认证返回 401", async () => {
  387. const { response, json } = await callActionsRoute({
  388. method: "POST",
  389. pathname: "/api/actions/my-usage/getMyStatsSummary",
  390. body: {},
  391. });
  392. expect(response.status).toBe(401);
  393. expect(json).toMatchObject({ ok: false });
  394. });
  395. test("getMyStatsSummary:基础聚合统计,排除 warmup,区分 key/user breakdown", async () => {
  396. const unique = `stats-summary-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  397. // 创建两个用户,每个用户一个 key
  398. const userA = await createTestUser(`Test ${unique}-A`);
  399. createdUserIds.push(userA.id);
  400. const keyA = await createTestKey({
  401. userId: userA.id,
  402. key: `test-stats-key-A-${unique}`,
  403. name: `stats-A-${unique}`,
  404. canLoginWebUi: false,
  405. });
  406. createdKeyIds.push(keyA.id);
  407. // 用户 A 的第二个 key(用于测试 user breakdown 聚合多个 key)
  408. const keyA2 = await createTestKey({
  409. userId: userA.id,
  410. key: `test-stats-key-A2-${unique}`,
  411. name: `stats-A2-${unique}`,
  412. canLoginWebUi: false,
  413. });
  414. createdKeyIds.push(keyA2.id);
  415. const userB = await createTestUser(`Test ${unique}-B`);
  416. createdUserIds.push(userB.id);
  417. const keyB = await createTestKey({
  418. userId: userB.id,
  419. key: `test-stats-key-B-${unique}`,
  420. name: `stats-B-${unique}`,
  421. canLoginWebUi: false,
  422. });
  423. createdKeyIds.push(keyB.id);
  424. const now = new Date();
  425. const today = now.toISOString().split("T")[0];
  426. const t0 = new Date(now.getTime() - 60 * 1000);
  427. // Key A 的请求
  428. const a1 = await createMessage({
  429. userId: userA.id,
  430. key: keyA.key,
  431. model: "claude-3-opus",
  432. endpoint: "/v1/messages",
  433. costUsd: "0.1000",
  434. inputTokens: 500,
  435. outputTokens: 200,
  436. createdAt: t0,
  437. });
  438. const a2 = await createMessage({
  439. userId: userA.id,
  440. key: keyA.key,
  441. model: "claude-3-sonnet",
  442. endpoint: "/v1/messages",
  443. costUsd: "0.0500",
  444. inputTokens: 300,
  445. outputTokens: 100,
  446. createdAt: t0,
  447. });
  448. // Key A 的 warmup(应被排除)
  449. const warmupA = await createMessage({
  450. userId: userA.id,
  451. key: keyA.key,
  452. model: "claude-3-opus",
  453. endpoint: "/v1/messages",
  454. costUsd: "0.9999",
  455. inputTokens: 9999,
  456. outputTokens: 9999,
  457. blockedBy: "warmup",
  458. createdAt: t0,
  459. });
  460. // Key A2 的请求(同一用户的不同 key,应在 userBreakdown 中聚合)
  461. const a2_1 = await createMessage({
  462. userId: userA.id,
  463. key: keyA2.key,
  464. model: "claude-3-opus",
  465. endpoint: "/v1/messages",
  466. costUsd: "0.0800",
  467. inputTokens: 400,
  468. outputTokens: 150,
  469. createdAt: t0,
  470. });
  471. // Key B 的请求(不应泄漏给 A)
  472. const b1 = await createMessage({
  473. userId: userB.id,
  474. key: keyB.key,
  475. model: "gpt-4",
  476. endpoint: "/v1/chat/completions",
  477. costUsd: "0.5000",
  478. inputTokens: 2000,
  479. outputTokens: 1000,
  480. createdAt: t0,
  481. });
  482. createdMessageIds.push(a1, a2, warmupA, a2_1, b1);
  483. currentAuthToken = keyA.key;
  484. // 调用 getMyStatsSummary
  485. const { response, json } = await callActionsRoute({
  486. method: "POST",
  487. pathname: "/api/actions/my-usage/getMyStatsSummary",
  488. authToken: keyA.key,
  489. body: { startDate: today, endDate: today },
  490. });
  491. expect(response.status).toBe(200);
  492. expect(json).toMatchObject({ ok: true });
  493. const data = (json as any).data as {
  494. totalRequests: number;
  495. totalCost: number;
  496. totalInputTokens: number;
  497. totalOutputTokens: number;
  498. keyModelBreakdown: Array<{
  499. model: string | null;
  500. requests: number;
  501. cost: number;
  502. inputTokens: number;
  503. outputTokens: number;
  504. }>;
  505. userModelBreakdown: Array<{
  506. model: string | null;
  507. requests: number;
  508. cost: number;
  509. inputTokens: number;
  510. outputTokens: number;
  511. }>;
  512. currencyCode: string;
  513. };
  514. // 验证总计(仅 key A,排除 warmup)
  515. expect(data.totalRequests).toBe(2); // a1, a2
  516. expect(data.totalInputTokens).toBe(800); // 500 + 300
  517. expect(data.totalOutputTokens).toBe(300); // 200 + 100
  518. expect(data.totalCost).toBeCloseTo(0.15, 4); // 0.1 + 0.05
  519. // 验证 keyModelBreakdown(仅当前 key A 的数据)
  520. const keyBreakdownMap = new Map(data.keyModelBreakdown.map((r) => [r.model, r]));
  521. expect(keyBreakdownMap.get("claude-3-opus")?.requests).toBe(1);
  522. expect(keyBreakdownMap.get("claude-3-opus")?.cost).toBeCloseTo(0.1, 4);
  523. expect(keyBreakdownMap.get("claude-3-sonnet")?.requests).toBe(1);
  524. expect(keyBreakdownMap.get("claude-3-sonnet")?.cost).toBeCloseTo(0.05, 4);
  525. // warmup 不应出现(blockedBy = 'warmup')
  526. // 其他用户的模型不应出现
  527. expect(keyBreakdownMap.has("gpt-4")).toBe(false);
  528. // 验证 userModelBreakdown(用户 A 的所有 key,包括 keyA2)
  529. const userBreakdownMap = new Map(data.userModelBreakdown.map((r) => [r.model, r]));
  530. // claude-3-opus: a1 (0.1) + a2_1 (0.08) = 0.18, requests = 2
  531. expect(userBreakdownMap.get("claude-3-opus")?.requests).toBe(2);
  532. expect(userBreakdownMap.get("claude-3-opus")?.cost).toBeCloseTo(0.18, 4);
  533. // claude-3-sonnet: a2 only
  534. expect(userBreakdownMap.get("claude-3-sonnet")?.requests).toBe(1);
  535. // 其他用户的模型不应出现
  536. expect(userBreakdownMap.has("gpt-4")).toBe(false);
  537. // 验证 currencyCode 存在
  538. expect(data.currencyCode).toBeDefined();
  539. });
  540. test("getMyStatsSummary:日期范围过滤", async () => {
  541. const unique = `stats-date-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  542. const user = await createTestUser(`Test ${unique}`);
  543. createdUserIds.push(user.id);
  544. const key = await createTestKey({
  545. userId: user.id,
  546. key: `test-stats-date-key-${unique}`,
  547. name: `stats-date-${unique}`,
  548. canLoginWebUi: false,
  549. });
  550. createdKeyIds.push(key.id);
  551. const today = new Date();
  552. const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
  553. const todayStr = today.toISOString().split("T")[0];
  554. const yesterdayStr = yesterday.toISOString().split("T")[0];
  555. // 昨天的请求
  556. const m1 = await createMessage({
  557. userId: user.id,
  558. key: key.key,
  559. model: "old-model",
  560. endpoint: "/v1/messages",
  561. costUsd: "0.0100",
  562. inputTokens: 100,
  563. outputTokens: 50,
  564. createdAt: yesterday,
  565. });
  566. // 今天的请求
  567. const m2 = await createMessage({
  568. userId: user.id,
  569. key: key.key,
  570. model: "new-model",
  571. endpoint: "/v1/messages",
  572. costUsd: "0.0200",
  573. inputTokens: 200,
  574. outputTokens: 100,
  575. createdAt: today,
  576. });
  577. createdMessageIds.push(m1, m2);
  578. currentAuthToken = key.key;
  579. // 仅查询今天
  580. const todayOnly = await callActionsRoute({
  581. method: "POST",
  582. pathname: "/api/actions/my-usage/getMyStatsSummary",
  583. authToken: key.key,
  584. body: { startDate: todayStr, endDate: todayStr },
  585. });
  586. expect(todayOnly.response.status).toBe(200);
  587. const todayData = (todayOnly.json as any).data;
  588. expect(todayData.totalRequests).toBe(1);
  589. expect(todayData.keyModelBreakdown.length).toBe(1);
  590. expect(todayData.keyModelBreakdown[0].model).toBe("new-model");
  591. // 查询昨天到今天
  592. const bothDays = await callActionsRoute({
  593. method: "POST",
  594. pathname: "/api/actions/my-usage/getMyStatsSummary",
  595. authToken: key.key,
  596. body: { startDate: yesterdayStr, endDate: todayStr },
  597. });
  598. expect(bothDays.response.status).toBe(200);
  599. const bothData = (bothDays.json as any).data;
  600. expect(bothData.totalRequests).toBe(2);
  601. expect(bothData.keyModelBreakdown.length).toBe(2);
  602. });
  603. });