usage-logs-export-retry-count.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
  2. const getSessionMock = vi.fn();
  3. const findUsageLogsWithDetailsMock = vi.fn();
  4. const findUsageLogsBatchMock = vi.fn();
  5. const findUsageLogsStatsMock = vi.fn();
  6. const exportStatusStore = new Map<string, unknown>();
  7. const exportCsvStore = new Map<string, string>();
  8. vi.mock("@/lib/auth", () => {
  9. return {
  10. getSession: getSessionMock,
  11. };
  12. });
  13. vi.mock("@/lib/redis/redis-kv-store", () => ({
  14. RedisKVStore: class MockRedisKVStore<T> {
  15. private readonly prefix: string;
  16. constructor(options: { prefix: string }) {
  17. this.prefix = options.prefix;
  18. }
  19. async set(key: string, value: T) {
  20. if (this.prefix.includes(":status:")) {
  21. exportStatusStore.set(key, value);
  22. } else {
  23. exportCsvStore.set(key, value as string);
  24. }
  25. return true;
  26. }
  27. async get(key: string) {
  28. if (this.prefix.includes(":status:")) {
  29. return (exportStatusStore.get(key) as T | undefined) ?? null;
  30. }
  31. return ((exportCsvStore.get(key) as T | undefined) ?? null) as T | null;
  32. }
  33. async getAndDelete(key: string) {
  34. if (this.prefix.includes(":status:")) {
  35. const value = (exportStatusStore.get(key) as T | undefined) ?? null;
  36. exportStatusStore.delete(key);
  37. return value;
  38. }
  39. const value = ((exportCsvStore.get(key) as T | undefined) ?? null) as T | null;
  40. exportCsvStore.delete(key);
  41. return value;
  42. }
  43. async delete(key: string) {
  44. if (this.prefix.includes(":status:")) {
  45. return exportStatusStore.delete(key);
  46. }
  47. return exportCsvStore.delete(key);
  48. }
  49. },
  50. }));
  51. vi.mock("@/repository/usage-logs", () => {
  52. return {
  53. findUsageLogSessionIdSuggestions: vi.fn(async () => []),
  54. findUsageLogsBatch: findUsageLogsBatchMock,
  55. findUsageLogsStats: findUsageLogsStatsMock,
  56. findUsageLogsWithDetails: findUsageLogsWithDetailsMock,
  57. getUsedEndpoints: vi.fn(async () => []),
  58. getUsedModels: vi.fn(async () => []),
  59. getUsedStatusCodes: vi.fn(async () => []),
  60. };
  61. });
  62. function createSummary(totalRequests = 0) {
  63. return {
  64. totalRequests,
  65. totalCost: 0,
  66. totalTokens: 0,
  67. totalInputTokens: 0,
  68. totalOutputTokens: 0,
  69. totalCacheCreationTokens: 0,
  70. totalCacheReadTokens: 0,
  71. totalCacheCreation5mTokens: 0,
  72. totalCacheCreation1hTokens: 0,
  73. };
  74. }
  75. function createLog(overrides: Record<string, unknown> = {}) {
  76. return {
  77. createdAt: new Date("2026-03-16T00:00:00.000Z"),
  78. userName: "u",
  79. keyName: "k",
  80. providerName: "p",
  81. model: "m",
  82. originalModel: "om",
  83. endpoint: "/v1/messages",
  84. statusCode: 200,
  85. inputTokens: 1,
  86. outputTokens: 2,
  87. cacheCreation5mInputTokens: 0,
  88. cacheCreation1hInputTokens: 0,
  89. cacheReadInputTokens: 0,
  90. totalTokens: 3,
  91. costUsd: "0",
  92. durationMs: 10,
  93. sessionId: "s1",
  94. providerChain: null,
  95. ...overrides,
  96. };
  97. }
  98. function parseCsvLine(line: string): string[] {
  99. const fields: string[] = [];
  100. let current = "";
  101. let inQuotes = false;
  102. for (let i = 0; i < line.length; i++) {
  103. const char = line[i];
  104. if (!char) continue;
  105. if (inQuotes) {
  106. if (char === '"') {
  107. const next = line[i + 1];
  108. if (next === '"') {
  109. current += '"';
  110. i += 1;
  111. continue;
  112. }
  113. inQuotes = false;
  114. continue;
  115. }
  116. current += char;
  117. continue;
  118. }
  119. if (char === ",") {
  120. fields.push(current);
  121. current = "";
  122. continue;
  123. }
  124. if (char === '"') {
  125. inQuotes = true;
  126. continue;
  127. }
  128. current += char;
  129. }
  130. fields.push(current);
  131. return fields;
  132. }
  133. describe("Usage logs CSV export retryCount", () => {
  134. beforeEach(() => {
  135. vi.resetModules();
  136. vi.clearAllMocks();
  137. vi.useRealTimers();
  138. exportStatusStore.clear();
  139. exportCsvStore.clear();
  140. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  141. findUsageLogsWithDetailsMock.mockResolvedValue({
  142. logs: [],
  143. total: 0,
  144. summary: createSummary(),
  145. });
  146. findUsageLogsBatchMock.mockResolvedValue({ logs: [], nextCursor: null, hasMore: false });
  147. findUsageLogsStatsMock.mockResolvedValue(createSummary());
  148. });
  149. afterEach(() => {
  150. vi.useRealTimers();
  151. });
  152. test("exportUsageLogs: Retry Count 应对齐 getRetryCount(hedge race 为 0)", async () => {
  153. findUsageLogsWithDetailsMock.mockResolvedValue({
  154. logs: [],
  155. total: 3,
  156. summary: createSummary(3),
  157. });
  158. findUsageLogsBatchMock.mockResolvedValueOnce({
  159. logs: [
  160. {
  161. createdAt: new Date("2026-03-16T00:00:00.000Z"),
  162. userName: "u",
  163. keyName: "k",
  164. providerName: "p",
  165. model: "m",
  166. originalModel: "om",
  167. endpoint: "/v1/messages",
  168. statusCode: 200,
  169. inputTokens: 1,
  170. outputTokens: 2,
  171. cacheCreation5mInputTokens: 0,
  172. cacheCreation1hInputTokens: 0,
  173. cacheReadInputTokens: 0,
  174. totalTokens: 3,
  175. costUsd: "0",
  176. durationMs: 10,
  177. sessionId: "s1",
  178. providerChain: [
  179. { reason: "initial_selection" },
  180. { reason: "request_success", statusCode: 200 },
  181. ],
  182. },
  183. {
  184. createdAt: new Date("2026-03-16T00:00:01.000Z"),
  185. userName: "u",
  186. keyName: "k",
  187. providerName: "p",
  188. model: "m",
  189. originalModel: "om",
  190. endpoint: "/v1/messages",
  191. statusCode: 200,
  192. inputTokens: 1,
  193. outputTokens: 2,
  194. cacheCreation5mInputTokens: 0,
  195. cacheCreation1hInputTokens: 0,
  196. cacheReadInputTokens: 0,
  197. totalTokens: 3,
  198. costUsd: "0",
  199. durationMs: 10,
  200. sessionId: "s2",
  201. providerChain: [
  202. { reason: "initial_selection" },
  203. { reason: "retry_failed", attemptNumber: 1 },
  204. { reason: "retry_success", statusCode: 200, attemptNumber: 1 },
  205. ],
  206. },
  207. {
  208. createdAt: new Date("2026-03-16T00:00:02.000Z"),
  209. userName: "u",
  210. keyName: "k",
  211. providerName: "p",
  212. model: "m",
  213. originalModel: "om",
  214. endpoint: "/v1/messages",
  215. statusCode: 200,
  216. inputTokens: 1,
  217. outputTokens: 2,
  218. cacheCreation5mInputTokens: 0,
  219. cacheCreation1hInputTokens: 0,
  220. cacheReadInputTokens: 0,
  221. totalTokens: 3,
  222. costUsd: "0",
  223. durationMs: 10,
  224. sessionId: "s3",
  225. providerChain: [
  226. { reason: "initial_selection" },
  227. { reason: "hedge_triggered" },
  228. { reason: "hedge_launched" },
  229. { reason: "hedge_winner", statusCode: 200 },
  230. { reason: "hedge_loser_cancelled" },
  231. ],
  232. },
  233. ],
  234. nextCursor: null,
  235. hasMore: false,
  236. });
  237. const { exportUsageLogs } = await import("@/actions/usage-logs");
  238. const result = await exportUsageLogs({});
  239. expect(result.ok).toBe(true);
  240. const csv = result.data;
  241. const csvNoBom = csv.replace(/^\uFEFF/, "");
  242. const lines = csvNoBom
  243. .trim()
  244. .split("\n")
  245. .map((line) => line.replace(/\r$/, ""));
  246. expect(lines).toHaveLength(4);
  247. const header = parseCsvLine(lines[0] ?? "");
  248. const retryCountIndex = header.indexOf("Retry Count");
  249. expect(retryCountIndex).toBeGreaterThanOrEqual(0);
  250. const row1 = parseCsvLine(lines[1] ?? "");
  251. const row2 = parseCsvLine(lines[2] ?? "");
  252. const row3 = parseCsvLine(lines[3] ?? "");
  253. expect(row1[retryCountIndex]).toBe("0");
  254. expect(row2[retryCountIndex]).toBe("1");
  255. expect(row3[retryCountIndex]).toBe("0");
  256. });
  257. test("exportUsageLogs: 按批次全量导出,并拦截前导空白公式注入", async () => {
  258. findUsageLogsWithDetailsMock.mockResolvedValue({
  259. logs: [],
  260. total: 3,
  261. summary: createSummary(3),
  262. });
  263. findUsageLogsBatchMock
  264. .mockResolvedValueOnce({
  265. logs: [
  266. createLog({ sessionId: "s1", model: " =1+1" }),
  267. createLog({ sessionId: "s2", model: "+2+2" }),
  268. ],
  269. nextCursor: { createdAt: "2026-03-16T00:00:01.000000Z", id: 2 },
  270. hasMore: true,
  271. })
  272. .mockResolvedValueOnce({
  273. logs: [createLog({ sessionId: "s3", endpoint: " \t@SUM(A1:A2)" })],
  274. nextCursor: null,
  275. hasMore: false,
  276. });
  277. const { exportUsageLogs } = await import("@/actions/usage-logs");
  278. const result = await exportUsageLogs({});
  279. expect(result.ok).toBe(true);
  280. expect(findUsageLogsBatchMock).toHaveBeenCalledTimes(2);
  281. const csvNoBom = result.data.replace(/^\uFEFF/, "");
  282. const lines = csvNoBom
  283. .trim()
  284. .split("\n")
  285. .map((line) => line.replace(/\r$/, ""));
  286. expect(lines).toHaveLength(4);
  287. const header = parseCsvLine(lines[0] ?? "");
  288. const modelIndex = header.indexOf("Model");
  289. const endpointIndex = header.indexOf("Endpoint");
  290. const row1 = parseCsvLine(lines[1] ?? "");
  291. const row2 = parseCsvLine(lines[2] ?? "");
  292. const row3 = parseCsvLine(lines[3] ?? "");
  293. expect(row1[modelIndex]).toBe("' =1+1");
  294. expect(row2[modelIndex]).toBe("'+2+2");
  295. expect(row3[endpointIndex]).toBe("' \t@SUM(A1:A2)");
  296. });
  297. test("startUsageLogsExport: 异步导出任务完成后可轮询并下载", async () => {
  298. vi.useFakeTimers();
  299. findUsageLogsWithDetailsMock.mockResolvedValue({
  300. logs: [],
  301. total: 1,
  302. summary: createSummary(1),
  303. });
  304. findUsageLogsBatchMock.mockResolvedValueOnce({
  305. logs: [createLog({ sessionId: "job-session" })],
  306. nextCursor: null,
  307. hasMore: false,
  308. });
  309. const { downloadUsageLogsExport, getUsageLogsExportStatus, startUsageLogsExport } =
  310. await import("@/actions/usage-logs");
  311. const startResult = await startUsageLogsExport({});
  312. expect(startResult.ok).toBe(true);
  313. const jobId = startResult.data.jobId;
  314. const queuedStatus = await getUsageLogsExportStatus(jobId);
  315. expect(queuedStatus.ok).toBe(true);
  316. expect(queuedStatus.data.status).toBe("queued");
  317. await vi.runAllTimersAsync();
  318. const completedStatus = await getUsageLogsExportStatus(jobId);
  319. expect(completedStatus.ok).toBe(true);
  320. expect(completedStatus.data.status).toBe("completed");
  321. expect(completedStatus.data.progressPercent).toBe(100);
  322. expect(completedStatus.data.processedRows).toBe(1);
  323. const downloadResult = await downloadUsageLogsExport(jobId);
  324. expect(downloadResult.ok).toBe(true);
  325. expect(downloadResult.data).toContain("Session ID");
  326. expect(downloadResult.data).toContain("job-session");
  327. });
  328. });