provider-batch-dialog-step1.test.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. /**
  2. * @vitest-environment happy-dom
  3. */
  4. import { act } from "react";
  5. import { createRoot } from "react-dom/client";
  6. import { beforeEach, describe, expect, it, vi } from "vitest";
  7. import { ProviderBatchDialog } from "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog";
  8. import type { ProviderDisplay } from "@/types/provider";
  9. // ---------------------------------------------------------------------------
  10. // Mutable mock state for useProviderForm
  11. // ---------------------------------------------------------------------------
  12. let mockDirtyFields = new Set<string>();
  13. const mockDispatch = vi.fn();
  14. let mockActiveTab = "basic";
  15. const mockState = {
  16. ui: { activeTab: mockActiveTab, isPending: false, showFailureThresholdConfirm: false },
  17. basic: { name: "", url: "", key: "", websiteUrl: "" },
  18. routing: {
  19. providerType: "claude" as const,
  20. groupTag: [],
  21. preserveClientIp: false,
  22. modelRedirects: {},
  23. allowedModels: [],
  24. priority: 0,
  25. groupPriorities: {},
  26. weight: 1,
  27. costMultiplier: 1,
  28. cacheTtlPreference: "inherit" as const,
  29. swapCacheTtlBilling: false,
  30. context1mPreference: "inherit" as const,
  31. codexReasoningEffortPreference: "inherit",
  32. codexReasoningSummaryPreference: "inherit",
  33. codexTextVerbosityPreference: "inherit",
  34. codexParallelToolCallsPreference: "inherit",
  35. anthropicMaxTokensPreference: "inherit",
  36. anthropicThinkingBudgetPreference: "inherit",
  37. anthropicAdaptiveThinking: null,
  38. geminiGoogleSearchPreference: "inherit",
  39. },
  40. rateLimit: {
  41. limit5hUsd: null,
  42. limitDailyUsd: null,
  43. dailyResetMode: "fixed" as const,
  44. dailyResetTime: "00:00",
  45. limitWeeklyUsd: null,
  46. limitMonthlyUsd: null,
  47. limitTotalUsd: null,
  48. limitConcurrentSessions: null,
  49. },
  50. circuitBreaker: {
  51. failureThreshold: undefined,
  52. openDurationMinutes: undefined,
  53. halfOpenSuccessThreshold: undefined,
  54. maxRetryAttempts: null,
  55. },
  56. network: {
  57. proxyUrl: "",
  58. proxyFallbackToDirect: false,
  59. firstByteTimeoutStreamingSeconds: undefined,
  60. streamingIdleTimeoutSeconds: undefined,
  61. requestTimeoutNonStreamingSeconds: undefined,
  62. },
  63. mcp: { mcpPassthroughType: "none" as const, mcpPassthroughUrl: "" },
  64. batch: { isEnabled: "no_change" as const },
  65. };
  66. // ---------------------------------------------------------------------------
  67. // Mocks
  68. // ---------------------------------------------------------------------------
  69. vi.mock("next-intl", () => ({
  70. useTranslations: () => {
  71. const t = (key: string, params?: Record<string, unknown>) => {
  72. if (params) {
  73. let result = key;
  74. for (const [k, v] of Object.entries(params)) {
  75. result = result.replace(`{${k}}`, String(v));
  76. }
  77. return result;
  78. }
  79. return key;
  80. };
  81. return t;
  82. },
  83. }));
  84. vi.mock("@tanstack/react-query", () => ({
  85. useQueryClient: () => ({
  86. invalidateQueries: vi.fn().mockResolvedValue(undefined),
  87. }),
  88. }));
  89. vi.mock("sonner", () => ({
  90. toast: {
  91. success: vi.fn(),
  92. error: vi.fn(),
  93. },
  94. }));
  95. vi.mock("@/actions/providers", () => ({
  96. previewProviderBatchPatch: vi.fn().mockResolvedValue({
  97. ok: true,
  98. data: {
  99. previewToken: "tok-1",
  100. previewRevision: "rev-1",
  101. rows: [],
  102. summary: { providerCount: 0, fieldCount: 0, skipCount: 0 },
  103. },
  104. }),
  105. applyProviderBatchPatch: vi.fn().mockResolvedValue({ ok: true, data: { updatedCount: 2 } }),
  106. undoProviderPatch: vi.fn().mockResolvedValue({ ok: true, data: { revertedCount: 2 } }),
  107. batchDeleteProviders: vi.fn().mockResolvedValue({ ok: true, data: { deletedCount: 2 } }),
  108. batchResetProviderCircuits: vi.fn().mockResolvedValue({ ok: true, data: { resetCount: 2 } }),
  109. }));
  110. // Mock ProviderFormProvider + useProviderForm
  111. vi.mock(
  112. "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context",
  113. () => ({
  114. ProviderFormProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
  115. useProviderForm: () => ({
  116. state: mockState,
  117. dispatch: mockDispatch,
  118. dirtyFields: mockDirtyFields,
  119. mode: "batch",
  120. }),
  121. })
  122. );
  123. // Mock all form section components as stubs
  124. vi.mock(
  125. "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section",
  126. () => ({
  127. BasicInfoSection: () => <div data-testid="basic-info-section">BasicInfoSection</div>,
  128. })
  129. );
  130. vi.mock(
  131. "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section",
  132. () => ({
  133. RoutingSection: () => <div data-testid="routing-section">RoutingSection</div>,
  134. })
  135. );
  136. vi.mock(
  137. "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section",
  138. () => ({
  139. LimitsSection: () => <div data-testid="limits-section">LimitsSection</div>,
  140. })
  141. );
  142. vi.mock(
  143. "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section",
  144. () => ({
  145. NetworkSection: () => <div data-testid="network-section">NetworkSection</div>,
  146. })
  147. );
  148. vi.mock(
  149. "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section",
  150. () => ({
  151. TestingSection: () => <div data-testid="testing-section">TestingSection</div>,
  152. })
  153. );
  154. // Mock FormTabNav
  155. vi.mock(
  156. "@/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav",
  157. () => ({
  158. FormTabNav: ({ activeTab }: { activeTab: string }) => (
  159. <div data-testid="form-tab-nav" data-active-tab={activeTab}>
  160. FormTabNav
  161. </div>
  162. ),
  163. })
  164. );
  165. // Mock ProviderBatchPreviewStep
  166. vi.mock(
  167. "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step",
  168. () => ({
  169. ProviderBatchPreviewStep: () => <div data-testid="preview-step">PreviewStep</div>,
  170. })
  171. );
  172. // Mock buildPatchDraftFromFormState
  173. vi.mock("@/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft", () => ({
  174. buildPatchDraftFromFormState: vi.fn().mockReturnValue({}),
  175. }));
  176. // UI component mocks
  177. vi.mock("@/components/ui/dialog", () => ({
  178. Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
  179. open ? <div data-testid="dialog">{children}</div> : null,
  180. DialogContent: ({ children }: { children: React.ReactNode }) => (
  181. <div data-testid="dialog-content">{children}</div>
  182. ),
  183. DialogDescription: ({ children }: { children: React.ReactNode }) => (
  184. <div data-testid="dialog-description">{children}</div>
  185. ),
  186. DialogFooter: ({ children }: { children: React.ReactNode }) => (
  187. <div data-testid="dialog-footer">{children}</div>
  188. ),
  189. DialogHeader: ({ children }: { children: React.ReactNode }) => (
  190. <div data-testid="dialog-header">{children}</div>
  191. ),
  192. DialogTitle: ({ children }: { children: React.ReactNode }) => (
  193. <div data-testid="dialog-title">{children}</div>
  194. ),
  195. }));
  196. vi.mock("@/components/ui/alert-dialog", () => ({
  197. AlertDialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
  198. open ? <div data-testid="alert-dialog">{children}</div> : null,
  199. AlertDialogAction: ({ children, ...props }: any) => <button {...props}>{children}</button>,
  200. AlertDialogCancel: ({ children, ...props }: any) => <button {...props}>{children}</button>,
  201. AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
  202. AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
  203. AlertDialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
  204. AlertDialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
  205. AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
  206. }));
  207. vi.mock("@/components/ui/button", () => ({
  208. Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
  209. }));
  210. vi.mock("lucide-react", () => ({
  211. Loader2: () => <div data-testid="loader-icon" />,
  212. }));
  213. // ---------------------------------------------------------------------------
  214. // Helpers
  215. // ---------------------------------------------------------------------------
  216. function createMockProvider(id: number, name: string, maskedKey: string): ProviderDisplay {
  217. return {
  218. id,
  219. name,
  220. url: "https://api.example.com",
  221. maskedKey,
  222. isEnabled: true,
  223. weight: 1,
  224. priority: 0,
  225. groupPriorities: null,
  226. costMultiplier: 1,
  227. groupTag: null,
  228. providerType: "claude",
  229. providerVendorId: null,
  230. preserveClientIp: false,
  231. modelRedirects: null,
  232. allowedModels: null,
  233. mcpPassthroughType: "none",
  234. mcpPassthroughUrl: null,
  235. limit5hUsd: null,
  236. limitDailyUsd: null,
  237. dailyResetMode: "fixed",
  238. dailyResetTime: "00:00",
  239. limitWeeklyUsd: null,
  240. limitMonthlyUsd: null,
  241. limitTotalUsd: null,
  242. limitConcurrentSessions: 10,
  243. maxRetryAttempts: null,
  244. circuitBreakerFailureThreshold: 5,
  245. circuitBreakerOpenDuration: 30000,
  246. circuitBreakerHalfOpenSuccessThreshold: 2,
  247. proxyUrl: null,
  248. proxyFallbackToDirect: false,
  249. firstByteTimeoutStreamingMs: 30000,
  250. streamingIdleTimeoutMs: 120000,
  251. requestTimeoutNonStreamingMs: 120000,
  252. websiteUrl: null,
  253. faviconUrl: null,
  254. cacheTtlPreference: null,
  255. swapCacheTtlBilling: false,
  256. context1mPreference: null,
  257. codexReasoningEffortPreference: null,
  258. codexReasoningSummaryPreference: null,
  259. codexTextVerbosityPreference: null,
  260. codexParallelToolCallsPreference: null,
  261. anthropicMaxTokensPreference: null,
  262. anthropicThinkingBudgetPreference: null,
  263. anthropicAdaptiveThinking: null,
  264. geminiGoogleSearchPreference: null,
  265. tpm: null,
  266. rpm: null,
  267. rpd: null,
  268. cc: null,
  269. createdAt: "2024-01-01T00:00:00Z",
  270. updatedAt: "2024-01-01T00:00:00Z",
  271. };
  272. }
  273. function render(node: React.ReactNode) {
  274. const container = document.createElement("div");
  275. document.body.appendChild(container);
  276. let root: ReturnType<typeof createRoot>;
  277. act(() => {
  278. root = createRoot(container);
  279. root.render(node);
  280. });
  281. return {
  282. container,
  283. unmount: () => {
  284. act(() => root.unmount());
  285. container.remove();
  286. },
  287. };
  288. }
  289. // ---------------------------------------------------------------------------
  290. // Fixtures
  291. // ---------------------------------------------------------------------------
  292. const twoProviders = [
  293. createMockProvider(1, "Provider1", "aaaa****1111"),
  294. createMockProvider(2, "Provider2", "bbbb****2222"),
  295. ];
  296. const eightProviders = Array.from({ length: 8 }, (_, i) =>
  297. createMockProvider(i + 1, `Provider${i + 1}`, `key${i + 1}****tail${i + 1}`)
  298. );
  299. function defaultProps(overrides: Record<string, unknown> = {}) {
  300. return {
  301. open: true,
  302. mode: "edit" as const,
  303. onOpenChange: vi.fn(),
  304. selectedProviderIds: new Set([1, 2]),
  305. providers: twoProviders,
  306. onSuccess: vi.fn(),
  307. ...overrides,
  308. };
  309. }
  310. // ---------------------------------------------------------------------------
  311. // Tests
  312. // ---------------------------------------------------------------------------
  313. describe("ProviderBatchDialog - Edit Mode Structure", () => {
  314. beforeEach(() => {
  315. vi.clearAllMocks();
  316. mockDirtyFields = new Set<string>();
  317. mockActiveTab = "basic";
  318. mockState.ui.activeTab = "basic";
  319. });
  320. it("renders edit mode with FormTabNav and basic section", () => {
  321. const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
  322. expect(container.querySelector('[data-testid="dialog"]')).toBeTruthy();
  323. expect(container.querySelector('[data-testid="form-tab-nav"]')).toBeTruthy();
  324. expect(container.querySelector('[data-testid="basic-info-section"]')).toBeTruthy();
  325. unmount();
  326. });
  327. it("renders dialog title and description in edit step", () => {
  328. const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
  329. const titleEl = container.querySelector('[data-testid="dialog-title"]');
  330. expect(titleEl?.textContent).toContain("dialog.editTitle");
  331. const descEl = container.querySelector('[data-testid="dialog-description"]');
  332. expect(descEl?.textContent).toContain("dialog.editDesc");
  333. unmount();
  334. });
  335. it("next button is disabled when no dirty fields", () => {
  336. const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
  337. const footer = container.querySelector('[data-testid="dialog-footer"]');
  338. const buttons = footer?.querySelectorAll("button") ?? [];
  339. // Second button in footer is "Next" (first is "Cancel")
  340. const nextButton = buttons[1] as HTMLButtonElement;
  341. expect(nextButton).toBeTruthy();
  342. expect(nextButton.disabled).toBe(true);
  343. unmount();
  344. });
  345. it("next button is enabled when dirty fields exist", () => {
  346. mockDirtyFields = new Set(["routing.priority"]);
  347. const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
  348. const footer = container.querySelector('[data-testid="dialog-footer"]');
  349. const buttons = footer?.querySelectorAll("button") ?? [];
  350. const nextButton = buttons[1] as HTMLButtonElement;
  351. expect(nextButton).toBeTruthy();
  352. expect(nextButton.disabled).toBe(false);
  353. unmount();
  354. });
  355. it("cancel button calls onOpenChange(false)", () => {
  356. const onOpenChange = vi.fn();
  357. const { container, unmount } = render(
  358. <ProviderBatchDialog {...defaultProps({ onOpenChange })} />
  359. );
  360. const footer = container.querySelector('[data-testid="dialog-footer"]');
  361. const buttons = footer?.querySelectorAll("button") ?? [];
  362. const cancelButton = buttons[0] as HTMLButtonElement;
  363. act(() => {
  364. cancelButton.click();
  365. });
  366. expect(onOpenChange).toHaveBeenCalledWith(false);
  367. unmount();
  368. });
  369. it("next button calls preview when dirty fields exist", async () => {
  370. mockDirtyFields = new Set(["routing.priority"]);
  371. const { previewProviderBatchPatch } = await import("@/actions/providers");
  372. const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
  373. const footer = container.querySelector('[data-testid="dialog-footer"]');
  374. const nextButton = (footer?.querySelectorAll("button") ?? [])[1] as HTMLButtonElement;
  375. await act(async () => {
  376. nextButton.click();
  377. });
  378. await act(async () => {
  379. await new Promise((r) => setTimeout(r, 10));
  380. });
  381. expect(previewProviderBatchPatch).toHaveBeenCalledTimes(1);
  382. unmount();
  383. });
  384. });
  385. describe("ProviderBatchDialog - Delete Mode", () => {
  386. it("renders AlertDialog for delete mode", () => {
  387. const { container, unmount } = render(
  388. <ProviderBatchDialog {...defaultProps({ mode: "delete" })} />
  389. );
  390. expect(container.querySelector('[data-testid="alert-dialog"]')).toBeTruthy();
  391. expect(container.querySelector('[data-testid="dialog"]')).toBeFalsy();
  392. const text = container.textContent ?? "";
  393. expect(text).toContain("dialog.deleteTitle");
  394. unmount();
  395. });
  396. });
  397. describe("ProviderBatchDialog - Reset Circuit Mode", () => {
  398. it("renders AlertDialog for resetCircuit mode", () => {
  399. const { container, unmount } = render(
  400. <ProviderBatchDialog {...defaultProps({ mode: "resetCircuit" })} />
  401. );
  402. expect(container.querySelector('[data-testid="alert-dialog"]')).toBeTruthy();
  403. expect(container.querySelector('[data-testid="dialog"]')).toBeFalsy();
  404. const text = container.textContent ?? "";
  405. expect(text).toContain("dialog.resetCircuitTitle");
  406. unmount();
  407. });
  408. });
  409. describe("ProviderBatchDialog - Closed State", () => {
  410. it("renders nothing when open is false", () => {
  411. const { container, unmount } = render(
  412. <ProviderBatchDialog {...defaultProps({ open: false })} />
  413. );
  414. expect(container.querySelector('[data-testid="dialog"]')).toBeFalsy();
  415. expect(container.querySelector('[data-testid="alert-dialog"]')).toBeFalsy();
  416. unmount();
  417. });
  418. });