provider-undo-edit.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "../../../src/lib/provider-batch-patch-error-codes";
  3. import { buildRedisMock, createRedisStore } from "./redis-mock-utils";
  4. const getSessionMock = vi.fn();
  5. const findProviderByIdMock = vi.fn();
  6. const updateProviderMock = vi.fn();
  7. const updateProvidersBatchMock = vi.fn();
  8. const publishCacheInvalidationMock = vi.fn();
  9. const clearProviderStateMock = vi.fn();
  10. const clearConfigCacheMock = vi.fn();
  11. const saveProviderCircuitConfigMock = vi.fn();
  12. const deleteProviderCircuitConfigMock = vi.fn();
  13. const { store: redisStore, mocks: redisMocks } = createRedisStore();
  14. vi.mock("@/lib/auth", () => ({
  15. getSession: getSessionMock,
  16. }));
  17. vi.mock("@/repository/provider", () => ({
  18. findProviderById: findProviderByIdMock,
  19. findAllProvidersFresh: vi.fn(),
  20. updateProvider: updateProviderMock,
  21. updateProvidersBatch: updateProvidersBatchMock,
  22. deleteProvidersBatch: vi.fn(),
  23. }));
  24. vi.mock("@/repository", () => ({
  25. restoreProvidersBatch: vi.fn(),
  26. }));
  27. vi.mock("@/lib/cache/provider-cache", () => ({
  28. publishProviderCacheInvalidation: publishCacheInvalidationMock,
  29. }));
  30. vi.mock("@/lib/circuit-breaker", () => ({
  31. clearProviderState: clearProviderStateMock,
  32. clearConfigCache: clearConfigCacheMock,
  33. resetCircuit: vi.fn(),
  34. getAllHealthStatusAsync: vi.fn(),
  35. }));
  36. vi.mock("@/lib/redis/circuit-breaker-config", () => ({
  37. saveProviderCircuitConfig: saveProviderCircuitConfigMock,
  38. deleteProviderCircuitConfig: deleteProviderCircuitConfigMock,
  39. }));
  40. vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks));
  41. vi.mock("@/lib/logger", () => ({
  42. logger: {
  43. trace: vi.fn(),
  44. debug: vi.fn(),
  45. info: vi.fn(),
  46. warn: vi.fn(),
  47. error: vi.fn(),
  48. },
  49. }));
  50. function makeProvider(id: number, overrides: Record<string, unknown> = {}) {
  51. return {
  52. id,
  53. name: `Provider-${id}`,
  54. url: "https://api.example.com/v1",
  55. key: "sk-test",
  56. providerVendorId: null,
  57. isEnabled: true,
  58. weight: 100,
  59. priority: 1,
  60. groupPriorities: null,
  61. costMultiplier: 1.0,
  62. groupTag: null,
  63. providerType: "claude",
  64. preserveClientIp: false,
  65. modelRedirects: null,
  66. allowedModels: null,
  67. mcpPassthroughType: "none",
  68. mcpPassthroughUrl: null,
  69. limit5hUsd: null,
  70. limitDailyUsd: null,
  71. dailyResetMode: "fixed",
  72. dailyResetTime: "00:00",
  73. limitWeeklyUsd: null,
  74. limitMonthlyUsd: null,
  75. limitTotalUsd: null,
  76. totalCostResetAt: null,
  77. limitConcurrentSessions: null,
  78. maxRetryAttempts: null,
  79. circuitBreakerFailureThreshold: 5,
  80. circuitBreakerOpenDuration: 1800000,
  81. circuitBreakerHalfOpenSuccessThreshold: 2,
  82. proxyUrl: null,
  83. proxyFallbackToDirect: false,
  84. firstByteTimeoutStreamingMs: 30000,
  85. streamingIdleTimeoutMs: 10000,
  86. requestTimeoutNonStreamingMs: 600000,
  87. websiteUrl: null,
  88. faviconUrl: null,
  89. cacheTtlPreference: null,
  90. swapCacheTtlBilling: false,
  91. context1mPreference: null,
  92. codexReasoningEffortPreference: null,
  93. codexReasoningSummaryPreference: null,
  94. codexTextVerbosityPreference: null,
  95. codexParallelToolCallsPreference: null,
  96. anthropicMaxTokensPreference: null,
  97. anthropicThinkingBudgetPreference: null,
  98. anthropicAdaptiveThinking: null,
  99. geminiGoogleSearchPreference: null,
  100. tpm: null,
  101. rpm: null,
  102. rpd: null,
  103. cc: null,
  104. createdAt: new Date("2025-01-01"),
  105. updatedAt: new Date("2025-01-01"),
  106. deletedAt: null,
  107. ...overrides,
  108. };
  109. }
  110. describe("Provider Single Edit Undo Actions", () => {
  111. beforeEach(() => {
  112. vi.clearAllMocks();
  113. vi.resetModules();
  114. redisStore.clear();
  115. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  116. findProviderByIdMock.mockResolvedValue(makeProvider(1, { name: "Before Name", key: "sk-old" }));
  117. updateProviderMock.mockResolvedValue(makeProvider(1, { name: "After Name", key: "sk-new" }));
  118. updateProvidersBatchMock.mockResolvedValue(1);
  119. publishCacheInvalidationMock.mockResolvedValue(undefined);
  120. clearProviderStateMock.mockReturnValue(undefined);
  121. clearConfigCacheMock.mockReturnValue(undefined);
  122. saveProviderCircuitConfigMock.mockResolvedValue(undefined);
  123. deleteProviderCircuitConfigMock.mockResolvedValue(undefined);
  124. });
  125. afterEach(() => {
  126. vi.useRealTimers();
  127. });
  128. it("editProvider should return undoToken and operationId", async () => {
  129. const { editProvider } = await import("../../../src/actions/providers");
  130. const result = await editProvider(1, { name: "After Name" });
  131. expect(result.ok).toBe(true);
  132. if (!result.ok) return;
  133. expect(result.data.undoToken).toMatch(/^provider_patch_undo_/);
  134. expect(result.data.operationId).toMatch(/^provider_patch_apply_/);
  135. expect(findProviderByIdMock).toHaveBeenCalledWith(1);
  136. expect(updateProviderMock).toHaveBeenCalledWith(
  137. 1,
  138. expect.objectContaining({
  139. name: "After Name",
  140. })
  141. );
  142. });
  143. it("editProvider should reject when provider is missing before update", async () => {
  144. findProviderByIdMock.mockResolvedValueOnce(null);
  145. const { editProvider } = await import("../../../src/actions/providers");
  146. const result = await editProvider(999, { name: "After Name" });
  147. expect(result.ok).toBe(false);
  148. if (result.ok) return;
  149. expect(result.error).toBe("供应商不存在");
  150. expect(updateProviderMock).not.toHaveBeenCalled();
  151. });
  152. it("editProvider should reject when repository update returns null", async () => {
  153. updateProviderMock.mockResolvedValueOnce(null);
  154. const { editProvider } = await import("../../../src/actions/providers");
  155. const result = await editProvider(1, { name: "After Name" });
  156. expect(result.ok).toBe(false);
  157. if (result.ok) return;
  158. expect(result.error).toBe("供应商不存在");
  159. });
  160. it("editProvider should continue when circuit config sync fails", async () => {
  161. updateProviderMock.mockResolvedValueOnce(
  162. makeProvider(1, {
  163. circuitBreakerFailureThreshold: 8,
  164. circuitBreakerOpenDuration: 1800000,
  165. circuitBreakerHalfOpenSuccessThreshold: 2,
  166. })
  167. );
  168. saveProviderCircuitConfigMock.mockRejectedValueOnce(new Error("redis down"));
  169. const { editProvider } = await import("../../../src/actions/providers");
  170. const result = await editProvider(1, {
  171. name: "After Name",
  172. circuit_breaker_failure_threshold: 8,
  173. });
  174. expect(result.ok).toBe(true);
  175. expect(saveProviderCircuitConfigMock).toHaveBeenCalledWith(
  176. 1,
  177. expect.objectContaining({
  178. failureThreshold: 8,
  179. })
  180. );
  181. expect(clearConfigCacheMock).not.toHaveBeenCalled();
  182. });
  183. it("undoProviderPatch should revert a single edit", async () => {
  184. const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers");
  185. const edited = await editProvider(1, { name: "After Name" });
  186. if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`);
  187. updateProvidersBatchMock.mockClear();
  188. publishCacheInvalidationMock.mockClear();
  189. const undone = await undoProviderPatch({
  190. undoToken: edited.data.undoToken,
  191. operationId: edited.data.operationId,
  192. });
  193. expect(undone.ok).toBe(true);
  194. if (!undone.ok) return;
  195. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  196. [1],
  197. expect.objectContaining({
  198. name: "Before Name",
  199. })
  200. );
  201. expect(undone.data.revertedCount).toBe(1);
  202. expect(publishCacheInvalidationMock).toHaveBeenCalledTimes(1);
  203. });
  204. it("undoProviderPatch should not include key field in preimage", async () => {
  205. findProviderByIdMock.mockResolvedValueOnce(makeProvider(1, { key: "sk-before" }));
  206. updateProviderMock.mockResolvedValueOnce(makeProvider(1, { key: "sk-after" }));
  207. const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers");
  208. const edited = await editProvider(1, { key: "sk-after" });
  209. if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`);
  210. updateProvidersBatchMock.mockClear();
  211. const undone = await undoProviderPatch({
  212. undoToken: edited.data.undoToken,
  213. operationId: edited.data.operationId,
  214. });
  215. expect(undone.ok).toBe(true);
  216. if (!undone.ok) return;
  217. expect(undone.data.revertedCount).toBe(0);
  218. expect(updateProvidersBatchMock).not.toHaveBeenCalled();
  219. });
  220. it("undoProviderPatch should skip unchanged values in single-edit preimage", async () => {
  221. findProviderByIdMock.mockResolvedValueOnce(makeProvider(1, { name: "Stable Name" }));
  222. updateProviderMock.mockResolvedValueOnce(makeProvider(1, { name: "Stable Name" }));
  223. const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers");
  224. const edited = await editProvider(1, { name: "Stable Name" });
  225. if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`);
  226. updateProvidersBatchMock.mockClear();
  227. publishCacheInvalidationMock.mockClear();
  228. const undone = await undoProviderPatch({
  229. undoToken: edited.data.undoToken,
  230. operationId: edited.data.operationId,
  231. });
  232. expect(undone.ok).toBe(true);
  233. if (!undone.ok) return;
  234. expect(undone.data.revertedCount).toBe(0);
  235. expect(updateProvidersBatchMock).not.toHaveBeenCalled();
  236. expect(publishCacheInvalidationMock).not.toHaveBeenCalled();
  237. });
  238. it("undoProviderPatch should stringify numeric costMultiplier on revert", async () => {
  239. findProviderByIdMock.mockResolvedValueOnce(makeProvider(1, { costMultiplier: 1.25 }));
  240. updateProviderMock.mockResolvedValueOnce(makeProvider(1, { costMultiplier: 2.5 }));
  241. const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers");
  242. const edited = await editProvider(1, { cost_multiplier: 2.5 });
  243. if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`);
  244. updateProvidersBatchMock.mockClear();
  245. const undone = await undoProviderPatch({
  246. undoToken: edited.data.undoToken,
  247. operationId: edited.data.operationId,
  248. });
  249. expect(undone.ok).toBe(true);
  250. if (!undone.ok) return;
  251. expect(updateProvidersBatchMock).toHaveBeenCalledWith(
  252. [1],
  253. expect.objectContaining({ costMultiplier: "1.25" })
  254. );
  255. });
  256. it("undoProviderPatch should expire after patch undo TTL", async () => {
  257. vi.useFakeTimers();
  258. vi.setSystemTime(new Date("2026-02-19T00:00:00.000Z"));
  259. const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers");
  260. const edited = await editProvider(1, { name: "After Name" });
  261. if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`);
  262. vi.advanceTimersByTime(10_001);
  263. const undone = await undoProviderPatch({
  264. undoToken: edited.data.undoToken,
  265. operationId: edited.data.operationId,
  266. });
  267. expect(undone.ok).toBe(false);
  268. if (undone.ok) return;
  269. expect(undone.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED);
  270. });
  271. it("undoProviderPatch should reject mismatched operation id", async () => {
  272. const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers");
  273. const edited = await editProvider(1, { name: "After Name" });
  274. if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`);
  275. const undone = await undoProviderPatch({
  276. undoToken: edited.data.undoToken,
  277. operationId: `${edited.data.operationId}-mismatch`,
  278. });
  279. expect(undone.ok).toBe(false);
  280. if (undone.ok) return;
  281. expect(undone.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_CONFLICT);
  282. expect(updateProvidersBatchMock).not.toHaveBeenCalled();
  283. });
  284. it("undoProviderPatch should reject invalid payload", async () => {
  285. const { undoProviderPatch } = await import("../../../src/actions/providers");
  286. const undone = await undoProviderPatch({
  287. undoToken: "",
  288. operationId: "provider_patch_apply_x",
  289. });
  290. expect(undone.ok).toBe(false);
  291. if (undone.ok) return;
  292. expect(undone.errorCode).toBeDefined();
  293. expect(updateProvidersBatchMock).not.toHaveBeenCalled();
  294. });
  295. it("undoProviderPatch should reject non-admin session", async () => {
  296. getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } });
  297. const { undoProviderPatch } = await import("../../../src/actions/providers");
  298. const undone = await undoProviderPatch({
  299. undoToken: "provider_patch_undo_x",
  300. operationId: "provider_patch_apply_x",
  301. });
  302. expect(undone.ok).toBe(false);
  303. if (undone.ok) return;
  304. expect(undone.error).toBe("无权限执行此操作");
  305. expect(updateProvidersBatchMock).not.toHaveBeenCalled();
  306. });
  307. it("undoProviderPatch should return repository errors when revert update fails", async () => {
  308. const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers");
  309. const edited = await editProvider(1, { name: "After Name" });
  310. if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`);
  311. updateProvidersBatchMock.mockRejectedValueOnce(new Error("undo write failed"));
  312. const undone = await undoProviderPatch({
  313. undoToken: edited.data.undoToken,
  314. operationId: edited.data.operationId,
  315. });
  316. expect(undone.ok).toBe(false);
  317. if (undone.ok) return;
  318. expect(undone.error).toBe("undo write failed");
  319. });
  320. });