probe.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. import { afterEach, describe, expect, test, vi } from "vitest";
  2. import type { ProviderEndpoint } from "@/types/provider";
  3. function makeEndpoint(overrides: Partial<ProviderEndpoint>): ProviderEndpoint {
  4. return {
  5. id: 1,
  6. vendorId: 1,
  7. providerType: "claude",
  8. url: "https://example.com",
  9. label: null,
  10. sortOrder: 0,
  11. isEnabled: true,
  12. lastProbedAt: null,
  13. lastProbeOk: null,
  14. lastProbeStatusCode: null,
  15. lastProbeLatencyMs: null,
  16. lastProbeErrorType: null,
  17. lastProbeErrorMessage: null,
  18. createdAt: new Date(0),
  19. updatedAt: new Date(0),
  20. deletedAt: null,
  21. ...overrides,
  22. };
  23. }
  24. function createCircuitBreakerMock(overrides: Partial<Record<string, unknown>> = {}) {
  25. return {
  26. getEndpointCircuitStateSync: vi.fn(() => "closed"),
  27. resetEndpointCircuit: vi.fn(async () => {}),
  28. recordEndpointFailure: vi.fn(async () => {}),
  29. ...overrides,
  30. };
  31. }
  32. afterEach(() => {
  33. vi.unstubAllGlobals();
  34. vi.useRealTimers();
  35. delete process.env.ENDPOINT_PROBE_METHOD;
  36. });
  37. describe("provider-endpoints: probe", () => {
  38. test("probeEndpointUrl: HEAD 成功时直接返回,不触发 GET", async () => {
  39. process.env.ENDPOINT_PROBE_METHOD = "HEAD";
  40. vi.resetModules();
  41. const logger = {
  42. debug: vi.fn(),
  43. info: vi.fn(),
  44. warn: vi.fn(),
  45. trace: vi.fn(),
  46. error: vi.fn(),
  47. fatal: vi.fn(),
  48. };
  49. vi.doMock("@/lib/logger", () => ({ logger }));
  50. vi.doMock("@/repository", () => ({
  51. findProviderEndpointById: vi.fn(),
  52. recordProviderEndpointProbeResult: vi.fn(),
  53. updateProviderEndpointProbeSnapshot: vi.fn(),
  54. }));
  55. vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
  56. const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
  57. if (init?.method === "HEAD") {
  58. return new Response(null, { status: 204 });
  59. }
  60. throw new Error("unexpected");
  61. });
  62. vi.stubGlobal("fetch", fetchMock);
  63. const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
  64. const result = await probeEndpointUrl("https://example.com", 1234);
  65. expect(result).toEqual(
  66. expect.objectContaining({ ok: true, method: "HEAD", statusCode: 204, errorType: null })
  67. );
  68. expect(fetchMock).toHaveBeenCalledTimes(1);
  69. });
  70. test("probeEndpointUrl: HEAD 网络错误时回退 GET", async () => {
  71. process.env.ENDPOINT_PROBE_METHOD = "HEAD";
  72. vi.resetModules();
  73. const logger = {
  74. debug: vi.fn(),
  75. info: vi.fn(),
  76. warn: vi.fn(),
  77. trace: vi.fn(),
  78. error: vi.fn(),
  79. fatal: vi.fn(),
  80. };
  81. vi.doMock("@/lib/logger", () => ({ logger }));
  82. vi.doMock("@/repository", () => ({
  83. findProviderEndpointById: vi.fn(),
  84. recordProviderEndpointProbeResult: vi.fn(),
  85. updateProviderEndpointProbeSnapshot: vi.fn(),
  86. }));
  87. vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
  88. const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
  89. if (init?.method === "HEAD") {
  90. throw new Error("boom");
  91. }
  92. if (init?.method === "GET") {
  93. return new Response(null, { status: 200 });
  94. }
  95. throw new Error("unexpected");
  96. });
  97. vi.stubGlobal("fetch", fetchMock);
  98. const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
  99. const result = await probeEndpointUrl("https://example.com", 1234);
  100. expect(result).toEqual(
  101. expect.objectContaining({ ok: true, method: "GET", statusCode: 200, errorType: null })
  102. );
  103. expect(fetchMock).toHaveBeenCalledTimes(2);
  104. });
  105. test("probeEndpointUrl: 5xx 返回 ok=false 且标注 http_5xx", async () => {
  106. process.env.ENDPOINT_PROBE_METHOD = "HEAD";
  107. vi.resetModules();
  108. const logger = {
  109. debug: vi.fn(),
  110. info: vi.fn(),
  111. warn: vi.fn(),
  112. trace: vi.fn(),
  113. error: vi.fn(),
  114. fatal: vi.fn(),
  115. };
  116. vi.doMock("@/lib/logger", () => ({ logger }));
  117. vi.doMock("@/repository", () => ({
  118. findProviderEndpointById: vi.fn(),
  119. recordProviderEndpointProbeResult: vi.fn(),
  120. updateProviderEndpointProbeSnapshot: vi.fn(),
  121. }));
  122. vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
  123. vi.stubGlobal(
  124. "fetch",
  125. vi.fn(async () => new Response(null, { status: 503 }))
  126. );
  127. const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
  128. const result = await probeEndpointUrl("https://example.com", 1234);
  129. expect(result.ok).toBe(false);
  130. expect(result.method).toBe("HEAD");
  131. expect(result.statusCode).toBe(503);
  132. expect(result.errorType).toBe("http_5xx");
  133. expect(result.errorMessage).toBe("HTTP 503");
  134. });
  135. test("probeEndpointUrl: 4xx 仍视为 ok=true", async () => {
  136. process.env.ENDPOINT_PROBE_METHOD = "HEAD";
  137. vi.resetModules();
  138. const logger = {
  139. debug: vi.fn(),
  140. info: vi.fn(),
  141. warn: vi.fn(),
  142. trace: vi.fn(),
  143. error: vi.fn(),
  144. fatal: vi.fn(),
  145. };
  146. vi.doMock("@/lib/logger", () => ({ logger }));
  147. vi.doMock("@/repository", () => ({
  148. findProviderEndpointById: vi.fn(),
  149. recordProviderEndpointProbeResult: vi.fn(),
  150. updateProviderEndpointProbeSnapshot: vi.fn(),
  151. }));
  152. vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
  153. vi.stubGlobal(
  154. "fetch",
  155. vi.fn(async () => new Response(null, { status: 404 }))
  156. );
  157. const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
  158. const result = await probeEndpointUrl("https://example.com", 1234);
  159. expect(result.ok).toBe(true);
  160. expect(result.statusCode).toBe(404);
  161. expect(result.errorType).toBeNull();
  162. });
  163. test("probeEndpointUrl: AbortError 归类为 timeout", async () => {
  164. process.env.ENDPOINT_PROBE_METHOD = "HEAD";
  165. vi.resetModules();
  166. const logger = {
  167. debug: vi.fn(),
  168. info: vi.fn(),
  169. warn: vi.fn(),
  170. trace: vi.fn(),
  171. error: vi.fn(),
  172. fatal: vi.fn(),
  173. };
  174. vi.doMock("@/lib/logger", () => ({ logger }));
  175. vi.doMock("@/repository", () => ({
  176. findProviderEndpointById: vi.fn(),
  177. recordProviderEndpointProbeResult: vi.fn(),
  178. updateProviderEndpointProbeSnapshot: vi.fn(),
  179. }));
  180. vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
  181. const fetchMock = vi.fn(async () => {
  182. const err = new Error("");
  183. err.name = "AbortError";
  184. throw err;
  185. });
  186. vi.stubGlobal("fetch", fetchMock);
  187. const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
  188. const result = await probeEndpointUrl("https://example.com", 1);
  189. expect(result.ok).toBe(false);
  190. expect(result.method).toBe("GET");
  191. expect(result.statusCode).toBeNull();
  192. expect(result.errorType).toBe("timeout");
  193. expect(result.errorMessage).toBe("timeout");
  194. });
  195. test("probeProviderEndpointAndRecord: endpoint 不存在时返回 null", async () => {
  196. vi.resetModules();
  197. const recordMock = vi.fn(async () => {});
  198. const snapshotMock = vi.fn(async () => {});
  199. const findMock = vi.fn(async () => null);
  200. const logger = {
  201. debug: vi.fn(),
  202. info: vi.fn(),
  203. warn: vi.fn(),
  204. trace: vi.fn(),
  205. error: vi.fn(),
  206. fatal: vi.fn(),
  207. };
  208. const recordFailureMock = vi.fn(async () => {});
  209. vi.doMock("@/lib/logger", () => ({ logger }));
  210. vi.doMock("@/repository", () => ({
  211. findProviderEndpointById: findMock,
  212. recordProviderEndpointProbeResult: recordMock,
  213. updateProviderEndpointProbeSnapshot: snapshotMock,
  214. }));
  215. vi.doMock("@/lib/endpoint-circuit-breaker", () =>
  216. createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
  217. );
  218. vi.stubGlobal(
  219. "fetch",
  220. vi.fn(async () => new Response(null, { status: 200 }))
  221. );
  222. const { probeProviderEndpointAndRecord } = await import("@/lib/provider-endpoints/probe");
  223. const result = await probeProviderEndpointAndRecord({ endpointId: 123, source: "manual" });
  224. expect(result).toBeNull();
  225. expect(recordMock).not.toHaveBeenCalled();
  226. expect(snapshotMock).not.toHaveBeenCalled();
  227. expect(recordFailureMock).not.toHaveBeenCalled();
  228. });
  229. test("probeProviderEndpointAndRecord: 记录入库字段包含 source/ok/statusCode/latency/probedAt", async () => {
  230. process.env.ENDPOINT_PROBE_METHOD = "HEAD";
  231. vi.useFakeTimers();
  232. vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
  233. vi.resetModules();
  234. const recordMock = vi.fn(async () => {});
  235. const snapshotMock = vi.fn(async () => {});
  236. const findMock = vi.fn(async () => makeEndpoint({ id: 123, url: "https://example.com" }));
  237. const logger = {
  238. debug: vi.fn(),
  239. info: vi.fn(),
  240. warn: vi.fn(),
  241. trace: vi.fn(),
  242. error: vi.fn(),
  243. fatal: vi.fn(),
  244. };
  245. const recordFailureMock = vi.fn(async () => {});
  246. vi.doMock("@/lib/logger", () => ({ logger }));
  247. vi.doMock("@/repository", () => ({
  248. findProviderEndpointById: findMock,
  249. recordProviderEndpointProbeResult: recordMock,
  250. updateProviderEndpointProbeSnapshot: snapshotMock,
  251. }));
  252. vi.doMock("@/lib/endpoint-circuit-breaker", () =>
  253. createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
  254. );
  255. vi.stubGlobal(
  256. "fetch",
  257. vi.fn(async () => new Response(null, { status: 200 }))
  258. );
  259. const { probeProviderEndpointAndRecord } = await import("@/lib/provider-endpoints/probe");
  260. const result = await probeProviderEndpointAndRecord({
  261. endpointId: 123,
  262. source: "manual",
  263. timeoutMs: 1111,
  264. });
  265. expect(result).toEqual(expect.objectContaining({ ok: true, statusCode: 200, errorType: null }));
  266. expect(recordMock).toHaveBeenCalledTimes(1);
  267. const payload = recordMock.mock.calls[0]?.[0];
  268. expect(payload).toEqual(
  269. expect.objectContaining({
  270. endpointId: 123,
  271. source: "manual",
  272. ok: true,
  273. statusCode: 200,
  274. errorType: null,
  275. errorMessage: null,
  276. })
  277. );
  278. const probedAt = (payload as { probedAt: Date }).probedAt;
  279. expect(probedAt).toBeInstanceOf(Date);
  280. expect(probedAt.toISOString()).toBe("2026-01-01T00:00:00.000Z");
  281. expect(snapshotMock).not.toHaveBeenCalled();
  282. expect(recordFailureMock).not.toHaveBeenCalled();
  283. });
  284. test("probeProviderEndpointAndRecord: scheduled 成功总是写入探测日志记录", async () => {
  285. process.env.ENDPOINT_PROBE_METHOD = "HEAD";
  286. vi.useFakeTimers();
  287. vi.setSystemTime(new Date("2026-01-01T00:00:30.000Z"));
  288. vi.resetModules();
  289. const recordMock = vi.fn(async () => {});
  290. const recordFailureMock = vi.fn(async () => {});
  291. const endpoint = makeEndpoint({
  292. id: 1,
  293. url: "https://example.com",
  294. lastProbeOk: true,
  295. lastProbedAt: new Date("2026-01-01T00:00:00.000Z"),
  296. });
  297. vi.doMock("@/lib/logger", () => ({
  298. logger: {
  299. debug: vi.fn(),
  300. info: vi.fn(),
  301. warn: vi.fn(),
  302. trace: vi.fn(),
  303. error: vi.fn(),
  304. fatal: vi.fn(),
  305. },
  306. }));
  307. vi.doMock("@/repository", () => ({
  308. findProviderEndpointById: vi.fn(async () => endpoint),
  309. recordProviderEndpointProbeResult: recordMock,
  310. }));
  311. vi.doMock("@/lib/endpoint-circuit-breaker", () =>
  312. createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
  313. );
  314. vi.stubGlobal(
  315. "fetch",
  316. vi.fn(async () => new Response(null, { status: 200 }))
  317. );
  318. const { probeProviderEndpointAndRecord } = await import("@/lib/provider-endpoints/probe");
  319. const result = await probeProviderEndpointAndRecord({ endpointId: 1, source: "scheduled" });
  320. expect(result).toEqual(expect.objectContaining({ ok: true, statusCode: 200 }));
  321. expect(recordMock).toHaveBeenCalledTimes(1);
  322. expect(recordFailureMock).not.toHaveBeenCalled();
  323. });
  324. test("probeProviderEndpointAndRecord: 失败会计入端点熔断计数(scheduled 与 manual)", async () => {
  325. process.env.ENDPOINT_PROBE_METHOD = "HEAD";
  326. vi.resetModules();
  327. const recordMock = vi.fn(async () => {});
  328. const recordFailureMock = vi.fn(async () => {});
  329. vi.doMock("@/lib/logger", () => ({
  330. logger: {
  331. debug: vi.fn(),
  332. info: vi.fn(),
  333. warn: vi.fn(),
  334. trace: vi.fn(),
  335. error: vi.fn(),
  336. fatal: vi.fn(),
  337. },
  338. }));
  339. vi.doMock("@/repository", () => ({
  340. findProviderEndpointById: vi.fn(async () =>
  341. makeEndpoint({ id: 123, url: "https://example.com" })
  342. ),
  343. recordProviderEndpointProbeResult: recordMock,
  344. }));
  345. vi.doMock("@/lib/endpoint-circuit-breaker", () =>
  346. createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
  347. );
  348. vi.stubGlobal(
  349. "fetch",
  350. vi.fn(async () => new Response(null, { status: 503 }))
  351. );
  352. const { probeProviderEndpointAndRecord } = await import("@/lib/provider-endpoints/probe");
  353. await probeProviderEndpointAndRecord({ endpointId: 123, source: "scheduled" });
  354. await probeProviderEndpointAndRecord({ endpointId: 123, source: "manual" });
  355. expect(recordFailureMock).toHaveBeenCalledTimes(2);
  356. expect(recordMock).toHaveBeenCalledTimes(2);
  357. });
  358. test("probeEndpointUrl: TCP mode connects to host:port without HTTP request", async () => {
  359. process.env.ENDPOINT_PROBE_METHOD = "TCP";
  360. vi.resetModules();
  361. const logger = {
  362. debug: vi.fn(),
  363. info: vi.fn(),
  364. warn: vi.fn(),
  365. trace: vi.fn(),
  366. error: vi.fn(),
  367. fatal: vi.fn(),
  368. };
  369. vi.doMock("@/lib/logger", () => ({ logger }));
  370. vi.doMock("@/repository", () => ({
  371. findProviderEndpointById: vi.fn(),
  372. recordProviderEndpointProbeResult: vi.fn(),
  373. }));
  374. vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
  375. // Mock net.createConnection to simulate successful TCP connection
  376. const mockSocket = {
  377. destroy: vi.fn(),
  378. on: vi.fn(),
  379. };
  380. vi.doMock("node:net", () => ({
  381. default: {
  382. createConnection: vi.fn((_opts: unknown, cb: () => void) => {
  383. // Simulate immediate successful connection
  384. setTimeout(() => cb(), 0);
  385. return mockSocket;
  386. }),
  387. },
  388. }));
  389. const fetchMock = vi.fn();
  390. vi.stubGlobal("fetch", fetchMock);
  391. const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
  392. const result = await probeEndpointUrl("https://api.example.com:8443/v1", 5000);
  393. expect(result.ok).toBe(true);
  394. expect(result.method).toBe("TCP");
  395. expect(result.statusCode).toBeNull();
  396. expect(result.errorType).toBeNull();
  397. expect(result.latencyMs).toBeTypeOf("number");
  398. // fetch should never be called in TCP mode
  399. expect(fetchMock).not.toHaveBeenCalled();
  400. });
  401. test("probeEndpointUrl: TCP mode defaults to port 80 for http URLs", async () => {
  402. process.env.ENDPOINT_PROBE_METHOD = "TCP";
  403. vi.resetModules();
  404. vi.doMock("@/lib/logger", () => ({
  405. logger: {
  406. debug: vi.fn(),
  407. info: vi.fn(),
  408. warn: vi.fn(),
  409. trace: vi.fn(),
  410. error: vi.fn(),
  411. fatal: vi.fn(),
  412. },
  413. }));
  414. vi.doMock("@/repository", () => ({
  415. findProviderEndpointById: vi.fn(),
  416. recordProviderEndpointProbeResult: vi.fn(),
  417. }));
  418. vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
  419. const mockSocket = {
  420. destroy: vi.fn(),
  421. on: vi.fn(),
  422. };
  423. vi.doMock("node:net", () => ({
  424. default: {
  425. createConnection: vi.fn((_opts: unknown, cb: () => void) => {
  426. setTimeout(() => cb(), 0);
  427. return mockSocket;
  428. }),
  429. },
  430. }));
  431. const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
  432. const result = await probeEndpointUrl("http://api.example.com/v1/messages", 5000);
  433. // TCP connection succeeds, no HTTP status code
  434. expect(result.ok).toBe(true);
  435. expect(result.method).toBe("TCP");
  436. expect(result.statusCode).toBeNull();
  437. });
  438. test("probeEndpointUrl: TCP mode returns invalid_url for bad URLs", async () => {
  439. process.env.ENDPOINT_PROBE_METHOD = "TCP";
  440. vi.resetModules();
  441. vi.doMock("@/lib/logger", () => ({
  442. logger: {
  443. debug: vi.fn(),
  444. info: vi.fn(),
  445. warn: vi.fn(),
  446. trace: vi.fn(),
  447. error: vi.fn(),
  448. fatal: vi.fn(),
  449. },
  450. }));
  451. vi.doMock("@/repository", () => ({
  452. findProviderEndpointById: vi.fn(),
  453. recordProviderEndpointProbeResult: vi.fn(),
  454. }));
  455. vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
  456. const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
  457. const result = await probeEndpointUrl("not-a-valid-url", 5000);
  458. expect(result.ok).toBe(false);
  459. expect(result.method).toBe("TCP");
  460. expect(result.errorType).toBe("invalid_url");
  461. });
  462. test("probeEndpointUrl: defaults to TCP when ENDPOINT_PROBE_METHOD is not set", async () => {
  463. delete process.env.ENDPOINT_PROBE_METHOD;
  464. vi.resetModules();
  465. vi.doMock("@/lib/logger", () => ({
  466. logger: {
  467. debug: vi.fn(),
  468. info: vi.fn(),
  469. warn: vi.fn(),
  470. trace: vi.fn(),
  471. error: vi.fn(),
  472. fatal: vi.fn(),
  473. },
  474. }));
  475. vi.doMock("@/repository", () => ({
  476. findProviderEndpointById: vi.fn(),
  477. recordProviderEndpointProbeResult: vi.fn(),
  478. }));
  479. vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
  480. const mockSocket = {
  481. destroy: vi.fn(),
  482. on: vi.fn(),
  483. };
  484. vi.doMock("node:net", () => ({
  485. default: {
  486. createConnection: vi.fn((_opts: unknown, cb: () => void) => {
  487. setTimeout(() => cb(), 0);
  488. return mockSocket;
  489. }),
  490. },
  491. }));
  492. const fetchMock = vi.fn();
  493. vi.stubGlobal("fetch", fetchMock);
  494. const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
  495. const result = await probeEndpointUrl("https://example.com", 5000);
  496. expect(result.method).toBe("TCP");
  497. expect(fetchMock).not.toHaveBeenCalled();
  498. });
  499. });