providers-undo-engine.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. // @vitest-environment node
  2. import { beforeEach, describe, expect, it, vi } from "vitest";
  3. import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes";
  4. import { buildRedisMock, createRedisStore } from "./redis-mock-utils";
  5. const getSessionMock = vi.fn();
  6. const findAllProvidersFreshMock = vi.fn();
  7. const updateProvidersBatchMock = vi.fn();
  8. const publishCacheInvalidationMock = vi.fn();
  9. const { store: redisStore, mocks: redisMocks } = createRedisStore();
  10. vi.mock("@/lib/auth", () => ({
  11. getSession: getSessionMock,
  12. }));
  13. vi.mock("@/repository/provider", () => ({
  14. findAllProvidersFresh: findAllProvidersFreshMock,
  15. updateProvidersBatch: updateProvidersBatchMock,
  16. deleteProvidersBatch: vi.fn(),
  17. }));
  18. vi.mock("@/lib/cache/provider-cache", () => ({
  19. publishProviderCacheInvalidation: publishCacheInvalidationMock,
  20. }));
  21. vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks));
  22. vi.mock("@/lib/circuit-breaker", () => ({
  23. clearProviderState: vi.fn(),
  24. clearConfigCache: vi.fn(),
  25. resetCircuit: vi.fn(),
  26. getAllHealthStatusAsync: vi.fn(),
  27. }));
  28. vi.mock("@/lib/logger", () => ({
  29. logger: {
  30. trace: vi.fn(),
  31. debug: vi.fn(),
  32. info: vi.fn(),
  33. warn: vi.fn(),
  34. error: vi.fn(),
  35. },
  36. }));
  37. function makeProvider(id: number, overrides: Record<string, unknown> = {}) {
  38. return {
  39. id,
  40. name: `Provider-${id}`,
  41. url: "https://api.example.com/v1",
  42. key: "sk-test",
  43. providerVendorId: null,
  44. isEnabled: true,
  45. weight: 100,
  46. priority: 1,
  47. groupPriorities: null,
  48. costMultiplier: 1.0,
  49. groupTag: null,
  50. providerType: "claude",
  51. preserveClientIp: false,
  52. modelRedirects: null,
  53. allowedModels: null,
  54. mcpPassthroughType: "none",
  55. mcpPassthroughUrl: null,
  56. limit5hUsd: null,
  57. limitDailyUsd: null,
  58. dailyResetMode: "fixed",
  59. dailyResetTime: "00:00",
  60. limitWeeklyUsd: null,
  61. limitMonthlyUsd: null,
  62. limitTotalUsd: null,
  63. totalCostResetAt: null,
  64. limitConcurrentSessions: null,
  65. maxRetryAttempts: null,
  66. circuitBreakerFailureThreshold: 5,
  67. circuitBreakerOpenDuration: 1800000,
  68. circuitBreakerHalfOpenSuccessThreshold: 2,
  69. proxyUrl: null,
  70. proxyFallbackToDirect: false,
  71. firstByteTimeoutStreamingMs: 30000,
  72. streamingIdleTimeoutMs: 10000,
  73. requestTimeoutNonStreamingMs: 600000,
  74. websiteUrl: null,
  75. faviconUrl: null,
  76. cacheTtlPreference: null,
  77. swapCacheTtlBilling: false,
  78. context1mPreference: null,
  79. codexReasoningEffortPreference: null,
  80. codexReasoningSummaryPreference: null,
  81. codexTextVerbosityPreference: null,
  82. codexParallelToolCallsPreference: null,
  83. anthropicMaxTokensPreference: null,
  84. anthropicThinkingBudgetPreference: null,
  85. anthropicAdaptiveThinking: null,
  86. geminiGoogleSearchPreference: null,
  87. tpm: null,
  88. rpm: null,
  89. rpd: null,
  90. cc: null,
  91. createdAt: new Date("2025-01-01"),
  92. updatedAt: new Date("2025-01-01"),
  93. deletedAt: null,
  94. ...overrides,
  95. };
  96. }
  97. describe("Undo Provider Batch Patch Engine", () => {
  98. beforeEach(() => {
  99. vi.clearAllMocks();
  100. vi.resetModules();
  101. redisStore.clear();
  102. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  103. findAllProvidersFreshMock.mockResolvedValue([]);
  104. updateProvidersBatchMock.mockResolvedValue(0);
  105. publishCacheInvalidationMock.mockResolvedValue(undefined);
  106. });
  107. /** Helper: preview -> apply -> return undo token + operationId + undoProviderPatch */
  108. async function setupPreviewApplyAndGetUndo(
  109. providers: ReturnType<typeof makeProvider>[],
  110. providerIds: number[],
  111. patch: Record<string, unknown>,
  112. applyOverrides: Record<string, unknown> = {}
  113. ) {
  114. findAllProvidersFreshMock.mockResolvedValue(providers);
  115. updateProvidersBatchMock.mockResolvedValue(providers.length);
  116. const { previewProviderBatchPatch, applyProviderBatchPatch, undoProviderPatch } = await import(
  117. "@/actions/providers"
  118. );
  119. const preview = await previewProviderBatchPatch({ providerIds, patch });
  120. if (!preview.ok) throw new Error(`Preview failed: ${preview.error}`);
  121. const apply = await applyProviderBatchPatch({
  122. previewToken: preview.data.previewToken,
  123. previewRevision: preview.data.previewRevision,
  124. providerIds,
  125. patch,
  126. ...applyOverrides,
  127. });
  128. if (!apply.ok) throw new Error(`Apply failed: ${apply.error}`);
  129. // Reset mocks after apply so undo assertions are clean
  130. updateProvidersBatchMock.mockClear();
  131. publishCacheInvalidationMock.mockClear();
  132. return {
  133. undoToken: apply.data.undoToken,
  134. operationId: apply.data.operationId,
  135. undoProviderPatch,
  136. };
  137. }
  138. it("should revert each provider's fields to preimage values", async () => {
  139. const providers = [
  140. makeProvider(1, { groupTag: "alpha" }),
  141. makeProvider(2, { groupTag: "beta" }),
  142. ];
  143. const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo(
  144. providers,
  145. [1, 2],
  146. { group_tag: { set: "gamma" } }
  147. );
  148. updateProvidersBatchMock.mockResolvedValue(1);
  149. const result = await undoProviderPatch({ undoToken, operationId });
  150. expect(result.ok).toBe(true);
  151. if (!result.ok) return;
  152. // Provider 1 had groupTag "alpha", provider 2 had "beta" -- different preimages
  153. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  154. [1],
  155. expect.objectContaining({ groupTag: "alpha" })
  156. );
  157. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  158. [2],
  159. expect.objectContaining({ groupTag: "beta" })
  160. );
  161. });
  162. it("should call updateProvidersBatch per unique preimage group", async () => {
  163. const providers = [
  164. makeProvider(1, { groupTag: "same" }),
  165. makeProvider(2, { groupTag: "same" }),
  166. makeProvider(3, { groupTag: "different" }),
  167. ];
  168. const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo(
  169. providers,
  170. [1, 2, 3],
  171. { group_tag: { set: "new-value" } }
  172. );
  173. updateProvidersBatchMock.mockResolvedValue(1);
  174. await undoProviderPatch({ undoToken, operationId });
  175. // 2 groups: [1,2] with "same" and [3] with "different"
  176. expect(updateProvidersBatchMock).toHaveBeenCalledTimes(2);
  177. // One call should batch providers 1 and 2 together
  178. const calls = updateProvidersBatchMock.mock.calls as Array<[number[], Record<string, unknown>]>;
  179. const groupedCall = calls.find((c) => c[0].length === 2);
  180. expect(groupedCall).toBeDefined();
  181. expect(groupedCall![0]).toEqual(expect.arrayContaining([1, 2]));
  182. });
  183. it("should publish cache invalidation after undo", async () => {
  184. const providers = [makeProvider(1, { groupTag: "old" })];
  185. const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo(
  186. providers,
  187. [1],
  188. { group_tag: { set: "new" } }
  189. );
  190. updateProvidersBatchMock.mockResolvedValue(1);
  191. const result = await undoProviderPatch({ undoToken, operationId });
  192. expect(result.ok).toBe(true);
  193. expect(publishCacheInvalidationMock).toHaveBeenCalledOnce();
  194. });
  195. it("should return correct revertedCount from actual DB writes", async () => {
  196. const providers = [
  197. makeProvider(1, { groupTag: "a" }),
  198. makeProvider(2, { groupTag: "b" }),
  199. makeProvider(3, { groupTag: "c" }),
  200. ];
  201. const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo(
  202. providers,
  203. [1, 2, 3],
  204. { group_tag: { set: "unified" } }
  205. );
  206. // Each per-group call returns 1
  207. updateProvidersBatchMock.mockResolvedValue(1);
  208. const result = await undoProviderPatch({ undoToken, operationId });
  209. expect(result.ok).toBe(true);
  210. if (!result.ok) return;
  211. // 3 different preimages -> 3 calls, each returning 1
  212. expect(result.data.revertedCount).toBe(3);
  213. });
  214. it("should return UNDO_EXPIRED for missing token", async () => {
  215. const { undoProviderPatch } = await import("@/actions/providers");
  216. const result = await undoProviderPatch({
  217. undoToken: "nonexistent_token",
  218. operationId: "op_123",
  219. });
  220. expect(result.ok).toBe(false);
  221. if (result.ok) return;
  222. expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED);
  223. });
  224. it("should return UNDO_CONFLICT for mismatched operationId", async () => {
  225. const providers = [makeProvider(1, { groupTag: "old" })];
  226. const { undoToken, undoProviderPatch } = await setupPreviewApplyAndGetUndo(providers, [1], {
  227. group_tag: { set: "new" },
  228. });
  229. const result = await undoProviderPatch({
  230. undoToken,
  231. operationId: "wrong_operation_id",
  232. });
  233. expect(result.ok).toBe(false);
  234. if (result.ok) return;
  235. expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_CONFLICT);
  236. expect(updateProvidersBatchMock).not.toHaveBeenCalled();
  237. });
  238. it("should consume undo token after successful undo", async () => {
  239. const providers = [makeProvider(1, { groupTag: "old" })];
  240. const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo(
  241. providers,
  242. [1],
  243. { group_tag: { set: "new" } }
  244. );
  245. updateProvidersBatchMock.mockResolvedValue(1);
  246. const first = await undoProviderPatch({ undoToken, operationId });
  247. expect(first.ok).toBe(true);
  248. // Second undo with same token should fail -- token was consumed
  249. const second = await undoProviderPatch({ undoToken, operationId });
  250. expect(second.ok).toBe(false);
  251. if (second.ok) return;
  252. expect(second.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED);
  253. });
  254. it("should handle costMultiplier number-to-string conversion", async () => {
  255. const providers = [makeProvider(1, { costMultiplier: 1.5 })];
  256. const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo(
  257. providers,
  258. [1],
  259. { cost_multiplier: { set: 2.5 } }
  260. );
  261. updateProvidersBatchMock.mockResolvedValue(1);
  262. const result = await undoProviderPatch({ undoToken, operationId });
  263. expect(result.ok).toBe(true);
  264. // The preimage stored costMultiplier as number 1.5; undo must convert to string "1.5"
  265. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  266. [1],
  267. expect.objectContaining({ costMultiplier: "1.5" })
  268. );
  269. });
  270. it("should handle providers with different preimage values individually", async () => {
  271. const providers = [
  272. makeProvider(1, { priority: 5, weight: 80 }),
  273. makeProvider(2, { priority: 10, weight: 60 }),
  274. ];
  275. const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo(
  276. providers,
  277. [1, 2],
  278. { priority: { set: 1 }, weight: { set: 100 } }
  279. );
  280. updateProvidersBatchMock.mockResolvedValue(1);
  281. const result = await undoProviderPatch({ undoToken, operationId });
  282. expect(result.ok).toBe(true);
  283. if (!result.ok) return;
  284. // Each provider should be reverted with its own original values
  285. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  286. [1],
  287. expect.objectContaining({ priority: 5, weight: 80 })
  288. );
  289. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  290. [2],
  291. expect.objectContaining({ priority: 10, weight: 60 })
  292. );
  293. expect(result.data.revertedCount).toBe(2);
  294. });
  295. it("should handle providerIds without preimage entries gracefully", async () => {
  296. // Only provider 1 exists in DB; provider 999 has no preimage
  297. const providers = [makeProvider(1, { groupTag: "old" })];
  298. findAllProvidersFreshMock.mockResolvedValue(providers);
  299. updateProvidersBatchMock.mockResolvedValue(1);
  300. const { previewProviderBatchPatch, applyProviderBatchPatch, undoProviderPatch } = await import(
  301. "@/actions/providers"
  302. );
  303. const preview = await previewProviderBatchPatch({
  304. providerIds: [1, 999],
  305. patch: { group_tag: { set: "new" } },
  306. });
  307. if (!preview.ok) throw new Error(`Preview failed: ${preview.error}`);
  308. const apply = await applyProviderBatchPatch({
  309. previewToken: preview.data.previewToken,
  310. previewRevision: preview.data.previewRevision,
  311. providerIds: [1, 999],
  312. patch: { group_tag: { set: "new" } },
  313. });
  314. if (!apply.ok) throw new Error(`Apply failed: ${apply.error}`);
  315. updateProvidersBatchMock.mockClear();
  316. publishCacheInvalidationMock.mockClear();
  317. updateProvidersBatchMock.mockResolvedValue(1);
  318. const result = await undoProviderPatch({
  319. undoToken: apply.data.undoToken,
  320. operationId: apply.data.operationId,
  321. });
  322. expect(result.ok).toBe(true);
  323. if (!result.ok) return;
  324. // Only provider 1 has preimage, provider 999 is skipped
  325. expect(updateProvidersBatchMock).toHaveBeenCalledTimes(1);
  326. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  327. [1],
  328. expect.objectContaining({ groupTag: "old" })
  329. );
  330. expect(result.data.revertedCount).toBe(1);
  331. });
  332. });