get-proxy-agent.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. /**
  2. * getProxyAgentForProvider Tests
  3. *
  4. * TDD: Tests written first, implementation follows
  5. */
  6. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  7. import type { Provider } from "@/types/provider";
  8. // Create mock objects outside the mock factory
  9. const mockAgent = {
  10. close: vi.fn().mockResolvedValue(undefined),
  11. destroy: vi.fn().mockResolvedValue(undefined),
  12. };
  13. const mockPool = {
  14. getAgent: vi.fn().mockResolvedValue({
  15. agent: mockAgent,
  16. isNew: true,
  17. cacheKey: "https://api.anthropic.com|direct|h1",
  18. }),
  19. markUnhealthy: vi.fn(),
  20. evictEndpoint: vi.fn().mockResolvedValue(undefined),
  21. getPoolStats: vi.fn().mockReturnValue({
  22. cacheSize: 1,
  23. totalRequests: 1,
  24. cacheHits: 0,
  25. cacheMisses: 1,
  26. hitRate: 0,
  27. unhealthyAgents: 0,
  28. evictedAgents: 0,
  29. }),
  30. cleanup: vi.fn().mockResolvedValue(0),
  31. shutdown: vi.fn().mockResolvedValue(undefined),
  32. };
  33. // Mock the agent pool module
  34. vi.mock("@/lib/proxy-agent/agent-pool", () => ({
  35. getGlobalAgentPool: vi.fn(() => mockPool),
  36. resetGlobalAgentPool: vi.fn().mockResolvedValue(undefined),
  37. generateAgentCacheKey: vi.fn().mockImplementation((params) => {
  38. const url = new URL(params.endpointUrl);
  39. const proxy = params.proxyUrl || "direct";
  40. const protocol = params.enableHttp2 ? "h2" : "h1";
  41. return `${url.origin}|${proxy}|${protocol}`;
  42. }),
  43. AgentPoolImpl: vi.fn(),
  44. }));
  45. // Import after mock setup
  46. import { getProxyAgentForProvider, type ProxyConfigWithCacheKey } from "@/lib/proxy-agent";
  47. describe("getProxyAgentForProvider", () => {
  48. beforeEach(() => {
  49. vi.clearAllMocks();
  50. // Reset default mock return value
  51. mockPool.getAgent.mockResolvedValue({
  52. agent: mockAgent,
  53. isNew: true,
  54. cacheKey: "https://api.anthropic.com|direct|h1",
  55. });
  56. });
  57. afterEach(() => {
  58. vi.clearAllMocks();
  59. });
  60. describe("direct connection (no proxy)", () => {
  61. it("should return null when provider has no proxy configured", async () => {
  62. const provider: Partial<Provider> = {
  63. id: 1,
  64. name: "Test Provider",
  65. proxyUrl: null,
  66. proxyFallbackToDirect: false,
  67. };
  68. const result = await getProxyAgentForProvider(
  69. provider as Provider,
  70. "https://api.anthropic.com/v1/messages",
  71. false
  72. );
  73. expect(result).toBeNull();
  74. expect(mockPool.getAgent).not.toHaveBeenCalled();
  75. });
  76. it("should return null when proxyUrl is empty string", async () => {
  77. const provider: Partial<Provider> = {
  78. id: 1,
  79. name: "Test Provider",
  80. proxyUrl: "",
  81. proxyFallbackToDirect: false,
  82. };
  83. const result = await getProxyAgentForProvider(
  84. provider as Provider,
  85. "https://api.anthropic.com/v1/messages",
  86. false
  87. );
  88. expect(result).toBeNull();
  89. });
  90. it("should return null when proxyUrl is whitespace only", async () => {
  91. const provider: Partial<Provider> = {
  92. id: 1,
  93. name: "Test Provider",
  94. proxyUrl: " ",
  95. proxyFallbackToDirect: false,
  96. };
  97. const result = await getProxyAgentForProvider(
  98. provider as Provider,
  99. "https://api.anthropic.com/v1/messages",
  100. false
  101. );
  102. expect(result).toBeNull();
  103. });
  104. });
  105. describe("with proxy configured", () => {
  106. it("should return ProxyConfig with cacheKey for HTTP proxy", async () => {
  107. const provider: Partial<Provider> = {
  108. id: 1,
  109. name: "Test Provider",
  110. proxyUrl: "http://proxy.example.com:8080",
  111. proxyFallbackToDirect: false,
  112. };
  113. mockPool.getAgent.mockResolvedValueOnce({
  114. agent: mockAgent,
  115. isNew: true,
  116. cacheKey: "https://api.anthropic.com|http://proxy.example.com:8080|h1",
  117. });
  118. const result = await getProxyAgentForProvider(
  119. provider as Provider,
  120. "https://api.anthropic.com/v1/messages",
  121. false
  122. );
  123. expect(result).not.toBeNull();
  124. expect(result?.cacheKey).toBe("https://api.anthropic.com|http://proxy.example.com:8080|h1");
  125. expect(result?.fallbackToDirect).toBe(false);
  126. expect(result?.http2Enabled).toBe(false);
  127. expect(mockPool.getAgent).toHaveBeenCalledWith({
  128. endpointUrl: "https://api.anthropic.com/v1/messages",
  129. proxyUrl: "http://proxy.example.com:8080",
  130. enableHttp2: false,
  131. });
  132. });
  133. it("should return ProxyConfig with HTTP/2 enabled", async () => {
  134. const provider: Partial<Provider> = {
  135. id: 1,
  136. name: "Test Provider",
  137. proxyUrl: "http://proxy.example.com:8080",
  138. proxyFallbackToDirect: true,
  139. };
  140. mockPool.getAgent.mockResolvedValueOnce({
  141. agent: mockAgent,
  142. isNew: true,
  143. cacheKey: "https://api.anthropic.com|http://proxy.example.com:8080|h2",
  144. });
  145. const result = await getProxyAgentForProvider(
  146. provider as Provider,
  147. "https://api.anthropic.com/v1/messages",
  148. true
  149. );
  150. expect(result).not.toBeNull();
  151. expect(result?.http2Enabled).toBe(true);
  152. expect(result?.fallbackToDirect).toBe(true);
  153. expect(mockPool.getAgent).toHaveBeenCalledWith({
  154. endpointUrl: "https://api.anthropic.com/v1/messages",
  155. proxyUrl: "http://proxy.example.com:8080",
  156. enableHttp2: true,
  157. });
  158. });
  159. it("should handle SOCKS proxy", async () => {
  160. const provider: Partial<Provider> = {
  161. id: 1,
  162. name: "Test Provider",
  163. proxyUrl: "socks5://proxy.example.com:1080",
  164. proxyFallbackToDirect: false,
  165. };
  166. mockPool.getAgent.mockResolvedValueOnce({
  167. agent: mockAgent,
  168. isNew: true,
  169. cacheKey: "https://api.anthropic.com|socks5://proxy.example.com:1080|h1",
  170. });
  171. const result = await getProxyAgentForProvider(
  172. provider as Provider,
  173. "https://api.anthropic.com/v1/messages",
  174. false
  175. );
  176. expect(result).not.toBeNull();
  177. expect(result?.cacheKey).toContain("socks5://");
  178. });
  179. it("should disable HTTP/2 for SOCKS proxy even when requested", async () => {
  180. const provider: Partial<Provider> = {
  181. id: 1,
  182. name: "Test Provider",
  183. proxyUrl: "socks5://proxy.example.com:1080",
  184. proxyFallbackToDirect: false,
  185. };
  186. mockPool.getAgent.mockResolvedValueOnce({
  187. agent: mockAgent,
  188. isNew: true,
  189. cacheKey: "https://api.anthropic.com|socks5://proxy.example.com:1080|h1",
  190. });
  191. const result = await getProxyAgentForProvider(
  192. provider as Provider,
  193. "https://api.anthropic.com/v1/messages",
  194. true // Request HTTP/2
  195. );
  196. expect(result).not.toBeNull();
  197. expect(result?.http2Enabled).toBe(false); // Should be false for SOCKS
  198. });
  199. it("should mask proxy URL in result", async () => {
  200. const provider: Partial<Provider> = {
  201. id: 1,
  202. name: "Test Provider",
  203. proxyUrl: "http://user:[email protected]:8080",
  204. proxyFallbackToDirect: false,
  205. };
  206. mockPool.getAgent.mockResolvedValueOnce({
  207. agent: mockAgent,
  208. isNew: true,
  209. cacheKey: "https://api.anthropic.com|http://user:[email protected]:8080|h1",
  210. });
  211. const result = await getProxyAgentForProvider(
  212. provider as Provider,
  213. "https://api.anthropic.com/v1/messages",
  214. false
  215. );
  216. expect(result).not.toBeNull();
  217. // proxyUrl should be masked (password hidden)
  218. expect(result?.proxyUrl).not.toContain("password");
  219. expect(result?.proxyUrl).toContain("***");
  220. });
  221. });
  222. describe("ProviderProxyConfig interface", () => {
  223. it("should work with minimal ProviderProxyConfig", async () => {
  224. const config = {
  225. id: 1,
  226. proxyUrl: "http://proxy.example.com:8080",
  227. proxyFallbackToDirect: false,
  228. };
  229. mockPool.getAgent.mockResolvedValueOnce({
  230. agent: mockAgent,
  231. isNew: true,
  232. cacheKey: "https://api.anthropic.com|http://proxy.example.com:8080|h1",
  233. });
  234. const result = await getProxyAgentForProvider(
  235. config,
  236. "https://api.anthropic.com/v1/messages",
  237. false
  238. );
  239. expect(result).not.toBeNull();
  240. expect(result?.cacheKey).toBeDefined();
  241. });
  242. it("should work with optional name field", async () => {
  243. const config = {
  244. id: 1,
  245. name: "My Proxy",
  246. proxyUrl: "http://proxy.example.com:8080",
  247. proxyFallbackToDirect: true,
  248. };
  249. mockPool.getAgent.mockResolvedValueOnce({
  250. agent: mockAgent,
  251. isNew: true,
  252. cacheKey: "https://api.anthropic.com|http://proxy.example.com:8080|h1",
  253. });
  254. const result = await getProxyAgentForProvider(
  255. config,
  256. "https://api.anthropic.com/v1/messages",
  257. false
  258. );
  259. expect(result).not.toBeNull();
  260. expect(result?.fallbackToDirect).toBe(true);
  261. });
  262. });
  263. describe("error handling", () => {
  264. it("should throw on invalid proxy URL", async () => {
  265. const provider: Partial<Provider> = {
  266. id: 1,
  267. name: "Test Provider",
  268. proxyUrl: "not-a-valid-url",
  269. proxyFallbackToDirect: false,
  270. };
  271. mockPool.getAgent.mockRejectedValueOnce(new Error("Invalid URL"));
  272. await expect(
  273. getProxyAgentForProvider(
  274. provider as Provider,
  275. "https://api.anthropic.com/v1/messages",
  276. false
  277. )
  278. ).rejects.toThrow();
  279. });
  280. it("should throw on unsupported proxy protocol", async () => {
  281. const provider: Partial<Provider> = {
  282. id: 1,
  283. name: "Test Provider",
  284. proxyUrl: "ftp://proxy.example.com:21",
  285. proxyFallbackToDirect: false,
  286. };
  287. mockPool.getAgent.mockRejectedValueOnce(new Error("Unsupported proxy protocol"));
  288. await expect(
  289. getProxyAgentForProvider(
  290. provider as Provider,
  291. "https://api.anthropic.com/v1/messages",
  292. false
  293. )
  294. ).rejects.toThrow();
  295. });
  296. });
  297. });