providers-patch-actions-contract.test.ts 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes";
  3. import { buildRedisMock, createRedisStore } from "./redis-mock-utils";
  4. const getSessionMock = vi.fn();
  5. const findAllProvidersFreshMock = vi.fn();
  6. const updateProvidersBatchMock = vi.fn();
  7. const { store: redisStore, mocks: redisMocks } = createRedisStore();
  8. vi.mock("@/lib/auth", () => ({
  9. getSession: getSessionMock,
  10. }));
  11. vi.mock("@/repository/provider", () => ({
  12. findAllProvidersFresh: findAllProvidersFreshMock,
  13. updateProvidersBatch: updateProvidersBatchMock,
  14. deleteProvidersBatch: vi.fn(),
  15. }));
  16. vi.mock("@/lib/cache/provider-cache", () => ({
  17. publishProviderCacheInvalidation: vi.fn(),
  18. }));
  19. vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks));
  20. vi.mock("@/lib/circuit-breaker", () => ({
  21. clearProviderState: vi.fn(),
  22. clearConfigCache: vi.fn(),
  23. resetCircuit: vi.fn(),
  24. }));
  25. vi.mock("@/lib/logger", () => ({
  26. logger: {
  27. trace: vi.fn(),
  28. debug: vi.fn(),
  29. info: vi.fn(),
  30. warn: vi.fn(),
  31. error: vi.fn(),
  32. },
  33. }));
  34. function makeProvider(id: number, overrides: Record<string, unknown> = {}) {
  35. return {
  36. id,
  37. name: `Provider-${id}`,
  38. url: "https://api.example.com/v1",
  39. key: "sk-test",
  40. providerVendorId: null,
  41. isEnabled: true,
  42. weight: 100,
  43. priority: 1,
  44. groupPriorities: null,
  45. costMultiplier: 1.0,
  46. groupTag: null,
  47. providerType: "claude",
  48. preserveClientIp: false,
  49. modelRedirects: null,
  50. allowedModels: null,
  51. mcpPassthroughType: "none",
  52. mcpPassthroughUrl: null,
  53. limit5hUsd: null,
  54. limitDailyUsd: null,
  55. dailyResetMode: "fixed",
  56. dailyResetTime: "00:00",
  57. limitWeeklyUsd: null,
  58. limitMonthlyUsd: null,
  59. limitTotalUsd: null,
  60. totalCostResetAt: null,
  61. limitConcurrentSessions: null,
  62. maxRetryAttempts: null,
  63. circuitBreakerFailureThreshold: 5,
  64. circuitBreakerOpenDuration: 1800000,
  65. circuitBreakerHalfOpenSuccessThreshold: 2,
  66. proxyUrl: null,
  67. proxyFallbackToDirect: false,
  68. firstByteTimeoutStreamingMs: 30000,
  69. streamingIdleTimeoutMs: 10000,
  70. requestTimeoutNonStreamingMs: 600000,
  71. websiteUrl: null,
  72. faviconUrl: null,
  73. cacheTtlPreference: null,
  74. swapCacheTtlBilling: false,
  75. context1mPreference: null,
  76. codexReasoningEffortPreference: null,
  77. codexReasoningSummaryPreference: null,
  78. codexTextVerbosityPreference: null,
  79. codexParallelToolCallsPreference: null,
  80. anthropicMaxTokensPreference: null,
  81. anthropicThinkingBudgetPreference: null,
  82. anthropicAdaptiveThinking: null,
  83. geminiGoogleSearchPreference: null,
  84. tpm: null,
  85. rpm: null,
  86. rpd: null,
  87. cc: null,
  88. createdAt: new Date("2025-01-01"),
  89. updatedAt: new Date("2025-01-01"),
  90. deletedAt: null,
  91. ...overrides,
  92. };
  93. }
  94. describe("Provider Batch Patch Action Contracts", () => {
  95. beforeEach(() => {
  96. vi.clearAllMocks();
  97. vi.resetModules();
  98. redisStore.clear();
  99. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  100. findAllProvidersFreshMock.mockResolvedValue([]);
  101. updateProvidersBatchMock.mockResolvedValue(0);
  102. });
  103. it("previewProviderBatchPatch should require admin role", async () => {
  104. getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } });
  105. const { previewProviderBatchPatch } = await import("@/actions/providers");
  106. const result = await previewProviderBatchPatch({
  107. providerIds: [1, 2],
  108. patch: { group_tag: { set: "ops" } },
  109. });
  110. expect(result.ok).toBe(false);
  111. if (result.ok) return;
  112. expect(result.error).toBe("无权限执行此操作");
  113. });
  114. it("previewProviderBatchPatch should return structured preview payload", async () => {
  115. const { previewProviderBatchPatch } = await import("@/actions/providers");
  116. const result = await previewProviderBatchPatch({
  117. providerIds: [3, 1, 3, 2],
  118. patch: {
  119. group_tag: { set: "blue" },
  120. allowed_models: { clear: true },
  121. },
  122. });
  123. expect(result.ok).toBe(true);
  124. if (!result.ok) return;
  125. expect(result.data.providerIds).toEqual([1, 2, 3]);
  126. expect(result.data.summary.providerCount).toBe(3);
  127. expect(result.data.summary.fieldCount).toBe(2);
  128. expect(result.data.changedFields).toEqual(["group_tag", "allowed_models"]);
  129. expect(result.data.previewToken).toMatch(/^provider_patch_preview_/);
  130. expect(result.data.previewRevision.length).toBeGreaterThan(0);
  131. expect(result.data.previewExpiresAt.length).toBeGreaterThan(0);
  132. });
  133. it("previewProviderBatchPatch should return NOTHING_TO_APPLY when patch has no changes", async () => {
  134. const { previewProviderBatchPatch } = await import("@/actions/providers");
  135. const result = await previewProviderBatchPatch({
  136. providerIds: [1],
  137. patch: { group_tag: { no_change: true } },
  138. });
  139. expect(result.ok).toBe(false);
  140. if (result.ok) return;
  141. expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.NOTHING_TO_APPLY);
  142. });
  143. it("applyProviderBatchPatch should reject unknown preview token", async () => {
  144. const { applyProviderBatchPatch } = await import("@/actions/providers");
  145. const result = await applyProviderBatchPatch({
  146. previewToken: "provider_patch_preview_missing",
  147. previewRevision: "rev",
  148. providerIds: [1],
  149. patch: { group_tag: { set: "x" } },
  150. });
  151. expect(result.ok).toBe(false);
  152. if (result.ok) return;
  153. expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_EXPIRED);
  154. });
  155. it("applyProviderBatchPatch should reject stale revision", async () => {
  156. const { previewProviderBatchPatch, applyProviderBatchPatch } = await import(
  157. "@/actions/providers"
  158. );
  159. const preview = await previewProviderBatchPatch({
  160. providerIds: [1],
  161. patch: { group_tag: { set: "x" } },
  162. });
  163. if (!preview.ok) throw new Error("Preview should be ok in test setup");
  164. const apply = await applyProviderBatchPatch({
  165. previewToken: preview.data.previewToken,
  166. previewRevision: `${preview.data.previewRevision}-stale`,
  167. providerIds: [1],
  168. patch: { group_tag: { set: "x" } },
  169. });
  170. expect(apply.ok).toBe(false);
  171. if (apply.ok) return;
  172. expect(apply.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_STALE);
  173. });
  174. it("applyProviderBatchPatch should return idempotent result for same idempotency key", async () => {
  175. const { previewProviderBatchPatch, applyProviderBatchPatch } = await import(
  176. "@/actions/providers"
  177. );
  178. const preview = await previewProviderBatchPatch({
  179. providerIds: [1, 2],
  180. patch: { group_tag: { set: "x" } },
  181. });
  182. if (!preview.ok) throw new Error("Preview should be ok in test setup");
  183. const firstApply = await applyProviderBatchPatch({
  184. previewToken: preview.data.previewToken,
  185. previewRevision: preview.data.previewRevision,
  186. providerIds: [1, 2],
  187. patch: { group_tag: { set: "x" } },
  188. idempotencyKey: "idempotency-key-1",
  189. });
  190. const secondApply = await applyProviderBatchPatch({
  191. previewToken: preview.data.previewToken,
  192. previewRevision: preview.data.previewRevision,
  193. providerIds: [1, 2],
  194. patch: { group_tag: { set: "x" } },
  195. idempotencyKey: "idempotency-key-1",
  196. });
  197. expect(firstApply.ok).toBe(true);
  198. expect(secondApply.ok).toBe(true);
  199. if (!firstApply.ok || !secondApply.ok) return;
  200. expect(secondApply.data.operationId).toBe(firstApply.data.operationId);
  201. expect(secondApply.data.undoToken).toBe(firstApply.data.undoToken);
  202. });
  203. it("undoProviderPatch should reject mismatched operation id", async () => {
  204. const { previewProviderBatchPatch, applyProviderBatchPatch, undoProviderPatch } = await import(
  205. "@/actions/providers"
  206. );
  207. const preview = await previewProviderBatchPatch({
  208. providerIds: [10],
  209. patch: { group_tag: { set: "undo-test" } },
  210. });
  211. if (!preview.ok) throw new Error("Preview should be ok in test setup");
  212. const apply = await applyProviderBatchPatch({
  213. previewToken: preview.data.previewToken,
  214. previewRevision: preview.data.previewRevision,
  215. providerIds: [10],
  216. patch: { group_tag: { set: "undo-test" } },
  217. idempotencyKey: "undo-case",
  218. });
  219. if (!apply.ok) throw new Error("Apply should be ok in test setup");
  220. const undo = await undoProviderPatch({
  221. undoToken: apply.data.undoToken,
  222. operationId: `${apply.data.operationId}-invalid`,
  223. });
  224. expect(undo.ok).toBe(false);
  225. if (undo.ok) return;
  226. expect(undo.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_CONFLICT);
  227. });
  228. it("undoProviderPatch should consume token on success", async () => {
  229. findAllProvidersFreshMock.mockResolvedValue([
  230. makeProvider(12, { groupTag: "before-12" }),
  231. makeProvider(13, { groupTag: "before-13" }),
  232. ]);
  233. updateProvidersBatchMock.mockResolvedValue(1);
  234. const { previewProviderBatchPatch, applyProviderBatchPatch, undoProviderPatch } = await import(
  235. "@/actions/providers"
  236. );
  237. const preview = await previewProviderBatchPatch({
  238. providerIds: [12, 13],
  239. patch: { group_tag: { set: "rollback" } },
  240. });
  241. if (!preview.ok) throw new Error("Preview should be ok in test setup");
  242. const apply = await applyProviderBatchPatch({
  243. previewToken: preview.data.previewToken,
  244. previewRevision: preview.data.previewRevision,
  245. providerIds: [12, 13],
  246. patch: { group_tag: { set: "rollback" } },
  247. idempotencyKey: "undo-consume",
  248. });
  249. if (!apply.ok) throw new Error("Apply should be ok in test setup");
  250. const firstUndo = await undoProviderPatch({
  251. undoToken: apply.data.undoToken,
  252. operationId: apply.data.operationId,
  253. });
  254. const secondUndo = await undoProviderPatch({
  255. undoToken: apply.data.undoToken,
  256. operationId: apply.data.operationId,
  257. });
  258. expect(firstUndo.ok).toBe(true);
  259. if (firstUndo.ok) {
  260. expect(firstUndo.data.revertedCount).toBe(2);
  261. }
  262. expect(secondUndo.ok).toBe(false);
  263. if (secondUndo.ok) return;
  264. expect(secondUndo.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED);
  265. });
  266. });