endpoint-selector.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import { 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. describe("provider-endpoints: endpoint-selector", () => {
  25. test("rankProviderEndpoints 应过滤 disabled/deleted,并按 lastProbeOk/sortOrder/latency/id 排序", async () => {
  26. vi.resetModules();
  27. vi.doMock("@/repository", () => ({
  28. findEnabledProviderEndpointsByVendorAndType: vi.fn(),
  29. findProviderEndpointsByVendorAndType: vi.fn(),
  30. }));
  31. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  32. getAllEndpointHealthStatusAsync: vi.fn(),
  33. }));
  34. vi.doMock("@/lib/config/env.schema", () => ({
  35. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  36. }));
  37. const { rankProviderEndpoints } = await import("@/lib/provider-endpoints/endpoint-selector");
  38. const healthyHighOrder = makeEndpoint({
  39. id: 10,
  40. lastProbeOk: true,
  41. sortOrder: 1,
  42. lastProbeLatencyMs: 50,
  43. });
  44. const healthyLowOrder = makeEndpoint({
  45. id: 11,
  46. lastProbeOk: true,
  47. sortOrder: 0,
  48. lastProbeLatencyMs: 999,
  49. });
  50. const unknownFast = makeEndpoint({
  51. id: 20,
  52. lastProbeOk: null,
  53. sortOrder: 0,
  54. lastProbeLatencyMs: 10,
  55. });
  56. const unknownNoLatency = makeEndpoint({
  57. id: 21,
  58. lastProbeOk: null,
  59. sortOrder: 0,
  60. lastProbeLatencyMs: null,
  61. });
  62. const unhealthyFast30 = makeEndpoint({
  63. id: 30,
  64. lastProbeOk: false,
  65. sortOrder: 0,
  66. lastProbeLatencyMs: 1,
  67. });
  68. const unhealthyFast31 = makeEndpoint({
  69. id: 31,
  70. lastProbeOk: false,
  71. sortOrder: 0,
  72. lastProbeLatencyMs: 1,
  73. });
  74. const disabled = makeEndpoint({ id: 40, isEnabled: false, lastProbeOk: true });
  75. const deleted = makeEndpoint({ id: 41, deletedAt: new Date(1), lastProbeOk: true });
  76. const ranked = rankProviderEndpoints([
  77. healthyHighOrder,
  78. healthyLowOrder,
  79. unknownFast,
  80. unknownNoLatency,
  81. unhealthyFast30,
  82. unhealthyFast31,
  83. disabled,
  84. deleted,
  85. ]);
  86. expect(ranked.map((e) => e.id)).toEqual([11, 10, 20, 21, 30, 31]);
  87. });
  88. test("getPreferredProviderEndpoints 应排除禁用/已删除/显式 exclude/熔断 open 的端点,并返回排序结果", async () => {
  89. vi.resetModules();
  90. // findEnabledProviderEndpointsByVendorAndType 语义:只返回 isEnabled=true 且 deletedAt=null 的端点
  91. const endpoints: ProviderEndpoint[] = [
  92. makeEndpoint({ id: 1, lastProbeOk: true, sortOrder: 1, lastProbeLatencyMs: 20 }),
  93. makeEndpoint({ id: 2, lastProbeOk: true, sortOrder: 0, lastProbeLatencyMs: 999 }),
  94. makeEndpoint({ id: 3, lastProbeOk: null, sortOrder: 0, lastProbeLatencyMs: 10 }),
  95. makeEndpoint({ id: 6, lastProbeOk: false, sortOrder: 0, lastProbeLatencyMs: 1 }),
  96. ];
  97. const findMock = vi.fn(async () => endpoints);
  98. const getAllStatusMock = vi.fn(async (endpointIds: number[]) => {
  99. const status: Record<
  100. number,
  101. {
  102. failureCount: number;
  103. lastFailureTime: number | null;
  104. circuitState: "closed" | "open" | "half-open";
  105. circuitOpenUntil: number | null;
  106. halfOpenSuccessCount: number;
  107. }
  108. > = {};
  109. for (const endpointId of endpointIds) {
  110. status[endpointId] = {
  111. failureCount: 0,
  112. lastFailureTime: null,
  113. circuitState: endpointId === 2 ? "open" : "closed",
  114. circuitOpenUntil: endpointId === 2 ? Date.now() + 60_000 : null,
  115. halfOpenSuccessCount: 0,
  116. };
  117. }
  118. return status;
  119. });
  120. vi.doMock("@/repository", () => ({
  121. findEnabledProviderEndpointsByVendorAndType: findMock,
  122. findProviderEndpointsByVendorAndType: vi.fn(async () => []),
  123. }));
  124. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  125. getAllEndpointHealthStatusAsync: getAllStatusMock,
  126. }));
  127. vi.doMock("@/lib/config/env.schema", () => ({
  128. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  129. }));
  130. const { getPreferredProviderEndpoints, pickBestProviderEndpoint } = await import(
  131. "@/lib/provider-endpoints/endpoint-selector"
  132. );
  133. const result = await getPreferredProviderEndpoints({
  134. vendorId: 123,
  135. providerType: "claude",
  136. excludeEndpointIds: [6],
  137. });
  138. expect(findMock).toHaveBeenCalledWith(123, "claude");
  139. expect(getAllStatusMock).toHaveBeenCalledWith([1, 2, 3]);
  140. expect(result.map((e) => e.id)).toEqual([1, 3]);
  141. const best = await pickBestProviderEndpoint({ vendorId: 123, providerType: "claude" });
  142. expect(best?.id).toBe(1);
  143. });
  144. test("getPreferredProviderEndpoints 过滤后无候选时返回空数组", async () => {
  145. vi.resetModules();
  146. const findMock = vi.fn(async () => []);
  147. const getAllStatusMock = vi.fn(async () => ({}));
  148. vi.doMock("@/repository", () => ({
  149. findEnabledProviderEndpointsByVendorAndType: findMock,
  150. findProviderEndpointsByVendorAndType: vi.fn(async () => []),
  151. }));
  152. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  153. getAllEndpointHealthStatusAsync: getAllStatusMock,
  154. }));
  155. vi.doMock("@/lib/config/env.schema", () => ({
  156. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  157. }));
  158. const { getPreferredProviderEndpoints, pickBestProviderEndpoint } = await import(
  159. "@/lib/provider-endpoints/endpoint-selector"
  160. );
  161. const result = await getPreferredProviderEndpoints({ vendorId: 1, providerType: "claude" });
  162. expect(result).toEqual([]);
  163. const best = await pickBestProviderEndpoint({ vendorId: 1, providerType: "claude" });
  164. expect(best).toBeNull();
  165. expect(getAllStatusMock).not.toHaveBeenCalled();
  166. });
  167. });
  168. describe("getEndpointFilterStats", () => {
  169. test("should correctly count total, enabled, circuitOpen, and available endpoints", async () => {
  170. vi.resetModules();
  171. const endpoints: ProviderEndpoint[] = [
  172. makeEndpoint({ id: 1, isEnabled: true, lastProbeOk: true }),
  173. makeEndpoint({ id: 2, isEnabled: true, lastProbeOk: true }),
  174. makeEndpoint({ id: 3, isEnabled: true, lastProbeOk: false }),
  175. makeEndpoint({ id: 4, isEnabled: false }),
  176. makeEndpoint({ id: 5, deletedAt: new Date(1) }),
  177. ];
  178. const findMock = vi.fn(async () => endpoints);
  179. const getAllStatusMock = vi.fn(async (endpointIds: number[]) => {
  180. const status: Record<
  181. number,
  182. {
  183. failureCount: number;
  184. lastFailureTime: number | null;
  185. circuitState: "closed" | "open" | "half-open";
  186. circuitOpenUntil: number | null;
  187. halfOpenSuccessCount: number;
  188. }
  189. > = {};
  190. for (const endpointId of endpointIds) {
  191. status[endpointId] = {
  192. failureCount: 0,
  193. lastFailureTime: null,
  194. circuitState: endpointId === 2 ? "open" : "closed",
  195. circuitOpenUntil: endpointId === 2 ? Date.now() + 60_000 : null,
  196. halfOpenSuccessCount: 0,
  197. };
  198. }
  199. return status;
  200. });
  201. vi.doMock("@/repository", () => ({
  202. findProviderEndpointsByVendorAndType: findMock,
  203. }));
  204. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  205. getAllEndpointHealthStatusAsync: getAllStatusMock,
  206. }));
  207. vi.doMock("@/lib/config/env.schema", () => ({
  208. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  209. }));
  210. const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
  211. const stats = await getEndpointFilterStats({ vendorId: 10, providerType: "claude" });
  212. expect(findMock).toHaveBeenCalledWith(10, "claude");
  213. expect(getAllStatusMock).toHaveBeenCalledWith([1, 2, 3]);
  214. expect(stats).toEqual({
  215. total: 5, // all endpoints
  216. enabled: 3, // id=1,2,3 (isEnabled && !deletedAt)
  217. circuitOpen: 1, // id=2
  218. available: 2, // enabled - circuitOpen = 3 - 1
  219. });
  220. });
  221. test("should return all zeros when no endpoints exist", async () => {
  222. vi.resetModules();
  223. const findMock = vi.fn(async () => []);
  224. const getAllStatusMock = vi.fn(async () => ({}));
  225. vi.doMock("@/repository", () => ({
  226. findProviderEndpointsByVendorAndType: findMock,
  227. }));
  228. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  229. getAllEndpointHealthStatusAsync: getAllStatusMock,
  230. }));
  231. vi.doMock("@/lib/config/env.schema", () => ({
  232. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  233. }));
  234. const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
  235. const stats = await getEndpointFilterStats({ vendorId: 99, providerType: "codex" });
  236. expect(stats).toEqual({
  237. total: 0,
  238. enabled: 0,
  239. circuitOpen: 0,
  240. available: 0,
  241. });
  242. expect(getAllStatusMock).not.toHaveBeenCalled();
  243. });
  244. test("should count all enabled endpoints as circuitOpen when all are open", async () => {
  245. vi.resetModules();
  246. const endpoints: ProviderEndpoint[] = [
  247. makeEndpoint({ id: 1, isEnabled: true }),
  248. makeEndpoint({ id: 2, isEnabled: true }),
  249. ];
  250. const findMock = vi.fn(async () => endpoints);
  251. const getAllStatusMock = vi.fn(async (endpointIds: number[]) => {
  252. const status: Record<
  253. number,
  254. {
  255. failureCount: number;
  256. lastFailureTime: number | null;
  257. circuitState: "closed" | "open" | "half-open";
  258. circuitOpenUntil: number | null;
  259. halfOpenSuccessCount: number;
  260. }
  261. > = {};
  262. for (const endpointId of endpointIds) {
  263. status[endpointId] = {
  264. failureCount: 0,
  265. lastFailureTime: null,
  266. circuitState: "open",
  267. circuitOpenUntil: Date.now() + 60_000,
  268. halfOpenSuccessCount: 0,
  269. };
  270. }
  271. return status;
  272. });
  273. vi.doMock("@/repository", () => ({
  274. findEnabledProviderEndpointsByVendorAndType: vi.fn(async () => []),
  275. findProviderEndpointsByVendorAndType: findMock,
  276. }));
  277. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  278. getAllEndpointHealthStatusAsync: getAllStatusMock,
  279. }));
  280. vi.doMock("@/lib/config/env.schema", () => ({
  281. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  282. }));
  283. const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
  284. const stats = await getEndpointFilterStats({ vendorId: 1, providerType: "openai-compatible" });
  285. expect(getAllStatusMock).toHaveBeenCalledWith([1, 2]);
  286. expect(stats).toEqual({
  287. total: 2,
  288. enabled: 2,
  289. circuitOpen: 2,
  290. available: 0,
  291. });
  292. });
  293. });
  294. describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => {
  295. test("getPreferredProviderEndpoints skips circuit check when disabled", async () => {
  296. vi.resetModules();
  297. const endpoints: ProviderEndpoint[] = [
  298. makeEndpoint({ id: 1, lastProbeOk: true, sortOrder: 0, lastProbeLatencyMs: 100 }),
  299. makeEndpoint({ id: 2, lastProbeOk: true, sortOrder: 1, lastProbeLatencyMs: 50 }),
  300. makeEndpoint({ id: 3, lastProbeOk: false, sortOrder: 0, lastProbeLatencyMs: 10 }),
  301. makeEndpoint({ id: 4, isEnabled: false }),
  302. makeEndpoint({ id: 5, deletedAt: new Date(1) }),
  303. ];
  304. const findMock = vi.fn(async () => endpoints);
  305. const getAllStatusMock = vi.fn(async () => ({}));
  306. vi.doMock("@/repository", () => ({
  307. findEnabledProviderEndpointsByVendorAndType: findMock,
  308. findProviderEndpointsByVendorAndType: vi.fn(async () => []),
  309. }));
  310. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  311. getAllEndpointHealthStatusAsync: getAllStatusMock,
  312. }));
  313. vi.doMock("@/lib/config/env.schema", () => ({
  314. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  315. }));
  316. const { getPreferredProviderEndpoints } = await import(
  317. "@/lib/provider-endpoints/endpoint-selector"
  318. );
  319. const result = await getPreferredProviderEndpoints({
  320. vendorId: 1,
  321. providerType: "claude",
  322. });
  323. expect(getAllStatusMock).not.toHaveBeenCalled();
  324. // All enabled, non-deleted endpoints returned (id=1,2,3), ranked by sortOrder/health
  325. expect(result.map((e) => e.id)).toEqual([1, 2, 3]);
  326. });
  327. test("getEndpointFilterStats returns circuitOpen=0 when disabled", async () => {
  328. vi.resetModules();
  329. const endpoints: ProviderEndpoint[] = [
  330. makeEndpoint({ id: 1, isEnabled: true, lastProbeOk: true }),
  331. makeEndpoint({ id: 2, isEnabled: true, lastProbeOk: false }),
  332. makeEndpoint({ id: 3, isEnabled: false }),
  333. makeEndpoint({ id: 4, deletedAt: new Date(1) }),
  334. ];
  335. const findMock = vi.fn(async () => endpoints);
  336. const getAllStatusMock = vi.fn(async () => ({}));
  337. vi.doMock("@/repository", () => ({
  338. findProviderEndpointsByVendorAndType: findMock,
  339. }));
  340. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  341. getAllEndpointHealthStatusAsync: getAllStatusMock,
  342. }));
  343. vi.doMock("@/lib/config/env.schema", () => ({
  344. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  345. }));
  346. const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
  347. const stats = await getEndpointFilterStats({ vendorId: 10, providerType: "claude" });
  348. expect(getAllStatusMock).not.toHaveBeenCalled();
  349. expect(stats).toEqual({
  350. total: 4,
  351. enabled: 2, // id=1,2 (isEnabled && !deletedAt)
  352. circuitOpen: 0, // always 0 when disabled
  353. available: 2, // equals enabled when disabled
  354. });
  355. });
  356. });