providers.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. const getSessionMock = vi.fn();
  3. const findProviderByIdMock = vi.fn();
  4. const findAllProvidersFreshMock = vi.fn();
  5. const getProviderStatisticsMock = vi.fn();
  6. const createProviderMock = vi.fn();
  7. const updateProviderMock = vi.fn();
  8. const deleteProviderMock = vi.fn();
  9. const updateProviderPrioritiesBatchMock = vi.fn();
  10. const publishProviderCacheInvalidationMock = vi.fn();
  11. const saveProviderCircuitConfigMock = vi.fn();
  12. const deleteProviderCircuitConfigMock = vi.fn();
  13. const clearConfigCacheMock = vi.fn();
  14. const clearProviderStateMock = vi.fn();
  15. const revalidatePathMock = vi.fn();
  16. vi.mock("@/lib/auth", () => ({
  17. getSession: getSessionMock,
  18. }));
  19. vi.mock("@/repository/provider", () => ({
  20. createProvider: createProviderMock,
  21. deleteProvider: deleteProviderMock,
  22. findAllProviders: vi.fn(async () => []),
  23. findAllProvidersFresh: findAllProvidersFreshMock,
  24. findProviderById: findProviderByIdMock,
  25. getProviderStatistics: getProviderStatisticsMock,
  26. resetProviderTotalCostResetAt: vi.fn(async () => {}),
  27. updateProvider: updateProviderMock,
  28. updateProviderPrioritiesBatch: updateProviderPrioritiesBatchMock,
  29. }));
  30. vi.mock("@/lib/cache/provider-cache", () => ({
  31. publishProviderCacheInvalidation: publishProviderCacheInvalidationMock,
  32. }));
  33. vi.mock("@/lib/redis/circuit-breaker-config", () => ({
  34. deleteProviderCircuitConfig: deleteProviderCircuitConfigMock,
  35. saveProviderCircuitConfig: saveProviderCircuitConfigMock,
  36. }));
  37. vi.mock("@/lib/circuit-breaker", () => ({
  38. clearConfigCache: clearConfigCacheMock,
  39. clearProviderState: clearProviderStateMock,
  40. getAllHealthStatusAsync: vi.fn(async () => ({})),
  41. resetCircuit: vi.fn(),
  42. }));
  43. vi.mock("@/lib/logger", () => ({
  44. logger: {
  45. trace: vi.fn(),
  46. debug: vi.fn(),
  47. info: vi.fn(),
  48. warn: vi.fn(),
  49. error: vi.fn(),
  50. },
  51. }));
  52. vi.mock("next/cache", () => ({
  53. revalidatePath: revalidatePathMock,
  54. }));
  55. function nowMs(): number {
  56. if (typeof performance !== "undefined" && typeof performance.now === "function") {
  57. return performance.now();
  58. }
  59. return Date.now();
  60. }
  61. async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  62. let timeoutId: ReturnType<typeof setTimeout> | undefined;
  63. const timeout = new Promise<never>((_, reject) => {
  64. timeoutId = setTimeout(() => reject(new Error(`超时:${ms}ms`)), ms);
  65. });
  66. try {
  67. return await Promise.race([promise, timeout]);
  68. } finally {
  69. if (timeoutId) clearTimeout(timeoutId);
  70. }
  71. }
  72. describe("Provider Actions - Async Optimization", () => {
  73. beforeEach(() => {
  74. vi.clearAllMocks();
  75. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  76. findAllProvidersFreshMock.mockResolvedValue([
  77. {
  78. id: 1,
  79. name: "p1",
  80. url: "https://api.example.com",
  81. key: "sk-test-1234567890",
  82. isEnabled: true,
  83. weight: 1,
  84. priority: 0,
  85. costMultiplier: 1,
  86. groupTag: "default",
  87. providerType: "claude",
  88. preserveClientIp: false,
  89. modelRedirects: null,
  90. allowedModels: null,
  91. mcpPassthroughType: "none",
  92. mcpPassthroughUrl: null,
  93. limit5hUsd: null,
  94. limitDailyUsd: null,
  95. dailyResetMode: "fixed",
  96. dailyResetTime: "00:00",
  97. limitWeeklyUsd: null,
  98. limitMonthlyUsd: null,
  99. limitTotalUsd: null,
  100. limitConcurrentSessions: 0,
  101. maxRetryAttempts: null,
  102. circuitBreakerFailureThreshold: 5,
  103. circuitBreakerOpenDuration: 1800000,
  104. circuitBreakerHalfOpenSuccessThreshold: 2,
  105. proxyUrl: null,
  106. proxyFallbackToDirect: false,
  107. firstByteTimeoutStreamingMs: null,
  108. streamingIdleTimeoutMs: null,
  109. requestTimeoutNonStreamingMs: null,
  110. websiteUrl: null,
  111. faviconUrl: null,
  112. cacheTtlPreference: "inherit",
  113. context1mPreference: "inherit",
  114. codexReasoningEffortPreference: "inherit",
  115. codexReasoningSummaryPreference: "inherit",
  116. codexTextVerbosityPreference: "inherit",
  117. codexParallelToolCallsPreference: "inherit",
  118. tpm: null,
  119. rpm: null,
  120. rpd: null,
  121. cc: null,
  122. createdAt: new Date("2026-01-01T00:00:00.000Z"),
  123. updatedAt: new Date("2026-01-01T00:00:00.000Z"),
  124. },
  125. ]);
  126. getProviderStatisticsMock.mockResolvedValue([]);
  127. findProviderByIdMock.mockImplementation(async (id: number) => {
  128. const providers = await findAllProvidersFreshMock();
  129. return providers.find((p: { id: number }) => p.id === id) ?? null;
  130. });
  131. createProviderMock.mockResolvedValue({
  132. id: 123,
  133. circuitBreakerFailureThreshold: 5,
  134. circuitBreakerOpenDuration: 1800000,
  135. circuitBreakerHalfOpenSuccessThreshold: 2,
  136. });
  137. updateProviderMock.mockResolvedValue({
  138. id: 1,
  139. circuitBreakerFailureThreshold: 5,
  140. circuitBreakerOpenDuration: 1800000,
  141. circuitBreakerHalfOpenSuccessThreshold: 2,
  142. });
  143. deleteProviderMock.mockResolvedValue(undefined);
  144. publishProviderCacheInvalidationMock.mockResolvedValue(undefined);
  145. saveProviderCircuitConfigMock.mockResolvedValue(undefined);
  146. deleteProviderCircuitConfigMock.mockResolvedValue(undefined);
  147. clearProviderStateMock.mockResolvedValue(undefined);
  148. updateProviderPrioritiesBatchMock.mockResolvedValue(0);
  149. });
  150. describe("getProviders", () => {
  151. it("should return providers without blocking on statistics", async () => {
  152. getProviderStatisticsMock.mockImplementation(() => new Promise(() => {}));
  153. const { getProviders } = await import("@/actions/providers");
  154. const result = await withTimeout(getProviders(), 200);
  155. expect(result).toHaveLength(1);
  156. expect(result[0]?.id).toBe(1);
  157. expect(getProviderStatisticsMock).not.toHaveBeenCalled();
  158. });
  159. it("should complete within 500ms", async () => {
  160. getProviderStatisticsMock.mockImplementation(() => new Promise(() => {}));
  161. const { getProviders } = await import("@/actions/providers");
  162. const start = nowMs();
  163. const result = await withTimeout(getProviders(), 500);
  164. const elapsed = nowMs() - start;
  165. expect(result).toHaveLength(1);
  166. expect(elapsed).toBeLessThan(500);
  167. });
  168. });
  169. describe("autoSortProviderPriority", () => {
  170. it("should return preview only when confirm is false", async () => {
  171. findAllProvidersFreshMock.mockResolvedValue([
  172. { id: 1, name: "a", costMultiplier: "2.0", priority: 0 } as any,
  173. { id: 2, name: "b", costMultiplier: "1.0", priority: 1 } as any,
  174. { id: 3, name: "c", costMultiplier: "1.0", priority: 9 } as any,
  175. ]);
  176. const { autoSortProviderPriority } = await import("@/actions/providers");
  177. const result = await autoSortProviderPriority({ confirm: false });
  178. expect(result.ok).toBe(true);
  179. if (!result.ok) return;
  180. expect(result.data.applied).toBe(false);
  181. expect(result.data.summary.groupCount).toBe(2);
  182. expect(result.data.summary.totalProviders).toBe(3);
  183. expect(result.data.summary.changedCount).toBe(3);
  184. expect(result.data.groups).toEqual([
  185. {
  186. costMultiplier: 1,
  187. priority: 0,
  188. providers: [
  189. { id: 2, name: "b" },
  190. { id: 3, name: "c" },
  191. ],
  192. },
  193. {
  194. costMultiplier: 2,
  195. priority: 1,
  196. providers: [{ id: 1, name: "a" }],
  197. },
  198. ]);
  199. expect(updateProviderPrioritiesBatchMock).not.toHaveBeenCalled();
  200. expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled();
  201. });
  202. it("should handle invalid costMultiplier values gracefully", async () => {
  203. findAllProvidersFreshMock.mockResolvedValue([
  204. { id: 1, name: "bad", costMultiplier: undefined, priority: 5 } as any,
  205. { id: 2, name: "good", costMultiplier: "1.0", priority: 0 } as any,
  206. ]);
  207. const { autoSortProviderPriority } = await import("@/actions/providers");
  208. const result = await autoSortProviderPriority({ confirm: false });
  209. expect(result.ok).toBe(true);
  210. if (!result.ok) return;
  211. expect(result.data.summary.groupCount).toBe(2);
  212. expect(result.data.groups).toEqual([
  213. {
  214. costMultiplier: 0,
  215. priority: 0,
  216. providers: [{ id: 1, name: "bad" }],
  217. },
  218. {
  219. costMultiplier: 1,
  220. priority: 1,
  221. providers: [{ id: 2, name: "good" }],
  222. },
  223. ]);
  224. });
  225. it("should apply changes when confirm is true", async () => {
  226. findAllProvidersFreshMock.mockResolvedValue([
  227. { id: 10, name: "x", costMultiplier: "2.0", priority: 0 } as any,
  228. { id: 20, name: "y", costMultiplier: "1.0", priority: 0 } as any,
  229. ]);
  230. const { autoSortProviderPriority } = await import("@/actions/providers");
  231. const result = await autoSortProviderPriority({ confirm: true });
  232. expect(result.ok).toBe(true);
  233. if (!result.ok) return;
  234. expect(result.data.applied).toBe(true);
  235. expect(result.data.summary.changedCount).toBe(1);
  236. expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledTimes(1);
  237. expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledWith([{ id: 10, priority: 1 }]);
  238. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
  239. });
  240. it("should work with a single provider", async () => {
  241. findAllProvidersFreshMock.mockResolvedValue([
  242. { id: 1, name: "only", costMultiplier: "1.0", priority: 9 } as any,
  243. ]);
  244. const { autoSortProviderPriority } = await import("@/actions/providers");
  245. const result = await autoSortProviderPriority({ confirm: true });
  246. expect(result.ok).toBe(true);
  247. if (!result.ok) return;
  248. expect(result.data.applied).toBe(true);
  249. expect(result.data.summary.groupCount).toBe(1);
  250. expect(result.data.summary.changedCount).toBe(1);
  251. expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledWith([{ id: 1, priority: 0 }]);
  252. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
  253. });
  254. it("should set priority 0 for all providers when costMultiplier is the same", async () => {
  255. findAllProvidersFreshMock.mockResolvedValue([
  256. { id: 1, name: "a", costMultiplier: "1.0", priority: 5 } as any,
  257. { id: 2, name: "b", costMultiplier: "1.0", priority: 6 } as any,
  258. { id: 3, name: "c", costMultiplier: "1.0", priority: 7 } as any,
  259. ]);
  260. const { autoSortProviderPriority } = await import("@/actions/providers");
  261. const result = await autoSortProviderPriority({ confirm: true });
  262. expect(result.ok).toBe(true);
  263. if (!result.ok) return;
  264. expect(result.data.groups).toEqual([
  265. {
  266. costMultiplier: 1,
  267. priority: 0,
  268. providers: [
  269. { id: 1, name: "a" },
  270. { id: 2, name: "b" },
  271. { id: 3, name: "c" },
  272. ],
  273. },
  274. ]);
  275. expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledWith([
  276. { id: 1, priority: 0 },
  277. { id: 2, priority: 0 },
  278. { id: 3, priority: 0 },
  279. ]);
  280. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
  281. });
  282. it("should reject non-admin users", async () => {
  283. getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } });
  284. const { autoSortProviderPriority } = await import("@/actions/providers");
  285. const result = await autoSortProviderPriority({ confirm: true });
  286. expect(result.ok).toBe(false);
  287. if (result.ok) return;
  288. expect(result.error).toBe("无权限执行此操作");
  289. expect(updateProviderPrioritiesBatchMock).not.toHaveBeenCalled();
  290. expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled();
  291. });
  292. it("should not fail when cache invalidation publish throws", async () => {
  293. publishProviderCacheInvalidationMock.mockRejectedValueOnce(new Error("boom"));
  294. findAllProvidersFreshMock.mockResolvedValue([
  295. { id: 10, name: "x", costMultiplier: "2.0", priority: 0 } as any,
  296. { id: 20, name: "y", costMultiplier: "1.0", priority: 0 } as any,
  297. ]);
  298. const { autoSortProviderPriority } = await import("@/actions/providers");
  299. const result = await autoSortProviderPriority({ confirm: true });
  300. expect(result.ok).toBe(true);
  301. expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledTimes(1);
  302. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
  303. });
  304. it("should not write or invalidate cache when already sorted", async () => {
  305. findAllProvidersFreshMock.mockResolvedValue([
  306. { id: 10, name: "x", costMultiplier: "1.0", priority: 0 } as any,
  307. { id: 20, name: "y", costMultiplier: "2.0", priority: 1 } as any,
  308. ]);
  309. const { autoSortProviderPriority } = await import("@/actions/providers");
  310. const result = await autoSortProviderPriority({ confirm: true });
  311. expect(result.ok).toBe(true);
  312. if (!result.ok) return;
  313. expect(result.data.applied).toBe(true);
  314. expect(result.data.changes).toEqual([]);
  315. expect(updateProviderPrioritiesBatchMock).not.toHaveBeenCalled();
  316. expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled();
  317. });
  318. it("should handle empty providers list", async () => {
  319. findAllProvidersFreshMock.mockResolvedValue([]);
  320. const { autoSortProviderPriority } = await import("@/actions/providers");
  321. const preview = await autoSortProviderPriority({ confirm: false });
  322. const applied = await autoSortProviderPriority({ confirm: true });
  323. expect(preview.ok).toBe(true);
  324. if (preview.ok) {
  325. expect(preview.data.summary.totalProviders).toBe(0);
  326. expect(preview.data.applied).toBe(false);
  327. }
  328. expect(applied.ok).toBe(true);
  329. if (applied.ok) {
  330. expect(applied.data.summary.totalProviders).toBe(0);
  331. expect(applied.data.applied).toBe(true);
  332. }
  333. });
  334. });
  335. describe("getProviderStatisticsAsync", () => {
  336. it("should return statistics map by provider id", async () => {
  337. getProviderStatisticsMock.mockResolvedValue([
  338. {
  339. id: 1,
  340. today_cost: "1.23",
  341. today_calls: 10,
  342. last_call_time: new Date("2026-01-01T00:00:00.000Z"),
  343. last_call_model: "model-a",
  344. },
  345. {
  346. id: 2,
  347. today_cost: "0",
  348. today_calls: 0,
  349. last_call_time: "2026-01-02T00:00:00.000Z",
  350. last_call_model: null,
  351. },
  352. ]);
  353. const { getProviderStatisticsAsync } = await import("@/actions/providers");
  354. const result = await getProviderStatisticsAsync();
  355. expect(result[1]).toEqual({
  356. todayCost: "1.23",
  357. todayCalls: 10,
  358. lastCallTime: "2026-01-01T00:00:00.000Z",
  359. lastCallModel: "model-a",
  360. });
  361. expect(result[2]).toEqual({
  362. todayCost: "0",
  363. todayCalls: 0,
  364. lastCallTime: "2026-01-02T00:00:00.000Z",
  365. lastCallModel: null,
  366. });
  367. });
  368. it("should return empty object for non-admin", async () => {
  369. getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } });
  370. const { getProviderStatisticsAsync } = await import("@/actions/providers");
  371. const result = await getProviderStatisticsAsync();
  372. expect(result).toEqual({});
  373. expect(getProviderStatisticsMock).not.toHaveBeenCalled();
  374. });
  375. it("should handle errors gracefully and return empty object", async () => {
  376. getProviderStatisticsMock.mockRejectedValueOnce(new Error("boom"));
  377. const { getProviderStatisticsAsync } = await import("@/actions/providers");
  378. const result = await getProviderStatisticsAsync();
  379. expect(result).toEqual({});
  380. });
  381. });
  382. describe("addProvider", () => {
  383. it("should not call revalidatePath", async () => {
  384. const { addProvider } = await import("@/actions/providers");
  385. const result = await addProvider({
  386. name: "p2",
  387. url: "https://api.example.com",
  388. key: "sk-test-2",
  389. tpm: null,
  390. rpm: null,
  391. rpd: null,
  392. cc: null,
  393. });
  394. expect(result.ok).toBe(true);
  395. expect(revalidatePathMock).not.toHaveBeenCalled();
  396. });
  397. it("should complete quickly without blocking", async () => {
  398. const { addProvider } = await import("@/actions/providers");
  399. const start = nowMs();
  400. await withTimeout(
  401. addProvider({
  402. name: "p2",
  403. url: "https://api.example.com",
  404. key: "sk-test-2",
  405. tpm: null,
  406. rpm: null,
  407. rpd: null,
  408. cc: null,
  409. }),
  410. 500
  411. );
  412. const elapsed = nowMs() - start;
  413. expect(elapsed).toBeLessThan(500);
  414. expect(revalidatePathMock).not.toHaveBeenCalled();
  415. });
  416. });
  417. // 说明:当前代码实现的函数名为 editProvider/removeProvider。
  418. // 这里按需求用例命名 describe,但实际调用对应实现以确保测试可编译、可运行。
  419. describe("updateProvider", () => {
  420. it("should not call revalidatePath", async () => {
  421. const { editProvider } = await import("@/actions/providers");
  422. const result = await editProvider(1, { name: "p1-updated" });
  423. expect(result.ok).toBe(true);
  424. expect(revalidatePathMock).not.toHaveBeenCalled();
  425. });
  426. it("editProvider endpoint sync: should forward url/provider_type edits to repository", async () => {
  427. const nextUrl = "https://new.example.com/v1/responses";
  428. const { editProvider } = await import("@/actions/providers");
  429. const result = await editProvider(1, {
  430. url: nextUrl,
  431. provider_type: "codex",
  432. });
  433. expect(result.ok).toBe(true);
  434. expect(updateProviderMock).toHaveBeenCalledWith(
  435. 1,
  436. expect.objectContaining({
  437. url: nextUrl,
  438. provider_type: "codex",
  439. })
  440. );
  441. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
  442. });
  443. it("editProvider endpoint sync: should generate favicon_url when website_url is updated", async () => {
  444. const nextUrl = "https://new.example.com/v1/messages";
  445. const nextWebsiteUrl = "https://vendor.example.com/home";
  446. const { editProvider } = await import("@/actions/providers");
  447. const result = await editProvider(1, {
  448. url: nextUrl,
  449. website_url: nextWebsiteUrl,
  450. });
  451. expect(result.ok).toBe(true);
  452. expect(updateProviderMock).toHaveBeenCalledWith(
  453. 1,
  454. expect.objectContaining({
  455. url: nextUrl,
  456. website_url: nextWebsiteUrl,
  457. favicon_url: "https://www.google.com/s2/favicons?domain=vendor.example.com&sz=32",
  458. })
  459. );
  460. });
  461. it("editProvider endpoint sync: should clear favicon_url when website_url is cleared", async () => {
  462. const { editProvider } = await import("@/actions/providers");
  463. const result = await editProvider(1, {
  464. url: "https://new.example.com/v1/messages",
  465. website_url: null,
  466. });
  467. expect(result.ok).toBe(true);
  468. expect(updateProviderMock).toHaveBeenCalledWith(
  469. 1,
  470. expect.objectContaining({
  471. website_url: null,
  472. favicon_url: null,
  473. })
  474. );
  475. });
  476. });
  477. describe("deleteProvider", () => {
  478. it("should not call revalidatePath", async () => {
  479. const { removeProvider } = await import("@/actions/providers");
  480. const result = await removeProvider(1);
  481. expect(result.ok).toBe(true);
  482. expect(revalidatePathMock).not.toHaveBeenCalled();
  483. });
  484. });
  485. });