allowed-model-rule-editor.test.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. /**
  2. * @vitest-environment happy-dom
  3. */
  4. import type { ReactNode } from "react";
  5. import { act, useState } from "react";
  6. import { createRoot } from "react-dom/client";
  7. import { NextIntlClientProvider } from "next-intl";
  8. import { beforeEach, describe, expect, test, vi } from "vitest";
  9. import { AllowedModelRuleEditor } from "@/app/[locale]/settings/providers/_components/allowed-model-rule-editor";
  10. import type { AllowedModelRule } from "@/types/provider";
  11. import commonMessages from "../../../../messages/en/common.json";
  12. import errorsMessages from "../../../../messages/en/errors.json";
  13. import formsMessages from "../../../../messages/en/forms.json";
  14. import settingsMessages from "../../../../messages/en/settings";
  15. import uiMessages from "../../../../messages/en/ui.json";
  16. const providerActionMocks = vi.hoisted(() => ({
  17. fetchUpstreamModels: vi.fn(async () => ({ ok: false, error: "upstream unavailable" })),
  18. getUnmaskedProviderKey: vi.fn(async () => ({ ok: false })),
  19. }));
  20. vi.mock("@/actions/providers", () => providerActionMocks);
  21. const modelPricesActionMocks = vi.hoisted(() => ({
  22. getAvailableModelCatalog: vi.fn(async () => [
  23. {
  24. modelName: "claude-opus-4-1",
  25. litellmProvider: "anthropic",
  26. updatedAt: "2026-04-05T00:00:00.000Z",
  27. },
  28. {
  29. modelName: "claude-sonnet-4-1",
  30. litellmProvider: "anthropic",
  31. updatedAt: "2026-04-04T00:00:00.000Z",
  32. },
  33. ]),
  34. getAvailableModelsByProviderType: vi.fn(async () => ["claude-opus-4-1", "claude-sonnet-4-1"]),
  35. }));
  36. vi.mock("@/actions/model-prices", () => modelPricesActionMocks);
  37. vi.mock("@/components/ui/popover", async () => {
  38. const React = await import("react");
  39. const PopoverContext = React.createContext<{
  40. open: boolean;
  41. setOpen: (value: boolean) => void;
  42. } | null>(null);
  43. function Popover({
  44. open,
  45. onOpenChange,
  46. children,
  47. }: {
  48. open?: boolean;
  49. onOpenChange?: (open: boolean) => void;
  50. children?: ReactNode;
  51. }) {
  52. const [internalOpen, setInternalOpen] = React.useState(Boolean(open));
  53. const setOpen = (value: boolean) => {
  54. setInternalOpen(value);
  55. onOpenChange?.(value);
  56. };
  57. return (
  58. <PopoverContext.Provider value={{ open: internalOpen, setOpen }}>
  59. {children}
  60. </PopoverContext.Provider>
  61. );
  62. }
  63. function PopoverTrigger({ children, asChild }: { children?: ReactNode; asChild?: boolean }) {
  64. const ctx = React.useContext(PopoverContext);
  65. if (!ctx) return null;
  66. if (!asChild || !React.isValidElement(children)) {
  67. return <button onClick={() => ctx.setOpen(!ctx.open)}>{children}</button>;
  68. }
  69. return React.cloneElement(children, {
  70. onClick: () => ctx.setOpen(!ctx.open),
  71. });
  72. }
  73. function PopoverContent({ children }: { children?: ReactNode }) {
  74. const ctx = React.useContext(PopoverContext);
  75. if (!ctx?.open) return null;
  76. return <div data-testid="mock-popover-content">{children}</div>;
  77. }
  78. return {
  79. Popover,
  80. PopoverTrigger,
  81. PopoverContent,
  82. };
  83. });
  84. function loadMessages() {
  85. return {
  86. common: commonMessages,
  87. errors: errorsMessages,
  88. ui: uiMessages,
  89. forms: formsMessages,
  90. settings: settingsMessages,
  91. };
  92. }
  93. function render(node: ReactNode) {
  94. const container = document.createElement("div");
  95. document.body.appendChild(container);
  96. const root = createRoot(container);
  97. act(() => {
  98. root.render(node);
  99. });
  100. return {
  101. unmount: () => {
  102. act(() => root.unmount());
  103. container.remove();
  104. },
  105. };
  106. }
  107. async function flushTicks(times = 3) {
  108. for (let i = 0; i < times; i += 1) {
  109. await act(async () => {
  110. await new Promise((resolve) => setTimeout(resolve, 0));
  111. });
  112. }
  113. }
  114. describe("AllowedModelRuleEditor", () => {
  115. beforeEach(() => {
  116. document.body.innerHTML = "";
  117. vi.clearAllMocks();
  118. });
  119. test("adds a new exact allowlist rule", async () => {
  120. const messages = loadMessages();
  121. const onChange = vi.fn();
  122. const { unmount } = render(
  123. <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
  124. <AllowedModelRuleEditor value={[]} onChange={onChange} providerType="claude" />
  125. </NextIntlClientProvider>
  126. );
  127. const patternInput = document.querySelector(
  128. "#new-allowed-model-pattern"
  129. ) as HTMLInputElement | null;
  130. expect(patternInput).toBeTruthy();
  131. await act(async () => {
  132. if (patternInput) {
  133. patternInput.value = "claude-opus-4-1";
  134. patternInput.dispatchEvent(new Event("input", { bubbles: true }));
  135. patternInput.dispatchEvent(new Event("change", { bubbles: true }));
  136. }
  137. });
  138. await flushTicks(2);
  139. const addButton = document.querySelector(
  140. "[data-allowed-model-add]"
  141. ) as HTMLButtonElement | null;
  142. expect(addButton).toBeTruthy();
  143. await act(async () => {
  144. addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  145. });
  146. await flushTicks(2);
  147. expect(onChange).toHaveBeenCalledWith([{ matchType: "exact", pattern: "claude-opus-4-1" }]);
  148. unmount();
  149. });
  150. test("supports editing an existing rule", async () => {
  151. const messages = loadMessages();
  152. const initialRules: AllowedModelRule[] = [{ matchType: "exact", pattern: "claude-opus-4-1" }];
  153. function StatefulHarness() {
  154. const [rules, setRules] = useState(initialRules);
  155. return (
  156. <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
  157. <AllowedModelRuleEditor value={rules} onChange={setRules} providerType="claude" />
  158. </NextIntlClientProvider>
  159. );
  160. }
  161. const { unmount } = render(<StatefulHarness />);
  162. await flushTicks(2);
  163. const editButton = document.querySelector(
  164. '[data-allowed-model-edit="exact:claude-opus-4-1"]'
  165. ) as HTMLButtonElement | null;
  166. expect(editButton).toBeTruthy();
  167. await act(async () => {
  168. editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  169. });
  170. await flushTicks(2);
  171. const editInput = document.querySelector(
  172. '[data-allowed-model-edit-pattern="exact:claude-opus-4-1"]'
  173. ) as HTMLInputElement | null;
  174. expect(editInput).toBeTruthy();
  175. await act(async () => {
  176. if (editInput) {
  177. editInput.value = "claude-opus-4-2";
  178. editInput.dispatchEvent(new Event("input", { bubbles: true }));
  179. editInput.dispatchEvent(new Event("change", { bubbles: true }));
  180. }
  181. });
  182. await flushTicks(2);
  183. const saveButton = document.querySelector(
  184. '[data-allowed-model-save="exact:claude-opus-4-1"]'
  185. ) as HTMLButtonElement | null;
  186. expect(saveButton).toBeTruthy();
  187. await act(async () => {
  188. saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  189. });
  190. await flushTicks(4);
  191. expect(document.body.textContent || "").toContain("claude-opus-4-2");
  192. expect(document.body.textContent || "").not.toContain("claude-opus-4-1");
  193. unmount();
  194. });
  195. test("允许 exact 规则同时保留 GLM-5 和 glm-5", async () => {
  196. const messages = loadMessages();
  197. const onChange = vi.fn();
  198. const { unmount } = render(
  199. <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
  200. <AllowedModelRuleEditor
  201. value={[{ matchType: "exact", pattern: "GLM-5" }]}
  202. onChange={onChange}
  203. providerType="openai"
  204. />
  205. </NextIntlClientProvider>
  206. );
  207. const patternInput = document.querySelector(
  208. "#new-allowed-model-pattern"
  209. ) as HTMLInputElement | null;
  210. expect(patternInput).toBeTruthy();
  211. await act(async () => {
  212. if (patternInput) {
  213. patternInput.value = "glm-5";
  214. patternInput.dispatchEvent(new Event("input", { bubbles: true }));
  215. patternInput.dispatchEvent(new Event("change", { bubbles: true }));
  216. }
  217. });
  218. await flushTicks(2);
  219. const addButton = document.querySelector(
  220. "[data-allowed-model-add]"
  221. ) as HTMLButtonElement | null;
  222. expect(addButton).toBeTruthy();
  223. await act(async () => {
  224. addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  225. });
  226. await flushTicks(2);
  227. expect(onChange).toHaveBeenCalledWith([
  228. { matchType: "exact", pattern: "GLM-5" },
  229. { matchType: "exact", pattern: "glm-5" },
  230. ]);
  231. expect(document.querySelector("[data-allowed-model-error]")?.textContent || "").toBe("");
  232. unmount();
  233. });
  234. test("adds models from picker as exact rules without changing existing advanced rules", async () => {
  235. const messages = loadMessages();
  236. const onChange = vi.fn();
  237. const { unmount } = render(
  238. <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
  239. <AllowedModelRuleEditor
  240. value={[{ matchType: "prefix", pattern: "claude-opus-" }]}
  241. onChange={onChange}
  242. providerType="claude"
  243. providerUrl="https://api.example.com"
  244. apiKey="sk-test"
  245. />
  246. </NextIntlClientProvider>
  247. );
  248. await flushTicks(4);
  249. const pickerTrigger = document.querySelector(
  250. "[data-allowed-model-picker-trigger]"
  251. ) as HTMLButtonElement | null;
  252. expect(pickerTrigger).toBeTruthy();
  253. await act(async () => {
  254. pickerTrigger?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
  255. pickerTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  256. });
  257. await flushTicks(4);
  258. const items = Array.from(document.querySelectorAll("[data-slot='command-item']"));
  259. const targetItem = items.find((element) =>
  260. (element.textContent || "").includes("claude-opus-4-1")
  261. );
  262. expect(targetItem).toBeTruthy();
  263. await act(async () => {
  264. targetItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  265. });
  266. await flushTicks(2);
  267. expect(onChange).toHaveBeenCalledWith([
  268. { matchType: "prefix", pattern: "claude-opus-" },
  269. { matchType: "exact", pattern: "claude-opus-4-1" },
  270. ]);
  271. unmount();
  272. });
  273. test("已有 100 条白名单规则时仍允许继续新增,避免旧的前端上限阻塞更大配置", async () => {
  274. const messages = loadMessages();
  275. const onChange = vi.fn();
  276. const existingRules: AllowedModelRule[] = Array.from({ length: 100 }, (_, index) => ({
  277. matchType: "exact",
  278. pattern: `claude-opus-${index}`,
  279. }));
  280. const { unmount } = render(
  281. <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
  282. <AllowedModelRuleEditor
  283. value={existingRules}
  284. onChange={onChange}
  285. providerType="claude"
  286. providerUrl="https://api.example.com"
  287. apiKey="sk-test"
  288. />
  289. </NextIntlClientProvider>
  290. );
  291. const patternInput = document.querySelector(
  292. "#new-allowed-model-pattern"
  293. ) as HTMLInputElement | null;
  294. expect(patternInput).toBeTruthy();
  295. await act(async () => {
  296. if (patternInput) {
  297. patternInput.value = "claude-opus-100";
  298. patternInput.dispatchEvent(new Event("input", { bubbles: true }));
  299. patternInput.dispatchEvent(new Event("change", { bubbles: true }));
  300. }
  301. });
  302. await flushTicks(2);
  303. const addButton = document.querySelector(
  304. "[data-allowed-model-add]"
  305. ) as HTMLButtonElement | null;
  306. expect(addButton).toBeTruthy();
  307. await act(async () => {
  308. addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  309. });
  310. await flushTicks(2);
  311. expect(onChange).toHaveBeenCalledWith([
  312. ...existingRules,
  313. { matchType: "exact", pattern: "claude-opus-100" },
  314. ]);
  315. unmount();
  316. });
  317. });