endpoint-circuit-breaker.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. import { afterEach, describe, expect, test, vi } from "vitest";
  2. type SavedEndpointCircuitState = {
  3. failureCount: number;
  4. lastFailureTime: number | null;
  5. circuitState: "closed" | "open" | "half-open";
  6. circuitOpenUntil: number | null;
  7. halfOpenSuccessCount: number;
  8. };
  9. function createLoggerMock() {
  10. return {
  11. debug: vi.fn(),
  12. info: vi.fn(),
  13. warn: vi.fn(),
  14. trace: vi.fn(),
  15. error: vi.fn(),
  16. fatal: vi.fn(),
  17. };
  18. }
  19. async function flushPromises(rounds = 2): Promise<void> {
  20. for (let i = 0; i < rounds; i++) {
  21. await new Promise((resolve) => setTimeout(resolve, 0));
  22. }
  23. }
  24. afterEach(() => {
  25. vi.useRealTimers();
  26. delete process.env.ENDPOINT_CIRCUIT_HEALTH_CACHE_MAX_SIZE;
  27. });
  28. describe("endpoint-circuit-breaker", () => {
  29. test("达到阈值后应打开熔断;到期后进入 half-open;成功后关闭并清零", async () => {
  30. vi.resetModules();
  31. let redisState: SavedEndpointCircuitState | null = null;
  32. const loadMock = vi.fn(async () => redisState);
  33. const saveMock = vi.fn(async (_endpointId: number, state: SavedEndpointCircuitState) => {
  34. redisState = state;
  35. });
  36. const deleteMock = vi.fn(async () => {
  37. redisState = null;
  38. });
  39. vi.doMock("@/lib/config/env.schema", () => ({
  40. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  41. }));
  42. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  43. const sendAlertMock = vi.fn(async () => {});
  44. vi.doMock("@/lib/notification/notifier", () => ({
  45. sendCircuitBreakerAlert: sendAlertMock,
  46. }));
  47. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  48. loadEndpointCircuitState: loadMock,
  49. saveEndpointCircuitState: saveMock,
  50. deleteEndpointCircuitState: deleteMock,
  51. }));
  52. vi.useFakeTimers();
  53. vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
  54. const {
  55. isEndpointCircuitOpen,
  56. getEndpointHealthInfo,
  57. recordEndpointFailure,
  58. recordEndpointSuccess,
  59. resetEndpointCircuit,
  60. } = await import("@/lib/endpoint-circuit-breaker");
  61. await recordEndpointFailure(1, new Error("boom"));
  62. await recordEndpointFailure(1, new Error("boom"));
  63. await recordEndpointFailure(1, new Error("boom"));
  64. const openState = saveMock.mock.calls[
  65. saveMock.mock.calls.length - 1
  66. ]?.[1] as SavedEndpointCircuitState;
  67. expect(openState.circuitState).toBe("open");
  68. expect(openState.failureCount).toBe(3);
  69. expect(openState.circuitOpenUntil).toBe(Date.now() + 300000);
  70. // Prime env module cache: under fake timers, dynamic import() inside isEndpointCircuitOpen
  71. // may fail to resolve the vi.doMock unless the module is already in the import cache.
  72. await import("@/lib/config/env.schema");
  73. expect(await isEndpointCircuitOpen(1)).toBe(true);
  74. vi.advanceTimersByTime(300000 + 1);
  75. expect(await isEndpointCircuitOpen(1)).toBe(false);
  76. const halfOpenState = saveMock.mock.calls[
  77. saveMock.mock.calls.length - 1
  78. ]?.[1] as SavedEndpointCircuitState;
  79. expect(halfOpenState.circuitState).toBe("half-open");
  80. await recordEndpointSuccess(1);
  81. expect(deleteMock).toHaveBeenCalledWith(1);
  82. const { health: afterSuccess } = await getEndpointHealthInfo(1);
  83. expect(afterSuccess.circuitState).toBe("closed");
  84. expect(afterSuccess.failureCount).toBe(0);
  85. expect(afterSuccess.circuitOpenUntil).toBeNull();
  86. expect(afterSuccess.lastFailureTime).toBeNull();
  87. expect(afterSuccess.halfOpenSuccessCount).toBe(0);
  88. expect(await isEndpointCircuitOpen(1)).toBe(false);
  89. const deleteCallsAfterSuccess = deleteMock.mock.calls.length;
  90. await resetEndpointCircuit(1);
  91. expect(deleteMock.mock.calls.length).toBeGreaterThan(deleteCallsAfterSuccess);
  92. // 说明:recordEndpointFailure 在达到阈值后会触发异步告警(dynamic import + await)。
  93. // 在 CI/bun 环境下,告警 Promise 可能在下一个测试开始后才完成,从而“借用”后续用例的 module mock,
  94. // 导致 sendAlertMock 被额外调用而产生偶发失败。这里用真实计时器让事件循环前进,确保告警任务尽快落地。
  95. vi.useRealTimers();
  96. const startedAt = Date.now();
  97. while (sendAlertMock.mock.calls.length === 0 && Date.now() - startedAt < 1000) {
  98. await new Promise<void>((resolve) => setTimeout(resolve, 0));
  99. }
  100. });
  101. test("recordEndpointSuccess: closed 且 failureCount>0 时应清零", async () => {
  102. vi.resetModules();
  103. const saveMock = vi.fn(async () => {});
  104. const deleteMock = vi.fn(async () => {});
  105. vi.doMock("@/lib/config/env.schema", () => ({
  106. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  107. }));
  108. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  109. vi.useFakeTimers();
  110. vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
  111. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  112. loadEndpointCircuitState: vi.fn(async () => null),
  113. saveEndpointCircuitState: saveMock,
  114. deleteEndpointCircuitState: deleteMock,
  115. }));
  116. const { recordEndpointFailure, recordEndpointSuccess, getEndpointHealthInfo } = await import(
  117. "@/lib/endpoint-circuit-breaker"
  118. );
  119. await recordEndpointFailure(2, new Error("boom"));
  120. await recordEndpointSuccess(2);
  121. const { health } = await getEndpointHealthInfo(2);
  122. expect(health.failureCount).toBe(0);
  123. expect(health.circuitState).toBe("closed");
  124. expect(saveMock).toHaveBeenCalledTimes(1);
  125. expect(deleteMock).toHaveBeenCalledWith(2);
  126. });
  127. test("getAllEndpointHealthStatusAsync: forceRefresh 时应同步 Redis 中的计数(即使 circuitState 未变化)", async () => {
  128. vi.resetModules();
  129. const endpointId = 42;
  130. const redisStates = new Map<number, SavedEndpointCircuitState>();
  131. const loadManyMock = vi.fn(async (endpointIds: number[]) => {
  132. const result = new Map<number, SavedEndpointCircuitState>();
  133. for (const id of endpointIds) {
  134. const state = redisStates.get(id);
  135. if (state) {
  136. result.set(id, state);
  137. }
  138. }
  139. return result;
  140. });
  141. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  142. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  143. loadEndpointCircuitState: vi.fn(async () => null),
  144. loadEndpointCircuitStates: loadManyMock,
  145. saveEndpointCircuitState: vi.fn(async () => {}),
  146. deleteEndpointCircuitState: vi.fn(async () => {}),
  147. }));
  148. vi.useFakeTimers();
  149. vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
  150. const t0 = Date.now();
  151. redisStates.set(endpointId, {
  152. failureCount: 1,
  153. lastFailureTime: t0 - 1000,
  154. circuitState: "closed",
  155. circuitOpenUntil: null,
  156. halfOpenSuccessCount: 0,
  157. });
  158. const { getAllEndpointHealthStatusAsync } = await import("@/lib/endpoint-circuit-breaker");
  159. const first = await getAllEndpointHealthStatusAsync([endpointId], { forceRefresh: true });
  160. expect(first[endpointId]).toMatchObject({
  161. failureCount: 1,
  162. lastFailureTime: t0 - 1000,
  163. circuitState: "closed",
  164. circuitOpenUntil: null,
  165. halfOpenSuccessCount: 0,
  166. });
  167. redisStates.set(endpointId, {
  168. failureCount: 2,
  169. lastFailureTime: t0 + 123,
  170. circuitState: "closed",
  171. circuitOpenUntil: null,
  172. halfOpenSuccessCount: 0,
  173. });
  174. const second = await getAllEndpointHealthStatusAsync([endpointId], { forceRefresh: true });
  175. expect(second[endpointId]).toMatchObject({
  176. failureCount: 2,
  177. lastFailureTime: t0 + 123,
  178. circuitState: "closed",
  179. circuitOpenUntil: null,
  180. halfOpenSuccessCount: 0,
  181. });
  182. expect(loadManyMock).toHaveBeenCalledTimes(2);
  183. });
  184. test("getAllEndpointHealthStatusAsync: 并发请求应复用 in-flight Redis 批量加载", async () => {
  185. vi.resetModules();
  186. const loadManyMock = vi.fn(async (_endpointIds: number[]) => {
  187. await new Promise((resolve) => setTimeout(resolve, 10));
  188. const result = new Map<number, SavedEndpointCircuitState>();
  189. result.set(1, {
  190. failureCount: 0,
  191. lastFailureTime: null,
  192. circuitState: "closed",
  193. circuitOpenUntil: null,
  194. halfOpenSuccessCount: 0,
  195. });
  196. result.set(2, {
  197. failureCount: 0,
  198. lastFailureTime: null,
  199. circuitState: "closed",
  200. circuitOpenUntil: null,
  201. halfOpenSuccessCount: 0,
  202. });
  203. result.set(3, {
  204. failureCount: 0,
  205. lastFailureTime: null,
  206. circuitState: "closed",
  207. circuitOpenUntil: null,
  208. halfOpenSuccessCount: 0,
  209. });
  210. return result;
  211. });
  212. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  213. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  214. loadEndpointCircuitState: vi.fn(async () => null),
  215. loadEndpointCircuitStates: loadManyMock,
  216. saveEndpointCircuitState: vi.fn(async () => {}),
  217. deleteEndpointCircuitState: vi.fn(async () => {}),
  218. }));
  219. vi.useFakeTimers();
  220. vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
  221. const { getAllEndpointHealthStatusAsync } = await import("@/lib/endpoint-circuit-breaker");
  222. const p1 = getAllEndpointHealthStatusAsync([1, 2, 3], { forceRefresh: true });
  223. const p2 = getAllEndpointHealthStatusAsync([1, 2, 3], { forceRefresh: true });
  224. vi.advanceTimersByTime(20);
  225. await Promise.all([p1, p2]);
  226. expect(loadManyMock).toHaveBeenCalledTimes(1);
  227. });
  228. test("triggerEndpointCircuitBreakerAlert should call sendCircuitBreakerAlert", async () => {
  229. vi.resetModules();
  230. const sendAlertMock = vi.fn(async () => {});
  231. vi.doMock("@/lib/config/env.schema", () => ({
  232. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  233. }));
  234. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  235. vi.doMock("@/lib/notification/notifier", () => ({
  236. sendCircuitBreakerAlert: sendAlertMock,
  237. }));
  238. vi.doMock("@/repository", () => ({
  239. findProviderEndpointById: vi.fn(async () => null),
  240. }));
  241. // recordEndpointFailure 会 non-blocking 触发告警;先让 event-loop 跑完再清空计数,避免串台导致误判
  242. await flushPromises();
  243. sendAlertMock.mockClear();
  244. // Prime module cache for dynamic import() consumers
  245. await import("@/lib/config/env.schema");
  246. await import("@/lib/notification/notifier");
  247. const { triggerEndpointCircuitBreakerAlert } = await import("@/lib/endpoint-circuit-breaker");
  248. await triggerEndpointCircuitBreakerAlert(
  249. 5,
  250. 3,
  251. "2026-01-01T00:05:00.000Z",
  252. "connection refused"
  253. );
  254. expect(sendAlertMock).toHaveBeenCalledTimes(1);
  255. expect(sendAlertMock).toHaveBeenCalledWith({
  256. providerId: 0,
  257. providerName: "endpoint:5",
  258. failureCount: 3,
  259. retryAt: "2026-01-01T00:05:00.000Z",
  260. lastError: "connection refused",
  261. incidentSource: "endpoint",
  262. endpointId: 5,
  263. endpointUrl: undefined,
  264. });
  265. });
  266. test("triggerEndpointCircuitBreakerAlert should include endpointUrl when available", async () => {
  267. vi.resetModules();
  268. const sendAlertMock = vi.fn(async () => {});
  269. vi.doMock("@/lib/config/env.schema", () => ({
  270. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  271. }));
  272. vi.doMock("@/lib/notification/notifier", () => ({
  273. sendCircuitBreakerAlert: sendAlertMock,
  274. }));
  275. vi.doMock("@/repository", () => ({
  276. findProviderEndpointById: vi.fn(async () => ({
  277. id: 10,
  278. url: "https://custom.example.com/v1/chat/completions",
  279. vendorId: 1,
  280. providerType: "openai",
  281. label: "Custom Endpoint",
  282. sortOrder: 0,
  283. isEnabled: true,
  284. lastProbedAt: null,
  285. lastProbeOk: null,
  286. lastProbeStatusCode: null,
  287. lastProbeLatencyMs: null,
  288. lastProbeErrorType: null,
  289. lastProbeErrorMessage: null,
  290. createdAt: new Date(),
  291. updatedAt: new Date(),
  292. deletedAt: null,
  293. })),
  294. }));
  295. // recordEndpointFailure 会 non-blocking 触发告警;先让 event-loop 跑完再清空计数,避免串台导致误判
  296. await flushPromises();
  297. sendAlertMock.mockClear();
  298. // Prime module cache for dynamic import() consumers
  299. await import("@/lib/config/env.schema");
  300. await import("@/lib/notification/notifier");
  301. const { triggerEndpointCircuitBreakerAlert } = await import("@/lib/endpoint-circuit-breaker");
  302. await triggerEndpointCircuitBreakerAlert(10, 3, "2026-01-01T00:05:00.000Z", "timeout");
  303. expect(sendAlertMock).toHaveBeenCalledTimes(1);
  304. expect(sendAlertMock).toHaveBeenCalledWith({
  305. providerId: 1,
  306. providerName: "Custom Endpoint",
  307. failureCount: 3,
  308. retryAt: "2026-01-01T00:05:00.000Z",
  309. lastError: "timeout",
  310. incidentSource: "endpoint",
  311. endpointId: 10,
  312. endpointUrl: "https://custom.example.com/v1/chat/completions",
  313. });
  314. });
  315. test("recordEndpointFailure should NOT reset circuitOpenUntil when already open", async () => {
  316. vi.resetModules();
  317. let redisState: SavedEndpointCircuitState | null = null;
  318. const saveMock = vi.fn(async (_endpointId: number, state: SavedEndpointCircuitState) => {
  319. redisState = state;
  320. });
  321. vi.doMock("@/lib/config/env.schema", () => ({
  322. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  323. }));
  324. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  325. vi.doMock("@/lib/notification/notifier", () => ({
  326. sendCircuitBreakerAlert: vi.fn(async () => {}),
  327. }));
  328. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  329. loadEndpointCircuitState: vi.fn(async () => redisState),
  330. saveEndpointCircuitState: saveMock,
  331. deleteEndpointCircuitState: vi.fn(async () => {}),
  332. }));
  333. vi.useFakeTimers();
  334. vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
  335. const { recordEndpointFailure, isEndpointCircuitOpen, getEndpointHealthInfo } = await import(
  336. "@/lib/endpoint-circuit-breaker"
  337. );
  338. // Record 3 failures to open the circuit
  339. await recordEndpointFailure(100, new Error("fail"));
  340. await recordEndpointFailure(100, new Error("fail"));
  341. await recordEndpointFailure(100, new Error("fail"));
  342. // Verify circuit was opened (also serves as async flush before isEndpointCircuitOpen)
  343. const { health: healthSnap } = await getEndpointHealthInfo(100);
  344. expect(healthSnap.circuitState).toBe("open");
  345. // Prime the env module cache: under fake timers, the dynamic import("@/lib/config/env.schema")
  346. // inside isEndpointCircuitOpen may fail to resolve the mock unless the module is already cached.
  347. const envMod = await import("@/lib/config/env.schema");
  348. expect(envMod.getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER).toBe(true);
  349. expect(await isEndpointCircuitOpen(100)).toBe(true);
  350. const originalOpenUntil = redisState!.circuitOpenUntil;
  351. expect(originalOpenUntil).toBe(Date.now() + 300000);
  352. // Advance 1 min and record another failure — timer must NOT reset
  353. vi.advanceTimersByTime(60_000);
  354. await recordEndpointFailure(100, new Error("fail again"));
  355. expect(redisState!.circuitState).toBe("open");
  356. expect(redisState!.circuitOpenUntil).toBe(originalOpenUntil); // unchanged!
  357. expect(redisState!.failureCount).toBe(4);
  358. });
  359. test("getEndpointCircuitStateSync returns correct state for known and unknown endpoints", async () => {
  360. vi.resetModules();
  361. vi.doMock("@/lib/config/env.schema", () => ({
  362. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  363. }));
  364. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  365. vi.doMock("@/lib/notification/notifier", () => ({
  366. sendCircuitBreakerAlert: vi.fn(async () => {}),
  367. }));
  368. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  369. loadEndpointCircuitState: vi.fn(async () => null),
  370. saveEndpointCircuitState: vi.fn(async () => {}),
  371. deleteEndpointCircuitState: vi.fn(async () => {}),
  372. }));
  373. const { getEndpointCircuitStateSync, recordEndpointFailure } = await import(
  374. "@/lib/endpoint-circuit-breaker"
  375. );
  376. // Unknown endpoint returns "closed"
  377. expect(getEndpointCircuitStateSync(9999)).toBe("closed");
  378. // After opening the circuit, sync accessor reflects "open"
  379. await recordEndpointFailure(200, new Error("a"));
  380. await recordEndpointFailure(200, new Error("b"));
  381. await recordEndpointFailure(200, new Error("c"));
  382. expect(getEndpointCircuitStateSync(200)).toBe("open");
  383. });
  384. describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => {
  385. test("isEndpointCircuitOpen returns false when ENABLE_ENDPOINT_CIRCUIT_BREAKER=false", async () => {
  386. vi.resetModules();
  387. vi.doMock("@/lib/config/env.schema", () => ({
  388. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  389. }));
  390. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  391. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  392. loadEndpointCircuitState: vi.fn(async () => null),
  393. saveEndpointCircuitState: vi.fn(async () => {}),
  394. deleteEndpointCircuitState: vi.fn(async () => {}),
  395. }));
  396. const { isEndpointCircuitOpen } = await import("@/lib/endpoint-circuit-breaker");
  397. expect(await isEndpointCircuitOpen(1)).toBe(false);
  398. expect(await isEndpointCircuitOpen(999)).toBe(false);
  399. });
  400. test("recordEndpointFailure is no-op when disabled", async () => {
  401. vi.resetModules();
  402. const saveMock = vi.fn(async () => {});
  403. vi.doMock("@/lib/config/env.schema", () => ({
  404. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  405. }));
  406. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  407. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  408. loadEndpointCircuitState: vi.fn(async () => null),
  409. saveEndpointCircuitState: saveMock,
  410. deleteEndpointCircuitState: vi.fn(async () => {}),
  411. }));
  412. const { recordEndpointFailure } = await import("@/lib/endpoint-circuit-breaker");
  413. await recordEndpointFailure(1, new Error("boom"));
  414. await recordEndpointFailure(1, new Error("boom"));
  415. await recordEndpointFailure(1, new Error("boom"));
  416. expect(saveMock).not.toHaveBeenCalled();
  417. });
  418. test("recordEndpointSuccess is no-op when disabled", async () => {
  419. vi.resetModules();
  420. const saveMock = vi.fn(async () => {});
  421. vi.doMock("@/lib/config/env.schema", () => ({
  422. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  423. }));
  424. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  425. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  426. loadEndpointCircuitState: vi.fn(async () => null),
  427. saveEndpointCircuitState: saveMock,
  428. deleteEndpointCircuitState: vi.fn(async () => {}),
  429. }));
  430. const { recordEndpointSuccess } = await import("@/lib/endpoint-circuit-breaker");
  431. await recordEndpointSuccess(1);
  432. expect(saveMock).not.toHaveBeenCalled();
  433. });
  434. test("triggerEndpointCircuitBreakerAlert is no-op when disabled", async () => {
  435. vi.resetModules();
  436. const sendAlertMock = vi.fn(async () => {});
  437. vi.doMock("@/lib/config/env.schema", () => ({
  438. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  439. }));
  440. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  441. vi.doMock("@/lib/notification/notifier", () => ({
  442. sendCircuitBreakerAlert: sendAlertMock,
  443. }));
  444. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  445. loadEndpointCircuitState: vi.fn(async () => null),
  446. saveEndpointCircuitState: vi.fn(async () => {}),
  447. deleteEndpointCircuitState: vi.fn(async () => {}),
  448. }));
  449. const { triggerEndpointCircuitBreakerAlert } = await import("@/lib/endpoint-circuit-breaker");
  450. await triggerEndpointCircuitBreakerAlert(
  451. 5,
  452. 3,
  453. "2026-01-01T00:05:00.000Z",
  454. "connection refused"
  455. );
  456. expect(sendAlertMock).not.toHaveBeenCalled();
  457. });
  458. test("initEndpointCircuitBreaker clears in-memory state and Redis keys when disabled", async () => {
  459. vi.resetModules();
  460. const redisMock = {
  461. scan: vi
  462. .fn()
  463. .mockResolvedValueOnce([
  464. "0",
  465. ["endpoint_circuit_breaker:state:1", "endpoint_circuit_breaker:state:2"],
  466. ]),
  467. del: vi.fn(async () => {}),
  468. };
  469. vi.doMock("@/lib/config/env.schema", () => ({
  470. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  471. }));
  472. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  473. vi.doMock("@/lib/redis/client", () => ({
  474. getRedisClient: () => redisMock,
  475. }));
  476. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  477. loadEndpointCircuitState: vi.fn(async () => null),
  478. saveEndpointCircuitState: vi.fn(async () => {}),
  479. deleteEndpointCircuitState: vi.fn(async () => {}),
  480. }));
  481. const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker");
  482. await initEndpointCircuitBreaker();
  483. expect(redisMock.scan).toHaveBeenCalled();
  484. expect(redisMock.del).toHaveBeenCalledWith(
  485. "endpoint_circuit_breaker:state:1",
  486. "endpoint_circuit_breaker:state:2"
  487. );
  488. });
  489. test("initEndpointCircuitBreaker is no-op when enabled", async () => {
  490. vi.resetModules();
  491. const redisMock = {
  492. scan: vi.fn(),
  493. del: vi.fn(),
  494. };
  495. vi.doMock("@/lib/config/env.schema", () => ({
  496. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  497. }));
  498. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  499. vi.doMock("@/lib/redis/client", () => ({
  500. getRedisClient: () => redisMock,
  501. }));
  502. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  503. loadEndpointCircuitState: vi.fn(async () => null),
  504. saveEndpointCircuitState: vi.fn(async () => {}),
  505. deleteEndpointCircuitState: vi.fn(async () => {}),
  506. }));
  507. const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker");
  508. await initEndpointCircuitBreaker();
  509. expect(redisMock.scan).not.toHaveBeenCalled();
  510. expect(redisMock.del).not.toHaveBeenCalled();
  511. });
  512. });
  513. });