providers-batch.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. const getSessionMock = vi.fn();
  3. const updateProvidersBatchMock = vi.fn();
  4. const deleteProvidersBatchMock = vi.fn();
  5. const publishProviderCacheInvalidationMock = vi.fn();
  6. const clearProviderStateMock = vi.fn();
  7. const clearConfigCacheMock = vi.fn();
  8. const resetCircuitMock = vi.fn();
  9. const terminateStickySessionsForProvidersMock = vi.fn();
  10. vi.mock("@/lib/auth", () => ({
  11. getSession: getSessionMock,
  12. }));
  13. vi.mock("@/repository/provider", () => ({
  14. updateProvidersBatch: updateProvidersBatchMock,
  15. deleteProvidersBatch: deleteProvidersBatchMock,
  16. }));
  17. vi.mock("@/lib/cache/provider-cache", () => ({
  18. publishProviderCacheInvalidation: publishProviderCacheInvalidationMock,
  19. }));
  20. vi.mock("@/lib/circuit-breaker", () => ({
  21. clearProviderState: clearProviderStateMock,
  22. clearConfigCache: clearConfigCacheMock,
  23. resetCircuit: resetCircuitMock,
  24. }));
  25. vi.mock("@/lib/session-manager", () => ({
  26. SessionManager: {
  27. terminateStickySessionsForProviders: terminateStickySessionsForProvidersMock,
  28. },
  29. }));
  30. vi.mock("@/lib/logger", () => ({
  31. logger: {
  32. trace: vi.fn(),
  33. debug: vi.fn(),
  34. info: vi.fn(),
  35. warn: vi.fn(),
  36. error: vi.fn(),
  37. },
  38. }));
  39. describe("Provider Batch Actions", () => {
  40. beforeEach(() => {
  41. vi.clearAllMocks();
  42. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  43. updateProvidersBatchMock.mockResolvedValue(3);
  44. deleteProvidersBatchMock.mockResolvedValue(3);
  45. publishProviderCacheInvalidationMock.mockResolvedValue(undefined);
  46. clearProviderStateMock.mockReturnValue(undefined);
  47. clearConfigCacheMock.mockReturnValue(undefined);
  48. resetCircuitMock.mockReturnValue(undefined);
  49. terminateStickySessionsForProvidersMock.mockResolvedValue(undefined);
  50. });
  51. describe("batchUpdateProviders", () => {
  52. it("should require admin role", async () => {
  53. getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } });
  54. const { batchUpdateProviders } = await import("@/actions/providers");
  55. const result = await batchUpdateProviders({
  56. providerIds: [1, 2, 3],
  57. updates: { is_enabled: true },
  58. });
  59. expect(result.ok).toBe(false);
  60. if (result.ok) return;
  61. expect(result.error).toBe("无权限执行此操作");
  62. expect(updateProvidersBatchMock).not.toHaveBeenCalled();
  63. expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled();
  64. });
  65. it("should reject empty providerIds", async () => {
  66. const { batchUpdateProviders } = await import("@/actions/providers");
  67. const result = await batchUpdateProviders({
  68. providerIds: [],
  69. updates: { is_enabled: true },
  70. });
  71. expect(result.ok).toBe(false);
  72. if (result.ok) return;
  73. expect(result.error).toBe("请选择要更新的供应商");
  74. expect(updateProvidersBatchMock).not.toHaveBeenCalled();
  75. });
  76. it("should enforce max batch size 500", async () => {
  77. const largeIds = Array.from({ length: 501 }, (_, i) => i + 1);
  78. const { batchUpdateProviders } = await import("@/actions/providers");
  79. const result = await batchUpdateProviders({
  80. providerIds: largeIds,
  81. updates: { is_enabled: true },
  82. });
  83. expect(result.ok).toBe(false);
  84. if (result.ok) return;
  85. expect(result.error).toBe("单次批量操作最多支持 500 个供应商");
  86. expect(updateProvidersBatchMock).not.toHaveBeenCalled();
  87. });
  88. it("should update specified fields for selected providers", async () => {
  89. const { batchUpdateProviders } = await import("@/actions/providers");
  90. const result = await batchUpdateProviders({
  91. providerIds: [10, 20, 30],
  92. updates: {
  93. is_enabled: false,
  94. priority: 5,
  95. weight: 2,
  96. cost_multiplier: 1.5,
  97. group_tag: "batch-test",
  98. },
  99. });
  100. expect(result.ok).toBe(true);
  101. if (!result.ok) return;
  102. expect(result.data.updatedCount).toBe(3);
  103. expect(updateProvidersBatchMock).toHaveBeenCalledWith([10, 20, 30], {
  104. isEnabled: false,
  105. priority: 5,
  106. weight: 2,
  107. costMultiplier: "1.5",
  108. groupTag: "batch-test",
  109. });
  110. });
  111. it("should invalidate cache after update", async () => {
  112. const { batchUpdateProviders } = await import("@/actions/providers");
  113. await batchUpdateProviders({
  114. providerIds: [1, 2],
  115. updates: { is_enabled: true },
  116. });
  117. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
  118. });
  119. it("should not fail when cache invalidation throws", async () => {
  120. publishProviderCacheInvalidationMock.mockRejectedValueOnce(new Error("cache error"));
  121. const { batchUpdateProviders } = await import("@/actions/providers");
  122. const result = await batchUpdateProviders({
  123. providerIds: [1, 2],
  124. updates: { priority: 10 },
  125. });
  126. expect(result.ok).toBe(true);
  127. expect(updateProvidersBatchMock).toHaveBeenCalledTimes(1);
  128. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
  129. });
  130. it("should handle partial updates with null group_tag", async () => {
  131. const { batchUpdateProviders } = await import("@/actions/providers");
  132. const result = await batchUpdateProviders({
  133. providerIds: [5],
  134. updates: { group_tag: null },
  135. });
  136. expect(result.ok).toBe(true);
  137. if (!result.ok) return;
  138. expect(updateProvidersBatchMock).toHaveBeenCalledWith([5], {
  139. groupTag: null,
  140. });
  141. });
  142. it("should handle partial updates with only one field", async () => {
  143. const { batchUpdateProviders } = await import("@/actions/providers");
  144. const result = await batchUpdateProviders({
  145. providerIds: [1, 2],
  146. updates: { priority: 0 },
  147. });
  148. expect(result.ok).toBe(true);
  149. if (!result.ok) return;
  150. expect(updateProvidersBatchMock).toHaveBeenCalledWith([1, 2], {
  151. priority: 0,
  152. });
  153. });
  154. it("should convert cost_multiplier to string", async () => {
  155. const { batchUpdateProviders } = await import("@/actions/providers");
  156. await batchUpdateProviders({
  157. providerIds: [1],
  158. updates: { cost_multiplier: 2.5 },
  159. });
  160. expect(updateProvidersBatchMock).toHaveBeenCalledWith([1], {
  161. costMultiplier: "2.5",
  162. });
  163. });
  164. it("should handle repository errors gracefully", async () => {
  165. updateProvidersBatchMock.mockRejectedValueOnce(new Error("DB error"));
  166. const { batchUpdateProviders } = await import("@/actions/providers");
  167. const result = await batchUpdateProviders({
  168. providerIds: [1, 2],
  169. updates: { is_enabled: true },
  170. });
  171. expect(result.ok).toBe(false);
  172. if (result.ok) return;
  173. expect(result.error).toBe("DB error");
  174. });
  175. it("should reject when no updates provided", async () => {
  176. const { batchUpdateProviders } = await import("@/actions/providers");
  177. const result = await batchUpdateProviders({
  178. providerIds: [1, 2],
  179. updates: {},
  180. });
  181. expect(result.ok).toBe(false);
  182. if (result.ok) return;
  183. expect(result.error).toBe("请指定要更新的字段");
  184. expect(updateProvidersBatchMock).not.toHaveBeenCalled();
  185. });
  186. });
  187. describe("batchDeleteProviders", () => {
  188. it("should require admin role", async () => {
  189. getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } });
  190. const { batchDeleteProviders } = await import("@/actions/providers");
  191. const result = await batchDeleteProviders({ providerIds: [1, 2] });
  192. expect(result.ok).toBe(false);
  193. if (result.ok) return;
  194. expect(result.error).toBe("无权限执行此操作");
  195. expect(deleteProvidersBatchMock).not.toHaveBeenCalled();
  196. });
  197. it("should reject empty providerIds", async () => {
  198. const { batchDeleteProviders } = await import("@/actions/providers");
  199. const result = await batchDeleteProviders({ providerIds: [] });
  200. expect(result.ok).toBe(false);
  201. if (result.ok) return;
  202. expect(result.error).toBe("请选择要删除的供应商");
  203. expect(deleteProvidersBatchMock).not.toHaveBeenCalled();
  204. });
  205. it("should enforce max batch size 500", async () => {
  206. const largeIds = Array.from({ length: 501 }, (_, i) => i + 1);
  207. const { batchDeleteProviders } = await import("@/actions/providers");
  208. const result = await batchDeleteProviders({ providerIds: largeIds });
  209. expect(result.ok).toBe(false);
  210. if (result.ok) return;
  211. expect(result.error).toBe("单次批量操作最多支持 500 个供应商");
  212. expect(deleteProvidersBatchMock).not.toHaveBeenCalled();
  213. });
  214. it("should soft delete providers", async () => {
  215. const { batchDeleteProviders } = await import("@/actions/providers");
  216. const result = await batchDeleteProviders({ providerIds: [10, 20, 30] });
  217. expect(result.ok).toBe(true);
  218. if (!result.ok) return;
  219. expect(result.data.deletedCount).toBe(3);
  220. expect(deleteProvidersBatchMock).toHaveBeenCalledWith([10, 20, 30]);
  221. });
  222. it("should clear circuit breaker state for each deleted provider", async () => {
  223. const { batchDeleteProviders } = await import("@/actions/providers");
  224. await batchDeleteProviders({ providerIds: [1, 2, 3] });
  225. expect(clearProviderStateMock).toHaveBeenCalledTimes(3);
  226. expect(clearProviderStateMock).toHaveBeenNthCalledWith(1, 1);
  227. expect(clearProviderStateMock).toHaveBeenNthCalledWith(2, 2);
  228. expect(clearProviderStateMock).toHaveBeenNthCalledWith(3, 3);
  229. expect(clearConfigCacheMock).toHaveBeenCalledTimes(3);
  230. expect(clearConfigCacheMock).toHaveBeenNthCalledWith(1, 1);
  231. expect(clearConfigCacheMock).toHaveBeenNthCalledWith(2, 2);
  232. expect(clearConfigCacheMock).toHaveBeenNthCalledWith(3, 3);
  233. });
  234. it("should invalidate cache after deletion", async () => {
  235. const { batchDeleteProviders } = await import("@/actions/providers");
  236. await batchDeleteProviders({ providerIds: [1, 2] });
  237. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
  238. });
  239. it("should not fail when cache invalidation throws", async () => {
  240. publishProviderCacheInvalidationMock.mockRejectedValueOnce(new Error("cache error"));
  241. const { batchDeleteProviders } = await import("@/actions/providers");
  242. const result = await batchDeleteProviders({ providerIds: [1, 2] });
  243. expect(result.ok).toBe(true);
  244. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
  245. });
  246. it("should handle repository errors gracefully", async () => {
  247. deleteProvidersBatchMock.mockRejectedValueOnce(new Error("DB error"));
  248. const { batchDeleteProviders } = await import("@/actions/providers");
  249. const result = await batchDeleteProviders({ providerIds: [1, 2] });
  250. expect(result.ok).toBe(false);
  251. if (result.ok) return;
  252. expect(result.error).toBe("DB error");
  253. });
  254. });
  255. describe("batchResetProviderCircuits", () => {
  256. it("should require admin role", async () => {
  257. getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } });
  258. const { batchResetProviderCircuits } = await import("@/actions/providers");
  259. const result = await batchResetProviderCircuits({ providerIds: [1, 2] });
  260. expect(result.ok).toBe(false);
  261. if (result.ok) return;
  262. expect(result.error).toBe("无权限执行此操作");
  263. expect(resetCircuitMock).not.toHaveBeenCalled();
  264. });
  265. it("should reject empty providerIds", async () => {
  266. const { batchResetProviderCircuits } = await import("@/actions/providers");
  267. const result = await batchResetProviderCircuits({ providerIds: [] });
  268. expect(result.ok).toBe(false);
  269. if (result.ok) return;
  270. expect(result.error).toBe("请选择要重置的供应商");
  271. expect(resetCircuitMock).not.toHaveBeenCalled();
  272. });
  273. it("should enforce max batch size 500", async () => {
  274. const largeIds = Array.from({ length: 501 }, (_, i) => i + 1);
  275. const { batchResetProviderCircuits } = await import("@/actions/providers");
  276. const result = await batchResetProviderCircuits({ providerIds: largeIds });
  277. expect(result.ok).toBe(false);
  278. if (result.ok) return;
  279. expect(result.error).toBe("单次批量操作最多支持 500 个供应商");
  280. expect(resetCircuitMock).not.toHaveBeenCalled();
  281. });
  282. it("should reset circuit state for all providers", async () => {
  283. const { batchResetProviderCircuits } = await import("@/actions/providers");
  284. const result = await batchResetProviderCircuits({ providerIds: [10, 20, 30] });
  285. expect(result.ok).toBe(true);
  286. if (!result.ok) return;
  287. expect(result.data.resetCount).toBe(3);
  288. expect(resetCircuitMock).toHaveBeenCalledTimes(3);
  289. expect(resetCircuitMock).toHaveBeenNthCalledWith(1, 10);
  290. expect(resetCircuitMock).toHaveBeenNthCalledWith(2, 20);
  291. expect(resetCircuitMock).toHaveBeenNthCalledWith(3, 30);
  292. });
  293. it("should clear config cache for each provider", async () => {
  294. const { batchResetProviderCircuits } = await import("@/actions/providers");
  295. await batchResetProviderCircuits({ providerIds: [1, 2] });
  296. expect(clearConfigCacheMock).toHaveBeenCalledTimes(2);
  297. expect(clearConfigCacheMock).toHaveBeenNthCalledWith(1, 1);
  298. expect(clearConfigCacheMock).toHaveBeenNthCalledWith(2, 2);
  299. });
  300. it("should handle single provider", async () => {
  301. const { batchResetProviderCircuits } = await import("@/actions/providers");
  302. const result = await batchResetProviderCircuits({ providerIds: [1] });
  303. expect(result.ok).toBe(true);
  304. if (!result.ok) return;
  305. expect(result.data.resetCount).toBe(1);
  306. expect(resetCircuitMock).toHaveBeenCalledWith(1);
  307. });
  308. it("should handle large batch within limit", async () => {
  309. const ids = Array.from({ length: 500 }, (_, i) => i + 1);
  310. const { batchResetProviderCircuits } = await import("@/actions/providers");
  311. const result = await batchResetProviderCircuits({ providerIds: ids });
  312. expect(result.ok).toBe(true);
  313. if (!result.ok) return;
  314. expect(result.data.resetCount).toBe(500);
  315. expect(resetCircuitMock).toHaveBeenCalledTimes(500);
  316. });
  317. it("should handle errors during reset", async () => {
  318. resetCircuitMock.mockImplementationOnce(() => {
  319. throw new Error("Reset failed");
  320. });
  321. const { batchResetProviderCircuits } = await import("@/actions/providers");
  322. const result = await batchResetProviderCircuits({ providerIds: [1] });
  323. expect(result.ok).toBe(false);
  324. if (result.ok) return;
  325. expect(result.error).toBe("Reset failed");
  326. });
  327. });
  328. describe("Batch Operations Integration", () => {
  329. it("should handle multiple operations in sequence", async () => {
  330. const { batchUpdateProviders, batchResetProviderCircuits, batchDeleteProviders } =
  331. await import("@/actions/providers");
  332. const updateResult = await batchUpdateProviders({
  333. providerIds: [1, 2],
  334. updates: { is_enabled: false },
  335. });
  336. expect(updateResult.ok).toBe(true);
  337. const resetResult = await batchResetProviderCircuits({ providerIds: [1, 2] });
  338. expect(resetResult.ok).toBe(true);
  339. const deleteResult = await batchDeleteProviders({ providerIds: [1, 2] });
  340. expect(deleteResult.ok).toBe(true);
  341. expect(updateProvidersBatchMock).toHaveBeenCalledTimes(1);
  342. expect(resetCircuitMock).toHaveBeenCalledTimes(2);
  343. expect(deleteProvidersBatchMock).toHaveBeenCalledTimes(1);
  344. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(2);
  345. });
  346. it("should handle overlapping provider sets", async () => {
  347. const { batchUpdateProviders } = await import("@/actions/providers");
  348. await batchUpdateProviders({
  349. providerIds: [1, 2, 3],
  350. updates: { priority: 0 },
  351. });
  352. await batchUpdateProviders({
  353. providerIds: [2, 3, 4],
  354. updates: { priority: 1 },
  355. });
  356. expect(updateProvidersBatchMock).toHaveBeenCalledTimes(2);
  357. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(2);
  358. });
  359. it("should maintain operation isolation on errors", async () => {
  360. updateProvidersBatchMock.mockRejectedValueOnce(new Error("update error"));
  361. const { batchUpdateProviders, batchResetProviderCircuits } = await import(
  362. "@/actions/providers"
  363. );
  364. const updateResult = await batchUpdateProviders({
  365. providerIds: [1],
  366. updates: { is_enabled: true },
  367. });
  368. expect(updateResult.ok).toBe(false);
  369. const resetResult = await batchResetProviderCircuits({ providerIds: [1] });
  370. expect(resetResult.ok).toBe(true);
  371. });
  372. });
  373. });