2
0

providers.test.ts 19 KB

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