build-patch-draft.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  1. import { describe, expect, it } from "vitest";
  2. import { buildPatchDraftFromFormState } from "@/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft";
  3. import type { ProviderFormState } from "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types";
  4. // ---------------------------------------------------------------------------
  5. // Helpers
  6. // ---------------------------------------------------------------------------
  7. function createBatchState(): ProviderFormState {
  8. return {
  9. basic: { name: "", url: "", key: "", websiteUrl: "" },
  10. routing: {
  11. providerType: "claude",
  12. groupTag: [],
  13. preserveClientIp: false,
  14. modelRedirects: {},
  15. allowedModels: [],
  16. allowedClients: [],
  17. blockedClients: [],
  18. priority: 0,
  19. groupPriorities: {},
  20. weight: 1,
  21. costMultiplier: 1.0,
  22. cacheTtlPreference: "inherit",
  23. swapCacheTtlBilling: false,
  24. context1mPreference: "inherit",
  25. codexReasoningEffortPreference: "inherit",
  26. codexReasoningSummaryPreference: "inherit",
  27. codexTextVerbosityPreference: "inherit",
  28. codexParallelToolCallsPreference: "inherit",
  29. anthropicMaxTokensPreference: "inherit",
  30. anthropicThinkingBudgetPreference: "inherit",
  31. anthropicAdaptiveThinking: null,
  32. geminiGoogleSearchPreference: "inherit",
  33. },
  34. rateLimit: {
  35. limit5hUsd: null,
  36. limitDailyUsd: null,
  37. dailyResetMode: "fixed",
  38. dailyResetTime: "00:00",
  39. limitWeeklyUsd: null,
  40. limitMonthlyUsd: null,
  41. limitTotalUsd: null,
  42. limitConcurrentSessions: null,
  43. },
  44. circuitBreaker: {
  45. failureThreshold: undefined,
  46. openDurationMinutes: undefined,
  47. halfOpenSuccessThreshold: undefined,
  48. maxRetryAttempts: null,
  49. },
  50. network: {
  51. proxyUrl: "",
  52. proxyFallbackToDirect: false,
  53. firstByteTimeoutStreamingSeconds: undefined,
  54. streamingIdleTimeoutSeconds: undefined,
  55. requestTimeoutNonStreamingSeconds: undefined,
  56. },
  57. mcp: {
  58. mcpPassthroughType: "none",
  59. mcpPassthroughUrl: "",
  60. },
  61. batch: { isEnabled: "no_change" },
  62. ui: {
  63. activeTab: "basic",
  64. isPending: false,
  65. showFailureThresholdConfirm: false,
  66. },
  67. };
  68. }
  69. // ---------------------------------------------------------------------------
  70. // Tests
  71. // ---------------------------------------------------------------------------
  72. describe("buildPatchDraftFromFormState", () => {
  73. it("returns empty draft when no fields are dirty", () => {
  74. const state = createBatchState();
  75. const dirty = new Set<string>();
  76. const draft = buildPatchDraftFromFormState(state, dirty);
  77. expect(draft).toEqual({});
  78. });
  79. it("includes isEnabled=true when dirty and set to true", () => {
  80. const state = createBatchState();
  81. state.batch.isEnabled = "true";
  82. const dirty = new Set(["batch.isEnabled"]);
  83. const draft = buildPatchDraftFromFormState(state, dirty);
  84. expect(draft.is_enabled).toEqual({ set: true });
  85. });
  86. it("includes isEnabled=false when dirty and set to false", () => {
  87. const state = createBatchState();
  88. state.batch.isEnabled = "false";
  89. const dirty = new Set(["batch.isEnabled"]);
  90. const draft = buildPatchDraftFromFormState(state, dirty);
  91. expect(draft.is_enabled).toEqual({ set: false });
  92. });
  93. it("skips isEnabled when dirty but value is no_change", () => {
  94. const state = createBatchState();
  95. state.batch.isEnabled = "no_change";
  96. const dirty = new Set(["batch.isEnabled"]);
  97. const draft = buildPatchDraftFromFormState(state, dirty);
  98. expect(draft.is_enabled).toBeUndefined();
  99. });
  100. it("sets priority when dirty", () => {
  101. const state = createBatchState();
  102. state.routing.priority = 10;
  103. const dirty = new Set(["routing.priority"]);
  104. const draft = buildPatchDraftFromFormState(state, dirty);
  105. expect(draft.priority).toEqual({ set: 10 });
  106. });
  107. it("sets weight when dirty", () => {
  108. const state = createBatchState();
  109. state.routing.weight = 5;
  110. const dirty = new Set(["routing.weight"]);
  111. const draft = buildPatchDraftFromFormState(state, dirty);
  112. expect(draft.weight).toEqual({ set: 5 });
  113. });
  114. it("sets costMultiplier when dirty", () => {
  115. const state = createBatchState();
  116. state.routing.costMultiplier = 2.5;
  117. const dirty = new Set(["routing.costMultiplier"]);
  118. const draft = buildPatchDraftFromFormState(state, dirty);
  119. expect(draft.cost_multiplier).toEqual({ set: 2.5 });
  120. });
  121. it("clears groupTag when dirty and empty array", () => {
  122. const state = createBatchState();
  123. state.routing.groupTag = [];
  124. const dirty = new Set(["routing.groupTag"]);
  125. const draft = buildPatchDraftFromFormState(state, dirty);
  126. expect(draft.group_tag).toEqual({ clear: true });
  127. });
  128. it("sets groupTag with joined value when dirty and non-empty", () => {
  129. const state = createBatchState();
  130. state.routing.groupTag = ["tagA", "tagB"];
  131. const dirty = new Set(["routing.groupTag"]);
  132. const draft = buildPatchDraftFromFormState(state, dirty);
  133. expect(draft.group_tag).toEqual({ set: "tagA, tagB" });
  134. });
  135. it("clears modelRedirects when dirty and empty object", () => {
  136. const state = createBatchState();
  137. const dirty = new Set(["routing.modelRedirects"]);
  138. const draft = buildPatchDraftFromFormState(state, dirty);
  139. expect(draft.model_redirects).toEqual({ clear: true });
  140. });
  141. it("sets modelRedirects when dirty and has entries", () => {
  142. const state = createBatchState();
  143. state.routing.modelRedirects = { "model-a": "model-b" };
  144. const dirty = new Set(["routing.modelRedirects"]);
  145. const draft = buildPatchDraftFromFormState(state, dirty);
  146. expect(draft.model_redirects).toEqual({ set: { "model-a": "model-b" } });
  147. });
  148. it("clears allowedModels when dirty and empty array", () => {
  149. const state = createBatchState();
  150. const dirty = new Set(["routing.allowedModels"]);
  151. const draft = buildPatchDraftFromFormState(state, dirty);
  152. expect(draft.allowed_models).toEqual({ clear: true });
  153. });
  154. it("sets allowedModels when dirty and non-empty", () => {
  155. const state = createBatchState();
  156. state.routing.allowedModels = ["claude-opus-4-6"];
  157. const dirty = new Set(["routing.allowedModels"]);
  158. const draft = buildPatchDraftFromFormState(state, dirty);
  159. expect(draft.allowed_models).toEqual({ set: ["claude-opus-4-6"] });
  160. });
  161. // --- inherit/clear pattern fields ---
  162. it("clears cacheTtlPreference when dirty and inherit", () => {
  163. const state = createBatchState();
  164. const dirty = new Set(["routing.cacheTtlPreference"]);
  165. const draft = buildPatchDraftFromFormState(state, dirty);
  166. expect(draft.cache_ttl_preference).toEqual({ clear: true });
  167. });
  168. it("sets cacheTtlPreference when dirty and not inherit", () => {
  169. const state = createBatchState();
  170. state.routing.cacheTtlPreference = "5m";
  171. const dirty = new Set(["routing.cacheTtlPreference"]);
  172. const draft = buildPatchDraftFromFormState(state, dirty);
  173. expect(draft.cache_ttl_preference).toEqual({ set: "5m" });
  174. });
  175. it("sets preserveClientIp when dirty", () => {
  176. const state = createBatchState();
  177. state.routing.preserveClientIp = true;
  178. const dirty = new Set(["routing.preserveClientIp"]);
  179. const draft = buildPatchDraftFromFormState(state, dirty);
  180. expect(draft.preserve_client_ip).toEqual({ set: true });
  181. });
  182. it("sets swapCacheTtlBilling when dirty", () => {
  183. const state = createBatchState();
  184. state.routing.swapCacheTtlBilling = true;
  185. const dirty = new Set(["routing.swapCacheTtlBilling"]);
  186. const draft = buildPatchDraftFromFormState(state, dirty);
  187. expect(draft.swap_cache_ttl_billing).toEqual({ set: true });
  188. });
  189. it("clears context1mPreference when dirty and inherit", () => {
  190. const state = createBatchState();
  191. const dirty = new Set(["routing.context1mPreference"]);
  192. const draft = buildPatchDraftFromFormState(state, dirty);
  193. expect(draft.context_1m_preference).toEqual({ clear: true });
  194. });
  195. it("sets context1mPreference when dirty and not inherit", () => {
  196. const state = createBatchState();
  197. state.routing.context1mPreference = "force_enable";
  198. const dirty = new Set(["routing.context1mPreference"]);
  199. const draft = buildPatchDraftFromFormState(state, dirty);
  200. expect(draft.context_1m_preference).toEqual({ set: "force_enable" });
  201. });
  202. it("clears codexReasoningEffortPreference when dirty and inherit", () => {
  203. const state = createBatchState();
  204. const dirty = new Set(["routing.codexReasoningEffortPreference"]);
  205. const draft = buildPatchDraftFromFormState(state, dirty);
  206. expect(draft.codex_reasoning_effort_preference).toEqual({ clear: true });
  207. });
  208. it("sets codexReasoningEffortPreference when dirty and not inherit", () => {
  209. const state = createBatchState();
  210. state.routing.codexReasoningEffortPreference = "high";
  211. const dirty = new Set(["routing.codexReasoningEffortPreference"]);
  212. const draft = buildPatchDraftFromFormState(state, dirty);
  213. expect(draft.codex_reasoning_effort_preference).toEqual({ set: "high" });
  214. });
  215. it("clears anthropicThinkingBudgetPreference when dirty and inherit", () => {
  216. const state = createBatchState();
  217. const dirty = new Set(["routing.anthropicThinkingBudgetPreference"]);
  218. const draft = buildPatchDraftFromFormState(state, dirty);
  219. expect(draft.anthropic_thinking_budget_preference).toEqual({ clear: true });
  220. });
  221. it("sets anthropicThinkingBudgetPreference when dirty and not inherit", () => {
  222. const state = createBatchState();
  223. state.routing.anthropicThinkingBudgetPreference = "32000";
  224. const dirty = new Set(["routing.anthropicThinkingBudgetPreference"]);
  225. const draft = buildPatchDraftFromFormState(state, dirty);
  226. expect(draft.anthropic_thinking_budget_preference).toEqual({ set: "32000" });
  227. });
  228. it("clears anthropicAdaptiveThinking when dirty and null", () => {
  229. const state = createBatchState();
  230. const dirty = new Set(["routing.anthropicAdaptiveThinking"]);
  231. const draft = buildPatchDraftFromFormState(state, dirty);
  232. expect(draft.anthropic_adaptive_thinking).toEqual({ clear: true });
  233. });
  234. it("sets anthropicAdaptiveThinking when dirty and configured", () => {
  235. const state = createBatchState();
  236. state.routing.anthropicAdaptiveThinking = {
  237. effort: "high",
  238. modelMatchMode: "specific",
  239. models: ["claude-opus-4-6"],
  240. };
  241. const dirty = new Set(["routing.anthropicAdaptiveThinking"]);
  242. const draft = buildPatchDraftFromFormState(state, dirty);
  243. expect(draft.anthropic_adaptive_thinking).toEqual({
  244. set: {
  245. effort: "high",
  246. modelMatchMode: "specific",
  247. models: ["claude-opus-4-6"],
  248. },
  249. });
  250. });
  251. it("clears geminiGoogleSearchPreference when dirty and inherit", () => {
  252. const state = createBatchState();
  253. const dirty = new Set(["routing.geminiGoogleSearchPreference"]);
  254. const draft = buildPatchDraftFromFormState(state, dirty);
  255. expect(draft.gemini_google_search_preference).toEqual({ clear: true });
  256. });
  257. it("sets geminiGoogleSearchPreference when dirty and not inherit", () => {
  258. const state = createBatchState();
  259. state.routing.geminiGoogleSearchPreference = "enabled";
  260. const dirty = new Set(["routing.geminiGoogleSearchPreference"]);
  261. const draft = buildPatchDraftFromFormState(state, dirty);
  262. expect(draft.gemini_google_search_preference).toEqual({ set: "enabled" });
  263. });
  264. // --- Rate limit fields ---
  265. it("clears limit5hUsd when dirty and null", () => {
  266. const state = createBatchState();
  267. const dirty = new Set(["rateLimit.limit5hUsd"]);
  268. const draft = buildPatchDraftFromFormState(state, dirty);
  269. expect(draft.limit_5h_usd).toEqual({ clear: true });
  270. });
  271. it("sets limit5hUsd when dirty and has value", () => {
  272. const state = createBatchState();
  273. state.rateLimit.limit5hUsd = 50;
  274. const dirty = new Set(["rateLimit.limit5hUsd"]);
  275. const draft = buildPatchDraftFromFormState(state, dirty);
  276. expect(draft.limit_5h_usd).toEqual({ set: 50 });
  277. });
  278. it("sets dailyResetMode when dirty", () => {
  279. const state = createBatchState();
  280. state.rateLimit.dailyResetMode = "rolling";
  281. const dirty = new Set(["rateLimit.dailyResetMode"]);
  282. const draft = buildPatchDraftFromFormState(state, dirty);
  283. expect(draft.daily_reset_mode).toEqual({ set: "rolling" });
  284. });
  285. it("sets dailyResetTime when dirty", () => {
  286. const state = createBatchState();
  287. state.rateLimit.dailyResetTime = "12:00";
  288. const dirty = new Set(["rateLimit.dailyResetTime"]);
  289. const draft = buildPatchDraftFromFormState(state, dirty);
  290. expect(draft.daily_reset_time).toEqual({ set: "12:00" });
  291. });
  292. it("clears maxRetryAttempts when dirty and null", () => {
  293. const state = createBatchState();
  294. const dirty = new Set(["circuitBreaker.maxRetryAttempts"]);
  295. const draft = buildPatchDraftFromFormState(state, dirty);
  296. expect(draft.max_retry_attempts).toEqual({ clear: true });
  297. });
  298. it("sets maxRetryAttempts when dirty and has value", () => {
  299. const state = createBatchState();
  300. state.circuitBreaker.maxRetryAttempts = 3;
  301. const dirty = new Set(["circuitBreaker.maxRetryAttempts"]);
  302. const draft = buildPatchDraftFromFormState(state, dirty);
  303. expect(draft.max_retry_attempts).toEqual({ set: 3 });
  304. });
  305. // --- Unit conversion: circuit breaker minutes -> ms ---
  306. it("converts openDurationMinutes to ms", () => {
  307. const state = createBatchState();
  308. state.circuitBreaker.openDurationMinutes = 5;
  309. const dirty = new Set(["circuitBreaker.openDurationMinutes"]);
  310. const draft = buildPatchDraftFromFormState(state, dirty);
  311. expect(draft.circuit_breaker_open_duration).toEqual({ set: 300000 });
  312. });
  313. it("sets openDuration to 0 when dirty and undefined", () => {
  314. const state = createBatchState();
  315. const dirty = new Set(["circuitBreaker.openDurationMinutes"]);
  316. const draft = buildPatchDraftFromFormState(state, dirty);
  317. expect(draft.circuit_breaker_open_duration).toEqual({ set: 0 });
  318. });
  319. it("sets failureThreshold to 0 when dirty and undefined", () => {
  320. const state = createBatchState();
  321. const dirty = new Set(["circuitBreaker.failureThreshold"]);
  322. const draft = buildPatchDraftFromFormState(state, dirty);
  323. expect(draft.circuit_breaker_failure_threshold).toEqual({ set: 0 });
  324. });
  325. it("sets failureThreshold when dirty and has value", () => {
  326. const state = createBatchState();
  327. state.circuitBreaker.failureThreshold = 10;
  328. const dirty = new Set(["circuitBreaker.failureThreshold"]);
  329. const draft = buildPatchDraftFromFormState(state, dirty);
  330. expect(draft.circuit_breaker_failure_threshold).toEqual({ set: 10 });
  331. });
  332. // --- Unit conversion: network seconds -> ms ---
  333. it("converts firstByteTimeoutStreamingSeconds to ms", () => {
  334. const state = createBatchState();
  335. state.network.firstByteTimeoutStreamingSeconds = 30;
  336. const dirty = new Set(["network.firstByteTimeoutStreamingSeconds"]);
  337. const draft = buildPatchDraftFromFormState(state, dirty);
  338. expect(draft.first_byte_timeout_streaming_ms).toEqual({ set: 30000 });
  339. });
  340. it("skips firstByteTimeoutStreamingMs when dirty and undefined", () => {
  341. const state = createBatchState();
  342. const dirty = new Set(["network.firstByteTimeoutStreamingSeconds"]);
  343. const draft = buildPatchDraftFromFormState(state, dirty);
  344. expect(draft.first_byte_timeout_streaming_ms).toBeUndefined();
  345. });
  346. it("converts streamingIdleTimeoutSeconds to ms", () => {
  347. const state = createBatchState();
  348. state.network.streamingIdleTimeoutSeconds = 120;
  349. const dirty = new Set(["network.streamingIdleTimeoutSeconds"]);
  350. const draft = buildPatchDraftFromFormState(state, dirty);
  351. expect(draft.streaming_idle_timeout_ms).toEqual({ set: 120000 });
  352. });
  353. it("converts requestTimeoutNonStreamingSeconds to ms", () => {
  354. const state = createBatchState();
  355. state.network.requestTimeoutNonStreamingSeconds = 60;
  356. const dirty = new Set(["network.requestTimeoutNonStreamingSeconds"]);
  357. const draft = buildPatchDraftFromFormState(state, dirty);
  358. expect(draft.request_timeout_non_streaming_ms).toEqual({ set: 60000 });
  359. });
  360. // --- Network fields ---
  361. it("clears proxyUrl when dirty and empty string", () => {
  362. const state = createBatchState();
  363. const dirty = new Set(["network.proxyUrl"]);
  364. const draft = buildPatchDraftFromFormState(state, dirty);
  365. expect(draft.proxy_url).toEqual({ clear: true });
  366. });
  367. it("sets proxyUrl when dirty and has value", () => {
  368. const state = createBatchState();
  369. state.network.proxyUrl = "socks5://proxy.example.com:1080";
  370. const dirty = new Set(["network.proxyUrl"]);
  371. const draft = buildPatchDraftFromFormState(state, dirty);
  372. expect(draft.proxy_url).toEqual({ set: "socks5://proxy.example.com:1080" });
  373. });
  374. it("sets proxyFallbackToDirect when dirty", () => {
  375. const state = createBatchState();
  376. state.network.proxyFallbackToDirect = true;
  377. const dirty = new Set(["network.proxyFallbackToDirect"]);
  378. const draft = buildPatchDraftFromFormState(state, dirty);
  379. expect(draft.proxy_fallback_to_direct).toEqual({ set: true });
  380. });
  381. // --- MCP fields ---
  382. it("sets mcpPassthroughType when dirty", () => {
  383. const state = createBatchState();
  384. state.mcp.mcpPassthroughType = "minimax";
  385. const dirty = new Set(["mcp.mcpPassthroughType"]);
  386. const draft = buildPatchDraftFromFormState(state, dirty);
  387. expect(draft.mcp_passthrough_type).toEqual({ set: "minimax" });
  388. });
  389. it("sets mcpPassthroughType to none when dirty", () => {
  390. const state = createBatchState();
  391. const dirty = new Set(["mcp.mcpPassthroughType"]);
  392. const draft = buildPatchDraftFromFormState(state, dirty);
  393. expect(draft.mcp_passthrough_type).toEqual({ set: "none" });
  394. });
  395. it("clears mcpPassthroughUrl when dirty and empty", () => {
  396. const state = createBatchState();
  397. const dirty = new Set(["mcp.mcpPassthroughUrl"]);
  398. const draft = buildPatchDraftFromFormState(state, dirty);
  399. expect(draft.mcp_passthrough_url).toEqual({ clear: true });
  400. });
  401. it("sets mcpPassthroughUrl when dirty and has value", () => {
  402. const state = createBatchState();
  403. state.mcp.mcpPassthroughUrl = "https://mcp.example.com";
  404. const dirty = new Set(["mcp.mcpPassthroughUrl"]);
  405. const draft = buildPatchDraftFromFormState(state, dirty);
  406. expect(draft.mcp_passthrough_url).toEqual({ set: "https://mcp.example.com" });
  407. });
  408. // --- Multi-field scenario ---
  409. it("only includes dirty fields in draft, ignoring non-dirty", () => {
  410. const state = createBatchState();
  411. state.routing.priority = 10;
  412. state.routing.weight = 5;
  413. state.routing.costMultiplier = 2.0;
  414. // Only mark priority as dirty
  415. const dirty = new Set(["routing.priority"]);
  416. const draft = buildPatchDraftFromFormState(state, dirty);
  417. expect(draft.priority).toEqual({ set: 10 });
  418. expect(draft.weight).toBeUndefined();
  419. expect(draft.cost_multiplier).toBeUndefined();
  420. });
  421. it("handles multiple dirty fields correctly", () => {
  422. const state = createBatchState();
  423. state.batch.isEnabled = "true";
  424. state.routing.priority = 5;
  425. state.routing.weight = 3;
  426. state.rateLimit.limit5hUsd = 100;
  427. state.network.proxyUrl = "http://proxy:8080";
  428. const dirty = new Set([
  429. "batch.isEnabled",
  430. "routing.priority",
  431. "routing.weight",
  432. "rateLimit.limit5hUsd",
  433. "network.proxyUrl",
  434. ]);
  435. const draft = buildPatchDraftFromFormState(state, dirty);
  436. expect(draft.is_enabled).toEqual({ set: true });
  437. expect(draft.priority).toEqual({ set: 5 });
  438. expect(draft.weight).toEqual({ set: 3 });
  439. expect(draft.limit_5h_usd).toEqual({ set: 100 });
  440. expect(draft.proxy_url).toEqual({ set: "http://proxy:8080" });
  441. // Non-dirty fields should be absent
  442. expect(draft.cost_multiplier).toBeUndefined();
  443. expect(draft.group_tag).toBeUndefined();
  444. });
  445. // --- groupPriorities ---
  446. it("clears groupPriorities when dirty and empty object", () => {
  447. const state = createBatchState();
  448. const dirty = new Set(["routing.groupPriorities"]);
  449. const draft = buildPatchDraftFromFormState(state, dirty);
  450. expect(draft.group_priorities).toEqual({ clear: true });
  451. });
  452. it("sets groupPriorities when dirty and has entries", () => {
  453. const state = createBatchState();
  454. state.routing.groupPriorities = { groupA: 1, groupB: 2 };
  455. const dirty = new Set(["routing.groupPriorities"]);
  456. const draft = buildPatchDraftFromFormState(state, dirty);
  457. expect(draft.group_priorities).toEqual({ set: { groupA: 1, groupB: 2 } });
  458. });
  459. // --- limitConcurrentSessions null -> 0 edge case ---
  460. it("sets limitConcurrentSessions to 0 when dirty and null", () => {
  461. const state = createBatchState();
  462. const dirty = new Set(["rateLimit.limitConcurrentSessions"]);
  463. const draft = buildPatchDraftFromFormState(state, dirty);
  464. expect(draft.limit_concurrent_sessions).toEqual({ set: 0 });
  465. });
  466. it("sets limitConcurrentSessions when dirty and has value", () => {
  467. const state = createBatchState();
  468. state.rateLimit.limitConcurrentSessions = 20;
  469. const dirty = new Set(["rateLimit.limitConcurrentSessions"]);
  470. const draft = buildPatchDraftFromFormState(state, dirty);
  471. expect(draft.limit_concurrent_sessions).toEqual({ set: 20 });
  472. });
  473. // --- Client restrictions ---
  474. it("clears allowedClients when dirty and empty array", () => {
  475. const state = createBatchState();
  476. const dirty = new Set(["routing.allowedClients"]);
  477. const draft = buildPatchDraftFromFormState(state, dirty);
  478. expect(draft.allowed_clients).toEqual({ clear: true });
  479. });
  480. it("sets allowedClients when dirty and non-empty", () => {
  481. const state = createBatchState();
  482. state.routing.allowedClients = ["client-a", "client-b"];
  483. const dirty = new Set(["routing.allowedClients"]);
  484. const draft = buildPatchDraftFromFormState(state, dirty);
  485. expect(draft.allowed_clients).toEqual({ set: ["client-a", "client-b"] });
  486. });
  487. it("clears blockedClients when dirty and empty array", () => {
  488. const state = createBatchState();
  489. const dirty = new Set(["routing.blockedClients"]);
  490. const draft = buildPatchDraftFromFormState(state, dirty);
  491. expect(draft.blocked_clients).toEqual({ clear: true });
  492. });
  493. it("sets blockedClients when dirty and non-empty", () => {
  494. const state = createBatchState();
  495. state.routing.blockedClients = ["bad-client"];
  496. const dirty = new Set(["routing.blockedClients"]);
  497. const draft = buildPatchDraftFromFormState(state, dirty);
  498. expect(draft.blocked_clients).toEqual({ set: ["bad-client"] });
  499. });
  500. });