From 2f90aeb7a3dd3eb49f975441b3194bade94e4a60 Mon Sep 17 00:00:00 2001 From: Datata1 Date: Sun, 12 Jul 2026 14:43:32 +0200 Subject: [PATCH] test: respx suite at the HTTP boundary; type-check tests (ratchet resolved) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements plans/refactor/06-test-suite-http-boundary.md: - Add respx (dev). New shared fixtures: mock_api (respx router on the test base URL) and sdk (opened CodesphereSDK against it) - tests/test_exceptions.py: full raise_for_status matrix over real httpx.Response objects (status->class for every exported exception, message-key extraction incl. joined error lists, non-JSON bodies, Retry-After parsing incl. non-numeric, APIError request context and __str__), default constructors for all 10 exception classes, plus end-to-end error surfacing through the real transport (404/429/ timeout/connect) - tests/core/test_execute_operation.py: executor behavior on the real wire — URL building from path params, auth header, input_model validation stopping bad payloads before any request, empty-dict body regression, NoneType responses, response validation, and bound ResourceList items making follow-up calls - tests/test_http_client.py: transport lifecycle (idempotent open, close-without-open), the full httpx->SDK error mapping matrix, timeout/stream_timeout properties, stream() passthrough - Coverage: exceptions/config/http_client at 100%, core/handler at 96% - ty ratchet fully resolved: tests/ removed from [tool.ty.src] exclude; the whole repo (src + tests) now type-checks. Fallout fixed properly where real (env_vars set/delete annotations didn't admit list[EnvVar]; isinstance narrowing in delete; typed workspace configs in the integration conftest) and suppressed surgically where ty is wrong (pytest.skip/fail signature resolution, funneled through skip_test/ fail_test helpers) - CONTRIBUTING.md documents the mocking policy: new tests mock the transport (respx), never SDK internals --- CONTRIBUTING.md | 8 + pyproject.toml | 13 +- .../resources/workspace/env_vars/resources.py | 11 +- tests/conftest.py | 23 ++ tests/core/test_base.py | 2 +- tests/core/test_execute_operation.py | 133 ++++++++++++ tests/core/test_operations.py | 6 +- tests/integration/conftest.py | 43 ++-- .../integration/test_list_client_injection.py | 6 +- tests/integration/test_usage.py | 7 +- .../workspace/landscape/test_landscape.py | 14 +- tests/test_exceptions.py | 203 ++++++++++++++++++ tests/test_http_client.py | 86 ++++++++ uv.lock | 20 ++ 14 files changed, 540 insertions(+), 35 deletions(-) create mode 100644 tests/core/test_execute_operation.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_http_client.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad85397..b88b1bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -152,6 +152,14 @@ tests/ └── test_workspaces.py ``` +### Mocking Policy + +Unit tests for request/response behavior mock the **HTTP transport**, not SDK +internals: use the `mock_api` respx fixture (see `tests/conftest.py`) so URL +building, headers, body encoding, and response validation run for real. Do +not mock `_execute` or other SDK-internal methods in new tests — if you need +a canned API response, register a respx route instead. + ### Unit Tests Unit tests mock HTTP responses and test SDK logic in isolation. They are fast and don't require API credentials. diff --git a/pyproject.toml b/pyproject.toml index ac1e55a..f62912d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,17 +85,18 @@ skips = ["B101"] [tool.ty.src] exclude = [ - # --- Type-check ratchet ------------------------------------------------- - # Each exclusion below is temporary and owned by a refactor ticket in - # plans/refactor/. When a ticket lands, its paths MUST be removed here. - # Do not add new exclusions without a ticket reference. - "tests/", # ticket 06 (respx-based test suite) - # --- Permanent exclusions ----------------------------------------------- + # The refactor-ticket ratchet (plans/refactor/) is fully resolved: src and + # tests are type-checked. Do not add exclusions without a ticket reference. # Examples have their own dependencies (e.g. fastapi) not installed in # this project's environment. "examples/", ] +[dependency-groups] +dev = [ + "respx>=0.23.1", +] + [project.urls] Homepage = "https://codesphere.com/" Repository = "https://github.com/Datata1/codesphere-python" diff --git a/src/codesphere/resources/workspace/env_vars/resources.py b/src/codesphere/resources/workspace/env_vars/resources.py index e029813..56dd12a 100644 --- a/src/codesphere/resources/workspace/env_vars/resources.py +++ b/src/codesphere/resources/workspace/env_vars/resources.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections.abc import Sequence from ....core.base import ResourceList, WorkspaceScopedResource from .operations import _BULK_DELETE_OP, _BULK_SET_OP, _GET_OP @@ -13,11 +14,15 @@ class WorkspaceEnvVarManager(WorkspaceScopedResource): async def get(self) -> ResourceList[EnvVar]: return await self._execute(_GET_OP, id=self.workspace_id) - async def set(self, env_vars: ResourceList[EnvVar] | list[dict[str, str]]) -> None: + async def set( + self, env_vars: ResourceList[EnvVar] | Sequence[EnvVar | dict[str, str]] + ) -> None: payload = ResourceList[EnvVar].model_validate(env_vars) await self._execute(_BULK_SET_OP, id=self.workspace_id, data=payload) - async def delete(self, items: list[str] | ResourceList[EnvVar]) -> None: + async def delete( + self, items: Sequence[str | EnvVar | dict[str, str]] | ResourceList[EnvVar] + ) -> None: if not items: return payload: list[str] = [] @@ -25,7 +30,7 @@ async def delete(self, items: list[str] | ResourceList[EnvVar]) -> None: for item in items: if isinstance(item, str): payload.append(item) - elif hasattr(item, "name"): + elif isinstance(item, EnvVar): payload.append(item.name) elif isinstance(item, dict) and "name" in item: payload.append(item["name"]) diff --git a/tests/conftest.py b/tests/conftest.py index 6adcf01..fe1472a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,9 @@ import httpx import pytest +import respx + +TEST_BASE_URL = "https://test.local/api" class MockResponseFactory: @@ -290,3 +293,23 @@ def _create(response_data: Any = None, domain_data: dict | None = None): return domain, mock_client return _create + + +@pytest.fixture +def mock_api(): + """respx router bound to the test base URL. + + Unit tests for request/response behavior mock the HTTP transport with + this router instead of patching SDK internals. + """ + with respx.mock(base_url=TEST_BASE_URL, assert_all_called=False) as router: + yield router + + +@pytest.fixture +async def sdk(mock_api): + """An opened CodesphereSDK wired to the respx-mocked transport.""" + from codesphere import CodesphereSDK + + async with CodesphereSDK(token="test-token", base_url=TEST_BASE_URL) as client: + yield client diff --git a/tests/core/test_base.py b/tests/core/test_base.py index b36fdac..9c143f9 100644 --- a/tests/core/test_base.py +++ b/tests/core/test_base.py @@ -52,7 +52,7 @@ def test_camel_case_alias_generation(self, case: CamelModelTestCase): """Test that snake_case fields are aliased to camelCase.""" from pydantic import create_model - DynamicModel = create_model( + DynamicModel = create_model( # ty: ignore[no-matching-overload] "DynamicModel", __base__=CamelModel, **{case.field_name: (str, "test")} ) diff --git a/tests/core/test_execute_operation.py b/tests/core/test_execute_operation.py new file mode 100644 index 0000000..65e0b28 --- /dev/null +++ b/tests/core/test_execute_operation.py @@ -0,0 +1,133 @@ +"""Executor behavior tests at the real HTTP boundary (respx). + +Unlike tests/core/test_handler.py (which unit-tests against a mocked +APIHttpClient), these drive execute_operation through the actual httpx +transport: URL building, headers, body encoding, and response parsing +are all real. +""" + +import json +from types import NoneType + +import pytest +from pydantic import BaseModel, ValidationError + +from codesphere.core.base import BoundModel, ResourceBase, ResourceList +from codesphere.core.operations import APIOperation + + +class Thing(BoundModel): + id: int + name: str + + async def rename(self, name: str) -> None: + await self._execute(RENAME_THING, thing_id=self.id, data={"name": name}) + + +class ThingCreate(BaseModel): + name: str + size: int + + +GET_THING = APIOperation( + method="GET", endpoint_template="/things/{thing_id}", response_model=Thing +) +LIST_THINGS = APIOperation( + method="GET", endpoint_template="/things", response_model=ResourceList[Thing] +) +CREATE_THING = APIOperation( + method="POST", + endpoint_template="/things", + input_model=ThingCreate, + response_model=Thing, +) +RENAME_THING = APIOperation( + method="PATCH", endpoint_template="/things/{thing_id}", response_model=NoneType +) + + +class ThingsResource(ResourceBase): + async def get(self, thing_id: int) -> Thing: + return await self._execute(GET_THING, thing_id=thing_id) + + async def list(self) -> list[Thing]: + return (await self._execute(LIST_THINGS)).root + + async def create(self, payload) -> Thing: + return await self._execute(CREATE_THING, data=payload) + + async def rename(self, thing_id: int, data) -> None: + await self._execute(RENAME_THING, thing_id=thing_id, data=data) + + +@pytest.fixture +def things(sdk): + return ThingsResource(sdk._http_client) + + +class TestRequestConstruction: + async def test_path_params_format_the_real_url(self, things, mock_api): + route = mock_api.get("/things/42").respond(200, json={"id": 42, "name": "x"}) + await things.get(thing_id=42) + assert route.called + + async def test_auth_header_reaches_the_wire(self, things, mock_api): + route = mock_api.get("/things/1").respond(200, json={"id": 1, "name": "x"}) + await things.get(thing_id=1) + request = route.calls.last.request + assert request.headers["authorization"] == "Bearer test-token" + + async def test_model_payload_is_serialized(self, things, mock_api): + route = mock_api.post("/things").respond(201, json={"id": 1, "name": "a"}) + await things.create(ThingCreate(name="a", size=3)) + assert json.loads(route.calls.last.request.content) == {"name": "a", "size": 3} + + async def test_dict_payload_is_validated_against_input_model( + self, things, mock_api + ): + route = mock_api.post("/things").respond(201, json={"id": 1, "name": "a"}) + await things.create({"name": "a", "size": 3}) + assert json.loads(route.calls.last.request.content) == {"name": "a", "size": 3} + + async def test_invalid_payload_never_reaches_the_wire(self, things, mock_api): + route = mock_api.post("/things").respond(201, json={}) + with pytest.raises(ValidationError): + await things.create({"name": "missing size"}) + assert not route.called + + async def test_empty_dict_body_is_sent(self, things, mock_api): + """Regression: falsy payloads must still be sent as the JSON body.""" + route = mock_api.patch("/things/1").respond(204) + await things.rename(1, data={}) + assert route.calls.last.request.content == b"{}" + + +class TestResponseHandling: + async def test_response_is_validated_into_model(self, things, mock_api): + mock_api.get("/things/7").respond(200, json={"id": 7, "name": "seven"}) + thing = await things.get(thing_id=7) + assert isinstance(thing, Thing) + assert thing.name == "seven" + + async def test_none_response_model_returns_none(self, things, mock_api): + mock_api.patch("/things/1").respond(204) + assert await things.rename(1, data={"name": "n"}) is None + + async def test_invalid_response_shape_raises(self, things, mock_api): + mock_api.get("/things/1").respond(200, json={"unexpected": True}) + with pytest.raises(ValidationError): + await things.get(thing_id=1) + + async def test_list_items_are_bound_and_can_make_calls(self, things, mock_api): + mock_api.get("/things").respond( + 200, json=[{"id": 1, "name": "a"}, {"id": 2, "name": "b"}] + ) + rename_route = mock_api.patch("/things/2").respond(204) + + items = await things.list() + await items[1].rename("renamed") + + assert rename_route.called + assert json.loads(rename_route.calls.last.request.content) == { + "name": "renamed" + } diff --git a/tests/core/test_operations.py b/tests/core/test_operations.py index 14304cf..63976a5 100644 --- a/tests/core/test_operations.py +++ b/tests/core/test_operations.py @@ -30,7 +30,7 @@ class APIOperationTestCase: method: str endpoint_template: str response_model: type - input_model: type | None = None + input_model: type[BaseModel] | None = None api_operation_test_cases = [ @@ -141,7 +141,7 @@ def test_api_operation_is_frozen(self): response_model=LogEntry, ) with pytest.raises(dataclasses.FrozenInstanceError): - op.method = "POST" + op.method = "POST" # ty: ignore[invalid-assignment] class TestStreamOperation: @@ -159,4 +159,4 @@ def test_stream_operation_is_frozen(self): entry_model=LogEntry, ) with pytest.raises(dataclasses.FrozenInstanceError): - op.endpoint_template = "/other" + op.endpoint_template = "/other" # ty: ignore[invalid-assignment] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f7a6ca7..73a6ab0 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -2,6 +2,7 @@ import logging import os from collections.abc import AsyncGenerator +from typing import NoReturn import pytest from dotenv import load_dotenv @@ -16,6 +17,20 @@ log = logging.getLogger(__name__) +def skip_test(reason: str) -> NoReturn: + """pytest.skip with the ty suppression funneled through one place. + + ty cannot resolve the decorated signatures of pytest's outcome + functions, so the (correct) call below needs a targeted ignore. + """ + pytest.skip(reason) # ty: ignore[too-many-positional-arguments] + + +def fail_test(reason: str) -> NoReturn: + """pytest.fail counterpart of :func:`skip_test`.""" + pytest.fail(reason) # ty: ignore[invalid-argument-type] + + def pytest_addoption(parser): parser.addoption( "--run-integration", @@ -47,7 +62,7 @@ def pytest_collection_modifyitems(config, items): def integration_token() -> str: token = os.environ.get("CS_TOKEN") if not token: - pytest.skip("CS_TOKEN environment variable not set") + skip_test("CS_TOKEN environment variable not set") return token @@ -94,7 +109,7 @@ async def test_team_id( teams = await session_sdk_client.teams.list() if not teams: - pytest.fail("No teams available for integration testing") + fail_test("No teams available for integration testing") return teams[0].id @@ -108,7 +123,7 @@ async def test_plan_id(session_sdk_client: CodesphereSDK) -> int: if micro_plan: return micro_plan.id - pytest.fail("No 'Micro' workspace plan available for testing") + fail_test("No 'Micro' workspace plan available for testing") @pytest.fixture(scope="session") @@ -119,31 +134,31 @@ async def test_workspaces( ) -> AsyncGenerator[list[Workspace], None]: created_workspaces: list[Workspace] = [] - workspace_configs = [ - {"name": f"{TEST_WORKSPACE_PREFIX}-1", "git_url": None}, - { - "name": f"{TEST_WORKSPACE_PREFIX}-git", - "git_url": "https://github.com/octocat/Hello-World.git", - }, + workspace_configs: list[tuple[str, str | None]] = [ + (f"{TEST_WORKSPACE_PREFIX}-1", None), + ( + f"{TEST_WORKSPACE_PREFIX}-git", + "https://github.com/octocat/Hello-World.git", + ), ] - for config in workspace_configs: + for ws_name, git_url in workspace_configs: payload = WorkspaceCreate( team_id=test_team_id, - name=config["name"], + name=ws_name, plan_id=test_plan_id, - git_url=config["git_url"], + git_url=git_url, ) try: workspace = await session_sdk_client.workspaces.create(payload=payload) created_workspaces.append(workspace) log.info(f"Created test workspace: {workspace.name} (ID: {workspace.id})") except Exception as e: - log.error(f"Failed to create test workspace {config['name']}: {e}") + log.error(f"Failed to create test workspace {ws_name}: {e}") for ws in created_workspaces: with contextlib.suppress(Exception): await ws.delete() - pytest.fail(f"Failed to create test workspaces: {e}") + fail_test(f"Failed to create test workspaces: {e}") yield created_workspaces diff --git a/tests/integration/test_list_client_injection.py b/tests/integration/test_list_client_injection.py index 049fb85..284774f 100644 --- a/tests/integration/test_list_client_injection.py +++ b/tests/integration/test_list_client_injection.py @@ -1,5 +1,7 @@ import pytest +from tests.integration.conftest import skip_test + @pytest.mark.integration class TestListClientInjection: @@ -13,7 +15,7 @@ async def test_workspaces_list_items_can_use_instance_methods( workspaces = await sdk_client.workspaces.list(team_id=test_team_id) if len(workspaces) == 0: - pytest.skip("No workspaces available for testing") + skip_test("No workspaces available for testing") workspace = workspaces[0] @@ -28,7 +30,7 @@ async def test_teams_list_items_can_access_sub_resources(self, sdk_client): teams = await sdk_client.teams.list() if len(teams) == 0: - pytest.skip("No teams available for testing") + skip_test("No teams available for testing") team = teams[0] diff --git a/tests/integration/test_usage.py b/tests/integration/test_usage.py index 504e385..0de3eb3 100644 --- a/tests/integration/test_usage.py +++ b/tests/integration/test_usage.py @@ -13,6 +13,7 @@ ) from codesphere.resources.workspace import Workspace from codesphere.resources.workspace.landscape import ProfileBuilder +from tests.integration.conftest import skip_test pytestmark = [pytest.mark.integration, pytest.mark.asyncio] @@ -146,7 +147,7 @@ async def test_get_landscape_events_returns_response( ) if summary.total_items == 0: - pytest.skip("No usage data available for testing events") + skip_test("No usage data available for testing events") resource_id = summary.items[0].resource_id @@ -175,7 +176,7 @@ async def test_get_landscape_events_items_are_typed( ) if summary.total_items == 0: - pytest.skip("No usage data available for testing events") + skip_test("No usage data available for testing events") resource_id = summary.items[0].resource_id @@ -289,7 +290,7 @@ async def test_iter_all_landscape_events( ) if summary.total_items == 0: - pytest.skip("No usage data available for testing event iteration") + skip_test("No usage data available for testing event iteration") resource_id = summary.items[0].resource_id diff --git a/tests/resources/workspace/landscape/test_landscape.py b/tests/resources/workspace/landscape/test_landscape.py index 947720d..a0306e6 100644 --- a/tests/resources/workspace/landscape/test_landscape.py +++ b/tests/resources/workspace/landscape/test_landscape.py @@ -447,6 +447,8 @@ def test_build_with_reactive_service_paths(self): service = profile.run["api"] assert isinstance(service, ReactiveServiceConfig) + assert service.network is not None + assert service.network.paths is not None assert len(service.network.paths) == 2 assert service.network.paths[0].path == "/api" assert service.network.paths[0].strip_path is True @@ -703,7 +705,9 @@ async def test_save_profile_roundtrips_adversarial_content( await manager_with_capture.save_profile("hardened", content) - command = manager_with_capture._run_command.await_args.args[0] + call = manager_with_capture._run_command.await_args + assert call is not None + command = call.args[0] match = re.fullmatch( r"printf '%s' '([A-Za-z0-9+/=]*)' \| base64 -d > 'ci\.hardened\.yml'", command, @@ -722,11 +726,15 @@ async def test_get_profile_quotes_filename(self, manager_with_capture): manager_with_capture._run_command = AsyncMock(return_value=response) await manager_with_capture.get_profile("prod") - command = manager_with_capture._run_command.await_args.args[0] + call = manager_with_capture._run_command.await_args + assert call is not None + command = call.args[0] assert command == "cat 'ci.prod.yml'" @pytest.mark.asyncio async def test_delete_profile_quotes_filename(self, manager_with_capture): await manager_with_capture.delete_profile("prod") - command = manager_with_capture._run_command.await_args.args[0] + call = manager_with_capture._run_command.await_args + assert call is not None + command = call.args[0] assert command == "rm -f 'ci.prod.yml'" diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..d228488 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,203 @@ +"""Unit tests for the exception hierarchy and raise_for_status mapping. + +The matrix uses real httpx.Response objects (no mocks); the end-to-end +cases drive errors through the actual transport via respx. +""" + +import httpx +import pytest + +from codesphere import ( + APIError, + AuthenticationError, + AuthorizationError, + CodesphereError, + ConflictError, + NetworkError, + NotFoundError, + RateLimitError, + TimeoutError, + ValidationError, +) +from codesphere.exceptions import raise_for_status + + +def make_response( + status_code: int, + json_body=None, + text: str | None = None, + headers: dict[str, str] | None = None, +) -> httpx.Response: + request = httpx.Request("GET", "https://test.local/api/things/1") + if json_body is not None: + return httpx.Response( + status_code, json=json_body, request=request, headers=headers + ) + return httpx.Response( + status_code, text=text or "", request=request, headers=headers + ) + + +class TestStatusMapping: + @pytest.mark.parametrize( + ("status", "expected"), + [ + (400, ValidationError), + (401, AuthenticationError), + (403, AuthorizationError), + (404, NotFoundError), + (409, ConflictError), + (422, ValidationError), + (429, RateLimitError), + (418, APIError), + (500, APIError), + (502, APIError), + (503, APIError), + ], + ) + def test_status_raises_expected_class(self, status, expected): + with pytest.raises(expected) as exc_info: + raise_for_status(make_response(status, json_body={"message": "boom"})) + assert isinstance(exc_info.value, CodesphereError) + assert exc_info.value.message == "boom" + + @pytest.mark.parametrize("status", [200, 201, 204]) + def test_success_statuses_do_not_raise(self, status): + raise_for_status(make_response(status)) + + +class TestErrorMessageExtraction: + @pytest.mark.parametrize( + "body", + [ + {"message": "from message"}, + {"error": "from error"}, + {"detail": "from detail"}, + ], + ids=["message", "error", "detail"], + ) + def test_message_keys(self, body): + with pytest.raises(NotFoundError) as exc_info: + raise_for_status(make_response(404, json_body=body)) + assert exc_info.value.message == next(iter(body.values())) + + def test_errors_list_is_joined(self): + with pytest.raises(NotFoundError) as exc_info: + raise_for_status(make_response(404, json_body={"errors": ["a", "b"]})) + assert exc_info.value.message == "a; b" + + def test_non_json_body_uses_text(self): + with pytest.raises(APIError) as exc_info: + raise_for_status(make_response(500, text="Bad Gateway HTML")) + assert exc_info.value.message == "Bad Gateway HTML" + + def test_empty_body_falls_back_to_default_message(self): + with pytest.raises(AuthorizationError) as exc_info: + raise_for_status(make_response(403, text="")) + assert "permission" in exc_info.value.message + + def test_validation_error_collects_errors_field(self): + body = {"message": "invalid", "errors": [{"field": "name"}]} + with pytest.raises(ValidationError) as exc_info: + raise_for_status(make_response(422, json_body=body)) + assert exc_info.value.errors == [{"field": "name"}] + + +class TestRateLimit: + def test_retry_after_header_is_parsed(self): + response = make_response( + 429, json_body={"message": "slow down"}, headers={"Retry-After": "17"} + ) + with pytest.raises(RateLimitError) as exc_info: + raise_for_status(response) + assert exc_info.value.retry_after == 17 + + def test_non_numeric_retry_after_is_ignored(self): + response = make_response( + 429, json_body={}, headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"} + ) + with pytest.raises(RateLimitError) as exc_info: + raise_for_status(response) + assert exc_info.value.retry_after is None + + def test_default_message_mentions_retry_after(self): + error = RateLimitError(retry_after=30) + assert "30" in error.message + + +class TestAPIErrorDetails: + def test_request_context_is_captured(self): + with pytest.raises(APIError) as exc_info: + raise_for_status(make_response(500, json_body={})) + error = exc_info.value + assert error.status_code == 500 + assert error.request_method == "GET" + assert error.request_url == "https://test.local/api/things/1" + + def test_str_includes_status_and_request(self): + error = APIError( + message="failed", + status_code=502, + request_url="https://x/y", + request_method="POST", + ) + assert str(error) == "failed | Status: 502 | Request: POST https://x/y" + + +class TestDefaultConstructors: + """Every exported exception must be constructible with defaults.""" + + @pytest.mark.parametrize( + ("cls", "fragment"), + [ + (CodesphereError, "error occurred"), + (AuthenticationError, "CS_TOKEN"), + (AuthorizationError, "permission"), + (NotFoundError, "not found"), + (ValidationError, "invalid"), + (ConflictError, "conflicts"), + (RateLimitError, "Rate limit"), + (APIError, "API request failed"), + (NetworkError, "network error"), + (TimeoutError, "timed out"), + ], + ) + def test_default_message(self, cls, fragment): + error = cls() + assert fragment in error.message + assert isinstance(error, CodesphereError) + + def test_not_found_with_resource_name(self): + assert "workspace" in NotFoundError(resource="workspace").message + + def test_network_error_keeps_original(self): + original = ConnectionResetError("reset") + assert NetworkError(original_error=original).original_error is original + + def test_timeout_is_a_network_error(self): + assert issubclass(TimeoutError, NetworkError) + + +class TestEndToEndViaTransport: + """Errors surface through the real client against a mocked transport.""" + + async def test_404_raises_not_found(self, sdk, mock_api): + mock_api.get("/teams/1").respond(404, json={"message": "no such team"}) + with pytest.raises(NotFoundError, match="no such team"): + await sdk.teams.get(team_id=1) + + async def test_429_carries_retry_after(self, sdk, mock_api): + mock_api.get("/teams").respond(429, headers={"Retry-After": "5"}, json={}) + with pytest.raises(RateLimitError) as exc_info: + await sdk.teams.list() + assert exc_info.value.retry_after == 5 + + async def test_timeout_maps_to_sdk_timeout_error(self, sdk, mock_api): + mock_api.get("/teams").mock(side_effect=httpx.ConnectTimeout("slow")) + with pytest.raises(TimeoutError): + await sdk.teams.list() + + async def test_connect_error_maps_to_network_error(self, sdk, mock_api): + mock_api.get("/teams").mock(side_effect=httpx.ConnectError("refused")) + with pytest.raises(NetworkError): + await sdk.teams.list() diff --git a/tests/test_http_client.py b/tests/test_http_client.py new file mode 100644 index 0000000..510434a --- /dev/null +++ b/tests/test_http_client.py @@ -0,0 +1,86 @@ +"""Direct unit tests for the APIHttpClient transport (respx-backed).""" + +import httpx +import pytest +import respx +from pydantic import SecretStr + +from codesphere.exceptions import NetworkError, TimeoutError +from codesphere.http_client import APIHttpClient + +BASE = "https://transport.test/api" + + +@pytest.fixture +def transport(): + return APIHttpClient( + token=SecretStr("secret"), + base_url=BASE, + timeout=httpx.Timeout(7.0, read=21.0), + ) + + +@pytest.fixture +def api(): + with respx.mock(base_url=BASE, assert_all_called=False) as router: + yield router + + +class TestLifecycle: + async def test_request_before_open_raises(self, transport): + with pytest.raises(RuntimeError, match="not open"): + await transport.request("GET", "/x") + + async def test_open_is_idempotent(self, transport, api): + await transport.open() + first = transport._client + await transport.open() + assert transport._client is first + await transport.close() + assert transport._client is None + + async def test_close_without_open_is_noop(self, transport): + await transport.close() + + +class TestErrorMapping: + @pytest.mark.parametrize( + ("side_effect", "expected"), + [ + (httpx.ConnectTimeout("t"), TimeoutError), + (httpx.ReadTimeout("t"), TimeoutError), + (httpx.PoolTimeout("t"), TimeoutError), + (httpx.ConnectError("refused"), NetworkError), + (httpx.ReadError("broken"), NetworkError), + (httpx.RemoteProtocolError("bad"), NetworkError), + ], + ) + async def test_httpx_errors_map_to_sdk_errors( + self, transport, api, side_effect, expected + ): + api.get("/x").mock(side_effect=side_effect) + async with transport: + with pytest.raises(expected): + await transport.request("GET", "/x") + + +class TestConfigurationSurface: + def test_timeout_property(self, transport): + assert transport.timeout.read == 21.0 + + def test_stream_timeout_keeps_connect_unbounds_read(self, transport): + stream_timeout = transport.stream_timeout + assert stream_timeout.connect == 7.0 + assert stream_timeout.read is None + + async def test_stream_passthrough(self, transport, api): + api.get("/logs").respond(200, text="data: hi\n\n") + async with transport, transport.stream("GET", "/logs") as response: + body = await response.aread() + assert b"data: hi" in body + + async def test_auth_header_configured(self, transport, api): + route = api.get("/x").respond(200, json={}) + async with transport: + await transport.request("GET", "/x") + assert route.calls.last.request.headers["authorization"] == "Bearer secret" diff --git a/uv.lock b/uv.lock index 583f8ac..8f8f53d 100644 --- a/uv.lock +++ b/uv.lock @@ -81,6 +81,11 @@ dev = [ { name = "ty" }, ] +[package.dev-dependencies] +dev = [ + { name = "respx" }, +] + [package.metadata] requires-dist = [ { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" }, @@ -98,6 +103,9 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [{ name = "respx", specifier = ">=0.23.1" }] + [[package]] name = "colorama" version = "0.4.6" @@ -461,6 +469,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + [[package]] name = "rich" version = "14.3.2"