provider-undo-delete.test.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  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 deleteProvidersBatchMock = vi.fn();
  6. const restoreProvidersBatchMock = vi.fn();
  7. const publishCacheInvalidationMock = vi.fn();
  8. const clearProviderStateMock = vi.fn();
  9. const clearConfigCacheMock = vi.fn();
  10. const { store: redisStore, mocks: redisMocks } = createRedisStore();
  11. vi.mock("@/lib/auth", () => ({
  12. getSession: getSessionMock,
  13. }));
  14. vi.mock("@/repository/provider", () => ({
  15. deleteProvidersBatch: deleteProvidersBatchMock,
  16. findAllProvidersFresh: vi.fn(),
  17. updateProvidersBatch: vi.fn(),
  18. }));
  19. vi.mock("@/repository", () => ({
  20. restoreProvidersBatch: restoreProvidersBatchMock,
  21. }));
  22. vi.mock("@/lib/cache/provider-cache", () => ({
  23. publishProviderCacheInvalidation: publishCacheInvalidationMock,
  24. }));
  25. vi.mock("@/lib/circuit-breaker", () => ({
  26. clearProviderState: clearProviderStateMock,
  27. clearConfigCache: clearConfigCacheMock,
  28. resetCircuit: vi.fn(),
  29. getAllHealthStatusAsync: vi.fn(),
  30. }));
  31. vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks));
  32. vi.mock("@/lib/logger", () => ({
  33. logger: {
  34. trace: vi.fn(),
  35. debug: vi.fn(),
  36. info: vi.fn(),
  37. warn: vi.fn(),
  38. error: vi.fn(),
  39. },
  40. }));
  41. describe("Provider Delete Undo Actions", () => {
  42. beforeEach(() => {
  43. vi.clearAllMocks();
  44. vi.resetModules();
  45. redisStore.clear();
  46. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  47. deleteProvidersBatchMock.mockResolvedValue(2);
  48. restoreProvidersBatchMock.mockResolvedValue(2);
  49. publishCacheInvalidationMock.mockResolvedValue(undefined);
  50. clearProviderStateMock.mockReturnValue(undefined);
  51. clearConfigCacheMock.mockReturnValue(undefined);
  52. });
  53. afterEach(() => {
  54. vi.useRealTimers();
  55. });
  56. it("batchDeleteProviders should return undoToken and operationId", async () => {
  57. const { batchDeleteProviders } = await import("../../../src/actions/providers");
  58. const result = await batchDeleteProviders({ providerIds: [3, 1, 3] });
  59. expect(result.ok).toBe(true);
  60. if (!result.ok) return;
  61. expect(deleteProvidersBatchMock).toHaveBeenCalledWith([1, 3]);
  62. expect(result.data.deletedCount).toBe(2);
  63. expect(result.data.undoToken).toMatch(/^provider_patch_undo_/);
  64. expect(result.data.operationId).toMatch(/^provider_patch_apply_/);
  65. });
  66. it("batchDeleteProviders should return repository errors", async () => {
  67. deleteProvidersBatchMock.mockRejectedValueOnce(new Error("delete failed"));
  68. const { batchDeleteProviders } = await import("../../../src/actions/providers");
  69. const result = await batchDeleteProviders({ providerIds: [7] });
  70. expect(result.ok).toBe(false);
  71. if (result.ok) return;
  72. expect(result.error).toBe("delete failed");
  73. });
  74. it("batchDeleteProviders should reject non-admin session", async () => {
  75. getSessionMock.mockResolvedValueOnce({ user: { id: 3, role: "user" } });
  76. const { batchDeleteProviders } = await import("../../../src/actions/providers");
  77. const result = await batchDeleteProviders({ providerIds: [1] });
  78. expect(result.ok).toBe(false);
  79. if (result.ok) return;
  80. expect(result.error).toBe("无权限执行此操作");
  81. expect(deleteProvidersBatchMock).not.toHaveBeenCalled();
  82. });
  83. it("batchDeleteProviders should reject empty provider list", async () => {
  84. const { batchDeleteProviders } = await import("../../../src/actions/providers");
  85. const result = await batchDeleteProviders({ providerIds: [] });
  86. expect(result.ok).toBe(false);
  87. if (result.ok) return;
  88. expect(result.error).toBe("请选择要删除的供应商");
  89. expect(deleteProvidersBatchMock).not.toHaveBeenCalled();
  90. });
  91. it("batchDeleteProviders should reject provider lists over max size", async () => {
  92. const { batchDeleteProviders } = await import("../../../src/actions/providers");
  93. const result = await batchDeleteProviders({
  94. providerIds: Array.from({ length: 501 }, (_, index) => index + 1),
  95. });
  96. expect(result.ok).toBe(false);
  97. if (result.ok) return;
  98. expect(result.error).toContain("单次批量操作最多支持");
  99. expect(deleteProvidersBatchMock).not.toHaveBeenCalled();
  100. });
  101. it("undoProviderDelete should restore providers by snapshot", async () => {
  102. const { batchDeleteProviders, undoProviderDelete } = await import(
  103. "../../../src/actions/providers"
  104. );
  105. const deleted = await batchDeleteProviders({ providerIds: [2, 4] });
  106. if (!deleted.ok) throw new Error(`Delete should succeed: ${deleted.error}`);
  107. restoreProvidersBatchMock.mockClear();
  108. publishCacheInvalidationMock.mockClear();
  109. clearProviderStateMock.mockClear();
  110. clearConfigCacheMock.mockClear();
  111. const undone = await undoProviderDelete({
  112. undoToken: deleted.data.undoToken,
  113. operationId: deleted.data.operationId,
  114. });
  115. expect(undone.ok).toBe(true);
  116. if (!undone.ok) return;
  117. expect(restoreProvidersBatchMock).toHaveBeenCalledWith([2, 4]);
  118. expect(undone.data.operationId).toBe(deleted.data.operationId);
  119. expect(undone.data.restoredCount).toBe(2);
  120. expect(clearProviderStateMock).toHaveBeenCalledTimes(2);
  121. expect(clearConfigCacheMock).toHaveBeenCalledTimes(2);
  122. expect(publishCacheInvalidationMock).toHaveBeenCalledTimes(1);
  123. });
  124. it("undoProviderDelete should expire after 61 seconds", async () => {
  125. vi.useFakeTimers();
  126. vi.setSystemTime(new Date("2026-02-19T00:00:00.000Z"));
  127. const { batchDeleteProviders, undoProviderDelete } = await import(
  128. "../../../src/actions/providers"
  129. );
  130. const deleted = await batchDeleteProviders({ providerIds: [9] });
  131. if (!deleted.ok) throw new Error(`Delete should succeed: ${deleted.error}`);
  132. restoreProvidersBatchMock.mockClear();
  133. vi.advanceTimersByTime(61_000);
  134. const undone = await undoProviderDelete({
  135. undoToken: deleted.data.undoToken,
  136. operationId: deleted.data.operationId,
  137. });
  138. expect(undone.ok).toBe(false);
  139. if (undone.ok) return;
  140. expect(undone.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED);
  141. expect(restoreProvidersBatchMock).not.toHaveBeenCalled();
  142. });
  143. it("undoProviderDelete should reject mismatched operation id", async () => {
  144. const { batchDeleteProviders, undoProviderDelete } = await import(
  145. "../../../src/actions/providers"
  146. );
  147. const deleted = await batchDeleteProviders({ providerIds: [10, 11] });
  148. if (!deleted.ok) throw new Error(`Delete should succeed: ${deleted.error}`);
  149. restoreProvidersBatchMock.mockClear();
  150. const undone = await undoProviderDelete({
  151. undoToken: deleted.data.undoToken,
  152. operationId: `${deleted.data.operationId}-mismatch`,
  153. });
  154. expect(undone.ok).toBe(false);
  155. if (undone.ok) return;
  156. expect(undone.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_CONFLICT);
  157. expect(restoreProvidersBatchMock).not.toHaveBeenCalled();
  158. });
  159. it("undoProviderDelete should reject invalid payload", async () => {
  160. const { undoProviderDelete } = await import("../../../src/actions/providers");
  161. const undone = await undoProviderDelete({
  162. undoToken: "",
  163. operationId: "provider_patch_apply_x",
  164. });
  165. expect(undone.ok).toBe(false);
  166. if (undone.ok) return;
  167. expect(undone.errorCode).toBeDefined();
  168. expect(restoreProvidersBatchMock).not.toHaveBeenCalled();
  169. });
  170. it("undoProviderDelete should reject non-admin session", async () => {
  171. getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } });
  172. const { undoProviderDelete } = await import("../../../src/actions/providers");
  173. const undone = await undoProviderDelete({
  174. undoToken: "provider_patch_undo_x",
  175. operationId: "provider_patch_apply_x",
  176. });
  177. expect(undone.ok).toBe(false);
  178. if (undone.ok) return;
  179. expect(undone.error).toBe("无权限执行此操作");
  180. expect(restoreProvidersBatchMock).not.toHaveBeenCalled();
  181. });
  182. it("undoProviderDelete should return repository errors when restore fails", async () => {
  183. const { batchDeleteProviders, undoProviderDelete } = await import(
  184. "../../../src/actions/providers"
  185. );
  186. const deleted = await batchDeleteProviders({ providerIds: [12] });
  187. if (!deleted.ok) throw new Error(`Delete should succeed: ${deleted.error}`);
  188. restoreProvidersBatchMock.mockRejectedValueOnce(new Error("restore failed"));
  189. const undone = await undoProviderDelete({
  190. undoToken: deleted.data.undoToken,
  191. operationId: deleted.data.operationId,
  192. });
  193. expect(undone.ok).toBe(false);
  194. if (undone.ok) return;
  195. expect(undone.error).toBe("restore failed");
  196. });
  197. });