provider-manager.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. /**
  2. * @vitest-environment happy-dom
  3. */
  4. import { NextIntlClientProvider } from "next-intl";
  5. import { type ReactNode, act } from "react";
  6. import { createRoot } from "react-dom/client";
  7. import { beforeEach, describe, expect, test, vi } from "vitest";
  8. import type { ProviderDisplay } from "@/types/provider";
  9. import enMessages from "../../../../messages/en";
  10. // ---------------------------------------------------------------------------
  11. // Mocks -- keep them minimal, only stub what provider-manager.tsx touches
  12. // ---------------------------------------------------------------------------
  13. vi.mock("@/lib/hooks/use-debounce", () => ({
  14. useDebounce: (value: string, _delay: number) => value,
  15. }));
  16. // Batch-edit subcomponents (heavy, irrelevant to this test scope)
  17. vi.mock("@/app/[locale]/settings/providers/_components/batch-edit", () => ({
  18. ProviderBatchActions: () => null,
  19. ProviderBatchDialog: () => null,
  20. ProviderBatchToolbar: () => null,
  21. }));
  22. // ProviderList -- render a simple list so we can inspect filtered output
  23. vi.mock("@/app/[locale]/settings/providers/_components/provider-list", () => ({
  24. ProviderList: ({ providers }: { providers: ProviderDisplay[] }) => (
  25. <ul data-testid="provider-list">
  26. {providers.map((p) => (
  27. <li key={p.id} data-testid={`provider-${p.id}`}>
  28. {p.name}
  29. </li>
  30. ))}
  31. </ul>
  32. ),
  33. }));
  34. // ProviderVendorView -- not under test
  35. vi.mock("@/app/[locale]/settings/providers/_components/provider-vendor-view", () => ({
  36. ProviderVendorView: () => null,
  37. }));
  38. // ProviderTypeFilter
  39. vi.mock("@/app/[locale]/settings/providers/_components/provider-type-filter", () => ({
  40. ProviderTypeFilter: () => null,
  41. }));
  42. // ProviderSortDropdown
  43. vi.mock("@/app/[locale]/settings/providers/_components/provider-sort-dropdown", () => ({
  44. ProviderSortDropdown: () => null,
  45. }));
  46. // ---------------------------------------------------------------------------
  47. // Helpers
  48. // ---------------------------------------------------------------------------
  49. function makeProvider(overrides: Partial<ProviderDisplay> = {}): ProviderDisplay {
  50. return {
  51. id: 1,
  52. name: "Provider A",
  53. url: "https://api.example.com",
  54. maskedKey: "sk-***",
  55. isEnabled: true,
  56. weight: 1,
  57. priority: 1,
  58. costMultiplier: 1,
  59. groupTag: null,
  60. groupPriorities: null,
  61. providerType: "claude",
  62. providerVendorId: null,
  63. preserveClientIp: false,
  64. modelRedirects: null,
  65. allowedModels: null,
  66. mcpPassthroughType: "none",
  67. mcpPassthroughUrl: null,
  68. limit5hUsd: null,
  69. limitDailyUsd: null,
  70. dailyResetMode: "fixed",
  71. dailyResetTime: "00:00",
  72. limitWeeklyUsd: null,
  73. limitMonthlyUsd: null,
  74. limitTotalUsd: null,
  75. limitConcurrentSessions: 1,
  76. maxRetryAttempts: null,
  77. circuitBreakerFailureThreshold: 1,
  78. circuitBreakerOpenDuration: 60,
  79. circuitBreakerHalfOpenSuccessThreshold: 1,
  80. proxyUrl: null,
  81. proxyFallbackToDirect: false,
  82. firstByteTimeoutStreamingMs: 0,
  83. streamingIdleTimeoutMs: 0,
  84. requestTimeoutNonStreamingMs: 0,
  85. websiteUrl: null,
  86. faviconUrl: null,
  87. cacheTtlPreference: null,
  88. context1mPreference: null,
  89. codexReasoningEffortPreference: null,
  90. codexReasoningSummaryPreference: null,
  91. codexTextVerbosityPreference: null,
  92. codexParallelToolCallsPreference: null,
  93. anthropicMaxTokensPreference: null,
  94. anthropicThinkingBudgetPreference: null,
  95. geminiGoogleSearchPreference: null,
  96. tpm: null,
  97. rpm: null,
  98. rpd: null,
  99. cc: null,
  100. createdAt: "2026-01-01",
  101. updatedAt: "2026-01-01",
  102. ...overrides,
  103. };
  104. }
  105. function renderWithProviders(node: ReactNode) {
  106. const container = document.createElement("div");
  107. document.body.appendChild(container);
  108. const root = createRoot(container);
  109. act(() => {
  110. root.render(
  111. <NextIntlClientProvider locale="en" messages={enMessages} timeZone="UTC">
  112. {node}
  113. </NextIntlClientProvider>
  114. );
  115. });
  116. return {
  117. unmount: () => {
  118. act(() => root.unmount());
  119. container.remove();
  120. },
  121. container,
  122. };
  123. }
  124. // ---------------------------------------------------------------------------
  125. // Tests
  126. // ---------------------------------------------------------------------------
  127. // Lazy-import after mocks are established
  128. let ProviderManager: typeof import("@/app/[locale]/settings/providers/_components/provider-manager").ProviderManager;
  129. beforeEach(async () => {
  130. vi.clearAllMocks();
  131. while (document.body.firstChild) {
  132. document.body.removeChild(document.body.firstChild);
  133. }
  134. // Dynamic import to ensure mocks take effect
  135. const mod = await import("@/app/[locale]/settings/providers/_components/provider-manager");
  136. ProviderManager = mod.ProviderManager;
  137. });
  138. describe("ProviderManager circuitBrokenCount with endpoint circuits", () => {
  139. const providers = [
  140. makeProvider({ id: 1, name: "Provider A" }),
  141. makeProvider({ id: 2, name: "Provider B" }),
  142. makeProvider({ id: 3, name: "Provider C" }),
  143. ];
  144. test("counts only key-level circuit breaker when no endpointCircuitInfo", () => {
  145. const healthStatus = {
  146. 1: {
  147. circuitState: "open" as const,
  148. failureCount: 5,
  149. lastFailureTime: Date.now(),
  150. circuitOpenUntil: Date.now() + 60000,
  151. recoveryMinutes: 1,
  152. },
  153. };
  154. const { unmount, container } = renderWithProviders(
  155. <ProviderManager
  156. providers={providers}
  157. healthStatus={healthStatus}
  158. enableMultiProviderTypes={true}
  159. />
  160. );
  161. // The circuit broken count should show 1 (only Provider A has key-level open)
  162. const text = container.textContent || "";
  163. expect(text).toContain("(1)");
  164. unmount();
  165. });
  166. test("counts providers with endpoint-level circuit open in addition to key-level", () => {
  167. // Provider 1: key-level circuit open
  168. // Provider 2: healthy key, but has an endpoint circuit open
  169. // Provider 3: all healthy
  170. const healthStatus = {
  171. 1: {
  172. circuitState: "open" as const,
  173. failureCount: 5,
  174. lastFailureTime: Date.now(),
  175. circuitOpenUntil: Date.now() + 60000,
  176. recoveryMinutes: 1,
  177. },
  178. };
  179. const endpointCircuitInfo: Record<
  180. number,
  181. Array<{
  182. endpointId: number;
  183. circuitState: "closed" | "open" | "half-open";
  184. failureCount: number;
  185. circuitOpenUntil: number | null;
  186. }>
  187. > = {
  188. 2: [
  189. {
  190. endpointId: 10,
  191. circuitState: "open",
  192. failureCount: 3,
  193. circuitOpenUntil: Date.now() + 60000,
  194. },
  195. {
  196. endpointId: 11,
  197. circuitState: "closed",
  198. failureCount: 0,
  199. circuitOpenUntil: null,
  200. },
  201. ],
  202. };
  203. const { unmount, container } = renderWithProviders(
  204. <ProviderManager
  205. providers={providers}
  206. healthStatus={healthStatus}
  207. endpointCircuitInfo={endpointCircuitInfo}
  208. enableMultiProviderTypes={true}
  209. />
  210. );
  211. // Count should be 2: Provider A (key open) + Provider B (endpoint open)
  212. const text = container.textContent || "";
  213. expect(text).toContain("(2)");
  214. unmount();
  215. });
  216. test("does not double-count provider with both key and endpoint circuits open", () => {
  217. const healthStatus = {
  218. 1: {
  219. circuitState: "open" as const,
  220. failureCount: 5,
  221. lastFailureTime: Date.now(),
  222. circuitOpenUntil: Date.now() + 60000,
  223. recoveryMinutes: 1,
  224. },
  225. };
  226. const endpointCircuitInfo: Record<
  227. number,
  228. Array<{
  229. endpointId: number;
  230. circuitState: "closed" | "open" | "half-open";
  231. failureCount: number;
  232. circuitOpenUntil: number | null;
  233. }>
  234. > = {
  235. 1: [
  236. {
  237. endpointId: 10,
  238. circuitState: "open",
  239. failureCount: 3,
  240. circuitOpenUntil: Date.now() + 60000,
  241. },
  242. ],
  243. };
  244. const { unmount, container } = renderWithProviders(
  245. <ProviderManager
  246. providers={providers}
  247. healthStatus={healthStatus}
  248. endpointCircuitInfo={endpointCircuitInfo}
  249. enableMultiProviderTypes={true}
  250. />
  251. );
  252. // Should still be 1 -- provider 1 has both, but count is deduplicated
  253. const text = container.textContent || "";
  254. expect(text).toContain("(1)");
  255. unmount();
  256. });
  257. test("circuit broken filter includes providers with endpoint circuits open", () => {
  258. // Use a state-based approach:
  259. // We'll set circuitBrokenFilter active programmatically by clicking the toggle.
  260. // Provider 2 only has an endpoint circuit open (no key circuit).
  261. const healthStatus = {};
  262. const endpointCircuitInfo: Record<
  263. number,
  264. Array<{
  265. endpointId: number;
  266. circuitState: "closed" | "open" | "half-open";
  267. failureCount: number;
  268. circuitOpenUntil: number | null;
  269. }>
  270. > = {
  271. 2: [
  272. {
  273. endpointId: 10,
  274. circuitState: "open",
  275. failureCount: 3,
  276. circuitOpenUntil: Date.now() + 60000,
  277. },
  278. ],
  279. };
  280. const { unmount, container } = renderWithProviders(
  281. <ProviderManager
  282. providers={providers}
  283. healthStatus={healthStatus}
  284. endpointCircuitInfo={endpointCircuitInfo}
  285. enableMultiProviderTypes={true}
  286. />
  287. );
  288. // Circuit broken count should be 1 (Provider B has endpoint open)
  289. const text = container.textContent || "";
  290. expect(text).toContain("(1)");
  291. // Find and click the circuit broken toggle
  292. const toggle = container.querySelector("#circuit-broken-filter");
  293. expect(toggle).not.toBeNull();
  294. act(() => {
  295. toggle!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  296. });
  297. // After activating the filter, only Provider B should be shown
  298. const listItems = container.querySelectorAll("[data-testid^='provider-']");
  299. const providerNames = Array.from(listItems).map((el) => el.textContent);
  300. expect(providerNames).toContain("Provider B");
  301. expect(providerNames).not.toContain("Provider A");
  302. expect(providerNames).not.toContain("Provider C");
  303. unmount();
  304. });
  305. test("shows zero circuit broken count when no circuits are open", () => {
  306. const healthStatus = {};
  307. const { unmount, container } = renderWithProviders(
  308. <ProviderManager
  309. providers={providers}
  310. healthStatus={healthStatus}
  311. enableMultiProviderTypes={true}
  312. />
  313. );
  314. // When count is 0, the circuit broken section should NOT be rendered
  315. const toggleDesktop = container.querySelector("#circuit-broken-filter");
  316. expect(toggleDesktop).toBeNull();
  317. unmount();
  318. });
  319. test("endpointCircuitInfo defaults to empty when not provided", () => {
  320. const healthStatus = {};
  321. const { unmount, container } = renderWithProviders(
  322. <ProviderManager
  323. providers={providers}
  324. healthStatus={healthStatus}
  325. enableMultiProviderTypes={true}
  326. />
  327. );
  328. // No circuit broken UI should appear
  329. const toggleDesktop = container.querySelector("#circuit-broken-filter");
  330. expect(toggleDesktop).toBeNull();
  331. unmount();
  332. });
  333. });
  334. describe("ProviderManager layered circuit labels", () => {
  335. const providers = [
  336. makeProvider({ id: 1, name: "Provider Key Broken" }),
  337. makeProvider({ id: 2, name: "Provider Endpoint Broken" }),
  338. makeProvider({ id: 3, name: "Provider Both Broken" }),
  339. ];
  340. test("counts all providers with any circuit open for layered labels", () => {
  341. const healthStatus = {
  342. 1: {
  343. circuitState: "open" as const,
  344. failureCount: 5,
  345. lastFailureTime: Date.now(),
  346. circuitOpenUntil: Date.now() + 60000,
  347. recoveryMinutes: 1,
  348. },
  349. 3: {
  350. circuitState: "open" as const,
  351. failureCount: 3,
  352. lastFailureTime: Date.now(),
  353. circuitOpenUntil: Date.now() + 30000,
  354. recoveryMinutes: 0.5,
  355. },
  356. };
  357. const endpointCircuitInfo = {
  358. 2: [
  359. {
  360. endpointId: 20,
  361. circuitState: "open" as const,
  362. failureCount: 2,
  363. circuitOpenUntil: Date.now() + 60000,
  364. },
  365. ],
  366. 3: [
  367. {
  368. endpointId: 30,
  369. circuitState: "open" as const,
  370. failureCount: 4,
  371. circuitOpenUntil: Date.now() + 60000,
  372. },
  373. ],
  374. };
  375. const { unmount, container } = renderWithProviders(
  376. <ProviderManager
  377. providers={providers}
  378. healthStatus={healthStatus}
  379. endpointCircuitInfo={endpointCircuitInfo}
  380. enableMultiProviderTypes={true}
  381. />
  382. );
  383. // The circuit broken count should be 3 (all three providers have some form of circuit open)
  384. const text = container.textContent || "";
  385. expect(text).toContain("(3)");
  386. unmount();
  387. });
  388. });