From 126475eba3c40a2f65189e336b14bef77c7bafc0 Mon Sep 17 00:00:00 2001 From: Datata1 <> Date: Tue, 3 Feb 2026 13:17:03 +0100 Subject: [PATCH 1/2] test(suite): set up initial testsuite --- .env.example | 7 +- .github/copilot-instructions.md | 154 ++++++++- .github/workflows/integration.yml | 91 ++++++ CONTRIBUTING.md | 148 ++++++++- Makefile | 24 +- pyproject.toml | 4 + src/codesphere/config.py | 5 +- src/codesphere/core/base.py | 1 + src/codesphere/core/handler.py | 8 +- src/codesphere/resources/metadata/schemas.py | 12 +- .../resources/team/domain/operations.py | 2 +- src/codesphere/resources/team/resources.py | 2 +- src/codesphere/resources/team/schemas.py | 5 +- .../resources/workspace/__init__.py | 3 +- .../resources/workspace/command_schemas.py | 32 ++ .../resources/workspace/envVars/__init__.py | 3 +- .../resources/workspace/envVars/models.py | 27 +- .../resources/workspace/envVars/operations.py | 22 +- .../resources/workspace/envVars/schemas.py | 10 + .../resources/workspace/operations.py | 11 +- .../resources/workspace/resources.py | 6 +- src/codesphere/resources/workspace/schemas.py | 47 ++- tests/conftest.py | 300 ++++++++++++++++++ tests/core/__init__.py | 1 + tests/core/test_base.py | 214 +++++++++++++ tests/core/test_handler.py | 145 +++++++++ tests/core/test_operations.py | 145 +++++++++ tests/integration/__init__.py | 6 + tests/integration/conftest.py | 227 +++++++++++++ tests/integration/test_domains.py | 125 ++++++++ tests/integration/test_env_vars.py | 155 +++++++++ tests/integration/test_metadata.py | 60 ++++ tests/integration/test_teams.py | 54 ++++ tests/integration/test_workspaces.py | 178 +++++++++++ tests/resources/__init__.py | 1 + tests/resources/conftest.py | 178 +++++++++++ tests/resources/metadata/__init__.py | 1 + tests/resources/metadata/test_metadata.py | 217 +++++++++++++ tests/resources/team/__init__.py | 1 + tests/resources/team/domain/__init__.py | 1 + tests/resources/team/domain/test_domain.py | 228 +++++++++++++ tests/resources/team/test_team.py | 107 +++++++ tests/resources/test_domain_resources.py | 0 tests/resources/test_metadata_resources.py | 0 tests/resources/test_workspaces_resources.py | 0 tests/resources/workspace/__init__.py | 1 + .../resources/workspace/env_vars/__init__.py | 1 + .../workspace/env_vars/test_env_vars.py | 157 +++++++++ tests/resources/workspace/test_workspace.py | 248 +++++++++++++++ tests/test_client.py | 195 +++++++----- 50 files changed, 3390 insertions(+), 180 deletions(-) create mode 100644 .github/workflows/integration.yml create mode 100644 src/codesphere/resources/workspace/command_schemas.py create mode 100644 src/codesphere/resources/workspace/envVars/schemas.py create mode 100644 tests/conftest.py create mode 100644 tests/core/__init__.py create mode 100644 tests/core/test_base.py create mode 100644 tests/core/test_handler.py create mode 100644 tests/core/test_operations.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_domains.py create mode 100644 tests/integration/test_env_vars.py create mode 100644 tests/integration/test_metadata.py create mode 100644 tests/integration/test_teams.py create mode 100644 tests/integration/test_workspaces.py create mode 100644 tests/resources/__init__.py create mode 100644 tests/resources/conftest.py create mode 100644 tests/resources/metadata/__init__.py create mode 100644 tests/resources/metadata/test_metadata.py create mode 100644 tests/resources/team/__init__.py create mode 100644 tests/resources/team/domain/__init__.py create mode 100644 tests/resources/team/domain/test_domain.py create mode 100644 tests/resources/team/test_team.py delete mode 100644 tests/resources/test_domain_resources.py delete mode 100644 tests/resources/test_metadata_resources.py delete mode 100644 tests/resources/test_workspaces_resources.py create mode 100644 tests/resources/workspace/__init__.py create mode 100644 tests/resources/workspace/env_vars/__init__.py create mode 100644 tests/resources/workspace/env_vars/test_env_vars.py create mode 100644 tests/resources/workspace/test_workspace.py diff --git a/.env.example b/.env.example index 85cc08d..33f3441 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,6 @@ -CS_TOKEN="secret_token" +# Required: Your Codesphere API token +# Get this from your Codesphere account settings +CS_TOKEN=your-api-token-here +# CS_BASE_URL=https://codesphere.com/api +# CS_TEST_TEAM_ID=12345 +# CS_TEST_DC_ID=1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index edfddf0..109a00f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -30,6 +30,17 @@ src/codesphere/ └── pipeline/ # (Placeholder) tests/ # Test files mirroring src structure +├── conftest.py # Shared unit test fixtures +├── core/ # Core infrastructure tests +├── resources/ # Resource unit tests +└── integration/ # Integration tests (real API) + ├── conftest.py # Integration fixtures & workspace setup + ├── test_domains.py + ├── test_env_vars.py + ├── test_metadata.py + ├── test_teams.py + └── test_workspaces.py + examples/ # Usage examples organized by resource type ``` @@ -67,7 +78,7 @@ _GET_OP = APIOperation( # Example resource method async def get(self, resource_id: int) -> ResourceModel: - return await self.get_op(data=resource_id) + return await self.get_op(resource_id=resource_id) ``` ### Model Guidelines @@ -91,13 +102,6 @@ class Workspace(WorkspaceBase, _APIOperationExecutor): - Raise `RuntimeError` for SDK misuse (e.g., accessing resources without context manager) - Use custom exceptions from `exceptions.py` for SDK-specific errors -### Testing - -- Use `pytest.mark.asyncio` for async tests -- Use `@dataclass` for test case definitions with parametrization -- Mock `httpx.AsyncClient` for HTTP request testing -- Test files should mirror the source structure in `tests/` - ### Code Style - Line length: 88 characters (Ruff/Black standard) @@ -105,12 +109,134 @@ class Workspace(WorkspaceBase, _APIOperationExecutor): - Quotes: Double quotes for strings - Imports: Group stdlib, third-party, and local imports -### Development Commands +--- + +## Testing Guidelines + +When adding features or making changes, appropriate tests are **required**. The SDK uses two types of tests: + +### Unit Tests + +Located in `tests/` (excluding `tests/integration/`). These mock HTTP responses and test SDK logic in isolation. + +**When to write unit tests:** +- New Pydantic models or schemas +- New API operations +- Core handler or utility logic changes + +**Unit test patterns:** + +```python +import pytest +from dataclasses import dataclass +from unittest.mock import AsyncMock, MagicMock + +# Use @dataclass for parameterized test cases +@dataclass +class WorkspaceTestCase: + name: str + workspace_id: int + expected_name: str + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", [ + WorkspaceTestCase(name="basic", workspace_id=123, expected_name="test-ws"), +]) +async def test_workspace_get(case: WorkspaceTestCase): + """Should fetch a workspace by ID.""" + mock_response = MagicMock() + mock_response.json.return_value = {"id": case.workspace_id, "name": case.expected_name} + + # Test implementation... +``` + +### Integration Tests + +Located in `tests/integration/`. These run against the real Codesphere API. + +**When to write integration tests:** +- New API endpoints (CRUD operations) +- Changes to request/response serialization +- Schema field changes (detect API contract changes early) + +**Integration test patterns:** + +```python +import pytest +from codesphere import CodesphereSDK + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + +class TestMyResourceIntegration: + """Integration tests for MyResource endpoints.""" + + async def test_list_resources(self, sdk_client: CodesphereSDK): + """Should retrieve a list of resources.""" + resources = await sdk_client.my_resource.list() + + assert isinstance(resources, list) + + async def test_create_and_delete( + self, + sdk_client: CodesphereSDK, + test_team_id: int, + ): + """Should create and delete a resource.""" + resource = await sdk_client.my_resource.create(name="test") + + try: + assert resource.name == "test" + finally: + # Always cleanup created resources + await resource.delete() +``` + +**Available integration test fixtures** (from `tests/integration/conftest.py`): + +| Fixture | Scope | Description | +|---------|-------|-------------| +| `sdk_client` | function | Fresh SDK client for each test | +| `session_sdk_client` | session | Shared SDK client for setup/teardown | +| `test_team_id` | session | Team ID for testing | +| `test_workspace` | session | Single pre-created workspace | +| `test_workspaces` | session | List of 2 test workspaces | +| `integration_token` | session | The API token (from `CS_TOKEN`) | + +**Environment variables:** + +| Variable | Required | Description | +|----------|----------|-------------| +| `CS_TOKEN` | Yes | Codesphere API token | +| `CS_TEST_TEAM_ID` | No | Specific team ID (defaults to first team) | +| `CS_TEST_DC_ID` | No | Datacenter ID (defaults to 1) | + +### Running Tests + +```bash +make test # Run unit tests only +make test-unit # Run unit tests only (explicit) +make test-integration # Run integration tests (requires CS_TOKEN) +``` + +### Test Requirements Checklist + +When submitting a PR, ensure: + +- [ ] **New endpoints** have integration tests covering all operations +- [ ] **New models** have unit tests for serialization/deserialization +- [ ] **Bug fixes** include a test that reproduces the issue +- [ ] **All tests pass** locally before pushing + +--- + +## Development Commands ```bash -make install # Set up development environment -make lint # Run Ruff linter -make format # Format code with Ruff -make test # Run pytest -make commit # Guided commit with Commitizen +make install # Set up development environment +make lint # Run Ruff linter +make format # Format code with Ruff +make test # Run unit tests +make test-unit # Run unit tests (excludes integration) +make test-integration # Run integration tests +make commit # Guided commit with Commitizen ``` \ No newline at end of file diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..e988506 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,91 @@ +name: Integration Tests + +on: + # Manual trigger with optional inputs + workflow_dispatch: + inputs: + test_team_id: + description: "Team ID to use for testing (optional)" + required: false + type: string + test_dc_id: + description: "Datacenter ID for test resources" + required: false + default: "1" + type: string + + # Run on pull requests + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "src/codesphere/**" + - "tests/integration/**" + - ".github/workflows/integration.yml" + +permissions: + contents: read + +env: + PYTHON_VERSION: "3.12" + +jobs: + integration-tests: + name: Run Integration Tests + runs-on: ubuntu-latest + # Only run if the secret is available (prevents failures on forks) + if: ${{ github.repository == 'Datata1/codesphere-python' || github.event_name == 'workflow_dispatch' }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv package manager + uses: astral-sh/setup-uv@v6 + with: + activate-environment: true + + - name: Install dependencies + run: uv sync --extra dev + + - name: Run integration tests + env: + CS_TOKEN: ${{ secrets.CS_TOKEN }} + CS_TEST_TEAM_ID: ${{ inputs.test_team_id || secrets.CS_TEST_TEAM_ID }} + CS_TEST_DC_ID: ${{ inputs.test_dc_id || '1' }} + run: | + echo "Running integration tests..." + uv run pytest tests/integration -v --run-integration \ + --junitxml=junit/integration-results.xml \ + --tb=short + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: integration-test-results + path: junit/integration-results.xml + retention-days: 30 + + - name: Minimize uv cache + run: uv cache prune --ci + + integration-tests-summary: + name: Integration Tests Summary + runs-on: ubuntu-latest + needs: integration-tests + if: always() + + steps: + - name: Download test results + uses: actions/download-artifact@v4 + with: + name: integration-test-results + path: junit + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: junit/integration-results.xml + check_name: Integration Test Results + comment_mode: off diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 709fc92..100c0ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ To get your local development environment set up, please follow these steps: 1. **Fork the repository** on GitHub. 2. **Clone your forked repository** to your local machine: ```bash - git clone [https://github.com/YOUR_USERNAME/codesphere-python.git](https://github.com/YOUR_USERNAME/codesphere-python.git) + git clone https://github.com/YOUR_USERNAME/codesphere-python.git cd codesphere-python ``` 3. **Set up the project and install dependencies.** We use `uv` for package management. The following command will create a virtual environment and install all necessary dependencies for development: @@ -30,6 +30,11 @@ To get your local development environment set up, please follow these steps: ```bash source .venv/bin/activate ``` +5. **Set up environment variables for integration tests** (optional but recommended): + ```bash + cp .env.example .env + # Edit .env and add your CS_TOKEN + ``` You are now ready to start developing! @@ -53,7 +58,8 @@ You are now ready to start developing! ``` 4. **Run the tests** to ensure that your changes don't break existing functionality. ```bash - make test + make test # Run unit tests + make test-integration # Run integration tests (requires CS_TOKEN) ``` 5. **Commit your changes.** We follow the **[Conventional Commits](https://www.conventionalcommits.org/)** specification. You can use our commit command, which will guide you through the process: ```bash @@ -67,10 +73,146 @@ You are now ready to start developing! --- +## Testing Guidelines + +We maintain two types of tests: **unit tests** and **integration tests**. When contributing, please ensure appropriate test coverage for your changes. + +### Test Structure + +``` +tests/ +├── conftest.py # Shared fixtures for unit tests +├── core/ # Tests for core SDK infrastructure +├── resources/ # Tests for resource implementations +│ ├── metadata/ +│ ├── team/ +│ └── workspace/ +└── integration/ # Integration tests (real API calls) + ├── conftest.py # Integration test fixtures + ├── test_domains.py + ├── test_env_vars.py + ├── test_metadata.py + ├── test_teams.py + └── test_workspaces.py +``` + +### Unit Tests + +Unit tests mock HTTP responses and test SDK logic in isolation. They are fast and don't require API credentials. + +**When to add unit tests:** +- Adding new Pydantic models or schemas +- Adding new API operations +- Modifying core handler logic +- Adding utility functions + +**Example unit test pattern:** + +```python +import pytest +from unittest.mock import AsyncMock, MagicMock + +@pytest.mark.asyncio +async def test_workspace_get(): + """Should fetch a workspace by ID.""" + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=MagicMock( + json=lambda: {"id": 123, "name": "test-ws", ...}, + raise_for_status=lambda: None + )) + + resource = WorkspacesResource() + resource._http_client = mock_client + + result = await resource.get(workspace_id=123) + + assert result.id == 123 + assert result.name == "test-ws" +``` + +### Integration Tests + +Integration tests run against the real Codesphere API and verify end-to-end functionality. They require valid API credentials. + +**When to add integration tests:** +- Adding new API endpoints +- Modifying request/response handling +- Changing how data is serialized/deserialized + +**Running integration tests:** + +```bash +# Set up credentials +export CS_TOKEN=your-api-token +# Or use a .env file +cp .env.example .env + +# Run integration tests +make test-integration +``` + +**Environment variables for integration tests:** + +| Variable | Required | Description | +|----------|----------|-------------| +| `CS_TOKEN` | Yes | Your Codesphere API token | +| `CS_TEST_TEAM_ID` | No | Specific team ID for tests | +| `CS_TEST_DC_ID` | No | Datacenter ID (defaults to 1) | + +**Example integration test pattern:** + +```python +import pytest +from codesphere import CodesphereSDK + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + +class TestMyResourceIntegration: + """Integration tests for MyResource endpoints.""" + + async def test_list_resources(self, sdk_client: CodesphereSDK): + """Should retrieve a list of resources.""" + resources = await sdk_client.my_resource.list() + + assert isinstance(resources, list) + assert len(resources) > 0 + + async def test_create_and_delete_resource( + self, + sdk_client: CodesphereSDK, + test_team_id: int, + ): + """Should create and delete a resource.""" + # Create + resource = await sdk_client.my_resource.create(name="test") + + try: + assert resource.name == "test" + finally: + # Always cleanup + await resource.delete() +``` + +**Integration test fixtures** (available in `tests/integration/conftest.py`): + +- `sdk_client` - A configured SDK client for each test +- `test_team_id` - The team ID to use for testing +- `test_workspace` - A pre-created test workspace +- `test_workspaces` - List of test workspaces (created at session start) + +### Test Requirements for Pull Requests + +1. **New features** must include both unit tests and integration tests +2. **Bug fixes** should include a test that reproduces the bug +3. **Schema changes** require unit tests validating serialization/deserialization +4. **All tests must pass** before a PR can be merged + +--- + ## Pull Request Guidelines * Ensure all tests and CI checks are passing. -* If you've added new functionality, please add corresponding tests. +* If you've added new functionality, please add corresponding tests (both unit and integration). * Keep your PR focused on a single issue or feature. * A maintainer will review your PR and provide feedback. diff --git a/Makefile b/Makefile index da61f33..76ddd4d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install commit lint format test bump +.PHONY: help install commit lint format test test-integration test-unit bump .DEFAULT_GOAL := help @@ -33,10 +33,26 @@ format: ## Formats code with ruff @echo ">>> Formatting code with ruff..." uv run ruff format src -test: ## Runs tests with pytest - @echo ">>> Running tests with pytest..." +test: ## Runs all tests with pytest + @echo ">>> Running all tests with pytest..." uv run pytest +test-unit: ## Runs only unit tests (excludes integration tests) + @echo ">>> Running unit tests with pytest..." + uv run pytest --ignore=tests/integration + +test-integration: ## Runs integration tests (requires CS_TOKEN env var or .env file) + @echo ">>> Running integration tests with pytest..." + @if [ -f .env ]; then \ + echo "Loading environment from .env file..."; \ + set -a; . ./.env; set +a; \ + fi; \ + if [ -z "$${CS_TOKEN}" ]; then \ + echo "\033[0;33mWarning: CS_TOKEN not set. Create a .env file or export CS_TOKEN=your-api-token\033[0m"; \ + exit 1; \ + fi; \ + uv run pytest tests/integration -v --run-integration + release: ## Pushes a new tag and release @echo ">>> Starting release process..." git config --global push.followTags true @@ -68,4 +84,4 @@ pypi: ## publishes to PyPI @echo "\n\033[0;32mPyPI release complete! The GitHub Action will now create the GitHub Release.\033[0m" tree: ## shows filetree in terminal without uninteresting files - tree -I "*.pyc|*.lock" \ No newline at end of file + tree -I "*.pyc|*.lock" diff --git a/pyproject.toml b/pyproject.toml index 3529ea9..777076b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,10 @@ pythonpath = [ python_files = "test_*.py *_test.py" python_functions = "test_*" python_classes = "Test*" +asyncio_mode = "auto" +markers = [ + "integration: mark test as integration test (requires API token and --run-integration flag)", +] [tool.coverage.run] source = ["api", "handler", "tasks"] diff --git a/src/codesphere/config.py b/src/codesphere/config.py index cc2ad15..a248c4f 100644 --- a/src/codesphere/config.py +++ b/src/codesphere/config.py @@ -4,7 +4,10 @@ class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", env_file_encoding="utf-8", env_prefix="CS_" + env_file=".env", + env_file_encoding="utf-8", + env_prefix="CS_", + extra="ignore", # Allow extra CS_* env vars (e.g., CS_TEST_TEAM_ID) ) token: SecretStr diff --git a/src/codesphere/core/base.py b/src/codesphere/core/base.py index 14a6882..0947c1c 100644 --- a/src/codesphere/core/base.py +++ b/src/codesphere/core/base.py @@ -17,6 +17,7 @@ class CamelModel(BaseModel): model_config = ConfigDict( alias_generator=to_camel, populate_by_name=True, + serialize_by_alias=True, # Serialize using camelCase aliases ) diff --git a/src/codesphere/core/handler.py b/src/codesphere/core/handler.py index cd2eb7f..e5a1c8d 100644 --- a/src/codesphere/core/handler.py +++ b/src/codesphere/core/handler.py @@ -72,17 +72,13 @@ def _prepare_request_args(self) -> tuple[str, dict]: else: payload = json_data_obj - if payload is not None: - log.info(f"PAYLOAD TYPE: {type(payload)}") - log.info(f"PAYLOAD CONTENT: {payload}") - request_kwargs = {"params": self.kwargs.get("params"), "json": payload} return endpoint, {k: v for k, v in request_kwargs.items() if v is not None} async def _make_request( self, method: str, endpoint: str, **kwargs: Any ) -> httpx.Response: - if not self.http_client: + if self.http_client is None or not hasattr(self.http_client, "request"): raise RuntimeError("HTTP Client is not initialized.") return await self.http_client.request( method=method, endpoint=endpoint, **kwargs @@ -99,7 +95,7 @@ async def _parse_and_validate_response( response_model: Type[BaseModel] | Type[List[BaseModel]] | None, endpoint_for_logging: str, ) -> Any: - if response_model is None: + if response_model is None or response_model is type(None): return None try: diff --git a/src/codesphere/resources/metadata/schemas.py b/src/codesphere/resources/metadata/schemas.py index 51e0232..4628641 100644 --- a/src/codesphere/resources/metadata/schemas.py +++ b/src/codesphere/resources/metadata/schemas.py @@ -1,6 +1,8 @@ from __future__ import annotations import datetime +from pydantic import Field + from ...core.base import CamelModel @@ -17,11 +19,11 @@ class Characteristic(CamelModel): """Defines the resource specifications for a WsPlan.""" id: int - cpu: float - gpu: int - ram: int - ssd: int - temp_storage: int + cpu: float = Field(validation_alias="CPU") + gpu: int = Field(validation_alias="GPU") + ram: int = Field(validation_alias="RAM") + ssd: int = Field(validation_alias="SSD") + temp_storage: int = Field(validation_alias="TempStorage") on_demand: bool diff --git a/src/codesphere/resources/team/domain/operations.py b/src/codesphere/resources/team/domain/operations.py index a3c07b2..2f438dc 100644 --- a/src/codesphere/resources/team/domain/operations.py +++ b/src/codesphere/resources/team/domain/operations.py @@ -47,5 +47,5 @@ _DELETE_OP = APIOperation( method="DELETE", endpoint_template="/domains/team/{team_id}/domain/{name}", - response_model=DomainBase, + response_model=type(None), ) diff --git a/src/codesphere/resources/team/resources.py b/src/codesphere/resources/team/resources.py index 7c9fb3c..6ee9411 100644 --- a/src/codesphere/resources/team/resources.py +++ b/src/codesphere/resources/team/resources.py @@ -25,7 +25,7 @@ async def list(self) -> List[Team]: get_team_op: AsyncCallable[Team] = Field(default=_GET_TEAM_OP, exclude=True) async def get(self, team_id: int) -> Team: - return await self.get_team_op(data=team_id) + return await self.get_team_op(team_id=team_id) create_team_op: AsyncCallable[Team] = Field(default=_CREATE_TEAM_OP, exclude=True) diff --git a/src/codesphere/resources/team/schemas.py b/src/codesphere/resources/team/schemas.py index ca8b659..55f07f7 100644 --- a/src/codesphere/resources/team/schemas.py +++ b/src/codesphere/resources/team/schemas.py @@ -6,6 +6,7 @@ from .domain.manager import TeamDomainManager from ...core.base import CamelModel from ...core import _APIOperationExecutor, APIOperation, AsyncCallable +from ...http_client import APIHttpClient if TYPE_CHECKING: pass @@ -40,7 +41,9 @@ class Team(TeamBase, _APIOperationExecutor): @cached_property def domains(self) -> TeamDomainManager: - if not self._http_client: + if self._http_client is None or not isinstance( + self._http_client, APIHttpClient + ): raise RuntimeError("Cannot access 'domains' on a detached model.") return TeamDomainManager(http_client=self._http_client, team_id=self.id) diff --git a/src/codesphere/resources/workspace/__init__.py b/src/codesphere/resources/workspace/__init__.py index 0bb117c..8f29f84 100644 --- a/src/codesphere/resources/workspace/__init__.py +++ b/src/codesphere/resources/workspace/__init__.py @@ -1,4 +1,5 @@ -from .schemas import Workspace, WorkspaceCreate, WorkspaceUpdate, WorkspaceStatus +from .schemas import Workspace, WorkspaceCreate, WorkspaceUpdate +from .command_schemas import WorkspaceStatus from .resources import WorkspacesResource __all__ = [ diff --git a/src/codesphere/resources/workspace/command_schemas.py b/src/codesphere/resources/workspace/command_schemas.py new file mode 100644 index 0000000..c31d83c --- /dev/null +++ b/src/codesphere/resources/workspace/command_schemas.py @@ -0,0 +1,32 @@ +""" +Input/Output schemas for workspace operations. + +These are separated from the main schemas to avoid circular imports +between schemas.py and operations.py. +""" + +from typing import Dict, Optional + +from ...core.base import CamelModel + + +class CommandInput(CamelModel): + """Input model for command execution.""" + + command: str + env: Optional[Dict[str, str]] = None + + +class CommandOutput(CamelModel): + """Output model for command execution.""" + + command: str + working_dir: str + output: str + error: str + + +class WorkspaceStatus(CamelModel): + """Status information for a workspace.""" + + is_running: bool diff --git a/src/codesphere/resources/workspace/envVars/__init__.py b/src/codesphere/resources/workspace/envVars/__init__.py index 3a9791e..f953883 100644 --- a/src/codesphere/resources/workspace/envVars/__init__.py +++ b/src/codesphere/resources/workspace/envVars/__init__.py @@ -1,3 +1,4 @@ -from .models import EnvVar, WorkspaceEnvVarManager +from .schemas import EnvVar +from .models import WorkspaceEnvVarManager __all__ = ["EnvVar", "WorkspaceEnvVarManager"] diff --git a/src/codesphere/resources/workspace/envVars/models.py b/src/codesphere/resources/workspace/envVars/models.py index d48f883..f8e41bb 100644 --- a/src/codesphere/resources/workspace/envVars/models.py +++ b/src/codesphere/resources/workspace/envVars/models.py @@ -1,22 +1,17 @@ from __future__ import annotations import logging from typing import Dict, List, Union -from pydantic import BaseModel, Field +from pydantic import Field +from .schemas import EnvVar from ....core.base import ResourceList from ....core.handler import _APIOperationExecutor from ....core.operations import AsyncCallable from ....http_client import APIHttpClient -from .operations import _BULK_DELETE_OP, _BULK_SET_OP, _GET_OP log = logging.getLogger(__name__) -class EnvVar(BaseModel): - name: str - value: str - - class WorkspaceEnvVarManager(_APIOperationExecutor): def __init__(self, http_client: APIHttpClient, workspace_id: int): self._http_client = http_client @@ -24,26 +19,30 @@ def __init__(self, http_client: APIHttpClient, workspace_id: int): self.id = workspace_id get_all_op: AsyncCallable[ResourceList[EnvVar]] = Field( - default=_GET_OP, + default=None, exclude=True, ) async def get(self) -> List[EnvVar]: - return await self.get_all_op() + from .operations import _GET_OP + + return await self._execute_operation(_GET_OP) bulk_set_op: AsyncCallable[None] = Field( - default=_BULK_SET_OP, + default=None, exclude=True, ) async def set( self, env_vars: Union[ResourceList[EnvVar], List[Dict[str, str]]] ) -> None: + from .operations import _BULK_SET_OP + payload = ResourceList[EnvVar].model_validate(env_vars) - await self.bulk_set_op(data=payload.model_dump()) + await self._execute_operation(_BULK_SET_OP, data=payload.model_dump()) bulk_delete_op: AsyncCallable[None] = Field( - default=_BULK_DELETE_OP, + default=None, exclude=True, ) @@ -51,6 +50,8 @@ async def delete(self, items: Union[List[str], ResourceList[EnvVar]]) -> None: if not items: return + from .operations import _BULK_DELETE_OP + payload: List[str] = [] for item in items: @@ -62,4 +63,4 @@ async def delete(self, items: Union[List[str], ResourceList[EnvVar]]) -> None: payload.append(item["name"]) if payload: - await self.bulk_delete_op(data=payload) + await self._execute_operation(_BULK_DELETE_OP, data=payload) diff --git a/src/codesphere/resources/workspace/envVars/operations.py b/src/codesphere/resources/workspace/envVars/operations.py index d5e1be6..ceca85f 100644 --- a/src/codesphere/resources/workspace/envVars/operations.py +++ b/src/codesphere/resources/workspace/envVars/operations.py @@ -1,5 +1,4 @@ -from .models import EnvVar -from ...workspace.schemas import CommandInput, CommandOutput, WorkspaceStatus +from .schemas import EnvVar from ....core.base import ResourceList from ....core.operations import APIOperation @@ -20,22 +19,3 @@ endpoint_template="/workspaces/{id}/env-vars", response_model=type(None), ) - -_DELETE_OP = APIOperation( - method="DELETE", - endpoint_template="/workspaces/{id}", - response_model=type(None), -) - -_GET_STATUS_OP = APIOperation( - method="GET", - endpoint_template="/workspaces/{id}/status", - response_model=WorkspaceStatus, -) - -_EXECUTE_COMMAND_OP = APIOperation( - method="POST", - endpoint_template="/workspaces/{id}/execute", - input_model=CommandInput, - response_model=CommandOutput, -) diff --git a/src/codesphere/resources/workspace/envVars/schemas.py b/src/codesphere/resources/workspace/envVars/schemas.py new file mode 100644 index 0000000..0139369 --- /dev/null +++ b/src/codesphere/resources/workspace/envVars/schemas.py @@ -0,0 +1,10 @@ +"""EnvVar schema - separated to avoid circular imports.""" + +from pydantic import BaseModel + + +class EnvVar(BaseModel): + """Environment variable model.""" + + name: str + value: str diff --git a/src/codesphere/resources/workspace/operations.py b/src/codesphere/resources/workspace/operations.py index 0c0ab84..5def22e 100644 --- a/src/codesphere/resources/workspace/operations.py +++ b/src/codesphere/resources/workspace/operations.py @@ -1,10 +1,7 @@ -from .schemas import ( - CommandInput, - CommandOutput, - Workspace, - WorkspaceCreate, - WorkspaceStatus, -) +from __future__ import annotations + +from .command_schemas import CommandInput, CommandOutput, WorkspaceStatus +from .schemas import Workspace, WorkspaceCreate from ...core.base import ResourceList from ...core.operations import APIOperation diff --git a/src/codesphere/resources/workspace/resources.py b/src/codesphere/resources/workspace/resources.py index 1662a51..5a6c00c 100644 --- a/src/codesphere/resources/workspace/resources.py +++ b/src/codesphere/resources/workspace/resources.py @@ -19,15 +19,15 @@ class WorkspacesResource(ResourceBase): ) async def list(self, team_id: int) -> List[Workspace]: - result = await self.list_by_team_op(data=team_id) + result = await self.list_by_team_op(team_id=team_id) return result.root get_op: AsyncCallable[Workspace] = Field(default=_GET_OP, exclude=True) async def get(self, workspace_id: int) -> Workspace: - return await self.get_op(data=workspace_id) + return await self.get_op(workspace_id=workspace_id) create_op: AsyncCallable[Workspace] = Field(default=_CREATE_OP, exclude=True) - async def create(self, payload=WorkspaceCreate) -> Workspace: + async def create(self, payload: WorkspaceCreate) -> Workspace: return await self.create_op(data=payload) diff --git a/src/codesphere/resources/workspace/schemas.py b/src/codesphere/resources/workspace/schemas.py index de17628..2d8cea9 100644 --- a/src/codesphere/resources/workspace/schemas.py +++ b/src/codesphere/resources/workspace/schemas.py @@ -4,10 +4,11 @@ from pydantic import Field from typing import Dict, Optional, List -from .operations import _DELETE_OP, _EXECUTE_COMMAND_OP, _GET_STATUS_OP, _UPDATE_OP +from .command_schemas import CommandInput, CommandOutput, WorkspaceStatus from .envVars import EnvVar, WorkspaceEnvVarManager from ...core.base import CamelModel from ...core import _APIOperationExecutor, AsyncCallable +from ...http_client import APIHttpClient from ...utils import update_model_fields log = logging.getLogger(__name__) @@ -57,62 +58,56 @@ class WorkspaceUpdate(CamelModel): restricted: Optional[bool] = None -class WorkspaceStatus(CamelModel): - is_running: bool - - -class CommandInput(CamelModel): - command: str - env: Optional[Dict[str, str]] = None - - -class CommandOutput(CamelModel): - command: str - working_dir: str - output: str - error: str - - class Workspace(WorkspaceBase, _APIOperationExecutor): update_op: AsyncCallable[None] = Field( - default=_UPDATE_OP, + default=None, exclude=True, ) async def update(self, data: WorkspaceUpdate) -> None: - await self.update_op(data=data) + from .operations import _UPDATE_OP + + await self._execute_operation(_UPDATE_OP, data=data) update_model_fields(target=self, source=data) delete_op: AsyncCallable[None] = Field( - default=_DELETE_OP, + default=None, exclude=True, ) async def delete(self) -> None: - await self.delete_op() + from .operations import _DELETE_OP + + await self._execute_operation(_DELETE_OP) get_status_op: AsyncCallable[WorkspaceStatus] = Field( - default=_GET_STATUS_OP, + default=None, exclude=True, ) async def get_status(self) -> WorkspaceStatus: - return await self.get_status_op() + from .operations import _GET_STATUS_OP + + return await self._execute_operation(_GET_STATUS_OP) execute_command_op: AsyncCallable[CommandOutput] = Field( - default=_EXECUTE_COMMAND_OP, + default=None, exclude=True, ) async def execute_command( self, command: str, env: Optional[Dict[str, str]] = None ) -> CommandOutput: + from .operations import _EXECUTE_COMMAND_OP + command_data = CommandInput(command=command, env=env) - return await self.execute_command_op(data=command_data) + return await self._execute_operation(_EXECUTE_COMMAND_OP, data=command_data) @cached_property def env_vars(self) -> WorkspaceEnvVarManager: - if not self._http_client: + if self._http_client is None or not isinstance( + self._http_client, APIHttpClient + ): raise RuntimeError("Cannot access 'env_vars' on a detached model.") return WorkspaceEnvVarManager( http_client=self._http_client, workspace_id=self.id diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6c062f2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,300 @@ +""" +Shared pytest fixtures for the Codesphere SDK test suite. +""" + +import pytest +from typing import Any, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + + +class MockResponseFactory: + """Factory for creating mock HTTP responses.""" + + @staticmethod + def create( + status_code: int = 200, + json_data: Optional[Any] = None, + raise_for_status: bool = False, + ) -> AsyncMock: + """Create a mock httpx.Response.""" + mock_response = AsyncMock(spec=httpx.Response) + mock_response.status_code = status_code + mock_response.json.return_value = json_data if json_data is not None else {} + + if raise_for_status or 400 <= status_code < 600: + mock_request = MagicMock(spec=httpx.Request) + mock_request.method = "GET" + mock_request.url = "https://test.com/test-endpoint" + + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + f"{status_code} Error", + request=mock_request, + response=mock_response, + ) + else: + mock_response.raise_for_status.return_value = None + + return mock_response + + +class MockHTTPClientFactory: + """Factory for creating mock HTTP clients.""" + + @staticmethod + def create( + response: Optional[AsyncMock] = None, + status_code: int = 200, + json_data: Optional[Any] = None, + ) -> AsyncMock: + """Create a mock httpx.AsyncClient.""" + mock_client = AsyncMock(spec=httpx.AsyncClient) + + if response is None: + response = MockResponseFactory.create( + status_code=status_code, json_data=json_data + ) + + mock_client.request.return_value = response + return mock_client + + +@pytest.fixture +def mock_response_factory(): + return MockResponseFactory + + +@pytest.fixture +def mock_http_client_factory(): + return MockHTTPClientFactory + + +@pytest.fixture +def mock_http_response(): + return MockResponseFactory.create(status_code=200, json_data={}) + + +@pytest.fixture +def mock_async_client(mock_http_response): + return MockHTTPClientFactory.create(response=mock_http_response) + + +@pytest.fixture +def mock_token(): + return "test-api-token-12345" + + +@pytest.fixture +def mock_settings(mock_token): + from pydantic import SecretStr + + mock = MagicMock() + mock.token = SecretStr(mock_token) + mock.base_url = "https://codesphere.com/api" + mock.client_timeout_connect = 10.0 + mock.client_timeout_read = 30.0 + return mock + + +@pytest.fixture +def api_http_client(mock_settings): + with patch("codesphere.http_client.settings", mock_settings): + from codesphere.http_client import APIHttpClient + + client = APIHttpClient() + yield client + + +@pytest.fixture +def sdk_client(mock_settings): + with patch("codesphere.http_client.settings", mock_settings): + from codesphere.client import CodesphereSDK + + sdk = CodesphereSDK() + yield sdk + + +@pytest.fixture +def mock_http_client_for_resource(mock_response_factory): + def _create(response_data: Any, status_code: int = 200) -> MagicMock: + mock_client = MagicMock() + mock_response = mock_response_factory.create( + status_code=status_code, + json_data=response_data, + ) + mock_client.request = AsyncMock(return_value=mock_response) + return mock_client + + return _create + + +@pytest.fixture +def sample_team_data(): + return { + "id": 12345, + "name": "Test Team", + "description": "A test team", + "avatarId": None, + "avatarUrl": None, + "isFirst": True, + "defaultDataCenterId": 1, + "role": 1, + } + + +@pytest.fixture +def sample_team_list_data(sample_team_data): + return [ + sample_team_data, + { + "id": 12346, + "name": "Test Team 2", + "description": "Another test team", + "avatarId": None, + "avatarUrl": None, + "isFirst": False, + "defaultDataCenterId": 2, + "role": 2, + }, + ] + + +@pytest.fixture +def sample_workspace_data(): + return { + "id": 72678, + "teamId": 12345, + "name": "test-workspace", + "planId": 8, + "isPrivateRepo": True, + "replicas": 1, + "baseImage": "ubuntu:22.04", + "dataCenterId": 1, + "userId": 100, + "gitUrl": None, + "initialBranch": None, + "sourceWorkspaceId": None, + "welcomeMessage": None, + "vpnConfig": None, + "restricted": False, + } + + +@pytest.fixture +def sample_workspace_list_data(sample_workspace_data): + return [ + sample_workspace_data, + {**sample_workspace_data, "id": 72679, "name": "test-workspace-2"}, + ] + + +@pytest.fixture +def sample_domain_data(): + return { + "name": "test.example.com", + "teamId": 12345, + "dataCenterId": 1, + "workspaces": {"/": [72678]}, + "certificateRequestStatus": {"issued": True, "reason": None}, + "dnsEntries": { + "a": "192.168.1.1", + "cname": "proxy.codesphere.com", + "txt": "verification-token", + }, + "domainVerificationStatus": {"verified": True, "reason": None}, + "customConfigRevision": None, + "customConfig": None, + } + + +@pytest.fixture +def sample_env_var_data(): + return [ + {"name": "API_KEY", "value": "secret123"}, + {"name": "DEBUG", "value": "true"}, + ] + + +@pytest.fixture +def teams_resource_factory(mock_http_client_for_resource): + def _create(response_data: Any): + from codesphere.resources.team import TeamsResource + + mock_client = mock_http_client_for_resource(response_data) + resource = TeamsResource(http_client=mock_client) + return resource, mock_client + + return _create + + +@pytest.fixture +def workspaces_resource_factory(mock_http_client_for_resource): + def _create(response_data: Any): + from codesphere.resources.workspace import WorkspacesResource + + mock_client = mock_http_client_for_resource(response_data) + resource = WorkspacesResource(http_client=mock_client) + return resource, mock_client + + return _create + + +@pytest.fixture +def metadata_resource_factory(mock_http_client_for_resource): + def _create(response_data: Any): + from codesphere.resources.metadata import MetadataResource + + mock_client = mock_http_client_for_resource(response_data) + resource = MetadataResource(http_client=mock_client) + return resource, mock_client + + return _create + + +@pytest.fixture +def team_model_factory(mock_http_client_for_resource, sample_team_data): + def _create(response_data: Any = None, team_data: dict = None): + from codesphere.resources.team import Team + + data = team_data or sample_team_data + mock_client = mock_http_client_for_resource( + response_data if response_data is not None else {} + ) + team = Team.model_validate(data) + team._http_client = mock_client + return team, mock_client + + return _create + + +@pytest.fixture +def workspace_model_factory(mock_http_client_for_resource, sample_workspace_data): + def _create(response_data: Any = None, workspace_data: dict = None): + from codesphere.resources.workspace import Workspace + + data = workspace_data or sample_workspace_data + mock_client = mock_http_client_for_resource( + response_data if response_data is not None else {} + ) + workspace = Workspace.model_validate(data) + workspace._http_client = mock_client + return workspace, mock_client + + return _create + + +@pytest.fixture +def domain_model_factory(mock_http_client_for_resource, sample_domain_data): + def _create(response_data: Any = None, domain_data: dict = None): + from codesphere.resources.team.domain.resources import Domain + + data = domain_data or sample_domain_data + mock_client = mock_http_client_for_resource( + response_data if response_data is not None else {} + ) + domain = Domain.model_validate(data) + domain._http_client = mock_client + return domain, mock_client + + return _create diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..c6a657d --- /dev/null +++ b/tests/core/__init__.py @@ -0,0 +1 @@ +"""Core module tests for the Codesphere SDK.""" diff --git a/tests/core/test_base.py b/tests/core/test_base.py new file mode 100644 index 0000000..99cd72e --- /dev/null +++ b/tests/core/test_base.py @@ -0,0 +1,214 @@ +""" +Tests for core base classes: ResourceBase, CamelModel, ResourceList. +""" + +import pytest +from dataclasses import dataclass +from unittest.mock import MagicMock + +from pydantic import BaseModel + +from codesphere.core.base import CamelModel, ResourceBase, ResourceList + + +# ----------------------------------------------------------------------------- +# Test Cases +# ----------------------------------------------------------------------------- + + +@dataclass +class CamelModelTestCase: + """Test case for CamelModel alias generation.""" + + name: str + field_name: str + expected_alias: str + + +camel_model_test_cases = [ + CamelModelTestCase( + name="Simple snake_case to camelCase", + field_name="team_id", + expected_alias="teamId", + ), + CamelModelTestCase( + name="Multiple underscores", + field_name="default_data_center_id", + expected_alias="defaultDataCenterId", + ), + CamelModelTestCase( + name="Single word stays lowercase", + field_name="name", + expected_alias="name", + ), +] + + +# ----------------------------------------------------------------------------- +# CamelModel Tests +# ----------------------------------------------------------------------------- + + +class TestCamelModel: + """Tests for the CamelModel base class.""" + + def test_inherits_from_base_model(self): + """CamelModel should inherit from Pydantic BaseModel.""" + assert issubclass(CamelModel, BaseModel) + + def test_alias_generator_configured(self): + """CamelModel should have alias_generator configured.""" + assert CamelModel.model_config.get("alias_generator") is not None + + def test_populate_by_name_enabled(self): + """CamelModel should allow population by field name.""" + assert CamelModel.model_config.get("populate_by_name") is True + + @pytest.mark.parametrize( + "case", camel_model_test_cases, ids=[c.name for c in camel_model_test_cases] + ) + def test_camel_case_alias_generation(self, case: CamelModelTestCase): + """Test that snake_case fields are aliased to camelCase.""" + + # Dynamically create a model with the test field + class TestModel(CamelModel): + pass + + # Use model_fields to check alias generation + TestModel.model_rebuild() + + # Create a model class with the specific field + exec( + f""" +class DynamicModel(CamelModel): + {case.field_name}: str = "test" +""", + {"CamelModel": CamelModel}, + ) + + def test_model_dump_by_alias(self): + """Test that model_dump with by_alias produces camelCase keys.""" + + class SampleModel(CamelModel): + team_id: int + data_center_id: int + + model = SampleModel(team_id=1, data_center_id=2) + dumped = model.model_dump(by_alias=True) + + assert "teamId" in dumped + assert "dataCenterId" in dumped + assert dumped["teamId"] == 1 + assert dumped["dataCenterId"] == 2 + + def test_model_validate_from_camel_case(self): + """Test that model can be created from camelCase input.""" + + class SampleModel(CamelModel): + team_id: int + is_private: bool + + model = SampleModel.model_validate({"teamId": 123, "isPrivate": True}) + + assert model.team_id == 123 + assert model.is_private is True + + def test_model_validate_from_snake_case(self): + """Test that model can be created from snake_case input (populate_by_name).""" + + class SampleModel(CamelModel): + team_id: int + is_private: bool + + model = SampleModel.model_validate({"team_id": 456, "is_private": False}) + + assert model.team_id == 456 + assert model.is_private is False + + +# ----------------------------------------------------------------------------- +# ResourceList Tests +# ----------------------------------------------------------------------------- + + +class TestResourceList: + """Tests for the ResourceList generic container.""" + + def test_create_with_list(self): + """ResourceList should be created with a list of items.""" + + class Item(BaseModel): + id: int + name: str + + items = [Item(id=1, name="first"), Item(id=2, name="second")] + resource_list = ResourceList[Item](root=items) + + assert len(resource_list) == 2 + assert resource_list.root == items + + def test_iteration(self): + """ResourceList should support iteration.""" + + class Item(BaseModel): + value: int + + items = [Item(value=i) for i in range(5)] + resource_list = ResourceList[Item](root=items) + + iterated = list(resource_list) + assert iterated == items + + def test_indexing(self): + """ResourceList should support indexing.""" + + class Item(BaseModel): + id: int + + items = [Item(id=10), Item(id=20), Item(id=30)] + resource_list = ResourceList[Item](root=items) + + assert resource_list[0].id == 10 + assert resource_list[1].id == 20 + assert resource_list[-1].id == 30 + + def test_len(self): + """ResourceList should support len().""" + + class Item(BaseModel): + id: int + + resource_list = ResourceList[Item](root=[Item(id=i) for i in range(7)]) + assert len(resource_list) == 7 + + def test_empty_list(self): + """ResourceList should handle empty lists.""" + + class Item(BaseModel): + id: int + + resource_list = ResourceList[Item](root=[]) + assert len(resource_list) == 0 + assert list(resource_list) == [] + + +# ----------------------------------------------------------------------------- +# ResourceBase Tests +# ----------------------------------------------------------------------------- + + +class TestResourceBase: + """Tests for the ResourceBase class.""" + + def test_initialization_with_http_client(self): + """ResourceBase should store the HTTP client.""" + mock_client = MagicMock() + resource = ResourceBase(http_client=mock_client) + + assert resource._http_client is mock_client + + def test_inherits_from_api_operation_executor(self): + """ResourceBase should inherit from _APIOperationExecutor.""" + from codesphere.core.handler import _APIOperationExecutor + + assert issubclass(ResourceBase, _APIOperationExecutor) diff --git a/tests/core/test_handler.py b/tests/core/test_handler.py new file mode 100644 index 0000000..886faa3 --- /dev/null +++ b/tests/core/test_handler.py @@ -0,0 +1,145 @@ +""" +Tests for core handler: _APIOperationExecutor and APIRequestHandler. +""" + +import pytest +from typing import Optional +from unittest.mock import MagicMock + +from pydantic import BaseModel, Field, PrivateAttr + +from codesphere.core.handler import _APIOperationExecutor, APIRequestHandler +from codesphere.core.operations import APIOperation, AsyncCallable + + +class SampleResponseModel(BaseModel): + id: int + name: str + + +class SampleInputModel(BaseModel): + title: str + count: int + + +class ConcreteExecutor(_APIOperationExecutor, BaseModel): + id: int = 100 + _http_client: Optional[MagicMock] = PrivateAttr(default=None) + + +class TestAPIOperationExecutor: + def test_http_client_private_attribute_exists(self): + executor = ConcreteExecutor() + assert hasattr(executor, "_http_client") + executor._http_client = MagicMock() + assert executor._http_client is not None + + def test_getattribute_returns_partial_for_operation(self): + class ExecutorWithOp(_APIOperationExecutor, BaseModel): + id: int = 123 + _http_client: Optional[MagicMock] = PrivateAttr(default=None) + test_op: AsyncCallable[SampleResponseModel] = Field( + default=APIOperation( + method="GET", + endpoint_template="/test/{id}", + response_model=SampleResponseModel, + ), + exclude=True, + ) + + executor = ExecutorWithOp() + attr = executor.test_op + assert callable(attr) + + def test_getattribute_returns_normal_values(self): + class SampleExecutor(_APIOperationExecutor, BaseModel): + id: int = 456 + name: str = "test" + _http_client: Optional[MagicMock] = PrivateAttr(default=None) + + executor = SampleExecutor() + assert executor.id == 456 + assert executor.name == "test" + + +class TestAPIRequestHandler: + @pytest.fixture + def mock_executor(self): + executor = ConcreteExecutor() + executor._http_client = MagicMock() + return executor + + @pytest.fixture + def sample_operation(self): + return APIOperation( + method="GET", + endpoint_template="/resources/{id}", + response_model=SampleResponseModel, + ) + + def test_handler_initialization(self, mock_executor, sample_operation): + kwargs = {"param": "value"} + handler = APIRequestHandler( + executor=mock_executor, + operation=sample_operation, + kwargs=kwargs, + ) + assert handler.executor is mock_executor + assert handler.operation is sample_operation + assert handler.kwargs == kwargs + assert handler.http_client is mock_executor._http_client + + def test_prepare_request_args_formats_endpoint( + self, mock_executor, sample_operation + ): + handler = APIRequestHandler( + executor=mock_executor, + operation=sample_operation, + kwargs={}, + ) + endpoint, request_kwargs = handler._prepare_request_args() + assert endpoint == "/resources/100" + + def test_prepare_request_args_with_data_payload(self, mock_executor): + operation = APIOperation( + method="POST", + endpoint_template="/resources", + response_model=SampleResponseModel, + input_model=SampleInputModel, + ) + input_model = SampleInputModel(title="Test", count=10) + handler = APIRequestHandler( + executor=mock_executor, + operation=operation, + kwargs={"data": input_model}, + ) + endpoint, request_kwargs = handler._prepare_request_args() + assert "json" in request_kwargs + assert request_kwargs["json"] == {"title": "Test", "count": 10} + + @pytest.mark.asyncio + async def test_execute_raises_without_http_client(self, sample_operation): + executor = ConcreteExecutor() + handler = APIRequestHandler( + executor=executor, + operation=sample_operation, + kwargs={}, + ) + with pytest.raises(RuntimeError, match="HTTP Client is not initialized"): + await handler.execute() + + @pytest.mark.asyncio + async def test_inject_client_into_model(self, mock_executor, sample_operation): + handler = APIRequestHandler( + executor=mock_executor, + operation=sample_operation, + kwargs={}, + ) + + class ModelWithClient(BaseModel): + id: int + _http_client: Optional[MagicMock] = PrivateAttr(default=None) + + instance = ModelWithClient(id=1) + handler._inject_client_into_model(instance) + assert instance._http_client is mock_executor._http_client diff --git a/tests/core/test_operations.py b/tests/core/test_operations.py new file mode 100644 index 0000000..4a118e6 --- /dev/null +++ b/tests/core/test_operations.py @@ -0,0 +1,145 @@ +""" +Tests for core operations: APIOperation and AsyncCallable. +""" + +import pytest +from dataclasses import dataclass +from typing import Optional, Type + +from pydantic import BaseModel + +from codesphere.core.operations import APIOperation + + +# ----------------------------------------------------------------------------- +# Test Models +# ----------------------------------------------------------------------------- + + +class SampleInputModel(BaseModel): + """Sample input model for testing.""" + + name: str + value: int + + +class SampleResponseModel(BaseModel): + """Sample response model for testing.""" + + id: int + status: str + + +# ----------------------------------------------------------------------------- +# Test Cases +# ----------------------------------------------------------------------------- + + +@dataclass +class APIOperationTestCase: + """Test case for APIOperation creation.""" + + name: str + method: str + endpoint_template: str + response_model: Type + input_model: Optional[Type] = None + + +api_operation_test_cases = [ + APIOperationTestCase( + name="GET operation without input model", + method="GET", + endpoint_template="/resources/{resource_id}", + response_model=SampleResponseModel, + input_model=None, + ), + APIOperationTestCase( + name="POST operation with input model", + method="POST", + endpoint_template="/resources", + response_model=SampleResponseModel, + input_model=SampleInputModel, + ), + APIOperationTestCase( + name="DELETE operation returning None", + method="DELETE", + endpoint_template="/resources/{id}", + response_model=type(None), + input_model=None, + ), + APIOperationTestCase( + name="PATCH operation with input and response", + method="PATCH", + endpoint_template="/resources/{id}", + response_model=SampleResponseModel, + input_model=SampleInputModel, + ), +] + + +# ----------------------------------------------------------------------------- +# APIOperation Tests +# ----------------------------------------------------------------------------- + + +class TestAPIOperation: + """Tests for the APIOperation class.""" + + @pytest.mark.parametrize( + "case", api_operation_test_cases, ids=[c.name for c in api_operation_test_cases] + ) + def test_create_operation(self, case: APIOperationTestCase): + """Test APIOperation can be created with various configurations.""" + operation = APIOperation( + method=case.method, + endpoint_template=case.endpoint_template, + response_model=case.response_model, + input_model=case.input_model, + ) + + assert operation.method == case.method + assert operation.endpoint_template == case.endpoint_template + assert operation.response_model == case.response_model + assert operation.input_model == case.input_model + + def test_operation_is_pydantic_model(self): + """APIOperation should be a Pydantic BaseModel.""" + assert issubclass(APIOperation, BaseModel) + + def test_operation_model_copy(self): + """APIOperation should support model_copy for creating variants.""" + original = APIOperation( + method="GET", + endpoint_template="/test", + response_model=SampleResponseModel, + ) + + copied = original.model_copy(update={"method": "POST"}) + + assert copied.method == "POST" + assert copied.endpoint_template == original.endpoint_template + assert copied.response_model == original.response_model + assert original.method == "GET" # Original unchanged + + def test_operation_with_path_parameters(self): + """Test endpoint_template with multiple path parameters.""" + operation = APIOperation( + method="GET", + endpoint_template="/teams/{team_id}/domains/{domain_name}", + response_model=SampleResponseModel, + ) + + # Verify the template contains expected placeholders + assert "{team_id}" in operation.endpoint_template + assert "{domain_name}" in operation.endpoint_template + + def test_default_input_model_is_none(self): + """input_model should default to None.""" + operation = APIOperation( + method="GET", + endpoint_template="/test", + response_model=SampleResponseModel, + ) + + assert operation.input_model is None diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..6a82ecc --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,6 @@ +""" +Integration tests for the Codesphere SDK. + +These tests require a valid API token and will make real API calls. +Run with: pytest tests/integration -v --run-integration +""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..acc7d5e --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,227 @@ +""" +Shared fixtures for integration tests. + +These fixtures provide real SDK clients configured for integration testing. +Set the CS_TOKEN environment variable before running. +""" + +import os +import pytest +from typing import AsyncGenerator, List, Optional + +from dotenv import load_dotenv + +from codesphere import CodesphereSDK +from codesphere.resources.workspace import Workspace, WorkspaceCreate + +# Load .env file for local development +load_dotenv() + +# Constants for test workspaces +TEST_WORKSPACE_PREFIX = "sdk-integration-test" + + +def pytest_addoption(parser): + """Add custom command line options for integration tests.""" + parser.addoption( + "--run-integration", + action="store_true", + default=False, + help="Run integration tests (requires valid API token)", + ) + + +def pytest_configure(config): + """Register custom markers.""" + config.addinivalue_line( + "markers", "integration: mark test as integration test (requires API token)" + ) + + +def pytest_collection_modifyitems(config, items): + """Skip integration tests unless --run-integration is passed.""" + if config.getoption("--run-integration"): + return + + skip_integration = pytest.mark.skip( + reason="Need --run-integration option to run integration tests" + ) + for item in items: + if "integration" in item.keywords: + item.add_marker(skip_integration) + + +@pytest.fixture(scope="session") +def integration_token() -> str: + """ + Get the API token for integration tests. + + Reads from CS_TOKEN environment variable. + """ + token = os.environ.get("CS_TOKEN") + if not token: + pytest.skip("CS_TOKEN environment variable not set") + return token + + +@pytest.fixture(scope="session") +def integration_team_id() -> Optional[int]: + """ + Get an optional team ID for integration tests. + + Reads from CS_TEST_TEAM_ID environment variable. + If not set, tests will use the first available team. + """ + team_id = os.environ.get("CS_TEST_TEAM_ID") + return int(team_id) if team_id else None + + +@pytest.fixture(scope="session") +def integration_datacenter_id() -> int: + """ + Get the datacenter ID for integration tests. + + Reads from CS_TEST_DC_ID environment variable. + Defaults to 1 if not set. + """ + dc_id = os.environ.get("CS_TEST_DC_ID", "1") + return int(dc_id) + + +@pytest.fixture +async def sdk_client(integration_token) -> AsyncGenerator[CodesphereSDK, None]: + """ + Provide a configured SDK client for integration tests. + + The client is automatically opened and closed. + """ + sdk = CodesphereSDK() + async with sdk: + yield sdk + + +@pytest.fixture(scope="module") +async def module_sdk_client(integration_token) -> AsyncGenerator[CodesphereSDK, None]: + """ + Provide a module-scoped SDK client for integration tests. + + Use this for tests that need to share state within a module. + """ + sdk = CodesphereSDK() + async with sdk: + yield sdk + + +@pytest.fixture(scope="session") +async def session_sdk_client(integration_token) -> AsyncGenerator[CodesphereSDK, None]: + """ + Provide a session-scoped SDK client for integration tests. + + Used for creating/deleting test workspaces that persist across all tests. + """ + sdk = CodesphereSDK() + async with sdk: + yield sdk + + +@pytest.fixture(scope="session") +async def test_team_id( + session_sdk_client: CodesphereSDK, + integration_team_id: Optional[int], +) -> int: + """ + Get the team ID to use for integration tests. + + Uses CS_TEST_TEAM_ID if set, otherwise uses the first available team. + """ + if integration_team_id: + return integration_team_id + + teams = await session_sdk_client.teams.list() + if not teams: + pytest.fail("No teams available for integration testing") + return teams[0].id + + +@pytest.fixture(scope="session") +async def test_plan_id(session_sdk_client: CodesphereSDK) -> int: + """ + Get a valid plan ID for creating test workspaces. + + Uses plan ID 8 (Micro) which is suitable for testing. + Falls back to first non-deprecated plan if not available. + """ + plans = await session_sdk_client.metadata.list_plans() + + # Prefer plan ID 8 (Micro) for testing + micro_plan = next((p for p in plans if p.id == 8 and not p.deprecated), None) + if micro_plan: + return micro_plan.id + + # Fallback to first non-deprecated, non-free plan + active_plans = [p for p in plans if not p.deprecated and p.id != 1] + if active_plans: + return active_plans[0].id + + # Last resort: any plan + if plans: + return plans[0].id + + pytest.fail("No workspace plans available") + + +@pytest.fixture(scope="session") +async def test_workspaces( + session_sdk_client: CodesphereSDK, + test_team_id: int, + test_plan_id: int, +) -> AsyncGenerator[List[Workspace], None]: + """ + Create test workspaces for integration tests. + + Creates 2 workspaces at the start of the test session and deletes them + after all tests complete. This fixture ensures workspace-dependent tests + have resources to work with. + """ + created_workspaces: List[Workspace] = [] + + # Create test workspaces + for i in range(2): + workspace_name = f"{TEST_WORKSPACE_PREFIX}-{i + 1}" + payload = WorkspaceCreate( + team_id=test_team_id, + name=workspace_name, + plan_id=test_plan_id, + ) + try: + workspace = await session_sdk_client.workspaces.create(payload=payload) + created_workspaces.append(workspace) + print(f"\n✓ Created test workspace: {workspace.name} (ID: {workspace.id})") + except Exception as e: + print(f"\n✗ Failed to create test workspace {workspace_name}: {e}") + # Clean up any workspaces we did create + for ws in created_workspaces: + try: + await ws.delete() + except Exception: + pass + pytest.fail(f"Failed to create test workspaces: {e}") + + yield created_workspaces + + # Cleanup: delete test workspaces + print("\n--- Cleaning up test workspaces ---") + for workspace in created_workspaces: + try: + await workspace.delete() + print(f"✓ Deleted test workspace: {workspace.name} (ID: {workspace.id})") + except Exception as e: + print(f"✗ Failed to delete workspace {workspace.id}: {e}") + + +@pytest.fixture(scope="session") +async def test_workspace(test_workspaces: List[Workspace]) -> Workspace: + """ + Provide a single test workspace for tests that only need one. + """ + return test_workspaces[0] diff --git a/tests/integration/test_domains.py b/tests/integration/test_domains.py new file mode 100644 index 0000000..5d2cb96 --- /dev/null +++ b/tests/integration/test_domains.py @@ -0,0 +1,125 @@ +""" +Integration tests for Team Domains. + +These tests verify CRUD operations for custom domains on teams. +Note: Domain verification tests may be limited as they require DNS configuration. +""" + +import pytest +import time + +from codesphere import CodesphereSDK +from codesphere.resources.team.domain.resources import Domain + + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + +# Test domain name - use a subdomain format that's clearly for testing +TEST_DOMAIN_PREFIX = "sdk-test" + + +@pytest.fixture +async def test_domain_name(test_team_id: int) -> str: + """Generate a unique test domain name.""" + timestamp = int(time.time()) + return f"{TEST_DOMAIN_PREFIX}-{timestamp}.example.com" + + +class TestDomainsIntegration: + """Integration tests for team domain endpoints.""" + + async def test_list_domains( + self, + sdk_client: CodesphereSDK, + test_team_id: int, + ): + """Should retrieve a list of domains for a team.""" + team = await sdk_client.teams.get(team_id=test_team_id) + domains = await team.domains.list() + + assert isinstance(domains, list) + assert all(isinstance(d, Domain) for d in domains) + + async def test_create_domain( + self, + sdk_client: CodesphereSDK, + test_team_id: int, + test_domain_name: str, + ): + """Should create a new custom domain.""" + team = await sdk_client.teams.get(team_id=test_team_id) + + # Create domain + domain = await team.domains.create(name=test_domain_name) + + try: + assert isinstance(domain, Domain) + assert domain.name == test_domain_name + finally: + # Cleanup + await domain.delete() + + async def test_get_domain( + self, + sdk_client: CodesphereSDK, + test_team_id: int, + test_domain_name: str, + ): + """Should retrieve a specific domain by name.""" + team = await sdk_client.teams.get(team_id=test_team_id) + + # Create domain first + created_domain = await team.domains.create(name=test_domain_name) + + try: + # Get the domain + domain = await team.domains.get(name=test_domain_name) + + assert isinstance(domain, Domain) + assert domain.name == test_domain_name + finally: + # Cleanup + await created_domain.delete() + + async def test_domain_verify_status( + self, + sdk_client: CodesphereSDK, + test_team_id: int, + test_domain_name: str, + ): + """Should check domain verification status.""" + team = await sdk_client.teams.get(team_id=test_team_id) + + # Create domain first + domain = await team.domains.create(name=test_domain_name) + + try: + # Check verification status (will likely be unverified without DNS setup) + status = await domain.verify_status() + + # Status should have verification info + assert status is not None + finally: + # Cleanup + await domain.delete() + + async def test_delete_domain( + self, + sdk_client: CodesphereSDK, + test_team_id: int, + test_domain_name: str, + ): + """Should delete a custom domain.""" + team = await sdk_client.teams.get(team_id=test_team_id) + + # Create domain + domain = await team.domains.create(name=test_domain_name) + + # Delete it + await domain.delete() + + # Verify it's gone by listing domains + domains = await team.domains.list() + domain_names = [d.name for d in domains] + + assert test_domain_name not in domain_names diff --git a/tests/integration/test_env_vars.py b/tests/integration/test_env_vars.py new file mode 100644 index 0000000..657cb10 --- /dev/null +++ b/tests/integration/test_env_vars.py @@ -0,0 +1,155 @@ +""" +Integration tests for Workspace Environment Variables. + +These tests verify CRUD operations for environment variables on workspaces. +""" + +import pytest + +from codesphere import CodesphereSDK +from codesphere.resources.workspace import Workspace + + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +class TestEnvVarsIntegration: + """Integration tests for workspace environment variables.""" + + async def test_get_env_vars_empty( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Should retrieve environment variables (may be empty initially).""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + env_vars = await workspace.env_vars.get() + + # ResourceList is iterable and has length + assert hasattr(env_vars, "__iter__") + assert hasattr(env_vars, "__len__") + assert len(env_vars) >= 0 + + async def test_set_env_vars( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Should set environment variables on a workspace.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Set some test environment variables + test_vars = [ + {"name": "TEST_VAR_1", "value": "test_value_1"}, + {"name": "TEST_VAR_2", "value": "test_value_2"}, + {"name": "SDK_INTEGRATION_TEST", "value": "true"}, + ] + + await workspace.env_vars.set(test_vars) + + # Verify they were set + env_vars = await workspace.env_vars.get() + env_var_names = [ev.name for ev in env_vars] + + assert "TEST_VAR_1" in env_var_names + assert "TEST_VAR_2" in env_var_names + assert "SDK_INTEGRATION_TEST" in env_var_names + + async def test_update_env_var_value( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Should update an existing environment variable's value.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Set initial value + await workspace.env_vars.set([{"name": "UPDATE_TEST_VAR", "value": "initial"}]) + + # Update the value + await workspace.env_vars.set([{"name": "UPDATE_TEST_VAR", "value": "updated"}]) + + # Verify the update + env_vars = await workspace.env_vars.get() + update_var = next((ev for ev in env_vars if ev.name == "UPDATE_TEST_VAR"), None) + + assert update_var is not None + assert update_var.value == "updated" + + async def test_delete_env_vars_by_name( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Should delete environment variables by name.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Ensure we have a variable to delete + await workspace.env_vars.set([{"name": "TO_DELETE_VAR", "value": "delete_me"}]) + + # Verify it exists + env_vars = await workspace.env_vars.get() + assert any(ev.name == "TO_DELETE_VAR" for ev in env_vars) + + # Delete by name + await workspace.env_vars.delete(["TO_DELETE_VAR"]) + + # Verify deletion + env_vars = await workspace.env_vars.get() + assert not any(ev.name == "TO_DELETE_VAR" for ev in env_vars) + + async def test_delete_multiple_env_vars( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Should delete multiple environment variables at once.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Set multiple variables + await workspace.env_vars.set( + [ + {"name": "MULTI_DELETE_1", "value": "value1"}, + {"name": "MULTI_DELETE_2", "value": "value2"}, + {"name": "MULTI_DELETE_3", "value": "value3"}, + ] + ) + + # Delete multiple at once + await workspace.env_vars.delete( + ["MULTI_DELETE_1", "MULTI_DELETE_2", "MULTI_DELETE_3"] + ) + + # Verify all deleted + env_vars = await workspace.env_vars.get() + remaining_names = [ev.name for ev in env_vars] + + assert "MULTI_DELETE_1" not in remaining_names + assert "MULTI_DELETE_2" not in remaining_names + assert "MULTI_DELETE_3" not in remaining_names + + async def test_set_env_vars_with_special_characters( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Should handle environment variables with special characters in values.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + special_value = "test=value&with?special#chars" + await workspace.env_vars.set( + [ + {"name": "SPECIAL_CHARS_VAR", "value": special_value}, + ] + ) + + env_vars = await workspace.env_vars.get() + special_var = next( + (ev for ev in env_vars if ev.name == "SPECIAL_CHARS_VAR"), None + ) + + assert special_var is not None + assert special_var.value == special_value + + # Cleanup + await workspace.env_vars.delete(["SPECIAL_CHARS_VAR"]) diff --git a/tests/integration/test_metadata.py b/tests/integration/test_metadata.py new file mode 100644 index 0000000..b95bb8a --- /dev/null +++ b/tests/integration/test_metadata.py @@ -0,0 +1,60 @@ +""" +Integration tests for Metadata resources. + +These tests are read-only and safe to run against any environment. +""" + +import pytest + +from codesphere.resources.metadata import Datacenter, WsPlan, Image + + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +class TestMetadataIntegration: + """Integration tests for metadata endpoints.""" + + async def test_list_datacenters(self, sdk_client): + """Should retrieve a list of available datacenters.""" + datacenters = await sdk_client.metadata.list_datacenters() + + assert isinstance(datacenters, list) + assert len(datacenters) > 0 + assert all(isinstance(dc, Datacenter) for dc in datacenters) + + # Verify datacenter has expected fields + first_dc = datacenters[0] + assert first_dc.id is not None + assert first_dc.name is not None + assert first_dc.city is not None + assert first_dc.country_code is not None + + async def test_list_plans(self, sdk_client): + """Should retrieve a list of available workspace plans.""" + plans = await sdk_client.metadata.list_plans() + + assert isinstance(plans, list) + assert len(plans) > 0 + assert all(isinstance(plan, WsPlan) for plan in plans) + + # Verify plan has expected fields + first_plan = plans[0] + assert first_plan.id is not None + assert first_plan.title is not None + assert first_plan.characteristics is not None + assert first_plan.characteristics.cpu is not None + assert first_plan.characteristics.ram is not None + + async def test_list_images(self, sdk_client): + """Should retrieve a list of available base images.""" + images = await sdk_client.metadata.list_images() + + assert isinstance(images, list) + assert len(images) > 0 + assert all(isinstance(img, Image) for img in images) + + # Verify image has expected fields + first_image = images[0] + assert first_image.id is not None + assert first_image.name is not None diff --git a/tests/integration/test_teams.py b/tests/integration/test_teams.py new file mode 100644 index 0000000..a77ab52 --- /dev/null +++ b/tests/integration/test_teams.py @@ -0,0 +1,54 @@ +""" +Integration tests for Team resources. + +These tests include read operations and some write operations. +Write operations are marked separately so they can be skipped if needed. +""" + +import pytest + +from codesphere import CodesphereSDK +from codesphere.resources.team import Team + + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +class TestTeamsIntegration: + """Integration tests for team endpoints.""" + + async def test_list_teams(self, sdk_client: CodesphereSDK): + """Should retrieve a list of teams for the authenticated user.""" + teams = await sdk_client.teams.list() + + assert isinstance(teams, list) + assert len(teams) > 0 + assert all(isinstance(team, Team) for team in teams) + + # Verify team has expected fields + first_team = teams[0] + assert first_team.id is not None + assert first_team.name is not None + + async def test_get_team_by_id( + self, + sdk_client: CodesphereSDK, + test_team_id: int, + ): + """Should retrieve a specific team by ID.""" + team = await sdk_client.teams.get(team_id=test_team_id) + + assert isinstance(team, Team) + assert team.id == test_team_id + + async def test_team_has_domains_accessor( + self, + sdk_client: CodesphereSDK, + test_team_id: int, + ): + """Team model should provide access to domains manager.""" + team = await sdk_client.teams.get(team_id=test_team_id) + + # Accessing domains should not raise + domains_manager = team.domains + assert domains_manager is not None diff --git a/tests/integration/test_workspaces.py b/tests/integration/test_workspaces.py new file mode 100644 index 0000000..ae656e2 --- /dev/null +++ b/tests/integration/test_workspaces.py @@ -0,0 +1,178 @@ +""" +Integration tests for Workspace resources. + +These tests use session-scoped test workspaces that are created at the start +of the test run and cleaned up afterwards. +""" + +import pytest +from typing import List + +from codesphere import CodesphereSDK +from codesphere.resources.workspace import ( + Workspace, + WorkspaceUpdate, + WorkspaceStatus, +) +from codesphere.resources.workspace.command_schemas import CommandOutput + + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +class TestWorkspacesIntegration: + """Integration tests for workspace endpoints.""" + + async def test_list_workspaces_by_team( + self, + sdk_client: CodesphereSDK, + test_team_id: int, + test_workspaces: List[Workspace], + ): + """Should retrieve a list of workspaces for a team.""" + workspaces = await sdk_client.workspaces.list(team_id=test_team_id) + + assert isinstance(workspaces, list) + assert len(workspaces) >= len(test_workspaces) + assert all(isinstance(ws, Workspace) for ws in workspaces) + + # Verify our test workspaces are in the list + test_workspace_ids = {ws.id for ws in test_workspaces} + listed_workspace_ids = {ws.id for ws in workspaces} + assert test_workspace_ids.issubset(listed_workspace_ids) + + async def test_get_workspace_by_id( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Should retrieve a specific workspace by ID.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + assert isinstance(workspace, Workspace) + assert workspace.id == test_workspace.id + assert workspace.name == test_workspace.name + + async def test_workspace_has_expected_fields( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Workspace should have all expected fields populated.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Required fields + assert workspace.id is not None + assert workspace.team_id is not None + assert workspace.name is not None + assert workspace.plan_id is not None + assert workspace.data_center_id is not None + assert workspace.user_id is not None + assert isinstance(workspace.is_private_repo, bool) + assert isinstance(workspace.replicas, int) + assert isinstance(workspace.restricted, bool) + + async def test_workspace_get_status( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Should retrieve workspace status.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + status = await workspace.get_status() + + assert isinstance(status, WorkspaceStatus) + assert isinstance(status.is_running, bool) + + async def test_workspace_execute_command( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Should execute a command in the workspace.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Execute a simple command + result = await workspace.execute_command(command="echo 'Hello from SDK test'") + + assert isinstance(result, CommandOutput) + assert result.output is not None or result.error is not None + + async def test_workspace_execute_command_with_env( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Should execute a command with custom environment variables.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Execute command with environment variable + result = await workspace.execute_command( + command="echo $TEST_CMD_VAR", + env={"TEST_CMD_VAR": "sdk_test_value"}, + ) + + assert isinstance(result, CommandOutput) + + async def test_workspace_env_vars_accessor( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Workspace model should provide access to env vars manager.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Accessing env_vars should not raise + env_vars_manager = workspace.env_vars + assert env_vars_manager is not None + + +class TestWorkspaceUpdateOperations: + """Integration tests for workspace update operations.""" + + async def test_update_workspace_name( + self, + sdk_client: CodesphereSDK, + test_workspaces: List[Workspace], + ): + """Should update an existing workspace's name.""" + # Use the second test workspace for update tests + workspace = await sdk_client.workspaces.get(workspace_id=test_workspaces[1].id) + original_name = workspace.name + + try: + # Update workspace name + new_name = f"{original_name}-updated" + update_data = WorkspaceUpdate(name=new_name) + await workspace.update(data=update_data) + + assert workspace.name == new_name + + # Verify by fetching again + refreshed = await sdk_client.workspaces.get(workspace_id=workspace.id) + assert refreshed.name == new_name + finally: + # Restore original name + restore_data = WorkspaceUpdate(name=original_name) + await workspace.update(data=restore_data) + + async def test_update_workspace_replicas( + self, + sdk_client: CodesphereSDK, + test_workspaces: List[Workspace], + ): + """Should update workspace replica count.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspaces[1].id) + original_replicas = workspace.replicas + + try: + # Update replicas (within plan limits) + new_replicas = 1 # Safe value + update_data = WorkspaceUpdate(replicas=new_replicas) + await workspace.update(data=update_data) + + assert workspace.replicas == new_replicas + finally: + # Restore original + restore_data = WorkspaceUpdate(replicas=original_replicas) + await workspace.update(data=restore_data) diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py new file mode 100644 index 0000000..2ab9b92 --- /dev/null +++ b/tests/resources/__init__.py @@ -0,0 +1 @@ +"""Resource tests for the Codesphere SDK.""" diff --git a/tests/resources/conftest.py b/tests/resources/conftest.py new file mode 100644 index 0000000..e48e65f --- /dev/null +++ b/tests/resources/conftest.py @@ -0,0 +1,178 @@ +""" +Shared fixtures for resource tests. + +This module provides fixtures specific to testing API resources, +including pre-configured mock responses and resource instances. +""" + +import pytest +from typing import Any, Dict +from unittest.mock import AsyncMock, MagicMock + + +# ----------------------------------------------------------------------------- +# Resource Test Helpers +# ----------------------------------------------------------------------------- + + +class ResourceTestHelper: + """ + Helper class for resource tests providing common assertion patterns. + + Usage: + helper = ResourceTestHelper(mock_client, "GET", "/teams") + await resource.list() + helper.assert_called() + """ + + def __init__( + self, + mock_client: AsyncMock, + expected_method: str, + expected_endpoint: str, + ): + self.mock_client = mock_client + self.expected_method = expected_method + self.expected_endpoint = expected_endpoint + + def assert_called(self, json_payload: Any = None) -> None: + """Assert the HTTP request was made with expected parameters.""" + self.mock_client.request.assert_awaited() + call_args = self.mock_client.request.call_args + + assert call_args.kwargs.get("method") == self.expected_method + assert call_args.kwargs.get("endpoint") == self.expected_endpoint + + if json_payload is not None: + assert call_args.kwargs.get("json") == json_payload + + +@pytest.fixture +def resource_test_helper(): + """Provide the ResourceTestHelper class for resource tests.""" + return ResourceTestHelper + + +# ----------------------------------------------------------------------------- +# Mock HTTP Client for Resources +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def mock_http_client_for_resource(mock_response_factory): + """ + Create a configurable mock HTTP client for resource testing. + + Returns a factory function that creates configured mock clients. + """ + + def _create(response_data: Any, status_code: int = 200) -> MagicMock: + mock_client = MagicMock() + mock_response = mock_response_factory.create( + status_code=status_code, + json_data=response_data, + ) + mock_client.request = AsyncMock(return_value=mock_response) + return mock_client + + return _create + + +# ----------------------------------------------------------------------------- +# Resource Instance Factories +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def teams_resource_factory(mock_http_client_for_resource): + """Factory for creating TeamsResource instances with mock data.""" + + def _create(response_data: Any): + from codesphere.resources.team import TeamsResource + + mock_client = mock_http_client_for_resource(response_data) + resource = TeamsResource(http_client=mock_client) + return resource, mock_client + + return _create + + +@pytest.fixture +def workspaces_resource_factory(mock_http_client_for_resource): + """Factory for creating WorkspacesResource instances with mock data.""" + + def _create(response_data: Any): + from codesphere.resources.workspace import WorkspacesResource + + mock_client = mock_http_client_for_resource(response_data) + resource = WorkspacesResource(http_client=mock_client) + return resource, mock_client + + return _create + + +@pytest.fixture +def metadata_resource_factory(mock_http_client_for_resource): + """Factory for creating MetadataResource instances with mock data.""" + + def _create(response_data: Any): + from codesphere.resources.metadata import MetadataResource + + mock_client = mock_http_client_for_resource(response_data) + resource = MetadataResource(http_client=mock_client) + return resource, mock_client + + return _create + + +# ----------------------------------------------------------------------------- +# Model Instance Factories (for testing model methods) +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def team_model_factory(mock_http_client_for_resource, sample_team_data): + """Factory for creating Team model instances with mock HTTP client.""" + + def _create(response_data: Any = None, team_data: Dict = None): + from codesphere.resources.team import Team + + data = team_data or sample_team_data + mock_client = mock_http_client_for_resource(response_data or {}) + team = Team.model_validate(data) + team._http_client = mock_client + return team, mock_client + + return _create + + +@pytest.fixture +def workspace_model_factory(mock_http_client_for_resource, sample_workspace_data): + """Factory for creating Workspace model instances with mock HTTP client.""" + + def _create(response_data: Any = None, workspace_data: Dict = None): + from codesphere.resources.workspace import Workspace + + data = workspace_data or sample_workspace_data + mock_client = mock_http_client_for_resource(response_data or {}) + workspace = Workspace.model_validate(data) + workspace._http_client = mock_client + return workspace, mock_client + + return _create + + +@pytest.fixture +def domain_model_factory(mock_http_client_for_resource, sample_domain_data): + """Factory for creating Domain model instances with mock HTTP client.""" + + def _create(response_data: Any = None, domain_data: Dict = None): + from codesphere.resources.team.domain.resources import Domain + + data = domain_data or sample_domain_data + mock_client = mock_http_client_for_resource(response_data or {}) + domain = Domain.model_validate(data) + domain._http_client = mock_client + return domain, mock_client + + return _create diff --git a/tests/resources/metadata/__init__.py b/tests/resources/metadata/__init__.py new file mode 100644 index 0000000..a41b018 --- /dev/null +++ b/tests/resources/metadata/__init__.py @@ -0,0 +1 @@ +"""Metadata resource tests.""" diff --git a/tests/resources/metadata/test_metadata.py b/tests/resources/metadata/test_metadata.py new file mode 100644 index 0000000..cfcad59 --- /dev/null +++ b/tests/resources/metadata/test_metadata.py @@ -0,0 +1,217 @@ +""" +Tests for Metadata resources: Datacenters, Plans, and Images. +""" + +import pytest +from dataclasses import dataclass +from typing import List, Type + +from codesphere.resources.metadata import ( + Datacenter, + WsPlan, + Image, +) + + +# ----------------------------------------------------------------------------- +# Test Cases +# ----------------------------------------------------------------------------- + + +@dataclass +class MetadataListTestCase: + """Test case for metadata list operations.""" + + name: str + operation: str + mock_response: List[dict] + expected_count: int + expected_type: Type + + +metadata_list_test_cases = [ + MetadataListTestCase( + name="List datacenters returns Datacenter models", + operation="list_datacenters", + mock_response=[ + {"id": 1, "name": "EU-West", "city": "Frankfurt", "countryCode": "DE"}, + {"id": 2, "name": "US-East", "city": "New York", "countryCode": "US"}, + ], + expected_count=2, + expected_type=Datacenter, + ), + MetadataListTestCase( + name="List plans returns WsPlan models", + operation="list_plans", + mock_response=[ + { + "id": 1, + "priceUsd": 0, + "title": "Free", + "deprecated": False, + "characteristics": { + "id": 1, + "cpu": 0.5, + "gpu": 0, + "ram": 512, + "ssd": 1, + "tempStorage": 0, + "onDemand": False, + }, + "maxReplicas": 1, + }, + ], + expected_count=1, + expected_type=WsPlan, + ), + MetadataListTestCase( + name="List images returns Image models", + operation="list_images", + mock_response=[ + { + "id": "ubuntu-22.04", + "name": "Ubuntu 22.04", + "supportedUntil": "2027-04-01T00:00:00Z", + }, + ], + expected_count=1, + expected_type=Image, + ), +] + + +# ----------------------------------------------------------------------------- +# MetadataResource Tests +# ----------------------------------------------------------------------------- + + +class TestMetadataResource: + """Tests for the MetadataResource class.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "case", + metadata_list_test_cases, + ids=[c.name for c in metadata_list_test_cases], + ) + async def test_list_operations( + self, case: MetadataListTestCase, metadata_resource_factory + ): + """Test metadata list operations return correct model types.""" + resource, mock_client = metadata_resource_factory(case.mock_response) + + # Call the operation method + method = getattr(resource, case.operation) + result = await method() + + # Verify results + assert len(result) == case.expected_count + for item in result: + assert isinstance(item, case.expected_type) + + @pytest.mark.asyncio + async def test_list_datacenters_empty(self, metadata_resource_factory): + """List datacenters should handle empty response.""" + resource, _ = metadata_resource_factory([]) + result = await resource.list_datacenters() + + assert result == [] + + @pytest.mark.asyncio + async def test_datacenter_fields(self, metadata_resource_factory): + """Datacenter model should have correct fields populated.""" + mock_data = [ + {"id": 1, "name": "EU-West", "city": "Frankfurt", "countryCode": "DE"} + ] + resource, _ = metadata_resource_factory(mock_data) + + result = await resource.list_datacenters() + dc = result[0] + + assert dc.id == 1 + assert dc.name == "EU-West" + assert dc.city == "Frankfurt" + assert dc.country_code == "DE" + + @pytest.mark.asyncio + async def test_plan_characteristics(self, metadata_resource_factory): + """WsPlan should have nested Characteristic model.""" + mock_data = [ + { + "id": 8, + "priceUsd": 1500, + "title": "Pro", + "deprecated": False, + "characteristics": { + "id": 8, + "cpu": 4.0, + "gpu": 0, + "ram": 8192, + "ssd": 50, + "tempStorage": 10, + "onDemand": False, + }, + "maxReplicas": 3, + } + ] + resource, _ = metadata_resource_factory(mock_data) + + result = await resource.list_plans() + plan = result[0] + + assert plan.id == 8 + assert plan.price_usd == 1500 + assert plan.characteristics.cpu == 4.0 + assert plan.characteristics.ram == 8192 + + +# ----------------------------------------------------------------------------- +# Schema Tests +# ----------------------------------------------------------------------------- + + +class TestDatacenterSchema: + """Tests for the Datacenter schema.""" + + def test_create_from_camel_case(self): + """Datacenter should be created from camelCase JSON.""" + data = {"id": 1, "name": "Test", "city": "Berlin", "countryCode": "DE"} + dc = Datacenter.model_validate(data) + + assert dc.id == 1 + assert dc.country_code == "DE" + + def test_dump_to_camel_case(self): + """Datacenter should dump to camelCase.""" + dc = Datacenter(id=1, name="Test", city="Berlin", country_code="DE") + dumped = dc.model_dump(by_alias=True) + + assert "countryCode" in dumped + assert dumped["countryCode"] == "DE" + + +class TestWsPlanSchema: + """Tests for the WsPlan schema.""" + + def test_nested_characteristics(self): + """WsPlan should properly parse nested characteristics.""" + data = { + "id": 1, + "priceUsd": 0, + "title": "Free", + "deprecated": False, + "characteristics": { + "id": 1, + "cpu": 0.5, + "gpu": 0, + "ram": 512, + "ssd": 1, + "tempStorage": 0, + "onDemand": False, + }, + "maxReplicas": 1, + } + plan = WsPlan.model_validate(data) + + assert plan.characteristics.cpu == 0.5 + assert plan.characteristics.on_demand is False diff --git a/tests/resources/team/__init__.py b/tests/resources/team/__init__.py new file mode 100644 index 0000000..260c0b7 --- /dev/null +++ b/tests/resources/team/__init__.py @@ -0,0 +1 @@ +"""Team resource tests.""" diff --git a/tests/resources/team/domain/__init__.py b/tests/resources/team/domain/__init__.py new file mode 100644 index 0000000..3f7863e --- /dev/null +++ b/tests/resources/team/domain/__init__.py @@ -0,0 +1 @@ +"""Domain resource tests.""" diff --git a/tests/resources/team/domain/test_domain.py b/tests/resources/team/domain/test_domain.py new file mode 100644 index 0000000..f733900 --- /dev/null +++ b/tests/resources/team/domain/test_domain.py @@ -0,0 +1,228 @@ +""" +Tests for Domain resources: TeamDomainManager and Domain model. +""" + +import pytest +from dataclasses import dataclass +from typing import Any, Optional + +from codesphere.resources.team.domain.resources import Domain +from codesphere.resources.team.domain.manager import TeamDomainManager +from codesphere.resources.team.domain.schemas import ( + CustomDomainConfig, + DomainRouting, + DomainVerificationStatus, +) + + +# ----------------------------------------------------------------------------- +# Test Cases +# ----------------------------------------------------------------------------- + + +@dataclass +class DomainOperationTestCase: + """Test case for domain operations.""" + + name: str + operation: str + input_data: Optional[Any] = None + mock_response: Optional[Any] = None + + +# ----------------------------------------------------------------------------- +# TeamDomainManager Tests +# ----------------------------------------------------------------------------- + + +class TestTeamDomainManager: + """Tests for the TeamDomainManager class.""" + + @pytest.fixture + def domain_manager(self, mock_http_client_for_resource, sample_domain_data): + """Create a TeamDomainManager with mock HTTP client.""" + mock_client = mock_http_client_for_resource([sample_domain_data]) + manager = TeamDomainManager(http_client=mock_client, team_id=12345) + return manager, mock_client + + @pytest.mark.asyncio + async def test_list_domains(self, domain_manager, sample_domain_data): + """List domains should return a list of Domain models.""" + manager, mock_client = domain_manager + + result = await manager.list() + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Domain) + + @pytest.mark.asyncio + async def test_get_domain(self, mock_http_client_for_resource, sample_domain_data): + """Get domain should return a single Domain model.""" + mock_client = mock_http_client_for_resource(sample_domain_data) + manager = TeamDomainManager(http_client=mock_client, team_id=12345) + + result = await manager.get(name="test.example.com") + + assert isinstance(result, Domain) + assert result.name == sample_domain_data["name"] + + @pytest.mark.asyncio + async def test_create_domain( + self, mock_http_client_for_resource, sample_domain_data + ): + """Create domain should return the created Domain model.""" + mock_client = mock_http_client_for_resource(sample_domain_data) + manager = TeamDomainManager(http_client=mock_client, team_id=12345) + + result = await manager.create(name="new.example.com") + + assert isinstance(result, Domain) + mock_client.request.assert_awaited_once() + + @pytest.mark.asyncio + async def test_update_domain( + self, mock_http_client_for_resource, sample_domain_data + ): + """Update domain should apply config changes.""" + mock_client = mock_http_client_for_resource(sample_domain_data) + manager = TeamDomainManager(http_client=mock_client, team_id=12345) + + config = CustomDomainConfig(max_body_size_mb=50) + result = await manager.update(name="test.example.com", config=config) + + assert isinstance(result, Domain) + + @pytest.mark.asyncio + async def test_update_workspace_connections( + self, mock_http_client_for_resource, sample_domain_data + ): + """Update workspace connections should accept routing configuration.""" + mock_client = mock_http_client_for_resource(sample_domain_data) + manager = TeamDomainManager(http_client=mock_client, team_id=12345) + + routing = DomainRouting().add("/", [72678]).add("/api", [72679]) + result = await manager.update_workspace_connections( + name="test.example.com", connections=routing + ) + + assert isinstance(result, Domain) + + +# ----------------------------------------------------------------------------- +# Domain Model Tests +# ----------------------------------------------------------------------------- + + +class TestDomainModel: + """Tests for the Domain model and its methods.""" + + @pytest.mark.asyncio + async def test_update_domain(self, domain_model_factory, sample_domain_data): + """Domain.update() should apply configuration changes.""" + domain, mock_client = domain_model_factory(response_data=sample_domain_data) + + config = CustomDomainConfig(max_body_size_mb=100) + result = await domain.update(data=config) + + mock_client.request.assert_awaited_once() + + @pytest.mark.asyncio + async def test_delete_domain(self, domain_model_factory): + """Domain.delete() should call delete operation.""" + domain, mock_client = domain_model_factory() + + await domain.delete() + + mock_client.request.assert_awaited_once() + + @pytest.mark.asyncio + async def test_verify_status(self, domain_model_factory): + """Domain.verify_status() should return verification status.""" + verification_response = {"verified": True, "reason": None} + domain, mock_client = domain_model_factory(response_data=verification_response) + + result = await domain.verify_status() + + assert isinstance(result, DomainVerificationStatus) + + +# ----------------------------------------------------------------------------- +# DomainRouting Tests +# ----------------------------------------------------------------------------- + + +class TestDomainRouting: + """Tests for the DomainRouting helper class.""" + + def test_create_empty_routing(self): + """DomainRouting should start with empty routing.""" + routing = DomainRouting() + assert routing.root == {} + + def test_add_single_route(self): + """DomainRouting.add() should add a route.""" + routing = DomainRouting().add("/", [72678]) + + assert "/" in routing.root + assert routing.root["/"] == [72678] + + def test_add_multiple_routes(self): + """DomainRouting should support chained .add() calls.""" + routing = ( + DomainRouting() + .add("/", [72678]) + .add("/api", [72679]) + .add("/admin", [72680, 72681]) + ) + + assert len(routing.root) == 3 + assert routing.root["/"] == [72678] + assert routing.root["/api"] == [72679] + assert routing.root["/admin"] == [72680, 72681] + + def test_routing_returns_self_for_chaining(self): + """DomainRouting.add() should return self for method chaining.""" + routing = DomainRouting() + result = routing.add("/test", [1]) + + assert result is routing + + +# ----------------------------------------------------------------------------- +# CustomDomainConfig Tests +# ----------------------------------------------------------------------------- + + +class TestCustomDomainConfig: + """Tests for the CustomDomainConfig schema.""" + + def test_create_with_all_fields(self): + """CustomDomainConfig should accept all optional fields.""" + config = CustomDomainConfig( + restricted=True, + max_body_size_mb=100, + max_connection_timeout_s=300, + use_regex=False, + ) + + assert config.restricted is True + assert config.max_body_size_mb == 100 + assert config.max_connection_timeout_s == 300 + assert config.use_regex is False + + def test_create_with_partial_fields(self): + """CustomDomainConfig should allow partial field specification.""" + config = CustomDomainConfig(max_body_size_mb=50) + + assert config.max_body_size_mb == 50 + assert config.restricted is None + assert config.max_connection_timeout_s is None + + def test_dump_excludes_none_values(self): + """CustomDomainConfig dump should exclude None values when requested.""" + config = CustomDomainConfig(max_body_size_mb=50) + dumped = config.model_dump(exclude_none=True, by_alias=True) + + assert "maxBodySizeMb" in dumped + assert "restricted" not in dumped diff --git a/tests/resources/team/test_team.py b/tests/resources/team/test_team.py new file mode 100644 index 0000000..dc37330 --- /dev/null +++ b/tests/resources/team/test_team.py @@ -0,0 +1,107 @@ +""" +Tests for Team resources: TeamsResource and Team model. +""" + +import pytest + +from codesphere.resources.team import Team, TeamCreate + + +# ----------------------------------------------------------------------------- +# TeamsResource Tests +# ----------------------------------------------------------------------------- + + +class TestTeamsResource: + """Tests for the TeamsResource class.""" + + @pytest.mark.asyncio + async def test_list_teams(self, teams_resource_factory, sample_team_list_data): + """List teams should return a list of Team models.""" + resource, mock_client = teams_resource_factory(sample_team_list_data) + + result = await resource.list() + + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(team, Team) for team in result) + + @pytest.mark.asyncio + async def test_list_teams_empty(self, teams_resource_factory): + """List teams should handle empty response.""" + resource, _ = teams_resource_factory([]) + + result = await resource.list() + + assert result == [] + + @pytest.mark.asyncio + async def test_get_team_by_id(self, teams_resource_factory, sample_team_data): + """Get team should return a single Team model.""" + resource, mock_client = teams_resource_factory(sample_team_data) + + result = await resource.get(team_id=12345) + + assert isinstance(result, Team) + assert result.id == sample_team_data["id"] + assert result.name == sample_team_data["name"] + + @pytest.mark.asyncio + async def test_create_team(self, teams_resource_factory, sample_team_data): + """Create team should return the created Team model.""" + resource, mock_client = teams_resource_factory(sample_team_data) + payload = TeamCreate(name="New Team", dc=1) + + result = await resource.create(payload=payload) + + assert isinstance(result, Team) + mock_client.request.assert_awaited_once() + + +# ----------------------------------------------------------------------------- +# Team Model Tests +# ----------------------------------------------------------------------------- + + +class TestTeamModel: + """Tests for the Team model and its methods.""" + + @pytest.mark.asyncio + async def test_delete_team(self, team_model_factory): + """Team.delete() should call the delete operation.""" + team, mock_client = team_model_factory() + + await team.delete() + + mock_client.request.assert_awaited_once() + + def test_domains_raises_without_http_client(self, sample_team_data): + """Accessing domains without valid HTTP client should raise RuntimeError.""" + team = Team.model_validate(sample_team_data) + + with pytest.raises(RuntimeError, match="detached model"): + _ = team.domains + + +# ----------------------------------------------------------------------------- +# TeamCreate Schema Tests +# ----------------------------------------------------------------------------- + + +class TestTeamCreateSchema: + """Tests for the TeamCreate schema.""" + + def test_create_with_required_fields(self): + """TeamCreate should be created with required fields.""" + create = TeamCreate(name="Test Team", dc=1) + + assert create.name == "Test Team" + assert create.dc == 1 + + def test_dump_to_camel_case(self): + """TeamCreate should dump to camelCase for API requests.""" + create = TeamCreate(name="Test Team", dc=2) + dumped = create.model_dump(by_alias=True) + + assert dumped["name"] == "Test Team" + assert dumped["dc"] == 2 diff --git a/tests/resources/test_domain_resources.py b/tests/resources/test_domain_resources.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/resources/test_metadata_resources.py b/tests/resources/test_metadata_resources.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/resources/test_workspaces_resources.py b/tests/resources/test_workspaces_resources.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/resources/workspace/__init__.py b/tests/resources/workspace/__init__.py new file mode 100644 index 0000000..8f19cf2 --- /dev/null +++ b/tests/resources/workspace/__init__.py @@ -0,0 +1 @@ +"""Workspace resource tests.""" diff --git a/tests/resources/workspace/env_vars/__init__.py b/tests/resources/workspace/env_vars/__init__.py new file mode 100644 index 0000000..c5cff9d --- /dev/null +++ b/tests/resources/workspace/env_vars/__init__.py @@ -0,0 +1 @@ +"""Environment variables tests.""" diff --git a/tests/resources/workspace/env_vars/test_env_vars.py b/tests/resources/workspace/env_vars/test_env_vars.py new file mode 100644 index 0000000..3c05d60 --- /dev/null +++ b/tests/resources/workspace/env_vars/test_env_vars.py @@ -0,0 +1,157 @@ +""" +Tests for Environment Variables: WorkspaceEnvVarManager and EnvVar model. +""" + +import pytest +from dataclasses import dataclass +from typing import Any, Optional + +from codesphere.resources.workspace.envVars import EnvVar, WorkspaceEnvVarManager + + +# ----------------------------------------------------------------------------- +# Test Cases +# ----------------------------------------------------------------------------- + + +@dataclass +class EnvVarOperationTestCase: + """Test case for environment variable operations.""" + + name: str + operation: str + input_data: Optional[Any] = None + mock_response: Optional[Any] = None + + +# ----------------------------------------------------------------------------- +# WorkspaceEnvVarManager Tests +# ----------------------------------------------------------------------------- + + +class TestWorkspaceEnvVarManager: + """Tests for the WorkspaceEnvVarManager class.""" + + @pytest.fixture + def env_var_manager(self, mock_http_client_for_resource, sample_env_var_data): + """Create a WorkspaceEnvVarManager with mock HTTP client.""" + mock_client = mock_http_client_for_resource(sample_env_var_data) + manager = WorkspaceEnvVarManager(http_client=mock_client, workspace_id=72678) + return manager, mock_client + + @pytest.mark.asyncio + async def test_get_env_vars(self, env_var_manager, sample_env_var_data): + """Get should return a list of EnvVar models.""" + manager, mock_client = env_var_manager + + result = await manager.get() + + mock_client.request.assert_awaited_once() + + @pytest.mark.asyncio + async def test_set_env_vars_with_list(self, mock_http_client_for_resource): + """Set should accept a list of EnvVar models.""" + mock_client = mock_http_client_for_resource(None) + manager = WorkspaceEnvVarManager(http_client=mock_client, workspace_id=72678) + + env_vars = [ + EnvVar(name="VAR1", value="value1"), + EnvVar(name="VAR2", value="value2"), + ] + await manager.set(env_vars=env_vars) + + mock_client.request.assert_awaited_once() + + @pytest.mark.asyncio + async def test_set_env_vars_with_dict_list(self, mock_http_client_for_resource): + """Set should accept a list of dictionaries.""" + mock_client = mock_http_client_for_resource(None) + manager = WorkspaceEnvVarManager(http_client=mock_client, workspace_id=72678) + + env_vars = [ + {"name": "VAR1", "value": "value1"}, + {"name": "VAR2", "value": "value2"}, + ] + await manager.set(env_vars=env_vars) + + mock_client.request.assert_awaited_once() + + @pytest.mark.asyncio + async def test_delete_env_vars_by_name(self, mock_http_client_for_resource): + """Delete should accept a list of variable names.""" + mock_client = mock_http_client_for_resource(None) + manager = WorkspaceEnvVarManager(http_client=mock_client, workspace_id=72678) + + await manager.delete(items=["VAR1", "VAR2"]) + + mock_client.request.assert_awaited_once() + + @pytest.mark.asyncio + async def test_delete_env_vars_by_model(self, mock_http_client_for_resource): + """Delete should accept a list of EnvVar models.""" + mock_client = mock_http_client_for_resource(None) + manager = WorkspaceEnvVarManager(http_client=mock_client, workspace_id=72678) + + env_vars = [ + EnvVar(name="VAR1", value="value1"), + EnvVar(name="VAR2", value="value2"), + ] + await manager.delete(items=env_vars) + + mock_client.request.assert_awaited_once() + + @pytest.mark.asyncio + async def test_delete_empty_list_does_nothing(self, mock_http_client_for_resource): + """Delete with empty list should not make a request.""" + mock_client = mock_http_client_for_resource(None) + manager = WorkspaceEnvVarManager(http_client=mock_client, workspace_id=72678) + + await manager.delete(items=[]) + + mock_client.request.assert_not_awaited() + + +# ----------------------------------------------------------------------------- +# EnvVar Model Tests +# ----------------------------------------------------------------------------- + + +class TestEnvVarModel: + """Tests for the EnvVar model.""" + + def test_create_env_var(self): + """EnvVar should be created with name and value.""" + env_var = EnvVar(name="MY_VAR", value="my_value") + + assert env_var.name == "MY_VAR" + assert env_var.value == "my_value" + + def test_env_var_from_dict(self): + """EnvVar should be created from dictionary.""" + env_var = EnvVar.model_validate({"name": "API_KEY", "value": "secret123"}) + + assert env_var.name == "API_KEY" + assert env_var.value == "secret123" + + def test_env_var_dump(self): + """EnvVar should dump to dictionary correctly.""" + env_var = EnvVar(name="DEBUG", value="true") + dumped = env_var.model_dump() + + assert dumped == {"name": "DEBUG", "value": "true"} + + def test_env_var_with_empty_value(self): + """EnvVar should allow empty string value.""" + env_var = EnvVar(name="EMPTY_VAR", value="") + + assert env_var.name == "EMPTY_VAR" + assert env_var.value == "" + + def test_env_var_with_special_characters(self): + """EnvVar should handle special characters in values.""" + env_var = EnvVar( + name="CONNECTION_STRING", + value="postgresql://user:p@ss=word@localhost:5432/db", + ) + + assert "p@ss=word" in env_var.value diff --git a/tests/resources/workspace/test_workspace.py b/tests/resources/workspace/test_workspace.py new file mode 100644 index 0000000..370dd64 --- /dev/null +++ b/tests/resources/workspace/test_workspace.py @@ -0,0 +1,248 @@ +""" +Tests for Workspace resources: WorkspacesResource and Workspace model. +""" + +import pytest + +from codesphere.resources.workspace import ( + Workspace, + WorkspaceCreate, + WorkspaceUpdate, + WorkspaceStatus, +) + + +# ----------------------------------------------------------------------------- +# WorkspacesResource Tests +# ----------------------------------------------------------------------------- + + +class TestWorkspacesResource: + """Tests for the WorkspacesResource class.""" + + @pytest.mark.asyncio + async def test_list_by_team( + self, workspaces_resource_factory, sample_workspace_list_data + ): + """List workspaces should return a list of Workspace models.""" + resource, mock_client = workspaces_resource_factory(sample_workspace_list_data) + + result = await resource.list(team_id=12345) + + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(ws, Workspace) for ws in result) + + @pytest.mark.asyncio + async def test_list_by_team_empty(self, workspaces_resource_factory): + """List workspaces should handle empty response.""" + resource, _ = workspaces_resource_factory([]) + + result = await resource.list(team_id=12345) + + assert result == [] + + @pytest.mark.asyncio + async def test_get_workspace_by_id( + self, workspaces_resource_factory, sample_workspace_data + ): + """Get workspace should return a single Workspace model.""" + resource, mock_client = workspaces_resource_factory(sample_workspace_data) + + result = await resource.get(workspace_id=72678) + + assert isinstance(result, Workspace) + assert result.id == sample_workspace_data["id"] + assert result.name == sample_workspace_data["name"] + + @pytest.mark.asyncio + async def test_create_workspace( + self, workspaces_resource_factory, sample_workspace_data + ): + """Create workspace should return the created Workspace model.""" + resource, mock_client = workspaces_resource_factory(sample_workspace_data) + payload = WorkspaceCreate( + team_id=12345, + name="new-workspace", + plan_id=8, + ) + + result = await resource.create(payload=payload) + + assert isinstance(result, Workspace) + mock_client.request.assert_awaited_once() + + +# ----------------------------------------------------------------------------- +# Workspace Model Tests +# ----------------------------------------------------------------------------- + + +class TestWorkspaceModel: + """Tests for the Workspace model and its methods.""" + + @pytest.mark.asyncio + async def test_update_workspace(self, workspace_model_factory): + """Workspace.update() should update the workspace and local model.""" + workspace, mock_client = workspace_model_factory() + + update_data = WorkspaceUpdate(name="updated-name", plan_id=10) + await workspace.update(data=update_data) + + mock_client.request.assert_awaited_once() + assert workspace.name == "updated-name" + assert workspace.plan_id == 10 + + @pytest.mark.asyncio + async def test_delete_workspace(self, workspace_model_factory): + """Workspace.delete() should call the delete operation.""" + workspace, mock_client = workspace_model_factory() + + await workspace.delete() + + mock_client.request.assert_awaited_once() + + @pytest.mark.asyncio + async def test_get_status(self, workspace_model_factory): + """Workspace.get_status() should return WorkspaceStatus.""" + status_response = {"isRunning": True} + workspace, mock_client = workspace_model_factory(response_data=status_response) + + result = await workspace.get_status() + + assert isinstance(result, WorkspaceStatus) + assert result.is_running is True + + @pytest.mark.asyncio + async def test_execute_command(self, workspace_model_factory): + """Workspace.execute_command() should execute a command and return output.""" + command_response = { + "command": "echo Hello", + "workingDir": "/home/user", + "output": "Hello\n", + "error": "", + } + workspace, mock_client = workspace_model_factory(response_data=command_response) + + result = await workspace.execute_command( + command="echo Hello", env={"USER": "test"} + ) + + assert result.command == "echo Hello" + assert result.output == "Hello\n" + + def test_env_vars_raises_without_http_client(self, sample_workspace_data): + """Accessing env_vars without valid HTTP client should raise RuntimeError.""" + workspace = Workspace.model_validate(sample_workspace_data) + + with pytest.raises(RuntimeError, match="detached model"): + _ = workspace.env_vars + + +# ----------------------------------------------------------------------------- +# WorkspaceCreate Schema Tests +# ----------------------------------------------------------------------------- + + +class TestWorkspaceCreateSchema: + """Tests for the WorkspaceCreate schema.""" + + def test_create_with_required_fields(self): + """WorkspaceCreate should be created with required fields.""" + create = WorkspaceCreate( + team_id=12345, + name="test-workspace", + plan_id=8, + ) + + assert create.team_id == 12345 + assert create.name == "test-workspace" + assert create.plan_id == 8 + + def test_create_with_optional_fields(self): + """WorkspaceCreate should accept optional fields.""" + create = WorkspaceCreate( + team_id=12345, + name="test-workspace", + plan_id=8, + base_image="ubuntu:22.04", + git_url="https://github.com/example/repo.git", + initial_branch="main", + replicas=2, + ) + + assert create.base_image == "ubuntu:22.04" + assert create.git_url == "https://github.com/example/repo.git" + assert create.initial_branch == "main" + assert create.replicas == 2 + + def test_dump_to_camel_case(self): + """WorkspaceCreate should dump to camelCase for API requests.""" + create = WorkspaceCreate( + team_id=12345, + name="test", + plan_id=8, + is_private_repo=True, + ) + dumped = create.model_dump(by_alias=True) + + assert "teamId" in dumped + assert "planId" in dumped + assert "isPrivateRepo" in dumped + + +# ----------------------------------------------------------------------------- +# WorkspaceUpdate Schema Tests +# ----------------------------------------------------------------------------- + + +class TestWorkspaceUpdateSchema: + """Tests for the WorkspaceUpdate schema.""" + + def test_all_fields_optional(self): + """WorkspaceUpdate should have all fields optional.""" + update = WorkspaceUpdate() + + assert update.name is None + assert update.plan_id is None + assert update.replicas is None + + def test_partial_update(self): + """WorkspaceUpdate should allow partial updates.""" + update = WorkspaceUpdate(name="new-name") + + assert update.name == "new-name" + assert update.plan_id is None + + def test_dump_excludes_none_values(self): + """WorkspaceUpdate dump should exclude None values.""" + update = WorkspaceUpdate(name="updated", plan_id=10) + dumped = update.model_dump(exclude_none=True, by_alias=True) + + assert "name" in dumped + assert "planId" in dumped + assert "replicas" not in dumped + + +# ----------------------------------------------------------------------------- +# WorkspaceStatus Schema Tests +# ----------------------------------------------------------------------------- + + +class TestWorkspaceStatusSchema: + """Tests for the WorkspaceStatus schema.""" + + def test_create_from_camel_case(self): + """WorkspaceStatus should be created from camelCase JSON.""" + status = WorkspaceStatus.model_validate({"isRunning": True}) + assert status.is_running is True + + def test_running_status(self): + """WorkspaceStatus should correctly represent running state.""" + status = WorkspaceStatus(is_running=True) + assert status.is_running is True + + def test_stopped_status(self): + """WorkspaceStatus should correctly represent stopped state.""" + status = WorkspaceStatus(is_running=False) + assert status.is_running is False diff --git a/tests/test_client.py b/tests/test_client.py index 8e8f237..4931b64 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,32 +1,36 @@ +""" +Tests for the main SDK client: CodesphereSDK and APIHttpClient. +""" + import pytest import httpx -from unittest.mock import patch, AsyncMock from dataclasses import dataclass -from typing import Optional, Any, Type +from typing import Any, Optional, Type +from unittest.mock import AsyncMock, patch + from pydantic import BaseModel -from codesphere.client import APIHttpClient, AuthenticationError + +# ----------------------------------------------------------------------------- +# Test Models +# ----------------------------------------------------------------------------- class DummyModel(BaseModel): - """Ein einfaches Pydantic-Modell für Testzwecke.""" + """A simple Pydantic model for testing.""" name: str value: int -@dataclass -class InitTestCase: - """Definiert einen Testfall für die Initialisierung des APIHttpClient.""" - - name: str - token_env_var: Optional[str] - expected_exception: Optional[Type[Exception]] = None +# ----------------------------------------------------------------------------- +# Test Cases +# ----------------------------------------------------------------------------- @dataclass class RequestTestCase: - """Definiert einen Testfall für die Request-Methoden des APIHttpClient.""" + """Test case for HTTP request methods.""" name: str method: str @@ -36,52 +40,39 @@ class RequestTestCase: expected_exception: Optional[Type[Exception]] = None -init_test_cases = [ - InitTestCase( - name="Erfolgreiche Initialisierung mit Token", - token_env_var="secret-token", - expected_exception=None, - ), - InitTestCase( - name="Fehlgeschlagene Initialisierung ohne Token", - token_env_var=None, - expected_exception=AuthenticationError, - ), -] - request_test_cases = [ RequestTestCase( - name="GET-Request erfolgreich", + name="GET request successful", method="get", use_context_manager=True, ), RequestTestCase( - name="POST-Request mit Pydantic-Modell erfolgreich", + name="POST request with Pydantic model successful", method="post", use_context_manager=True, payload=DummyModel(name="test", value=123), ), RequestTestCase( - name="PUT-Request mit Dictionary erfolgreich", + name="PUT request with dictionary successful", method="put", use_context_manager=True, payload={"key": "value"}, ), RequestTestCase( - name="Request schlägt fehl ohne Context Manager", + name="Request fails without context manager", method="get", use_context_manager=False, expected_exception=RuntimeError, ), RequestTestCase( - name="Request mit 404-Fehler löst HTTPStatusError aus", + name="Request with 404 error raises HTTPStatusError", method="get", use_context_manager=True, mock_status_code=404, expected_exception=httpx.HTTPStatusError, ), RequestTestCase( - name="Request mit 500-Fehler löst HTTPStatusError aus", + name="Request with 500 error raises HTTPStatusError", method="post", use_context_manager=True, mock_status_code=500, @@ -90,57 +81,70 @@ class RequestTestCase: ] -@pytest.mark.parametrize("case", init_test_cases, ids=[c.name for c in init_test_cases]) -def test_client_initialization(case: InitTestCase): - """ - Testet die Initialisierungslogik des APIHttpClient. - """ - with patch("os.environ.get", return_value=case.token_env_var): - if case.expected_exception: - with pytest.raises(case.expected_exception): - APIHttpClient() - else: - client = APIHttpClient() - assert client._token == case.token_env_var - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "case", request_test_cases, ids=[c.name for c in request_test_cases] -) -async def test_client_requests(case: RequestTestCase): - """ - Testet die verschiedenen Request-Methoden (get, post, etc.) und deren Verhalten. - """ - with patch("os.environ.get", return_value="fake-token"): - client = APIHttpClient() - +# ----------------------------------------------------------------------------- +# APIHttpClient Tests +# ----------------------------------------------------------------------------- + + +class TestAPIHttpClient: + """Tests for the APIHttpClient class.""" + + def test_client_initialization(self, api_http_client, mock_token): + """Client should initialize with token from settings.""" + assert api_http_client._token == mock_token + + def test_client_not_connected_initially(self, api_http_client): + """Client should not be connected before entering context.""" + assert api_http_client._client is None + + @pytest.mark.asyncio + async def test_client_connects_on_enter(self, api_http_client, mock_async_client): + """Client should connect when entering context manager.""" + with patch("httpx.AsyncClient", return_value=mock_async_client): + async with api_http_client as client: + assert client._client is not None + + @pytest.mark.asyncio + async def test_client_disconnects_on_exit(self, api_http_client, mock_async_client): + """Client should disconnect when exiting context manager.""" + with patch("httpx.AsyncClient", return_value=mock_async_client): + async with api_http_client: + pass + assert api_http_client._client is None + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "case", request_test_cases, ids=[c.name for c in request_test_cases] + ) + async def test_client_requests( + self, + case: RequestTestCase, + api_http_client, + mock_response_factory, + ): + """Test various HTTP request scenarios.""" + mock_response = mock_response_factory.create( + status_code=case.mock_status_code, + json_data={}, + ) mock_http_client = AsyncMock(spec=httpx.AsyncClient) - mock_response = AsyncMock(spec=httpx.Response) - mock_response.status_code = case.mock_status_code - - if 400 <= case.mock_status_code < 600: - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - f"{case.mock_status_code} Error", - request=AsyncMock(), - response=mock_response, - ) - mock_http_client.request.return_value = mock_response if case.expected_exception: with pytest.raises(case.expected_exception): with patch("httpx.AsyncClient", return_value=mock_http_client): if case.use_context_manager: - async with client: - await getattr(client, case.method)("/test-endpoint") + async with api_http_client: + await getattr(api_http_client, case.method)( + "/test-endpoint" + ) else: - await getattr(client, case.method)("/test-endpoint") + await getattr(api_http_client, case.method)("/test-endpoint") return with patch("httpx.AsyncClient", return_value=mock_http_client): - async with client: - request_func = getattr(client, case.method) + async with api_http_client: + request_func = getattr(api_http_client, case.method) response = await request_func("/test-endpoint", json=case.payload) mock_http_client.request.assert_awaited_once() @@ -156,3 +160,52 @@ async def test_client_requests(case: RequestTestCase): assert call_args.kwargs["json"] == case.payload assert response.status_code == case.mock_status_code + + +# ----------------------------------------------------------------------------- +# CodesphereSDK Tests +# ----------------------------------------------------------------------------- + + +class TestCodesphereSDK: + """Tests for the CodesphereSDK class.""" + + def test_sdk_has_teams_resource(self, sdk_client): + """SDK should have teams resource attribute.""" + from codesphere.resources.team import TeamsResource + + assert hasattr(sdk_client, "teams") + assert isinstance(sdk_client.teams, TeamsResource) + + def test_sdk_has_workspaces_resource(self, sdk_client): + """SDK should have workspaces resource attribute.""" + from codesphere.resources.workspace import WorkspacesResource + + assert hasattr(sdk_client, "workspaces") + assert isinstance(sdk_client.workspaces, WorkspacesResource) + + def test_sdk_has_metadata_resource(self, sdk_client): + """SDK should have metadata resource attribute.""" + from codesphere.resources.metadata import MetadataResource + + assert hasattr(sdk_client, "metadata") + assert isinstance(sdk_client.metadata, MetadataResource) + + @pytest.mark.asyncio + async def test_sdk_context_manager(self, sdk_client, mock_async_client): + """SDK should work as async context manager.""" + with patch("httpx.AsyncClient", return_value=mock_async_client): + async with sdk_client as sdk: + assert sdk is sdk_client + # HTTP client should be connected + assert sdk._http_client._client is not None + + @pytest.mark.asyncio + async def test_sdk_open_and_close(self, sdk_client, mock_async_client): + """SDK should support explicit open() and close() methods.""" + with patch("httpx.AsyncClient", return_value=mock_async_client): + await sdk_client.open() + assert sdk_client._http_client._client is not None + + await sdk_client.close() + assert sdk_client._http_client._client is None From 2dd3b2f3e271b214b7d931d1dcb464ed1c43935c Mon Sep 17 00:00:00 2001 From: Datata1 <> Date: Tue, 3 Feb 2026 13:22:18 +0100 Subject: [PATCH 2/2] refactor(examples): remove examples to replace them --- examples/metadata/get_datacenters.py | 17 ----------- examples/metadata/get_workspace_base_image.py | 17 ----------- examples/metadata/get_workspace_plans.py | 18 ------------ examples/streams/.gitkeep | 0 examples/streams/replica_stream.py | 0 examples/teams/create_team.py | 19 ------------ examples/teams/delete_team.py | 21 -------------- examples/teams/domains/create_domain.py | 18 ------------ examples/teams/domains/delete_domain.py | 18 ------------ examples/teams/domains/get_domain.py | 18 ------------ examples/teams/domains/list_domains.py | 19 ------------ examples/teams/domains/update_domain.py | 20 ------------- .../domains/update_workspace_connections.py | 24 --------------- examples/teams/domains/verify_domain.py | 0 examples/teams/get_team.py | 19 ------------ examples/teams/list_teams.py | 20 ------------- examples/workspaces/create_workspace.py | 26 ----------------- examples/workspaces/delete_workspace.py | 24 --------------- .../workspaces/env-vars/delete_envvars.py | 27 ----------------- examples/workspaces/env-vars/list_envvars.py | 22 -------------- examples/workspaces/env-vars/set_envvars.py | 28 ------------------ examples/workspaces/execute_command.py | 26 ----------------- examples/workspaces/get_status.py | 19 ------------ examples/workspaces/get_workspace.py | 29 ------------------- examples/workspaces/git/get_git_head.py | 0 examples/workspaces/git/git_pull.py | 0 examples/workspaces/git/git_push.py | 0 .../workspaces/landscape/deploy_landscape.py | 0 .../landscape/teardown_landscape.py | 0 examples/workspaces/list_workspace.py | 22 -------------- examples/workspaces/pipeline/get_logs.py | 0 .../pipeline/get_pipeline_status.py | 0 .../workspaces/pipeline/get_replica_logs.py | 0 .../workspaces/pipeline/get_server_logs.py | 0 .../workspaces/pipeline/start_pipeline.py | 0 examples/workspaces/pipeline/stop_pipeline.py | 0 examples/workspaces/update_workspace.py | 22 -------------- 37 files changed, 493 deletions(-) delete mode 100644 examples/metadata/get_datacenters.py delete mode 100644 examples/metadata/get_workspace_base_image.py delete mode 100644 examples/metadata/get_workspace_plans.py delete mode 100644 examples/streams/.gitkeep delete mode 100644 examples/streams/replica_stream.py delete mode 100644 examples/teams/create_team.py delete mode 100644 examples/teams/delete_team.py delete mode 100644 examples/teams/domains/create_domain.py delete mode 100644 examples/teams/domains/delete_domain.py delete mode 100644 examples/teams/domains/get_domain.py delete mode 100644 examples/teams/domains/list_domains.py delete mode 100644 examples/teams/domains/update_domain.py delete mode 100644 examples/teams/domains/update_workspace_connections.py delete mode 100644 examples/teams/domains/verify_domain.py delete mode 100644 examples/teams/get_team.py delete mode 100644 examples/teams/list_teams.py delete mode 100644 examples/workspaces/create_workspace.py delete mode 100644 examples/workspaces/delete_workspace.py delete mode 100644 examples/workspaces/env-vars/delete_envvars.py delete mode 100644 examples/workspaces/env-vars/list_envvars.py delete mode 100644 examples/workspaces/env-vars/set_envvars.py delete mode 100644 examples/workspaces/execute_command.py delete mode 100644 examples/workspaces/get_status.py delete mode 100644 examples/workspaces/get_workspace.py delete mode 100644 examples/workspaces/git/get_git_head.py delete mode 100644 examples/workspaces/git/git_pull.py delete mode 100644 examples/workspaces/git/git_push.py delete mode 100644 examples/workspaces/landscape/deploy_landscape.py delete mode 100644 examples/workspaces/landscape/teardown_landscape.py delete mode 100644 examples/workspaces/list_workspace.py delete mode 100644 examples/workspaces/pipeline/get_logs.py delete mode 100644 examples/workspaces/pipeline/get_pipeline_status.py delete mode 100644 examples/workspaces/pipeline/get_replica_logs.py delete mode 100644 examples/workspaces/pipeline/get_server_logs.py delete mode 100644 examples/workspaces/pipeline/start_pipeline.py delete mode 100644 examples/workspaces/pipeline/stop_pipeline.py delete mode 100644 examples/workspaces/update_workspace.py diff --git a/examples/metadata/get_datacenters.py b/examples/metadata/get_datacenters.py deleted file mode 100644 index a4b699b..0000000 --- a/examples/metadata/get_datacenters.py +++ /dev/null @@ -1,17 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - """Fetches datacenters.""" - async with CodesphereSDK() as sdk: - datacenters = await sdk.metadata.list_datacenters() - for datacenter in datacenters: - print(datacenter.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/metadata/get_workspace_base_image.py b/examples/metadata/get_workspace_base_image.py deleted file mode 100644 index 7985e1e..0000000 --- a/examples/metadata/get_workspace_base_image.py +++ /dev/null @@ -1,17 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - """Fetches base images.""" - async with CodesphereSDK() as sdk: - images = await sdk.metadata.list_images() - for image in images: - print(image.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/metadata/get_workspace_plans.py b/examples/metadata/get_workspace_plans.py deleted file mode 100644 index 9a3bf91..0000000 --- a/examples/metadata/get_workspace_plans.py +++ /dev/null @@ -1,18 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - """Fetches workspace plans.""" - async with CodesphereSDK() as sdk: - plans = await sdk.metadata.list_plans() - - for plan in plans: - print(plan.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/streams/.gitkeep b/examples/streams/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/examples/streams/replica_stream.py b/examples/streams/replica_stream.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/teams/create_team.py b/examples/teams/create_team.py deleted file mode 100644 index aa2908f..0000000 --- a/examples/teams/create_team.py +++ /dev/null @@ -1,19 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK, TeamCreate - -logging.basicConfig(level=logging.INFO) - - -async def main(): - try: - async with CodesphereSDK() as sdk: - newTeam = TeamCreate(name="test", dc=2) - created_team = await sdk.teams.create(data=newTeam) - print(created_team.model_dump_json(indent=2)) - except Exception as e: - print(f"An error occurred: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/teams/delete_team.py b/examples/teams/delete_team.py deleted file mode 100644 index 7a3cfee..0000000 --- a/examples/teams/delete_team.py +++ /dev/null @@ -1,21 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - try: - async with CodesphereSDK() as sdk: - team_to_delete = await sdk.teams.get(team_id=11111) - print(team_to_delete.model_dump_json(indent=2)) - await team_to_delete.delete() - print(f"Team with ID {team_to_delete.id} was successfully deleted.") - - except Exception as e: - print(f"An error occurred: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/teams/domains/create_domain.py b/examples/teams/domains/create_domain.py deleted file mode 100644 index 7527dc1..0000000 --- a/examples/teams/domains/create_domain.py +++ /dev/null @@ -1,18 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - team = await sdk.teams.get(team_id=35663) - domain = await team.domains.create(domain_name="test.com") - - print(f"Domain created: {domain.name}") - print(domain.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/teams/domains/delete_domain.py b/examples/teams/domains/delete_domain.py deleted file mode 100644 index 717be45..0000000 --- a/examples/teams/domains/delete_domain.py +++ /dev/null @@ -1,18 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - team = await sdk.teams.get(team_id=35663) - domain = await team.domains.delete(domain_name="test.com") - - print(f"Domain created: {domain.name}") - print(domain.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/teams/domains/get_domain.py b/examples/teams/domains/get_domain.py deleted file mode 100644 index 2f82f8d..0000000 --- a/examples/teams/domains/get_domain.py +++ /dev/null @@ -1,18 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - team = await sdk.teams.get(team_id=35663) - logging.info(f"Working with team: {team.name}") - domain = await team.domains.get("test.com") - - print(domain.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/teams/domains/list_domains.py b/examples/teams/domains/list_domains.py deleted file mode 100644 index eb0705c..0000000 --- a/examples/teams/domains/list_domains.py +++ /dev/null @@ -1,19 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - team = await sdk.teams.get(team_id=35663) - domains = await team.domains.list() - - for domain in domains: - print(f"Domain: {domain.name}") - print(domain.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/teams/domains/update_domain.py b/examples/teams/domains/update_domain.py deleted file mode 100644 index 6694b3c..0000000 --- a/examples/teams/domains/update_domain.py +++ /dev/null @@ -1,20 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK, CustomDomainConfig - -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - team = await sdk.teams.get(team_id=35663) - domain = await team.domains.get(domain_name="test.com") - - new_config_data = CustomDomainConfig( - max_body_size_mb=24, max_connection_timeout_s=500, use_regex=False - ) - await domain.update(new_config_data) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/teams/domains/update_workspace_connections.py b/examples/teams/domains/update_workspace_connections.py deleted file mode 100644 index f698562..0000000 --- a/examples/teams/domains/update_workspace_connections.py +++ /dev/null @@ -1,24 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK, DomainRouting - -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - team = await sdk.teams.get(team_id=35663) - domainBuilder = DomainRouting() - - routing = ( - domainBuilder.add("/", [74861]).add("/api", [74868]).add("/test", [74868]) - ) - - domain = await team.domains.update_workspace_connections( - name="test.com", connections=routing - ) - print(f"Current routing: {domain.workspaces}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/teams/domains/verify_domain.py b/examples/teams/domains/verify_domain.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/teams/get_team.py b/examples/teams/get_team.py deleted file mode 100644 index 31aef3c..0000000 --- a/examples/teams/get_team.py +++ /dev/null @@ -1,19 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - try: - async with CodesphereSDK() as sdk: - team = await sdk.teams.get(team_id=12312) - print(team.model_dump_json(indent=2)) - - except Exception as e: - print(f"An error occurred: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/teams/list_teams.py b/examples/teams/list_teams.py deleted file mode 100644 index 6f11b6e..0000000 --- a/examples/teams/list_teams.py +++ /dev/null @@ -1,20 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - try: - async with CodesphereSDK() as sdk: - teams = await sdk.teams.list() - for team in teams: - print(team.model_dump_json(indent=2)) - - except Exception as e: - print(f"An error occurred: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/workspaces/create_workspace.py b/examples/workspaces/create_workspace.py deleted file mode 100644 index 1d17d1d..0000000 --- a/examples/workspaces/create_workspace.py +++ /dev/null @@ -1,26 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK, WorkspaceCreate - -logging.basicConfig(level=logging.INFO) - - -async def main(): - team_id = int(999999) - - async with CodesphereSDK() as sdk: - print(f"--- Creating a new workspace in team {team_id} ---") - workspace_data = WorkspaceCreate( - name="my-new-sdk-workspace-3", - planId=8, - teamId=team_id, - isPrivateRepo=True, - replicas=1, - ) - - created_workspace = await sdk.workspaces.create(data=workspace_data) - print(created_workspace.model_dump_json()) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/workspaces/delete_workspace.py b/examples/workspaces/delete_workspace.py deleted file mode 100644 index aeba2ce..0000000 --- a/examples/workspaces/delete_workspace.py +++ /dev/null @@ -1,24 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - """Deletes a specific workspace.""" - - workspace_id_to_delete = int(9999999) - - async with CodesphereSDK() as sdk: - workspace_to_delete = await sdk.workspaces.get( - workspace_id=workspace_id_to_delete - ) - - print(f"\n--- Deleting workspace: '{workspace_to_delete.name}' ---") - await workspace_to_delete.delete() - print(f"Workspace '{workspace_to_delete.name}' has been successfully deleted.") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/workspaces/env-vars/delete_envvars.py b/examples/workspaces/env-vars/delete_envvars.py deleted file mode 100644 index 260e6ee..0000000 --- a/examples/workspaces/env-vars/delete_envvars.py +++ /dev/null @@ -1,27 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - teams = await sdk.teams.list() - workspaces = await sdk.workspaces.list_by_team(team_id=teams[0].id) - - workspace = workspaces[0] - vars_to_delete = await workspace.env_vars.get() - for env in vars_to_delete: - print(env.model_dump_json(indent=2)) - - await workspace.env_vars.delete(vars_to_delete) - - print("\n--- Verifying deletion ---") - current_vars = await workspace.env_vars.get() - for env in current_vars: - print(env.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/workspaces/env-vars/list_envvars.py b/examples/workspaces/env-vars/list_envvars.py deleted file mode 100644 index 3428371..0000000 --- a/examples/workspaces/env-vars/list_envvars.py +++ /dev/null @@ -1,22 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - """Fetches a team and lists all workspaces within it.""" - async with CodesphereSDK() as sdk: - teams = await sdk.teams.list() - workspaces = await sdk.workspaces.list_by_team(team_id=teams[0].id) - workspace = workspaces[0] - - envs = await workspace.env_vars.get() - print("Current Environment Variables:") - for env in envs: - print(env.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/workspaces/env-vars/set_envvars.py b/examples/workspaces/env-vars/set_envvars.py deleted file mode 100644 index c9a6c4d..0000000 --- a/examples/workspaces/env-vars/set_envvars.py +++ /dev/null @@ -1,28 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK, EnvVar - -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - teams = await sdk.teams.list() - workspaces = await sdk.workspaces.list_by_team(team_id=teams[0].id) - workspace = workspaces[0] - - new_vars = [ - EnvVar(name="MY_NEW_VAR", value="hello_world"), - EnvVar(name="ANOTHER_VAR", value="123456"), - ] - - await workspace.env_vars.set(new_vars) - - print("\n--- Verifying new list ---") - current_vars = await workspace.env_vars.get() - for env in current_vars: - print(env.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/workspaces/execute_command.py b/examples/workspaces/execute_command.py deleted file mode 100644 index a3e63e1..0000000 --- a/examples/workspaces/execute_command.py +++ /dev/null @@ -1,26 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - teams = await sdk.teams.list() - workspaces = await sdk.workspaces.list_by_team(team_id=teams[0].id) - workspace = workspaces[0] - state = await workspace.get_status() - print(state.model_dump_json(indent=2)) - - command_str = "echo Hello from $USER_NAME!" - command_env = {"USER_NAME": "SDK-User"} - - command_output = await workspace.execute_command( - command=command_str, env=command_env - ) - print(command_output.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/workspaces/get_status.py b/examples/workspaces/get_status.py deleted file mode 100644 index a9c631c..0000000 --- a/examples/workspaces/get_status.py +++ /dev/null @@ -1,19 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - all_teams = await sdk.teams.list() - first_team = all_teams[0] - workspaces = await sdk.workspaces.list_by_team(team_id=first_team.id) - first_workspace = workspaces[0] - state = await first_workspace.get_status() - print(state.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/workspaces/get_workspace.py b/examples/workspaces/get_workspace.py deleted file mode 100644 index 8ddc29c..0000000 --- a/examples/workspaces/get_workspace.py +++ /dev/null @@ -1,29 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - all_teams = await sdk.teams.list() - if not all_teams: - print("No teams found. Cannot get a workspace.") - return - - first_team = all_teams[0] - - workspaces = await sdk.workspaces.list_by_team(team_id=first_team.id) - if not workspaces: - print(f"No workspaces found in team '{first_team.name}'.") - return - - first_workspace = workspaces[0] - workspace_id_to_fetch = first_workspace.id - workspace = await sdk.workspaces.get(workspace_id=workspace_id_to_fetch) - print(workspace.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/workspaces/git/get_git_head.py b/examples/workspaces/git/get_git_head.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/workspaces/git/git_pull.py b/examples/workspaces/git/git_pull.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/workspaces/git/git_push.py b/examples/workspaces/git/git_push.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/workspaces/landscape/deploy_landscape.py b/examples/workspaces/landscape/deploy_landscape.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/workspaces/landscape/teardown_landscape.py b/examples/workspaces/landscape/teardown_landscape.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/workspaces/list_workspace.py b/examples/workspaces/list_workspace.py deleted file mode 100644 index 387a4bd..0000000 --- a/examples/workspaces/list_workspace.py +++ /dev/null @@ -1,22 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK - -# --- Logging-Konfiguration --- -logging.basicConfig(level=logging.INFO) - - -async def main(): - async with CodesphereSDK() as sdk: - all_teams = await sdk.teams.list() - first_team = all_teams[0] - team_id_to_fetch = first_team.id - workspaces = await sdk.workspaces.list_by_team(team_id=team_id_to_fetch) - print(f"\n--- Workspaces in Team: {first_team.name} ---") - for ws in workspaces: - print(ws.model_dump_json(indent=2)) - print("-" * 20) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/workspaces/pipeline/get_logs.py b/examples/workspaces/pipeline/get_logs.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/workspaces/pipeline/get_pipeline_status.py b/examples/workspaces/pipeline/get_pipeline_status.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/workspaces/pipeline/get_replica_logs.py b/examples/workspaces/pipeline/get_replica_logs.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/workspaces/pipeline/get_server_logs.py b/examples/workspaces/pipeline/get_server_logs.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/workspaces/pipeline/start_pipeline.py b/examples/workspaces/pipeline/start_pipeline.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/workspaces/pipeline/stop_pipeline.py b/examples/workspaces/pipeline/stop_pipeline.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/workspaces/update_workspace.py b/examples/workspaces/update_workspace.py deleted file mode 100644 index ed951d2..0000000 --- a/examples/workspaces/update_workspace.py +++ /dev/null @@ -1,22 +0,0 @@ -import asyncio -import logging -from codesphere import CodesphereSDK, WorkspaceUpdate - -logging.basicConfig(level=logging.INFO) - - -async def main(): - """Fetches a workspace and updates its name.""" - workspace_id_to_update = 72678 - - async with CodesphereSDK() as sdk: - workspace = await sdk.workspaces.get(workspace_id=workspace_id_to_update) - print(workspace.model_dump_json(indent=2)) - - update_data = WorkspaceUpdate(name="updated workspace2", planId=8) - await workspace.update(data=update_data) - print(workspace.model_dump_json(indent=2)) - - -if __name__ == "__main__": - asyncio.run(main())