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
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 7 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 8 additions & 3 deletions src/codesphere/resources/workspace/env_vars/resources.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,19 +14,23 @@ 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] = []

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"])
Expand Down
23 changes: 23 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

import httpx
import pytest
import respx

TEST_BASE_URL = "https://test.local/api"


class MockResponseFactory:
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion tests/core/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
)

Expand Down
133 changes: 133 additions & 0 deletions tests/core/test_execute_operation.py
Original file line number Diff line number Diff line change
@@ -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"
}
6 changes: 3 additions & 3 deletions tests/core/test_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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:
Expand All @@ -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]
43 changes: 29 additions & 14 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import os
from collections.abc import AsyncGenerator
from typing import NoReturn

import pytest
from dotenv import load_dotenv
Expand All @@ -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",
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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


Expand All @@ -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")
Expand All @@ -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

Expand Down
Loading
Loading