system-settings-form-ip-extraction.test.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import fs from "node:fs";
  2. import path from "node:path";
  3. import type { ReactNode } from "react";
  4. import { act } from "react";
  5. import { createRoot } from "react-dom/client";
  6. import { NextIntlClientProvider } from "next-intl";
  7. import { beforeEach, describe, expect, test, vi } from "vitest";
  8. import { SystemSettingsForm } from "@/app/[locale]/settings/config/_components/system-settings-form";
  9. import { DEFAULT_IP_EXTRACTION_CONFIG } from "@/types/ip-extraction";
  10. import type { SystemSettings } from "@/types/system-config";
  11. vi.mock("next/navigation", () => ({
  12. useRouter: () => ({ refresh: vi.fn() }),
  13. }));
  14. const systemConfigActionMocks = vi.hoisted(() => ({
  15. saveSystemSettings: vi.fn(async () => ({ ok: true })),
  16. }));
  17. vi.mock("@/actions/system-config", () => systemConfigActionMocks);
  18. const sonnerMocks = vi.hoisted(() => ({
  19. toast: {
  20. success: vi.fn(),
  21. error: vi.fn(),
  22. },
  23. }));
  24. vi.mock("sonner", () => sonnerMocks);
  25. function loadMessages() {
  26. const base = path.join(process.cwd(), "messages/en/settings");
  27. const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8"));
  28. return {
  29. settings: {
  30. common: read("common.json"),
  31. config: read("config.json"),
  32. },
  33. };
  34. }
  35. const baseSettings = {
  36. siteTitle: "Claude Code Hub",
  37. allowGlobalUsageView: true,
  38. currencyDisplay: "USD",
  39. billingModelSource: "redirected",
  40. codexPriorityBillingSource: "requested",
  41. timezone: "UTC",
  42. verboseProviderError: false,
  43. enableHttp2: true,
  44. enableHighConcurrencyMode: false,
  45. interceptAnthropicWarmupRequests: true,
  46. enableThinkingSignatureRectifier: true,
  47. enableBillingHeaderRectifier: true,
  48. enableResponseInputRectifier: true,
  49. enableThinkingBudgetRectifier: true,
  50. enableCodexSessionIdCompletion: true,
  51. enableClaudeMetadataUserIdInjection: true,
  52. enableResponseFixer: true,
  53. responseFixerConfig: {
  54. fixEncoding: true,
  55. fixSseFormat: true,
  56. fixTruncatedJson: true,
  57. },
  58. quotaDbRefreshIntervalSeconds: 10,
  59. quotaLeasePercent5h: 0.05,
  60. quotaLeasePercentDaily: 0.05,
  61. quotaLeasePercentWeekly: 0.05,
  62. quotaLeasePercentMonthly: 0.05,
  63. quotaLeaseCapUsd: null,
  64. ipGeoLookupEnabled: true,
  65. ipExtractionConfig: null,
  66. } satisfies Pick<
  67. SystemSettings,
  68. | "siteTitle"
  69. | "allowGlobalUsageView"
  70. | "currencyDisplay"
  71. | "billingModelSource"
  72. | "codexPriorityBillingSource"
  73. | "timezone"
  74. | "verboseProviderError"
  75. | "enableHttp2"
  76. | "enableHighConcurrencyMode"
  77. | "interceptAnthropicWarmupRequests"
  78. | "enableThinkingSignatureRectifier"
  79. | "enableBillingHeaderRectifier"
  80. | "enableResponseInputRectifier"
  81. | "enableThinkingBudgetRectifier"
  82. | "enableCodexSessionIdCompletion"
  83. | "enableClaudeMetadataUserIdInjection"
  84. | "enableResponseFixer"
  85. | "responseFixerConfig"
  86. | "quotaDbRefreshIntervalSeconds"
  87. | "quotaLeasePercent5h"
  88. | "quotaLeasePercentDaily"
  89. | "quotaLeasePercentWeekly"
  90. | "quotaLeasePercentMonthly"
  91. | "quotaLeaseCapUsd"
  92. | "ipGeoLookupEnabled"
  93. | "ipExtractionConfig"
  94. >;
  95. function buildSettings(
  96. overrides: Partial<Pick<SystemSettings, keyof typeof baseSettings>> = {}
  97. ): Pick<SystemSettings, keyof typeof baseSettings> {
  98. return {
  99. ...baseSettings,
  100. ...overrides,
  101. };
  102. }
  103. function render(node: ReactNode) {
  104. const container = document.createElement("div");
  105. document.body.appendChild(container);
  106. const root = createRoot(container);
  107. act(() => {
  108. root.render(
  109. <NextIntlClientProvider locale="en" messages={loadMessages()} timeZone="UTC">
  110. {node}
  111. </NextIntlClientProvider>
  112. );
  113. });
  114. return {
  115. container,
  116. unmount: () => {
  117. act(() => root.unmount());
  118. container.remove();
  119. },
  120. };
  121. }
  122. function renderForm(settings: Pick<SystemSettings, keyof typeof baseSettings> = baseSettings) {
  123. return render(<SystemSettingsForm initialSettings={settings} />);
  124. }
  125. function getIpExtractionTextarea() {
  126. const textarea = document.getElementById("ip-extraction-config") as HTMLTextAreaElement | null;
  127. if (!textarea) throw new Error("未找到 IP 提取配置输入框");
  128. return textarea;
  129. }
  130. function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
  131. act(() => {
  132. const valueSetter = Object.getOwnPropertyDescriptor(
  133. HTMLTextAreaElement.prototype,
  134. "value"
  135. )?.set;
  136. valueSetter?.call(textarea, value);
  137. textarea.dispatchEvent(new Event("input", { bubbles: true }));
  138. });
  139. }
  140. function clickButtonByText(text: string) {
  141. const button = Array.from(document.body.querySelectorAll("button")).find((element) =>
  142. (element.textContent || "").includes(text)
  143. );
  144. if (!button) throw new Error(`未找到按钮: ${text}`);
  145. act(() => {
  146. button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  147. });
  148. }
  149. async function submitForm() {
  150. const form = document.body.querySelector("form");
  151. if (!form) throw new Error("未找到系统设置表单");
  152. await act(async () => {
  153. form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
  154. await Promise.resolve();
  155. await new Promise((resolve) => setTimeout(resolve, 0));
  156. });
  157. }
  158. describe("SystemSettingsForm IP 提取配置 JSON 输入框", () => {
  159. beforeEach(() => {
  160. document.body.innerHTML = "";
  161. vi.clearAllMocks();
  162. });
  163. test("未配置自定义链时直接显示格式化后的内置默认 JSON", () => {
  164. const { unmount } = renderForm(buildSettings({ ipExtractionConfig: null }));
  165. const textarea = getIpExtractionTextarea();
  166. const formattedDefault = JSON.stringify(DEFAULT_IP_EXTRACTION_CONFIG, null, 2);
  167. expect(textarea.value).toBe(formattedDefault);
  168. expect(textarea.placeholder).toBe(formattedDefault);
  169. unmount();
  170. });
  171. test("恢复默认只把默认 JSON 插入输入框,不会立即保存", () => {
  172. const { unmount } = renderForm(
  173. buildSettings({
  174. ipExtractionConfig: {
  175. headers: [{ name: "cf-connecting-ip" }],
  176. },
  177. })
  178. );
  179. const textarea = getIpExtractionTextarea();
  180. setTextareaValue(textarea, '{"headers":[]}');
  181. clickButtonByText("Reset to default");
  182. expect(textarea.value).toBe(JSON.stringify(DEFAULT_IP_EXTRACTION_CONFIG, null, 2));
  183. expect(systemConfigActionMocks.saveSystemSettings).not.toHaveBeenCalled();
  184. unmount();
  185. });
  186. test("直接保存默认 JSON 时提交默认对象", async () => {
  187. const { unmount } = renderForm(buildSettings({ ipExtractionConfig: null }));
  188. await submitForm();
  189. expect(systemConfigActionMocks.saveSystemSettings).toHaveBeenCalledWith(
  190. expect.objectContaining({
  191. ipExtractionConfig: DEFAULT_IP_EXTRACTION_CONFIG,
  192. })
  193. );
  194. unmount();
  195. });
  196. test("用户显式清空输入框后保存仍提交 null", async () => {
  197. const { unmount } = renderForm(buildSettings({ ipExtractionConfig: null }));
  198. setTextareaValue(getIpExtractionTextarea(), "");
  199. await submitForm();
  200. expect(systemConfigActionMocks.saveSystemSettings).toHaveBeenCalledWith(
  201. expect.objectContaining({
  202. ipExtractionConfig: null,
  203. })
  204. );
  205. unmount();
  206. });
  207. });