options-section.test.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. /** @vitest-environment happy-dom */
  2. const mockDispatch = vi.fn();
  3. const mockUseProviderForm = vi.fn();
  4. vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
  5. vi.mock("framer-motion", () => ({
  6. motion: { div: ({ children, ...rest }: any) => <div {...rest}>{children}</div> },
  7. }));
  8. vi.mock("lucide-react", () => {
  9. const stub = ({ className }: any) => <span data-testid="icon" className={className} />;
  10. return { Clock: stub, Info: stub, Settings: stub, Timer: stub };
  11. });
  12. vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
  13. vi.mock(
  14. "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context",
  15. () => ({
  16. useProviderForm: (...args: any[]) => mockUseProviderForm(...args),
  17. })
  18. );
  19. vi.mock("@/app/[locale]/settings/providers/_components/adaptive-thinking-editor", () => ({
  20. AdaptiveThinkingEditor: (props: any) => <div data-testid="adaptive-thinking-editor" />,
  21. }));
  22. vi.mock("@/app/[locale]/settings/providers/_components/thinking-budget-editor", () => ({
  23. ThinkingBudgetEditor: (props: any) => <div data-testid="thinking-budget-editor" />,
  24. }));
  25. vi.mock("@/components/ui/badge", () => ({
  26. Badge: ({ children, className }: any) => <span className={className}>{children}</span>,
  27. }));
  28. vi.mock("@/components/ui/input", () => ({
  29. Input: (props: any) => <input {...props} />,
  30. }));
  31. vi.mock("@/components/ui/select", () => ({
  32. Select: ({ children }: any) => <div>{children}</div>,
  33. SelectContent: ({ children }: any) => <div>{children}</div>,
  34. SelectItem: ({ children, value }: any) => <div data-value={value}>{children}</div>,
  35. SelectTrigger: ({ children, className }: any) => <div className={className}>{children}</div>,
  36. SelectValue: ({ placeholder }: any) => <span>{placeholder}</span>,
  37. }));
  38. vi.mock("@/components/ui/switch", () => ({
  39. Switch: ({ id, checked, onCheckedChange, disabled }: any) => (
  40. <button
  41. id={id}
  42. type="button"
  43. role="switch"
  44. aria-checked={checked}
  45. disabled={disabled}
  46. data-testid="switch"
  47. onClick={() => onCheckedChange(!checked)}
  48. />
  49. ),
  50. }));
  51. vi.mock("@/components/ui/tooltip", () => ({
  52. TooltipProvider: ({ children }: any) => <>{children}</>,
  53. Tooltip: ({ children }: any) => <>{children}</>,
  54. TooltipTrigger: ({ children }: any) => <>{children}</>,
  55. TooltipContent: ({ children }: any) => <>{children}</>,
  56. }));
  57. import type React from "react";
  58. import { act } from "react";
  59. import { createRoot } from "react-dom/client";
  60. import { OptionsSection } from "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/options-section";
  61. import type { ProviderFormState } from "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types";
  62. function render(node: React.ReactNode) {
  63. const container = document.createElement("div");
  64. document.body.appendChild(container);
  65. const root = createRoot(container);
  66. act(() => {
  67. root.render(node);
  68. });
  69. return {
  70. container,
  71. unmount: () => {
  72. act(() => root.unmount());
  73. container.remove();
  74. },
  75. };
  76. }
  77. function createMockState(
  78. overrides: {
  79. routing?: Partial<ProviderFormState["routing"]>;
  80. ui?: Partial<ProviderFormState["ui"]>;
  81. } = {}
  82. ): ProviderFormState {
  83. return {
  84. basic: {
  85. name: "",
  86. url: "",
  87. key: "",
  88. websiteUrl: "",
  89. },
  90. routing: {
  91. providerType: "claude",
  92. groupTag: [],
  93. preserveClientIp: false,
  94. disableSessionReuse: false,
  95. modelRedirects: {},
  96. allowedModels: [],
  97. allowedClients: [],
  98. blockedClients: [],
  99. priority: 0,
  100. groupPriorities: {},
  101. weight: 1,
  102. costMultiplier: 1,
  103. cacheTtlPreference: "inherit",
  104. swapCacheTtlBilling: false,
  105. codexReasoningEffortPreference: "inherit",
  106. codexReasoningSummaryPreference: "inherit",
  107. codexTextVerbosityPreference: "inherit",
  108. codexParallelToolCallsPreference: "inherit",
  109. codexServiceTierPreference: "inherit",
  110. anthropicMaxTokensPreference: "inherit",
  111. anthropicThinkingBudgetPreference: "inherit",
  112. anthropicAdaptiveThinking: null,
  113. geminiGoogleSearchPreference: "inherit",
  114. activeTimeStart: null,
  115. activeTimeEnd: null,
  116. ...overrides.routing,
  117. },
  118. rateLimit: {
  119. limit5hUsd: null,
  120. limitDailyUsd: null,
  121. dailyResetMode: "fixed",
  122. dailyResetTime: "00:00",
  123. limitWeeklyUsd: null,
  124. limitMonthlyUsd: null,
  125. limitTotalUsd: null,
  126. limitConcurrentSessions: null,
  127. },
  128. circuitBreaker: {
  129. failureThreshold: undefined,
  130. openDurationMinutes: undefined,
  131. halfOpenSuccessThreshold: undefined,
  132. maxRetryAttempts: null,
  133. },
  134. network: {
  135. proxyUrl: "",
  136. proxyFallbackToDirect: false,
  137. firstByteTimeoutStreamingSeconds: undefined,
  138. streamingIdleTimeoutSeconds: undefined,
  139. requestTimeoutNonStreamingSeconds: undefined,
  140. },
  141. mcp: {
  142. mcpPassthroughType: "none",
  143. mcpPassthroughUrl: "",
  144. },
  145. batch: {
  146. isEnabled: "no_change",
  147. },
  148. ui: {
  149. activeTab: "basic",
  150. activeSubTab: null,
  151. isPending: false,
  152. showFailureThresholdConfirm: false,
  153. ...overrides.ui,
  154. },
  155. };
  156. }
  157. function setMockForm({
  158. state = createMockState(),
  159. mode = "create",
  160. }: {
  161. state?: ProviderFormState;
  162. mode?: "create" | "edit" | "batch";
  163. } = {}) {
  164. mockUseProviderForm.mockReturnValue({
  165. state,
  166. dispatch: mockDispatch,
  167. mode,
  168. enableMultiProviderTypes: true,
  169. hideUrl: false,
  170. hideWebsiteUrl: false,
  171. groupSuggestions: [],
  172. dirtyFields: new Set(),
  173. });
  174. }
  175. function renderSection({
  176. state = createMockState(),
  177. mode = "create",
  178. }: {
  179. state?: ProviderFormState;
  180. mode?: "create" | "edit" | "batch";
  181. } = {}) {
  182. setMockForm({ state, mode });
  183. return render(<OptionsSection />);
  184. }
  185. function getBodyText() {
  186. return document.body.textContent || "";
  187. }
  188. function getActiveTimeToggle(container: HTMLDivElement) {
  189. return container.querySelector("#active-time-toggle") as HTMLButtonElement | null;
  190. }
  191. describe("OptionsSection", () => {
  192. beforeEach(() => {
  193. while (document.body.firstChild) {
  194. document.body.removeChild(document.body.firstChild);
  195. }
  196. vi.clearAllMocks();
  197. setMockForm();
  198. });
  199. describe("common section rendering", () => {
  200. it("renders Advanced Settings section", () => {
  201. const { unmount } = renderSection();
  202. expect(getBodyText()).toContain("sections.routing.options.title");
  203. unmount();
  204. });
  205. it("renders preserveClientIp toggle", () => {
  206. const { unmount } = renderSection();
  207. expect(document.getElementById("preserve-client-ip")).toBeTruthy();
  208. unmount();
  209. });
  210. it("renders swapCacheTtlBilling toggle", () => {
  211. const { unmount } = renderSection();
  212. expect(document.getElementById("swap-cache-ttl-billing")).toBeTruthy();
  213. unmount();
  214. });
  215. it("renders disableSessionReuse toggle", () => {
  216. const { unmount } = renderSection();
  217. expect(document.getElementById("disable-session-reuse")).toBeTruthy();
  218. unmount();
  219. });
  220. it("renders active time section", () => {
  221. const { unmount } = renderSection();
  222. expect(getBodyText()).toContain("sections.routing.activeTime.title");
  223. unmount();
  224. });
  225. });
  226. describe("conditional rendering - claude provider", () => {
  227. it("shows Anthropic overrides for claude type", () => {
  228. const { unmount } = renderSection({
  229. state: createMockState({ routing: { providerType: "claude" } }),
  230. });
  231. expect(getBodyText()).toContain("sections.routing.anthropicOverrides.maxTokens.label");
  232. unmount();
  233. });
  234. it("hides Codex overrides for claude type", () => {
  235. const { unmount } = renderSection({
  236. state: createMockState({ routing: { providerType: "claude" } }),
  237. });
  238. expect(getBodyText()).not.toContain("sections.routing.codexOverrides.title");
  239. unmount();
  240. });
  241. it("hides Gemini overrides for claude type", () => {
  242. const { unmount } = renderSection({
  243. state: createMockState({ routing: { providerType: "claude" } }),
  244. });
  245. expect(getBodyText()).not.toContain("sections.routing.geminiOverrides.title");
  246. unmount();
  247. });
  248. });
  249. describe("conditional rendering - codex provider", () => {
  250. it("shows Codex overrides for codex type", () => {
  251. const { unmount } = renderSection({
  252. state: createMockState({ routing: { providerType: "codex" } }),
  253. });
  254. expect(getBodyText()).toContain("sections.routing.codexOverrides.title");
  255. unmount();
  256. });
  257. it("hides Anthropic overrides for codex type", () => {
  258. const { unmount } = renderSection({
  259. state: createMockState({ routing: { providerType: "codex" } }),
  260. });
  261. expect(getBodyText()).not.toContain("sections.routing.anthropicOverrides.maxTokens.label");
  262. unmount();
  263. });
  264. });
  265. describe("conditional rendering - gemini provider", () => {
  266. it("shows Gemini overrides for gemini type", () => {
  267. const { unmount } = renderSection({
  268. state: createMockState({ routing: { providerType: "gemini" } }),
  269. });
  270. expect(getBodyText()).toContain("sections.routing.geminiOverrides.title");
  271. unmount();
  272. });
  273. it("hides Codex overrides for gemini type", () => {
  274. const { unmount } = renderSection({
  275. state: createMockState({ routing: { providerType: "gemini" } }),
  276. });
  277. expect(getBodyText()).not.toContain("sections.routing.codexOverrides.title");
  278. unmount();
  279. });
  280. it("hides Anthropic overrides for gemini type", () => {
  281. const { unmount } = renderSection({
  282. state: createMockState({ routing: { providerType: "gemini" } }),
  283. });
  284. expect(getBodyText()).not.toContain("sections.routing.anthropicOverrides.maxTokens.label");
  285. unmount();
  286. });
  287. });
  288. describe("conditional rendering - batch mode", () => {
  289. it("shows all override sections in batch mode", () => {
  290. const { unmount } = renderSection({ mode: "batch" });
  291. expect(getBodyText()).toContain("sections.routing.codexOverrides.title");
  292. expect(getBodyText()).toContain("sections.routing.anthropicOverrides.maxTokens.label");
  293. expect(getBodyText()).toContain("sections.routing.geminiOverrides.title");
  294. unmount();
  295. });
  296. });
  297. describe("dispatch actions", () => {
  298. it("dispatches SET_PRESERVE_CLIENT_IP on toggle", () => {
  299. const { unmount } = renderSection();
  300. const toggle = document.getElementById("preserve-client-ip") as HTMLButtonElement;
  301. act(() => {
  302. toggle.click();
  303. });
  304. expect(mockDispatch).toHaveBeenCalledWith({
  305. type: "SET_PRESERVE_CLIENT_IP",
  306. payload: true,
  307. });
  308. unmount();
  309. });
  310. it("dispatches SET_SWAP_CACHE_TTL_BILLING on toggle", () => {
  311. const { unmount } = renderSection();
  312. const toggle = document.getElementById("swap-cache-ttl-billing") as HTMLButtonElement;
  313. act(() => {
  314. toggle.click();
  315. });
  316. expect(mockDispatch).toHaveBeenCalledWith({
  317. type: "SET_SWAP_CACHE_TTL_BILLING",
  318. payload: true,
  319. });
  320. unmount();
  321. });
  322. it("dispatches SET_DISABLE_SESSION_REUSE on toggle", () => {
  323. const { unmount } = renderSection();
  324. const toggle = document.getElementById("disable-session-reuse") as HTMLButtonElement;
  325. act(() => {
  326. toggle.click();
  327. });
  328. expect(mockDispatch).toHaveBeenCalledWith({
  329. type: "SET_DISABLE_SESSION_REUSE",
  330. payload: true,
  331. });
  332. unmount();
  333. });
  334. it("dispatches active time start/end when enabling", () => {
  335. const { container, unmount } = renderSection();
  336. const toggle = getActiveTimeToggle(container);
  337. act(() => {
  338. toggle?.click();
  339. });
  340. expect(mockDispatch).toHaveBeenCalledWith({
  341. type: "SET_ACTIVE_TIME_START",
  342. payload: "09:00",
  343. });
  344. expect(mockDispatch).toHaveBeenCalledWith({
  345. type: "SET_ACTIVE_TIME_END",
  346. payload: "22:00",
  347. });
  348. unmount();
  349. });
  350. it("dispatches null when disabling active time", () => {
  351. const { container, unmount } = renderSection({
  352. state: createMockState({
  353. routing: {
  354. activeTimeStart: "09:00",
  355. activeTimeEnd: "22:00",
  356. },
  357. }),
  358. });
  359. const toggle = getActiveTimeToggle(container);
  360. act(() => {
  361. toggle?.click();
  362. });
  363. expect(mockDispatch).toHaveBeenCalledWith({
  364. type: "SET_ACTIVE_TIME_START",
  365. payload: null,
  366. });
  367. expect(mockDispatch).toHaveBeenCalledWith({
  368. type: "SET_ACTIVE_TIME_END",
  369. payload: null,
  370. });
  371. unmount();
  372. });
  373. });
  374. describe("active time UI", () => {
  375. it("shows time inputs when active time enabled", () => {
  376. const { container, unmount } = renderSection({
  377. state: createMockState({
  378. routing: {
  379. activeTimeStart: "09:00",
  380. activeTimeEnd: "22:00",
  381. },
  382. }),
  383. });
  384. expect(container.querySelectorAll('input[type="time"]')).toHaveLength(2);
  385. unmount();
  386. });
  387. it("hides time inputs when active time disabled", () => {
  388. const { container, unmount } = renderSection({
  389. state: createMockState({
  390. routing: {
  391. activeTimeStart: null,
  392. activeTimeEnd: null,
  393. },
  394. }),
  395. });
  396. expect(container.querySelectorAll('input[type="time"]')).toHaveLength(0);
  397. unmount();
  398. });
  399. it("shows cross-day hint when start > end", () => {
  400. const { unmount } = renderSection({
  401. state: createMockState({
  402. routing: {
  403. activeTimeStart: "22:00",
  404. activeTimeEnd: "06:00",
  405. },
  406. }),
  407. });
  408. expect(getBodyText()).toContain("sections.routing.activeTime.crossDayHint");
  409. unmount();
  410. });
  411. });
  412. describe("disabled state", () => {
  413. it("disables switches when isPending", () => {
  414. const { container, unmount } = renderSection({
  415. state: createMockState({
  416. ui: {
  417. isPending: true,
  418. },
  419. }),
  420. });
  421. const switches = Array.from(
  422. container.querySelectorAll('[data-testid="switch"]')
  423. ) as HTMLButtonElement[];
  424. expect(switches).toHaveLength(4);
  425. for (const toggle of switches) {
  426. expect(toggle.hasAttribute("disabled")).toBe(true);
  427. }
  428. unmount();
  429. });
  430. });
  431. describe("edit mode", () => {
  432. it("uses edit- prefixed IDs in edit mode", () => {
  433. const { unmount } = renderSection({ mode: "edit" });
  434. expect(document.getElementById("edit-preserve-client-ip")).toBeTruthy();
  435. unmount();
  436. });
  437. });
  438. describe("batch mode badges", () => {
  439. it("shows codex-only badge in batch mode", () => {
  440. const { unmount } = renderSection({
  441. mode: "batch",
  442. state: createMockState({ routing: { providerType: "codex" } }),
  443. });
  444. expect(getBodyText()).toContain("batchNotes.codexOnly");
  445. unmount();
  446. });
  447. });
  448. });