Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions packages/api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions packages/api/tests/unit/test_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
18 changes: 12 additions & 6 deletions packages/api/tests/unit/test_conversation_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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 (
Expand Down
35 changes: 35 additions & 0 deletions packages/api/tests/unit/test_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down
93 changes: 56 additions & 37 deletions packages/common/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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"):
Expand All @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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")
Expand Down
Loading