providers-apply-engine.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes";
  3. const getSessionMock = vi.fn();
  4. const findAllProvidersFreshMock = vi.fn();
  5. const updateProvidersBatchMock = vi.fn();
  6. const publishCacheInvalidationMock = vi.fn();
  7. const redisStore = new Map<string, { value: string; expiresAt: number }>();
  8. function readRedisValue(key: string): string | null {
  9. const entry = redisStore.get(key);
  10. if (!entry) {
  11. return null;
  12. }
  13. if (entry.expiresAt <= Date.now()) {
  14. redisStore.delete(key);
  15. return null;
  16. }
  17. return entry.value;
  18. }
  19. const redisSetexMock = vi.fn(async (key: string, ttlSeconds: number, value: string) => {
  20. redisStore.set(key, {
  21. value,
  22. expiresAt: Date.now() + ttlSeconds * 1000,
  23. });
  24. return "OK";
  25. });
  26. const redisGetMock = vi.fn(async (key: string) => readRedisValue(key));
  27. const redisDelMock = vi.fn(async (key: string) => {
  28. const existed = redisStore.delete(key);
  29. return existed ? 1 : 0;
  30. });
  31. const redisEvalMock = vi.fn(async (_script: string, _numKeys: number, key: string) => {
  32. const value = readRedisValue(key);
  33. if (value === null) {
  34. return null;
  35. }
  36. redisStore.delete(key);
  37. return value;
  38. });
  39. vi.mock("@/lib/auth", () => ({
  40. getSession: getSessionMock,
  41. }));
  42. vi.mock("@/repository/provider", () => ({
  43. findAllProvidersFresh: findAllProvidersFreshMock,
  44. updateProvidersBatch: updateProvidersBatchMock,
  45. deleteProvidersBatch: vi.fn(),
  46. }));
  47. vi.mock("@/lib/cache/provider-cache", () => ({
  48. publishProviderCacheInvalidation: publishCacheInvalidationMock,
  49. }));
  50. vi.mock("@/lib/redis/client", () => ({
  51. getRedisClient: () => ({
  52. status: "ready",
  53. setex: redisSetexMock,
  54. get: redisGetMock,
  55. del: redisDelMock,
  56. eval: redisEvalMock,
  57. }),
  58. }));
  59. vi.mock("@/lib/circuit-breaker", () => ({
  60. clearProviderState: vi.fn(),
  61. clearConfigCache: vi.fn(),
  62. resetCircuit: vi.fn(),
  63. getAllHealthStatusAsync: vi.fn(),
  64. }));
  65. vi.mock("@/lib/logger", () => ({
  66. logger: {
  67. trace: vi.fn(),
  68. debug: vi.fn(),
  69. info: vi.fn(),
  70. warn: vi.fn(),
  71. error: vi.fn(),
  72. },
  73. }));
  74. function makeProvider(id: number, overrides: Record<string, unknown> = {}) {
  75. return {
  76. id,
  77. name: `Provider-${id}`,
  78. url: "https://api.example.com/v1",
  79. key: "sk-test",
  80. providerVendorId: null,
  81. isEnabled: true,
  82. weight: 100,
  83. priority: 1,
  84. groupPriorities: null,
  85. costMultiplier: 1.0,
  86. groupTag: null,
  87. providerType: "claude",
  88. preserveClientIp: false,
  89. modelRedirects: null,
  90. allowedModels: null,
  91. mcpPassthroughType: "none",
  92. mcpPassthroughUrl: null,
  93. limit5hUsd: null,
  94. limitDailyUsd: null,
  95. dailyResetMode: "fixed",
  96. dailyResetTime: "00:00",
  97. limitWeeklyUsd: null,
  98. limitMonthlyUsd: null,
  99. limitTotalUsd: null,
  100. totalCostResetAt: null,
  101. limitConcurrentSessions: null,
  102. maxRetryAttempts: null,
  103. circuitBreakerFailureThreshold: 5,
  104. circuitBreakerOpenDuration: 1800000,
  105. circuitBreakerHalfOpenSuccessThreshold: 2,
  106. proxyUrl: null,
  107. proxyFallbackToDirect: false,
  108. firstByteTimeoutStreamingMs: 30000,
  109. streamingIdleTimeoutMs: 10000,
  110. requestTimeoutNonStreamingMs: 600000,
  111. websiteUrl: null,
  112. faviconUrl: null,
  113. cacheTtlPreference: null,
  114. swapCacheTtlBilling: false,
  115. context1mPreference: null,
  116. codexReasoningEffortPreference: null,
  117. codexReasoningSummaryPreference: null,
  118. codexTextVerbosityPreference: null,
  119. codexParallelToolCallsPreference: null,
  120. anthropicMaxTokensPreference: null,
  121. anthropicThinkingBudgetPreference: null,
  122. anthropicAdaptiveThinking: null,
  123. geminiGoogleSearchPreference: null,
  124. tpm: null,
  125. rpm: null,
  126. rpd: null,
  127. cc: null,
  128. createdAt: new Date("2025-01-01"),
  129. updatedAt: new Date("2025-01-01"),
  130. deletedAt: null,
  131. ...overrides,
  132. };
  133. }
  134. describe("Apply Provider Batch Patch Engine", () => {
  135. beforeEach(() => {
  136. vi.clearAllMocks();
  137. vi.resetModules();
  138. redisStore.clear();
  139. redisSetexMock.mockClear();
  140. redisGetMock.mockClear();
  141. redisDelMock.mockClear();
  142. redisEvalMock.mockClear();
  143. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  144. findAllProvidersFreshMock.mockResolvedValue([]);
  145. updateProvidersBatchMock.mockResolvedValue(0);
  146. publishCacheInvalidationMock.mockResolvedValue(undefined);
  147. });
  148. /** Helper: create preview then apply with optional overrides */
  149. async function setupPreviewAndApply(
  150. providerIds: number[],
  151. patch: Record<string, unknown>,
  152. applyOverrides: Record<string, unknown> = {}
  153. ) {
  154. const { previewProviderBatchPatch, applyProviderBatchPatch } = await import(
  155. "@/actions/providers"
  156. );
  157. const preview = await previewProviderBatchPatch({ providerIds, patch });
  158. if (!preview.ok) throw new Error(`Preview failed: ${preview.error}`);
  159. const applyInput = {
  160. previewToken: preview.data.previewToken,
  161. previewRevision: preview.data.previewRevision,
  162. providerIds,
  163. patch,
  164. ...applyOverrides,
  165. };
  166. const apply = await applyProviderBatchPatch(applyInput);
  167. return { preview, apply, applyProviderBatchPatch };
  168. }
  169. it("should call updateProvidersBatch with correct IDs and updates", async () => {
  170. const providers = [makeProvider(1, { groupTag: "old" }), makeProvider(2, { groupTag: "old" })];
  171. findAllProvidersFreshMock.mockResolvedValue(providers);
  172. updateProvidersBatchMock.mockResolvedValue(2);
  173. const { apply } = await setupPreviewAndApply([1, 2], { group_tag: { set: "new-group" } });
  174. expect(apply.ok).toBe(true);
  175. expect(updateProvidersBatchMock).toHaveBeenCalledOnce();
  176. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  177. [1, 2],
  178. expect.objectContaining({ groupTag: "new-group" })
  179. );
  180. });
  181. it("should publish cache invalidation after successful write", async () => {
  182. findAllProvidersFreshMock.mockResolvedValue([makeProvider(1)]);
  183. updateProvidersBatchMock.mockResolvedValue(1);
  184. const { apply } = await setupPreviewAndApply([1], { is_enabled: { set: false } });
  185. expect(apply.ok).toBe(true);
  186. expect(publishCacheInvalidationMock).toHaveBeenCalledOnce();
  187. });
  188. it("should fetch providers for preimage during apply", async () => {
  189. const providers = [
  190. makeProvider(1, { groupTag: "alpha", priority: 5 }),
  191. makeProvider(2, { groupTag: "beta", priority: 10 }),
  192. ];
  193. findAllProvidersFreshMock.mockResolvedValue(providers);
  194. updateProvidersBatchMock.mockResolvedValue(2);
  195. const { apply } = await setupPreviewAndApply([1, 2], { group_tag: { set: "gamma" } });
  196. expect(apply.ok).toBe(true);
  197. // preview calls findAllProvidersFresh once, apply calls it once more
  198. expect(findAllProvidersFreshMock).toHaveBeenCalledTimes(2);
  199. });
  200. it("should only apply to non-excluded providers with excludeProviderIds", async () => {
  201. const providers = [
  202. makeProvider(1, { groupTag: "a" }),
  203. makeProvider(2, { groupTag: "b" }),
  204. makeProvider(3, { groupTag: "c" }),
  205. ];
  206. findAllProvidersFreshMock.mockResolvedValue(providers);
  207. updateProvidersBatchMock.mockResolvedValue(2);
  208. const { apply } = await setupPreviewAndApply(
  209. [1, 2, 3],
  210. { group_tag: { set: "unified" } },
  211. { excludeProviderIds: [2] }
  212. );
  213. expect(apply.ok).toBe(true);
  214. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  215. [1, 3],
  216. expect.objectContaining({ groupTag: "unified" })
  217. );
  218. });
  219. it("should return NOTHING_TO_APPLY when all providers are excluded", async () => {
  220. findAllProvidersFreshMock.mockResolvedValue([makeProvider(1), makeProvider(2)]);
  221. const { apply } = await setupPreviewAndApply(
  222. [1, 2],
  223. { group_tag: { set: "x" } },
  224. { excludeProviderIds: [1, 2] }
  225. );
  226. expect(apply.ok).toBe(false);
  227. if (apply.ok) return;
  228. expect(apply.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.NOTHING_TO_APPLY);
  229. expect(updateProvidersBatchMock).not.toHaveBeenCalled();
  230. });
  231. it("should set updatedCount from updateProvidersBatch return value", async () => {
  232. findAllProvidersFreshMock.mockResolvedValue([
  233. makeProvider(1),
  234. makeProvider(2),
  235. makeProvider(3),
  236. ]);
  237. updateProvidersBatchMock.mockResolvedValue(3);
  238. const { apply } = await setupPreviewAndApply([1, 2, 3], { weight: { set: 50 } });
  239. expect(apply.ok).toBe(true);
  240. if (!apply.ok) return;
  241. expect(apply.data.updatedCount).toBe(3);
  242. });
  243. it("should reflect exclusions in updatedCount", async () => {
  244. findAllProvidersFreshMock.mockResolvedValue([
  245. makeProvider(1),
  246. makeProvider(2),
  247. makeProvider(3),
  248. ]);
  249. updateProvidersBatchMock.mockResolvedValue(2);
  250. const { apply } = await setupPreviewAndApply(
  251. [1, 2, 3],
  252. { weight: { set: 50 } },
  253. { excludeProviderIds: [3] }
  254. );
  255. expect(apply.ok).toBe(true);
  256. if (!apply.ok) return;
  257. expect(apply.data.updatedCount).toBe(2);
  258. });
  259. it("should return PREVIEW_EXPIRED for unknown preview token", async () => {
  260. const { applyProviderBatchPatch } = await import("@/actions/providers");
  261. const result = await applyProviderBatchPatch({
  262. previewToken: "provider_patch_preview_nonexistent",
  263. previewRevision: "rev",
  264. providerIds: [1],
  265. patch: { group_tag: { set: "x" } },
  266. });
  267. expect(result.ok).toBe(false);
  268. if (result.ok) return;
  269. expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_EXPIRED);
  270. });
  271. it("should return PREVIEW_STALE for mismatched patch", async () => {
  272. findAllProvidersFreshMock.mockResolvedValue([makeProvider(1)]);
  273. const { previewProviderBatchPatch, applyProviderBatchPatch } = await import(
  274. "@/actions/providers"
  275. );
  276. const preview = await previewProviderBatchPatch({
  277. providerIds: [1],
  278. patch: { group_tag: { set: "original" } },
  279. });
  280. if (!preview.ok) throw new Error("Preview should succeed");
  281. const result = await applyProviderBatchPatch({
  282. previewToken: preview.data.previewToken,
  283. previewRevision: preview.data.previewRevision,
  284. providerIds: [1],
  285. patch: { group_tag: { set: "different" } },
  286. });
  287. expect(result.ok).toBe(false);
  288. if (result.ok) return;
  289. expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_STALE);
  290. });
  291. it("should return cached result for same idempotencyKey without re-writing to DB", async () => {
  292. findAllProvidersFreshMock.mockResolvedValue([makeProvider(1), makeProvider(2)]);
  293. updateProvidersBatchMock.mockResolvedValue(2);
  294. const { previewProviderBatchPatch, applyProviderBatchPatch } = await import(
  295. "@/actions/providers"
  296. );
  297. const preview = await previewProviderBatchPatch({
  298. providerIds: [1, 2],
  299. patch: { group_tag: { set: "idem" } },
  300. });
  301. if (!preview.ok) throw new Error("Preview should succeed");
  302. const applyInput = {
  303. previewToken: preview.data.previewToken,
  304. previewRevision: preview.data.previewRevision,
  305. providerIds: [1, 2],
  306. patch: { group_tag: { set: "idem" } },
  307. idempotencyKey: "idem-key-1",
  308. };
  309. const first = await applyProviderBatchPatch(applyInput);
  310. const second = await applyProviderBatchPatch(applyInput);
  311. expect(first.ok).toBe(true);
  312. expect(second.ok).toBe(true);
  313. if (!first.ok || !second.ok) return;
  314. expect(second.data.operationId).toBe(first.data.operationId);
  315. expect(updateProvidersBatchMock).toHaveBeenCalledOnce();
  316. });
  317. it("should prevent double-apply by marking snapshot as applied", async () => {
  318. findAllProvidersFreshMock.mockResolvedValue([makeProvider(1)]);
  319. updateProvidersBatchMock.mockResolvedValue(1);
  320. const { previewProviderBatchPatch, applyProviderBatchPatch } = await import(
  321. "@/actions/providers"
  322. );
  323. const preview = await previewProviderBatchPatch({
  324. providerIds: [1],
  325. patch: { group_tag: { set: "x" } },
  326. });
  327. if (!preview.ok) throw new Error("Preview should succeed");
  328. const applyInput = {
  329. previewToken: preview.data.previewToken,
  330. previewRevision: preview.data.previewRevision,
  331. providerIds: [1],
  332. patch: { group_tag: { set: "x" } },
  333. };
  334. const first = await applyProviderBatchPatch(applyInput);
  335. const second = await applyProviderBatchPatch(applyInput);
  336. expect(first.ok).toBe(true);
  337. expect(second.ok).toBe(false);
  338. if (second.ok) return;
  339. expect(second.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_STALE);
  340. });
  341. it("should map cost_multiplier to string for repository", async () => {
  342. findAllProvidersFreshMock.mockResolvedValue([makeProvider(1, { costMultiplier: 1.0 })]);
  343. updateProvidersBatchMock.mockResolvedValue(1);
  344. const { apply } = await setupPreviewAndApply([1], { cost_multiplier: { set: 2.5 } });
  345. expect(apply.ok).toBe(true);
  346. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  347. [1],
  348. expect.objectContaining({ costMultiplier: "2.5" })
  349. );
  350. });
  351. it("should map multiple fields correctly to repository format", async () => {
  352. findAllProvidersFreshMock.mockResolvedValue([
  353. makeProvider(1, { groupTag: "old", weight: 100, priority: 1 }),
  354. ]);
  355. updateProvidersBatchMock.mockResolvedValue(1);
  356. const { apply } = await setupPreviewAndApply([1], {
  357. group_tag: { set: "new" },
  358. weight: { set: 80 },
  359. priority: { set: 5 },
  360. });
  361. expect(apply.ok).toBe(true);
  362. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  363. [1],
  364. expect.objectContaining({
  365. groupTag: "new",
  366. weight: 80,
  367. priority: 5,
  368. })
  369. );
  370. });
  371. it("should map clear mode to null for clearable fields", async () => {
  372. findAllProvidersFreshMock.mockResolvedValue([
  373. makeProvider(1, { groupTag: "has-tag", modelRedirects: { a: "b" } }),
  374. ]);
  375. updateProvidersBatchMock.mockResolvedValue(1);
  376. const { apply } = await setupPreviewAndApply([1], {
  377. group_tag: { clear: true },
  378. model_redirects: { clear: true },
  379. });
  380. expect(apply.ok).toBe(true);
  381. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  382. [1],
  383. expect.objectContaining({
  384. groupTag: null,
  385. modelRedirects: null,
  386. })
  387. );
  388. });
  389. it("should map anthropic_thinking_budget_preference clear to inherit", async () => {
  390. findAllProvidersFreshMock.mockResolvedValue([
  391. makeProvider(1, { anthropicThinkingBudgetPreference: "8192" }),
  392. ]);
  393. updateProvidersBatchMock.mockResolvedValue(1);
  394. const { apply } = await setupPreviewAndApply([1], {
  395. anthropic_thinking_budget_preference: { clear: true },
  396. });
  397. expect(apply.ok).toBe(true);
  398. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  399. [1],
  400. expect.objectContaining({
  401. anthropicThinkingBudgetPreference: "inherit",
  402. })
  403. );
  404. });
  405. });