Skip to content

Commit 2f90aeb

Browse files
committed
test: respx suite at the HTTP boundary; type-check tests (ratchet resolved)
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
1 parent a812cf6 commit 2f90aeb

14 files changed

Lines changed: 540 additions & 35 deletions

File tree

CONTRIBUTING.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ tests/
152152
└── test_workspaces.py
153153
```
154154
155+
### Mocking Policy
156+
157+
Unit tests for request/response behavior mock the **HTTP transport**, not SDK
158+
internals: use the `mock_api` respx fixture (see `tests/conftest.py`) so URL
159+
building, headers, body encoding, and response validation run for real. Do
160+
not mock `_execute` or other SDK-internal methods in new tests — if you need
161+
a canned API response, register a respx route instead.
162+
155163
### Unit Tests
156164
157165
Unit tests mock HTTP responses and test SDK logic in isolation. They are fast and don't require API credentials.

pyproject.toml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,18 @@ skips = ["B101"]
8585

8686
[tool.ty.src]
8787
exclude = [
88-
# --- Type-check ratchet -------------------------------------------------
89-
# Each exclusion below is temporary and owned by a refactor ticket in
90-
# plans/refactor/. When a ticket lands, its paths MUST be removed here.
91-
# Do not add new exclusions without a ticket reference.
92-
"tests/", # ticket 06 (respx-based test suite)
93-
# --- Permanent exclusions -----------------------------------------------
88+
# The refactor-ticket ratchet (plans/refactor/) is fully resolved: src and
89+
# tests are type-checked. Do not add exclusions without a ticket reference.
9490
# Examples have their own dependencies (e.g. fastapi) not installed in
9591
# this project's environment.
9692
"examples/",
9793
]
9894

95+
[dependency-groups]
96+
dev = [
97+
"respx>=0.23.1",
98+
]
99+
99100
[project.urls]
100101
Homepage = "https://codesphere.com/"
101102
Repository = "https://github.com/Datata1/codesphere-python"

src/codesphere/resources/workspace/env_vars/resources.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4+
from collections.abc import Sequence
45

56
from ....core.base import ResourceList, WorkspaceScopedResource
67
from .operations import _BULK_DELETE_OP, _BULK_SET_OP, _GET_OP
@@ -13,19 +14,23 @@ class WorkspaceEnvVarManager(WorkspaceScopedResource):
1314
async def get(self) -> ResourceList[EnvVar]:
1415
return await self._execute(_GET_OP, id=self.workspace_id)
1516

16-
async def set(self, env_vars: ResourceList[EnvVar] | list[dict[str, str]]) -> None:
17+
async def set(
18+
self, env_vars: ResourceList[EnvVar] | Sequence[EnvVar | dict[str, str]]
19+
) -> None:
1720
payload = ResourceList[EnvVar].model_validate(env_vars)
1821
await self._execute(_BULK_SET_OP, id=self.workspace_id, data=payload)
1922

20-
async def delete(self, items: list[str] | ResourceList[EnvVar]) -> None:
23+
async def delete(
24+
self, items: Sequence[str | EnvVar | dict[str, str]] | ResourceList[EnvVar]
25+
) -> None:
2126
if not items:
2227
return
2328
payload: list[str] = []
2429

2530
for item in items:
2631
if isinstance(item, str):
2732
payload.append(item)
28-
elif hasattr(item, "name"):
33+
elif isinstance(item, EnvVar):
2934
payload.append(item.name)
3035
elif isinstance(item, dict) and "name" in item:
3136
payload.append(item["name"])

tests/conftest.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
import httpx
55
import pytest
6+
import respx
7+
8+
TEST_BASE_URL = "https://test.local/api"
69

710

811
class MockResponseFactory:
@@ -290,3 +293,23 @@ def _create(response_data: Any = None, domain_data: dict | None = None):
290293
return domain, mock_client
291294

292295
return _create
296+
297+
298+
@pytest.fixture
299+
def mock_api():
300+
"""respx router bound to the test base URL.
301+
302+
Unit tests for request/response behavior mock the HTTP transport with
303+
this router instead of patching SDK internals.
304+
"""
305+
with respx.mock(base_url=TEST_BASE_URL, assert_all_called=False) as router:
306+
yield router
307+
308+
309+
@pytest.fixture
310+
async def sdk(mock_api):
311+
"""An opened CodesphereSDK wired to the respx-mocked transport."""
312+
from codesphere import CodesphereSDK
313+
314+
async with CodesphereSDK(token="test-token", base_url=TEST_BASE_URL) as client:
315+
yield client

tests/core/test_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def test_camel_case_alias_generation(self, case: CamelModelTestCase):
5252
"""Test that snake_case fields are aliased to camelCase."""
5353
from pydantic import create_model
5454

55-
DynamicModel = create_model(
55+
DynamicModel = create_model( # ty: ignore[no-matching-overload]
5656
"DynamicModel", __base__=CamelModel, **{case.field_name: (str, "test")}
5757
)
5858

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Executor behavior tests at the real HTTP boundary (respx).
2+
3+
Unlike tests/core/test_handler.py (which unit-tests against a mocked
4+
APIHttpClient), these drive execute_operation through the actual httpx
5+
transport: URL building, headers, body encoding, and response parsing
6+
are all real.
7+
"""
8+
9+
import json
10+
from types import NoneType
11+
12+
import pytest
13+
from pydantic import BaseModel, ValidationError
14+
15+
from codesphere.core.base import BoundModel, ResourceBase, ResourceList
16+
from codesphere.core.operations import APIOperation
17+
18+
19+
class Thing(BoundModel):
20+
id: int
21+
name: str
22+
23+
async def rename(self, name: str) -> None:
24+
await self._execute(RENAME_THING, thing_id=self.id, data={"name": name})
25+
26+
27+
class ThingCreate(BaseModel):
28+
name: str
29+
size: int
30+
31+
32+
GET_THING = APIOperation(
33+
method="GET", endpoint_template="/things/{thing_id}", response_model=Thing
34+
)
35+
LIST_THINGS = APIOperation(
36+
method="GET", endpoint_template="/things", response_model=ResourceList[Thing]
37+
)
38+
CREATE_THING = APIOperation(
39+
method="POST",
40+
endpoint_template="/things",
41+
input_model=ThingCreate,
42+
response_model=Thing,
43+
)
44+
RENAME_THING = APIOperation(
45+
method="PATCH", endpoint_template="/things/{thing_id}", response_model=NoneType
46+
)
47+
48+
49+
class ThingsResource(ResourceBase):
50+
async def get(self, thing_id: int) -> Thing:
51+
return await self._execute(GET_THING, thing_id=thing_id)
52+
53+
async def list(self) -> list[Thing]:
54+
return (await self._execute(LIST_THINGS)).root
55+
56+
async def create(self, payload) -> Thing:
57+
return await self._execute(CREATE_THING, data=payload)
58+
59+
async def rename(self, thing_id: int, data) -> None:
60+
await self._execute(RENAME_THING, thing_id=thing_id, data=data)
61+
62+
63+
@pytest.fixture
64+
def things(sdk):
65+
return ThingsResource(sdk._http_client)
66+
67+
68+
class TestRequestConstruction:
69+
async def test_path_params_format_the_real_url(self, things, mock_api):
70+
route = mock_api.get("/things/42").respond(200, json={"id": 42, "name": "x"})
71+
await things.get(thing_id=42)
72+
assert route.called
73+
74+
async def test_auth_header_reaches_the_wire(self, things, mock_api):
75+
route = mock_api.get("/things/1").respond(200, json={"id": 1, "name": "x"})
76+
await things.get(thing_id=1)
77+
request = route.calls.last.request
78+
assert request.headers["authorization"] == "Bearer test-token"
79+
80+
async def test_model_payload_is_serialized(self, things, mock_api):
81+
route = mock_api.post("/things").respond(201, json={"id": 1, "name": "a"})
82+
await things.create(ThingCreate(name="a", size=3))
83+
assert json.loads(route.calls.last.request.content) == {"name": "a", "size": 3}
84+
85+
async def test_dict_payload_is_validated_against_input_model(
86+
self, things, mock_api
87+
):
88+
route = mock_api.post("/things").respond(201, json={"id": 1, "name": "a"})
89+
await things.create({"name": "a", "size": 3})
90+
assert json.loads(route.calls.last.request.content) == {"name": "a", "size": 3}
91+
92+
async def test_invalid_payload_never_reaches_the_wire(self, things, mock_api):
93+
route = mock_api.post("/things").respond(201, json={})
94+
with pytest.raises(ValidationError):
95+
await things.create({"name": "missing size"})
96+
assert not route.called
97+
98+
async def test_empty_dict_body_is_sent(self, things, mock_api):
99+
"""Regression: falsy payloads must still be sent as the JSON body."""
100+
route = mock_api.patch("/things/1").respond(204)
101+
await things.rename(1, data={})
102+
assert route.calls.last.request.content == b"{}"
103+
104+
105+
class TestResponseHandling:
106+
async def test_response_is_validated_into_model(self, things, mock_api):
107+
mock_api.get("/things/7").respond(200, json={"id": 7, "name": "seven"})
108+
thing = await things.get(thing_id=7)
109+
assert isinstance(thing, Thing)
110+
assert thing.name == "seven"
111+
112+
async def test_none_response_model_returns_none(self, things, mock_api):
113+
mock_api.patch("/things/1").respond(204)
114+
assert await things.rename(1, data={"name": "n"}) is None
115+
116+
async def test_invalid_response_shape_raises(self, things, mock_api):
117+
mock_api.get("/things/1").respond(200, json={"unexpected": True})
118+
with pytest.raises(ValidationError):
119+
await things.get(thing_id=1)
120+
121+
async def test_list_items_are_bound_and_can_make_calls(self, things, mock_api):
122+
mock_api.get("/things").respond(
123+
200, json=[{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]
124+
)
125+
rename_route = mock_api.patch("/things/2").respond(204)
126+
127+
items = await things.list()
128+
await items[1].rename("renamed")
129+
130+
assert rename_route.called
131+
assert json.loads(rename_route.calls.last.request.content) == {
132+
"name": "renamed"
133+
}

tests/core/test_operations.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class APIOperationTestCase:
3030
method: str
3131
endpoint_template: str
3232
response_model: type
33-
input_model: type | None = None
33+
input_model: type[BaseModel] | None = None
3434

3535

3636
api_operation_test_cases = [
@@ -141,7 +141,7 @@ def test_api_operation_is_frozen(self):
141141
response_model=LogEntry,
142142
)
143143
with pytest.raises(dataclasses.FrozenInstanceError):
144-
op.method = "POST"
144+
op.method = "POST" # ty: ignore[invalid-assignment]
145145

146146

147147
class TestStreamOperation:
@@ -159,4 +159,4 @@ def test_stream_operation_is_frozen(self):
159159
entry_model=LogEntry,
160160
)
161161
with pytest.raises(dataclasses.FrozenInstanceError):
162-
op.endpoint_template = "/other"
162+
op.endpoint_template = "/other" # ty: ignore[invalid-assignment]

tests/integration/conftest.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import os
44
from collections.abc import AsyncGenerator
5+
from typing import NoReturn
56

67
import pytest
78
from dotenv import load_dotenv
@@ -16,6 +17,20 @@
1617
log = logging.getLogger(__name__)
1718

1819

20+
def skip_test(reason: str) -> NoReturn:
21+
"""pytest.skip with the ty suppression funneled through one place.
22+
23+
ty cannot resolve the decorated signatures of pytest's outcome
24+
functions, so the (correct) call below needs a targeted ignore.
25+
"""
26+
pytest.skip(reason) # ty: ignore[too-many-positional-arguments]
27+
28+
29+
def fail_test(reason: str) -> NoReturn:
30+
"""pytest.fail counterpart of :func:`skip_test`."""
31+
pytest.fail(reason) # ty: ignore[invalid-argument-type]
32+
33+
1934
def pytest_addoption(parser):
2035
parser.addoption(
2136
"--run-integration",
@@ -47,7 +62,7 @@ def pytest_collection_modifyitems(config, items):
4762
def integration_token() -> str:
4863
token = os.environ.get("CS_TOKEN")
4964
if not token:
50-
pytest.skip("CS_TOKEN environment variable not set")
65+
skip_test("CS_TOKEN environment variable not set")
5166
return token
5267

5368

@@ -94,7 +109,7 @@ async def test_team_id(
94109

95110
teams = await session_sdk_client.teams.list()
96111
if not teams:
97-
pytest.fail("No teams available for integration testing")
112+
fail_test("No teams available for integration testing")
98113
return teams[0].id
99114

100115

@@ -108,7 +123,7 @@ async def test_plan_id(session_sdk_client: CodesphereSDK) -> int:
108123
if micro_plan:
109124
return micro_plan.id
110125

111-
pytest.fail("No 'Micro' workspace plan available for testing")
126+
fail_test("No 'Micro' workspace plan available for testing")
112127

113128

114129
@pytest.fixture(scope="session")
@@ -119,31 +134,31 @@ async def test_workspaces(
119134
) -> AsyncGenerator[list[Workspace], None]:
120135
created_workspaces: list[Workspace] = []
121136

122-
workspace_configs = [
123-
{"name": f"{TEST_WORKSPACE_PREFIX}-1", "git_url": None},
124-
{
125-
"name": f"{TEST_WORKSPACE_PREFIX}-git",
126-
"git_url": "https://github.com/octocat/Hello-World.git",
127-
},
137+
workspace_configs: list[tuple[str, str | None]] = [
138+
(f"{TEST_WORKSPACE_PREFIX}-1", None),
139+
(
140+
f"{TEST_WORKSPACE_PREFIX}-git",
141+
"https://github.com/octocat/Hello-World.git",
142+
),
128143
]
129144

130-
for config in workspace_configs:
145+
for ws_name, git_url in workspace_configs:
131146
payload = WorkspaceCreate(
132147
team_id=test_team_id,
133-
name=config["name"],
148+
name=ws_name,
134149
plan_id=test_plan_id,
135-
git_url=config["git_url"],
150+
git_url=git_url,
136151
)
137152
try:
138153
workspace = await session_sdk_client.workspaces.create(payload=payload)
139154
created_workspaces.append(workspace)
140155
log.info(f"Created test workspace: {workspace.name} (ID: {workspace.id})")
141156
except Exception as e:
142-
log.error(f"Failed to create test workspace {config['name']}: {e}")
157+
log.error(f"Failed to create test workspace {ws_name}: {e}")
143158
for ws in created_workspaces:
144159
with contextlib.suppress(Exception):
145160
await ws.delete()
146-
pytest.fail(f"Failed to create test workspaces: {e}")
161+
fail_test(f"Failed to create test workspaces: {e}")
147162

148163
yield created_workspaces
149164

0 commit comments

Comments
 (0)