diff --git a/packages/api/tests/conftest.py b/packages/api/tests/conftest.py index 775b9c45e..bbf45fa10 100644 --- a/packages/api/tests/conftest.py +++ b/packages/api/tests/conftest.py @@ -235,15 +235,24 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.fixture def mock_http_client(mock_transport): """Create a mock HTTP client with transport.""" - client = Client(ClientOptions(base_url="https://mock.api.com")) - client.http._transport = mock_transport + client = Client( + ClientOptions(base_url="https://mock.api.com"), + _http=httpx.AsyncClient(transport=mock_transport, base_url="https://mock.api.com"), + ) return client def _preserve_test_transport_on_clone(client: Client, transport: httpx.MockTransport) -> Client: def clone(overrides: Optional[ClientOptions] = None, *, share_http: bool = False) -> Client: cloned = Client.clone(client, overrides, share_http=share_http) - cloned.http._transport = transport + if not share_http: + cloned.http = httpx.AsyncClient( + transport=transport, + base_url=cloned.http.base_url, + headers=cloned.http.headers, + timeout=cloned.http.timeout, + event_hooks=cloned.http.event_hooks, + ) return _preserve_test_transport_on_clone(cloned, transport) client.clone = clone # type: ignore[method-assign] @@ -366,8 +375,10 @@ def clear(self): capture = RequestCapture() transport = httpx.MockTransport(capture.handler) - client = Client(ClientOptions(base_url="https://mock.api.com")) - client.http._transport = transport + client = Client( + ClientOptions(base_url="https://mock.api.com"), + _http=httpx.AsyncClient(transport=transport, base_url="https://mock.api.com"), + ) _preserve_test_transport_on_clone(client, transport) client._capture = capture # type: ignore[attr-defined] # Attach for test access return client diff --git a/packages/api/tests/unit/test_base_client.py b/packages/api/tests/unit/test_base_client.py index cae61f284..5cc5b32e3 100644 --- a/packages/api/tests/unit/test_base_client.py +++ b/packages/api/tests/unit/test_base_client.py @@ -104,8 +104,10 @@ async def post_resource( def create_client(*, default_token: Token | None = None) -> tuple[Client, RequestRecorder]: recorder = RequestRecorder() - client = Client(ClientOptions(base_url="https://mock.api.com", token=default_token)) - client.http._transport = httpx.MockTransport(recorder.handler) + client = Client( + ClientOptions(base_url="https://mock.api.com", token=default_token), + _http=httpx.AsyncClient(transport=httpx.MockTransport(recorder.handler), base_url="https://mock.api.com"), + ) return client, recorder diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py index ca2fc3e58..90d701b61 100644 --- a/packages/api/tests/unit/test_conversation_client.py +++ b/packages/api/tests/unit/test_conversation_client.py @@ -682,12 +682,14 @@ async def test_api_client_middleware_uses_supplied_attributes_without_deriving_s async def test_api_client_middleware_invokes_response_hook(self): tracer = RecordingTracer() hook_responses: list[httpx.Response] = [] - http_client = Client(ClientOptions(base_url="https://test.service.url")) def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"id": "response-id"}, headers={"content-type": "application/json"}) - http_client.http._transport = httpx.MockTransport(handler) + http_client = Client( + ClientOptions(base_url="https://test.service.url"), + _http=httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="https://test.service.url"), + ) api_client = ApiClient("https://test.service.url", http_client) async def on_response(span: Span, response: httpx.Response) -> None: @@ -714,13 +716,15 @@ async def on_response(span: Span, response: httpx.Response) -> None: async def test_api_client_middleware_swallows_response_hook_failure(self): tracer = RecordingTracer() - http_client = Client(ClientOptions(base_url="https://test.service.url")) response = httpx.Response(200, json={"id": "response-id"}, headers={"content-type": "application/json"}) def handler(request: httpx.Request) -> httpx.Response: return response - http_client.http._transport = httpx.MockTransport(handler) + http_client = Client( + ClientOptions(base_url="https://test.service.url"), + _http=httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="https://test.service.url"), + ) api_client = ApiClient("https://test.service.url", http_client) async def on_response(span: Span, response: httpx.Response) -> None: @@ -855,8 +859,10 @@ async def test_activity_create_records_diagnostics_on_error(self, mock_http_clie def handler(request: httpx.Request) -> httpx.Response: raise error - http_client = Client(ClientOptions(base_url="https://mock.api.com")) - http_client.http._transport = httpx.MockTransport(handler) + http_client = Client( + ClientOptions(base_url="https://mock.api.com"), + _http=httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="https://mock.api.com"), + ) client = ConversationClient("https://test.service.url", http_client) with ( diff --git a/packages/api/tests/unit/test_fixtures.py b/packages/api/tests/unit/test_fixtures.py index 56927ea8c..17d777122 100644 --- a/packages/api/tests/unit/test_fixtures.py +++ b/packages/api/tests/unit/test_fixtures.py @@ -10,6 +10,7 @@ import pytest from microsoft_teams.api.activities.invoke.sign_in import SignInFailureInvokeActivity from microsoft_teams.api.activities.invoke.suggested_action_submit import SuggestedActionSubmitInvokeActivity +from microsoft_teams.common.http import ClientOptions from pydantic import BaseModel # Map fixture filenames to their expected activity types @@ -19,6 +20,40 @@ } +@pytest.mark.parametrize("fixture_name", ["mock_http_client", "request_capture"]) +async def test_mock_http_clients_ignore_environment_proxy( + fixture_name: str, request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("ALL_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("NO_PROXY", "") + client = request.getfixturevalue(fixture_name) + + response = await client.get("/mock-only") + + assert response.status_code == 200 + assert response.json()["url"] == "https://mock.api.com/mock-only" + + +@pytest.mark.parametrize("share_http", [False, True]) +async def test_request_capture_clones_preserve_mock_transport_with_proxy( + share_http: bool, request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("ALL_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("NO_PROXY", "") + client = request.getfixturevalue("request_capture") + clone = client.clone(ClientOptions(base_url="https://cloned.api.com"), share_http=share_http) + nested_clone = clone.clone() + + response = await nested_clone.get("/mock-only") + + assert response.status_code == 200 + assert response.json()["url"] == "https://cloned.api.com/mock-only" + assert len(client._capture.requests) == 1 + assert (clone.http is client.http) == share_http + + @pytest.mark.unit class TestFixtures: """Test deserialization of various activity fixtures.""" diff --git a/packages/common/tests/test_client.py b/packages/common/tests/test_client.py index f5244bd09..6df494e41 100644 --- a/packages/common/tests/test_client.py +++ b/packages/common/tests/test_client.py @@ -67,6 +67,19 @@ def sync_token_factory() -> str: return "sync-token" +def create_mock_client(transport: httpx.MockTransport, options: ClientOptions | None = None) -> Client: + options = options or ClientOptions(base_url="https://example.com") + return Client( + options, + _http=httpx.AsyncClient( + transport=transport, + base_url=options.base_url or "", + headers=options.headers, + timeout=options.timeout, + ), + ) + + @pytest.fixture def mock_transport(): def handler(request): @@ -76,17 +89,22 @@ def handler(request): @pytest.mark.asyncio -async def test_get_request_merges_headers_and_token(mock_transport): +@pytest.mark.parametrize("proxy", [None, "http://127.0.0.1:1"]) +async def test_get_request_merges_headers_and_token(mock_transport, monkeypatch, proxy): + if proxy is not None: + monkeypatch.setenv("HTTPS_PROXY", proxy) + monkeypatch.setenv("ALL_PROXY", proxy) + monkeypatch.setenv("NO_PROXY", "") interceptor = DummyAsyncInterceptor() - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", headers={"X-Default": "foo"}, token="abc123", interceptors=[interceptor], - ) + ), ) - client.http._transport = mock_transport resp = await client.get("/test", headers={"X-Custom": "bar"}, token="override") data = resp.json() @@ -104,13 +122,13 @@ async def test_get_request_merges_headers_and_token(mock_transport): ) @pytest.mark.asyncio async def test_post_request_and_response_interceptor(mock_transport, interceptor): - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", interceptors=[interceptor], - ) + ), ) - client.http._transport = mock_transport resp = await client.post("/post", data={"payload": "test_data"}) assert resp.status_code == 200 @@ -121,18 +139,24 @@ async def test_post_request_and_response_interceptor(mock_transport, interceptor @pytest.mark.asyncio async def test_clone_merges_options_and_interceptors(mock_transport): interceptor1 = DummyAsyncInterceptor() - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", headers={"X-Default": "foo"}, interceptors=[interceptor1], - ) + ), ) - client.http._transport = mock_transport interceptor2 = DummyAsyncInterceptor() clone = client.clone(ClientOptions(headers={"X-Clone": "bar"}, interceptors=[interceptor2])) - clone.http._transport = mock_transport + clone.http = httpx.AsyncClient( + transport=mock_transport, + base_url=clone.http.base_url, + headers=clone.http.headers, + timeout=clone.http.timeout, + event_hooks=clone.http.event_hooks, + ) resp = await clone.get("/clone") assert resp.status_code == 200 @@ -210,8 +234,7 @@ def handler(request: httpx.Request) -> httpx.Response: events.append("terminal") return httpx.Response(200, json={"ok": True}, headers={"content-type": "application/json"}) - client = Client(ClientOptions(base_url="https://example.com")) - client.http._transport = httpx.MockTransport(handler) + client = create_mock_client(httpx.MockTransport(handler)) client.use(RecordingMiddleware("one", events)) client.use(RecordingMiddleware("two", events)) @@ -244,8 +267,7 @@ async def send(self, context: MiddlewareContext, next: MiddlewareNext) -> httpx. context.kwargs["extensions"] = {"middleware": "extension-value"} return await next() - client = Client(ClientOptions(base_url="https://example.com")) - client.http._transport = httpx.MockTransport(handler) + client = create_mock_client(httpx.MockTransport(handler)) client.use(MutatingMiddleware()) response = await client.get("/mutated") @@ -259,8 +281,7 @@ class ShortCircuitMiddleware: def send(self, context: MiddlewareContext, next: MiddlewareNext) -> httpx.Response: return httpx.Response(204) - client = Client(ClientOptions(base_url="https://example.com")) - client.http._transport = httpx.MockTransport(lambda request: httpx.Response(500)) + client = create_mock_client(httpx.MockTransport(lambda request: httpx.Response(500))) client.use(ShortCircuitMiddleware()) response = await client.get("/short-circuit") @@ -284,8 +305,7 @@ async def send(self, context: MiddlewareContext, next: MiddlewareNext) -> httpx. events.append(str(exception)) raise - client = Client(ClientOptions(base_url="https://example.com")) - client.http._transport = httpx.MockTransport(handler) + client = create_mock_client(httpx.MockTransport(handler)) client.use(ErrorRecordingMiddleware()) with pytest.raises(RuntimeError, match="boom"): @@ -312,8 +332,9 @@ async def send(self, context: MiddlewareContext, next: MiddlewareNext) -> httpx. events.append("middleware:after") return response - client = Client(ClientOptions(base_url="https://example.com", interceptors=[RecordingInterceptor()])) - client.http._transport = mock_transport + client = create_mock_client( + mock_transport, ClientOptions(base_url="https://example.com", interceptors=[RecordingInterceptor()]) + ) client.use(EventMiddleware()) await client.get("/with-interceptors") @@ -339,8 +360,7 @@ async def send(self, context: MiddlewareContext, next: MiddlewareNext) -> httpx. metadata = object() middleware = MetadataMiddleware() - client = Client(ClientOptions(base_url="https://example.com")) - client.http._transport = httpx.MockTransport(handler) + client = create_mock_client(httpx.MockTransport(handler)) client.use(middleware) await client.get("/metadata", _metadata=metadata) @@ -386,13 +406,13 @@ def test_clone_can_clear_interceptors_with_empty_override(): ) @pytest.mark.asyncio async def test_token_types(mock_transport, token, expected): - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", token=token, - ) + ), ) - client.http._transport = mock_transport resp = await client.get("/token-test") data = resp.json() @@ -410,13 +430,13 @@ async def async_none_factory() -> None: @pytest.mark.asyncio async def test_async_none_token(mock_transport): - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", token=async_none_factory, - ) + ), ) - client.http._transport = mock_transport resp = await client.get("/token-test") data = resp.json() @@ -425,8 +445,7 @@ async def test_async_none_token(mock_transport): @pytest.mark.asyncio async def test_explicit_authorization_header_wins_over_default_token(mock_transport): - client = Client(ClientOptions(base_url="https://example.com", token="default-token")) - client.http._transport = mock_transport + client = create_mock_client(mock_transport, ClientOptions(base_url="https://example.com", token="default-token")) resp = await client.get("/token-test", headers={"Authorization": "******"}) data = resp.json() @@ -436,14 +455,14 @@ async def test_explicit_authorization_header_wins_over_default_token(mock_transp @pytest.mark.asyncio async def test_default_authorization_header_bypasses_token_resolution(mock_transport): - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", headers={"Authorization": "******"}, token=failing_token_factory, - ) + ), ) - client.http._transport = mock_transport resp = await client.get("/token-test") data = resp.json() @@ -458,13 +477,13 @@ def failing_token_factory() -> str: @pytest.mark.asyncio async def test_failing_token_factory(mock_transport): - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", token=failing_token_factory, - ) + ), ) - client.http._transport = mock_transport with pytest.raises(ValueError, match="Token factory failed"): await client.get("/token-test")