providers-preview-engine.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import type { Provider } from "@/types/provider";
  3. const getSessionMock = vi.fn();
  4. const findAllProvidersFreshMock = vi.fn();
  5. vi.mock("@/lib/auth", () => ({
  6. getSession: getSessionMock,
  7. }));
  8. vi.mock("@/repository/provider", () => ({
  9. findAllProvidersFresh: findAllProvidersFreshMock,
  10. updateProvidersBatch: vi.fn(),
  11. deleteProvidersBatch: vi.fn(),
  12. }));
  13. vi.mock("@/lib/cache/provider-cache", () => ({
  14. publishProviderCacheInvalidation: vi.fn(),
  15. }));
  16. vi.mock("@/lib/circuit-breaker", () => ({
  17. clearProviderState: vi.fn(),
  18. clearConfigCache: vi.fn(),
  19. resetCircuit: vi.fn(),
  20. }));
  21. vi.mock("@/lib/logger", () => ({
  22. logger: {
  23. trace: vi.fn(),
  24. debug: vi.fn(),
  25. info: vi.fn(),
  26. warn: vi.fn(),
  27. error: vi.fn(),
  28. },
  29. }));
  30. function buildTestProvider(overrides: Partial<Provider> = {}): Provider {
  31. return {
  32. id: 1,
  33. name: "Test Provider",
  34. url: "https://api.example.com",
  35. key: "test-key",
  36. providerVendorId: null,
  37. isEnabled: true,
  38. weight: 10,
  39. priority: 1,
  40. groupPriorities: null,
  41. costMultiplier: 1.0,
  42. groupTag: null,
  43. providerType: "claude",
  44. preserveClientIp: false,
  45. modelRedirects: null,
  46. allowedModels: null,
  47. mcpPassthroughType: "none",
  48. mcpPassthroughUrl: null,
  49. limit5hUsd: null,
  50. limitDailyUsd: null,
  51. dailyResetMode: "fixed",
  52. dailyResetTime: "00:00",
  53. limitWeeklyUsd: null,
  54. limitMonthlyUsd: null,
  55. limitTotalUsd: null,
  56. totalCostResetAt: null,
  57. limitConcurrentSessions: 10,
  58. maxRetryAttempts: null,
  59. circuitBreakerFailureThreshold: 5,
  60. circuitBreakerOpenDuration: 1800000,
  61. circuitBreakerHalfOpenSuccessThreshold: 2,
  62. proxyUrl: null,
  63. proxyFallbackToDirect: false,
  64. firstByteTimeoutStreamingMs: 30000,
  65. streamingIdleTimeoutMs: 10000,
  66. requestTimeoutNonStreamingMs: 600000,
  67. websiteUrl: null,
  68. faviconUrl: null,
  69. cacheTtlPreference: null,
  70. swapCacheTtlBilling: false,
  71. context1mPreference: null,
  72. codexReasoningEffortPreference: null,
  73. codexReasoningSummaryPreference: null,
  74. codexTextVerbosityPreference: null,
  75. codexParallelToolCallsPreference: null,
  76. anthropicMaxTokensPreference: null,
  77. anthropicThinkingBudgetPreference: null,
  78. anthropicAdaptiveThinking: null,
  79. geminiGoogleSearchPreference: null,
  80. tpm: null,
  81. rpm: null,
  82. rpd: null,
  83. cc: null,
  84. createdAt: new Date("2026-01-01"),
  85. updatedAt: new Date("2026-01-01"),
  86. ...overrides,
  87. };
  88. }
  89. describe("Provider Batch Preview Engine - Row Generation", () => {
  90. beforeEach(() => {
  91. vi.clearAllMocks();
  92. vi.resetModules();
  93. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  94. });
  95. it("generates correct before/after row for single provider single field change", async () => {
  96. const provider = buildTestProvider({
  97. id: 5,
  98. name: "Claude One",
  99. groupTag: "old-group",
  100. });
  101. findAllProvidersFreshMock.mockResolvedValue([provider]);
  102. const { previewProviderBatchPatch } = await import("@/actions/providers");
  103. const result = await previewProviderBatchPatch({
  104. providerIds: [5],
  105. patch: { group_tag: { set: "new-group" } },
  106. });
  107. expect(result.ok).toBe(true);
  108. if (!result.ok) return;
  109. expect(result.data.rows).toHaveLength(1);
  110. expect(result.data.rows[0]).toEqual({
  111. providerId: 5,
  112. providerName: "Claude One",
  113. field: "group_tag",
  114. status: "changed",
  115. before: "old-group",
  116. after: "new-group",
  117. });
  118. });
  119. it("generates rows for each provider-field combination", async () => {
  120. const providerA = buildTestProvider({
  121. id: 1,
  122. name: "Provider A",
  123. priority: 5,
  124. weight: 10,
  125. });
  126. const providerB = buildTestProvider({
  127. id: 2,
  128. name: "Provider B",
  129. priority: 3,
  130. weight: 20,
  131. });
  132. findAllProvidersFreshMock.mockResolvedValue([providerA, providerB]);
  133. const { previewProviderBatchPatch } = await import("@/actions/providers");
  134. const result = await previewProviderBatchPatch({
  135. providerIds: [1, 2],
  136. patch: {
  137. priority: { set: 10 },
  138. weight: { set: 50 },
  139. },
  140. });
  141. expect(result.ok).toBe(true);
  142. if (!result.ok) return;
  143. expect(result.data.rows).toHaveLength(4);
  144. expect(result.data.rows).toContainEqual({
  145. providerId: 1,
  146. providerName: "Provider A",
  147. field: "priority",
  148. status: "changed",
  149. before: 5,
  150. after: 10,
  151. });
  152. expect(result.data.rows).toContainEqual({
  153. providerId: 1,
  154. providerName: "Provider A",
  155. field: "weight",
  156. status: "changed",
  157. before: 10,
  158. after: 50,
  159. });
  160. expect(result.data.rows).toContainEqual({
  161. providerId: 2,
  162. providerName: "Provider B",
  163. field: "priority",
  164. status: "changed",
  165. before: 3,
  166. after: 10,
  167. });
  168. expect(result.data.rows).toContainEqual({
  169. providerId: 2,
  170. providerName: "Provider B",
  171. field: "weight",
  172. status: "changed",
  173. before: 20,
  174. after: 50,
  175. });
  176. });
  177. it("marks anthropic fields as skipped for non-claude providers", async () => {
  178. const provider = buildTestProvider({
  179. id: 10,
  180. name: "OpenAI Compat",
  181. providerType: "openai-compatible",
  182. anthropicThinkingBudgetPreference: null,
  183. anthropicAdaptiveThinking: null,
  184. });
  185. findAllProvidersFreshMock.mockResolvedValue([provider]);
  186. const { previewProviderBatchPatch } = await import("@/actions/providers");
  187. const result = await previewProviderBatchPatch({
  188. providerIds: [10],
  189. patch: {
  190. anthropic_thinking_budget_preference: { set: "8192" },
  191. anthropic_adaptive_thinking: {
  192. set: { effort: "high", modelMatchMode: "all", models: [] },
  193. },
  194. },
  195. });
  196. expect(result.ok).toBe(true);
  197. if (!result.ok) return;
  198. expect(result.data.rows).toHaveLength(2);
  199. const budgetRow = result.data.rows.find(
  200. (r: { field: string }) => r.field === "anthropic_thinking_budget_preference"
  201. );
  202. expect(budgetRow).toEqual({
  203. providerId: 10,
  204. providerName: "OpenAI Compat",
  205. field: "anthropic_thinking_budget_preference",
  206. status: "skipped",
  207. before: null,
  208. after: "8192",
  209. skipReason: expect.any(String),
  210. });
  211. const adaptiveRow = result.data.rows.find(
  212. (r: { field: string }) => r.field === "anthropic_adaptive_thinking"
  213. );
  214. expect(adaptiveRow).toEqual({
  215. providerId: 10,
  216. providerName: "OpenAI Compat",
  217. field: "anthropic_adaptive_thinking",
  218. status: "skipped",
  219. before: null,
  220. after: { effort: "high", modelMatchMode: "all", models: [] },
  221. skipReason: expect.any(String),
  222. });
  223. });
  224. it("marks anthropic fields as changed for claude providers", async () => {
  225. const provider = buildTestProvider({
  226. id: 20,
  227. name: "Claude Main",
  228. providerType: "claude",
  229. anthropicThinkingBudgetPreference: "inherit",
  230. });
  231. findAllProvidersFreshMock.mockResolvedValue([provider]);
  232. const { previewProviderBatchPatch } = await import("@/actions/providers");
  233. const result = await previewProviderBatchPatch({
  234. providerIds: [20],
  235. patch: { anthropic_thinking_budget_preference: { set: "16000" } },
  236. });
  237. expect(result.ok).toBe(true);
  238. if (!result.ok) return;
  239. expect(result.data.rows).toHaveLength(1);
  240. expect(result.data.rows[0]).toEqual({
  241. providerId: 20,
  242. providerName: "Claude Main",
  243. field: "anthropic_thinking_budget_preference",
  244. status: "changed",
  245. before: "inherit",
  246. after: "16000",
  247. });
  248. });
  249. it("marks anthropic fields as changed for claude-auth providers", async () => {
  250. const provider = buildTestProvider({
  251. id: 21,
  252. name: "Claude Auth",
  253. providerType: "claude-auth",
  254. anthropicAdaptiveThinking: null,
  255. });
  256. findAllProvidersFreshMock.mockResolvedValue([provider]);
  257. const { previewProviderBatchPatch } = await import("@/actions/providers");
  258. const result = await previewProviderBatchPatch({
  259. providerIds: [21],
  260. patch: {
  261. anthropic_adaptive_thinking: {
  262. set: { effort: "medium", modelMatchMode: "all", models: [] },
  263. },
  264. },
  265. });
  266. expect(result.ok).toBe(true);
  267. if (!result.ok) return;
  268. expect(result.data.rows).toHaveLength(1);
  269. expect(result.data.rows[0].status).toBe("changed");
  270. expect(result.data.rows[0].providerId).toBe(21);
  271. });
  272. it("computes correct after values for clear mode", async () => {
  273. const provider = buildTestProvider({
  274. id: 30,
  275. name: "Clear Test",
  276. providerType: "claude",
  277. groupTag: "old-tag",
  278. modelRedirects: { "model-a": "model-b" },
  279. allowedModels: ["claude-3"],
  280. anthropicThinkingBudgetPreference: "8192",
  281. anthropicAdaptiveThinking: {
  282. effort: "high",
  283. modelMatchMode: "all",
  284. models: [],
  285. },
  286. });
  287. findAllProvidersFreshMock.mockResolvedValue([provider]);
  288. const { previewProviderBatchPatch } = await import("@/actions/providers");
  289. const result = await previewProviderBatchPatch({
  290. providerIds: [30],
  291. patch: {
  292. group_tag: { clear: true },
  293. model_redirects: { clear: true },
  294. allowed_models: { clear: true },
  295. anthropic_thinking_budget_preference: { clear: true },
  296. anthropic_adaptive_thinking: { clear: true },
  297. },
  298. });
  299. expect(result.ok).toBe(true);
  300. if (!result.ok) return;
  301. expect(result.data.rows).toHaveLength(5);
  302. const groupTagRow = result.data.rows.find((r: { field: string }) => r.field === "group_tag");
  303. expect(groupTagRow?.before).toBe("old-tag");
  304. expect(groupTagRow?.after).toBeNull();
  305. const modelRedirectsRow = result.data.rows.find(
  306. (r: { field: string }) => r.field === "model_redirects"
  307. );
  308. expect(modelRedirectsRow?.before).toEqual({ "model-a": "model-b" });
  309. expect(modelRedirectsRow?.after).toBeNull();
  310. const allowedModelsRow = result.data.rows.find(
  311. (r: { field: string }) => r.field === "allowed_models"
  312. );
  313. expect(allowedModelsRow?.before).toEqual(["claude-3"]);
  314. expect(allowedModelsRow?.after).toBeNull();
  315. // anthropic_thinking_budget_preference clears to "inherit"
  316. const budgetRow = result.data.rows.find(
  317. (r: { field: string }) => r.field === "anthropic_thinking_budget_preference"
  318. );
  319. expect(budgetRow?.before).toBe("8192");
  320. expect(budgetRow?.after).toBe("inherit");
  321. const adaptiveRow = result.data.rows.find(
  322. (r: { field: string }) => r.field === "anthropic_adaptive_thinking"
  323. );
  324. expect(adaptiveRow?.before).toEqual({
  325. effort: "high",
  326. modelMatchMode: "all",
  327. models: [],
  328. });
  329. expect(adaptiveRow?.after).toBeNull();
  330. });
  331. it("normalizes empty allowed_models array to null in after value", async () => {
  332. const provider = buildTestProvider({
  333. id: 40,
  334. name: "Models Test",
  335. allowedModels: ["claude-3"],
  336. });
  337. findAllProvidersFreshMock.mockResolvedValue([provider]);
  338. const { previewProviderBatchPatch } = await import("@/actions/providers");
  339. const result = await previewProviderBatchPatch({
  340. providerIds: [40],
  341. patch: { allowed_models: { set: [] } },
  342. });
  343. expect(result.ok).toBe(true);
  344. if (!result.ok) return;
  345. expect(result.data.rows).toHaveLength(1);
  346. expect(result.data.rows[0].before).toEqual(["claude-3"]);
  347. expect(result.data.rows[0].after).toBeNull();
  348. });
  349. it("includes correct skipCount in summary", async () => {
  350. const claudeProvider = buildTestProvider({
  351. id: 50,
  352. name: "Claude",
  353. providerType: "claude",
  354. });
  355. const openaiProvider = buildTestProvider({
  356. id: 51,
  357. name: "OpenAI",
  358. providerType: "openai-compatible",
  359. });
  360. const geminiProvider = buildTestProvider({
  361. id: 52,
  362. name: "Gemini",
  363. providerType: "gemini",
  364. });
  365. findAllProvidersFreshMock.mockResolvedValue([claudeProvider, openaiProvider, geminiProvider]);
  366. const { previewProviderBatchPatch } = await import("@/actions/providers");
  367. const result = await previewProviderBatchPatch({
  368. providerIds: [50, 51, 52],
  369. patch: {
  370. anthropic_thinking_budget_preference: { set: "8192" },
  371. group_tag: { set: "new-tag" },
  372. },
  373. });
  374. expect(result.ok).toBe(true);
  375. if (!result.ok) return;
  376. // 3 providers x 2 fields = 6 rows
  377. expect(result.data.rows).toHaveLength(6);
  378. // 2 non-claude providers x 1 anthropic field = 2 skipped
  379. expect(result.data.summary.skipCount).toBe(2);
  380. expect(result.data.summary.providerCount).toBe(3);
  381. expect(result.data.summary.fieldCount).toBe(2);
  382. });
  383. it("returns rows in the preview result for snapshot storage", async () => {
  384. const provider = buildTestProvider({
  385. id: 60,
  386. name: "Snapshot Test",
  387. isEnabled: true,
  388. });
  389. findAllProvidersFreshMock.mockResolvedValue([provider]);
  390. const { previewProviderBatchPatch } = await import("@/actions/providers");
  391. const result = await previewProviderBatchPatch({
  392. providerIds: [60],
  393. patch: { is_enabled: { set: false } },
  394. });
  395. expect(result.ok).toBe(true);
  396. if (!result.ok) return;
  397. expect(result.data.rows).toBeDefined();
  398. expect(Array.isArray(result.data.rows)).toBe(true);
  399. expect(result.data.rows).toHaveLength(1);
  400. expect(result.data.rows[0]).toEqual({
  401. providerId: 60,
  402. providerName: "Snapshot Test",
  403. field: "is_enabled",
  404. status: "changed",
  405. before: true,
  406. after: false,
  407. });
  408. });
  409. it("only generates rows for providers matching requested IDs", async () => {
  410. const providerA = buildTestProvider({ id: 100, name: "Match" });
  411. const providerB = buildTestProvider({ id: 200, name: "No Match" });
  412. findAllProvidersFreshMock.mockResolvedValue([providerA, providerB]);
  413. const { previewProviderBatchPatch } = await import("@/actions/providers");
  414. const result = await previewProviderBatchPatch({
  415. providerIds: [100],
  416. patch: { priority: { set: 99 } },
  417. });
  418. expect(result.ok).toBe(true);
  419. if (!result.ok) return;
  420. expect(result.data.rows).toHaveLength(1);
  421. expect(result.data.rows[0].providerId).toBe(100);
  422. });
  423. it("skips anthropic fields for all non-claude provider types", async () => {
  424. const codexProvider = buildTestProvider({
  425. id: 70,
  426. name: "Codex",
  427. providerType: "codex",
  428. });
  429. const geminiCliProvider = buildTestProvider({
  430. id: 71,
  431. name: "Gemini CLI",
  432. providerType: "gemini-cli",
  433. });
  434. findAllProvidersFreshMock.mockResolvedValue([codexProvider, geminiCliProvider]);
  435. const { previewProviderBatchPatch } = await import("@/actions/providers");
  436. const result = await previewProviderBatchPatch({
  437. providerIds: [70, 71],
  438. patch: {
  439. anthropic_adaptive_thinking: {
  440. set: { effort: "low", modelMatchMode: "all", models: [] },
  441. },
  442. },
  443. });
  444. expect(result.ok).toBe(true);
  445. if (!result.ok) return;
  446. expect(result.data.rows).toHaveLength(2);
  447. expect(result.data.rows.every((r: { status: string }) => r.status === "skipped")).toBe(true);
  448. expect(result.data.summary.skipCount).toBe(2);
  449. });
  450. it("handles mixed changed and skipped rows across providers", async () => {
  451. const claudeProvider = buildTestProvider({
  452. id: 80,
  453. name: "Claude",
  454. providerType: "claude",
  455. groupTag: "alpha",
  456. anthropicThinkingBudgetPreference: null,
  457. });
  458. const openaiProvider = buildTestProvider({
  459. id: 81,
  460. name: "OpenAI",
  461. providerType: "openai-compatible",
  462. groupTag: "beta",
  463. anthropicThinkingBudgetPreference: null,
  464. });
  465. findAllProvidersFreshMock.mockResolvedValue([claudeProvider, openaiProvider]);
  466. const { previewProviderBatchPatch } = await import("@/actions/providers");
  467. const result = await previewProviderBatchPatch({
  468. providerIds: [80, 81],
  469. patch: {
  470. group_tag: { set: "gamma" },
  471. anthropic_thinking_budget_preference: { set: "4096" },
  472. },
  473. });
  474. expect(result.ok).toBe(true);
  475. if (!result.ok) return;
  476. // 2 providers x 2 fields = 4 rows
  477. expect(result.data.rows).toHaveLength(4);
  478. // group_tag: both changed (universal field)
  479. const groupTagRows = result.data.rows.filter((r: { field: string }) => r.field === "group_tag");
  480. expect(groupTagRows).toHaveLength(2);
  481. expect(groupTagRows.every((r: { status: string }) => r.status === "changed")).toBe(true);
  482. // anthropic_thinking_budget_preference: claude changed, openai skipped
  483. const budgetRows = result.data.rows.filter(
  484. (r: { field: string }) => r.field === "anthropic_thinking_budget_preference"
  485. );
  486. expect(budgetRows).toHaveLength(2);
  487. const claudeBudget = budgetRows.find((r: { providerId: number }) => r.providerId === 80);
  488. expect(claudeBudget?.status).toBe("changed");
  489. const openaiBudget = budgetRows.find((r: { providerId: number }) => r.providerId === 81);
  490. expect(openaiBudget?.status).toBe("skipped");
  491. expect(openaiBudget?.skipReason).toBeTruthy();
  492. expect(result.data.summary.skipCount).toBe(1);
  493. });
  494. });