Files
cleveragents-core/features/steps/remote_project_steps.py
freemo 8e9aa7af48 fix(client): address server client chain review findings
- Promote _request() to public method request() in ServerHttpClient
  to fix encapsulation violation across sync_client and remote_project
- Fix WebSocket connect() to raise NotImplementedError with clear TODO
  documenting that real websockets transport is not yet implemented
- Fix thread safety: protect last_event_id write under self._lock
  in ws_client.process_event()
- Broaden exception handling in sync() to catch ServerTimeoutError
  and A2aNotAvailableError in addition to ServerConnectionError
- Add has_next to PageResult in benchmark and test helpers
- Add comments explaining Retry-After header logging-only behavior
  and why blocking time.sleep is acceptable in sync client
- Update all test steps, robot helpers, and benchmarks to use the
  renamed public request() method and updated connect() behavior

Refs: #335, #336, #337, #338
2026-03-24 20:28:19 +00:00

306 lines
10 KiB
Python

"""Step definitions for remote project client feature tests."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import httpx
from behave import given, then, when
from behave.runner import Context
from cleveragents.client.http_client import PageResult, ServerHttpClient
from cleveragents.client.remote_project import (
RemoteProject,
RemoteProjectClient,
)
from cleveragents.core.exceptions import ResourceNotFoundError
def _mock_page_result(
items: list[dict[str, Any]],
) -> PageResult:
return PageResult(
items=items, page=1, per_page=50, total=len(items), has_next=False
)
_DEFAULT_PROJECTS = [
{"id": "rp-001", "name": "project-alpha", "alias": "", "description": "Alpha"},
{"id": "rp-002", "name": "project-beta", "alias": "", "description": "Beta"},
]
_ALIASED_PROJECTS = [
{"id": "rp-001", "name": "project-alpha", "alias": "alpha", "description": "Alpha"},
{"id": "rp-002", "name": "project-beta", "alias": "beta", "description": "Beta"},
]
def _build_remote_client(
projects: list[dict[str, Any]] | None = None,
cache_ttl: float = 300.0,
) -> tuple[RemoteProjectClient, MagicMock]:
mock_http = MagicMock(spec=ServerHttpClient)
items = projects if projects is not None else _DEFAULT_PROJECTS
mock_http.list_endpoint.return_value = _mock_page_result(items)
client = RemoteProjectClient(mock_http, cache_ttl=cache_ttl)
return client, mock_http
# ---------------------------------------------------------------------------
# Project listing
# ---------------------------------------------------------------------------
@given("a RemoteProjectClient with mock projects in default namespace")
def step_remote_client(context: Context) -> None:
context.remote_client, context.mock_http = _build_remote_client()
context.call_error = None
context.http_call_count = 0
@given("a RemoteProjectClient with mock projects with aliases")
def step_remote_client_aliases(context: Context) -> None:
context.remote_client, context.mock_http = _build_remote_client(_ALIASED_PROJECTS)
context.call_error = None
@when('I list remote projects in namespace "{ns}"')
def step_list_projects(context: Context, ns: str) -> None:
context.remote_projects = context.remote_client.list_projects(ns)
context.http_call_count = context.mock_http.list_endpoint.call_count
@when('I list remote projects in namespace "{ns}" again')
def step_list_projects_again(context: Context, ns: str) -> None:
context.remote_projects = context.remote_client.list_projects(ns)
@when("I list remote projects with force_refresh")
def step_list_force_refresh(context: Context) -> None:
context.remote_projects = context.remote_client.list_projects(
"default", force_refresh=True
)
@then("I should receive {n:d} remote projects")
def step_check_project_count(context: Context, n: int) -> None:
assert len(context.remote_projects) == n
@then('the first remote project name should be "{expected}"')
def step_check_first_name(context: Context, expected: str) -> None:
assert context.remote_projects[0].name == expected
@then("the HTTP client should have been called once for project listing")
def step_http_called_once(context: Context) -> None:
assert context.mock_http.list_endpoint.call_count == 1
@then("the HTTP client should have been called twice for project listing")
def step_http_called_twice(context: Context) -> None:
assert context.mock_http.list_endpoint.call_count == 2
# ---------------------------------------------------------------------------
# Project resolution
# ---------------------------------------------------------------------------
@when('I get remote project "{name}" in namespace "{ns}"')
def step_get_project(context: Context, name: str, ns: str) -> None:
context.resolved_project = context.remote_client.get_project(name, ns)
@when('I get remote project "{name}" in namespace "{ns}" expecting error')
def step_get_project_error(context: Context, name: str, ns: str) -> None:
try:
context.remote_client.get_project(name, ns)
except ResourceNotFoundError as exc:
context.call_error = exc
@when("I get remote project with empty name")
def step_get_empty(context: Context) -> None:
try:
context.remote_client.get_project("")
except ValueError as exc:
context.call_error = exc
@when('I resolve project "{name}" in namespace "{ns}" with fallback')
def step_resolve_fallback(context: Context, name: str, ns: str) -> None:
# Mock: custom namespace returns empty, default has projects
def _side_effect(path: str, **kwargs: Any) -> PageResult:
ns_param = kwargs.get("params", {}).get("namespace", "default")
if ns_param == "custom":
return _mock_page_result([])
return _mock_page_result(_DEFAULT_PROJECTS)
context.mock_http.list_endpoint.side_effect = _side_effect
context.resolved_project = context.remote_client.resolve_project(name, ns)
@when("I resolve project with empty name")
def step_resolve_empty(context: Context) -> None:
try:
context.remote_client.resolve_project("")
except ValueError as exc:
context.call_error = exc
@then('the resolved project name should be "{expected}"')
def step_check_resolved_name(context: Context, expected: str) -> None:
assert context.resolved_project.name == expected
@then('the resolved project namespace should be "{expected}"')
def step_check_resolved_ns(context: Context, expected: str) -> None:
assert context.resolved_project.namespace == expected
@then("a ResourceNotFoundError should be raised for remote project")
def step_not_found_error(context: Context) -> None:
assert isinstance(context.call_error, ResourceNotFoundError)
@then("a ValueError should be raised for remote project name")
def step_value_error_name(context: Context) -> None:
assert isinstance(context.call_error, ValueError)
@then("a ValueError should be raised for remote resolve name")
def step_value_error_resolve(context: Context) -> None:
assert isinstance(context.call_error, ValueError)
# ---------------------------------------------------------------------------
# Execution
# ---------------------------------------------------------------------------
def _mock_exec_response(
status: int = 200, body: dict[str, Any] | None = None
) -> httpx.Response:
return httpx.Response(
status_code=status,
json=body,
headers={},
request=httpx.Request("POST", "http://mock"),
)
@given("a RemoteProjectClient with mock execution endpoint")
def step_remote_exec(context: Context) -> None:
context.remote_client, context.mock_http = _build_remote_client()
context.mock_http.request.return_value = _mock_exec_response(
200, {"status": "submitted", "execution_id": "exec-001"}
)
context.call_error = None
@when('I request execution of remote project "{pid}"')
def step_request_exec(context: Context, pid: str) -> None:
context.exec_response = context.remote_client.request_execution(pid)
@when('I request remote project "{pid}" execution using plan "{plan}"')
def step_request_exec_plan(context: Context, pid: str, plan: str) -> None:
context.exec_response = context.remote_client.request_execution(pid, plan_name=plan)
@when("I request execution with empty project_id")
def step_request_exec_empty(context: Context) -> None:
try:
context.remote_client.request_execution("")
except ValueError as exc:
context.call_error = exc
@then('the execution response should contain status "{expected}"')
def step_check_exec_response(context: Context, expected: str) -> None:
assert context.exec_response.get("status") == expected
@then("a ValueError should be raised for remote project_id")
def step_value_error_pid(context: Context) -> None:
assert isinstance(context.call_error, ValueError)
# ---------------------------------------------------------------------------
# Cache management
# ---------------------------------------------------------------------------
@when('I invalidate cache for namespace "{ns}"')
def step_invalidate_ns(context: Context, ns: str) -> None:
context.remote_client.invalidate_cache(ns)
@when("I invalidate all cache")
def step_invalidate_all(context: Context) -> None:
context.remote_client.invalidate_cache()
@then("the remote project cache size should be {expected:d}")
def step_check_cache_size(context: Context, expected: int) -> None:
assert context.remote_client.cache_size == expected
# ---------------------------------------------------------------------------
# RemoteProject attributes
# ---------------------------------------------------------------------------
@given(
'a RemoteProject with id "{pid}" name "{name}" namespace "{ns}" '
'alias "{alias}" description "{desc}"'
)
def step_remote_project_attrs(
context: Context,
pid: str,
name: str,
ns: str,
alias: str,
desc: str,
) -> None:
context.test_project = RemoteProject(
project_id=pid,
name=name,
namespace=ns,
alias=alias,
description=desc,
)
@then('the remote project project_id should be "{expected}"')
def step_check_pid(context: Context, expected: str) -> None:
assert context.test_project.project_id == expected
@then('the remote project alias should be "{expected}"')
def step_check_alias(context: Context, expected: str) -> None:
assert context.test_project.alias == expected
@then('the remote project description should be "{expected}"')
def step_check_desc(context: Context, expected: str) -> None:
assert context.test_project.description == expected
# ---------------------------------------------------------------------------
# Cache TTL
# ---------------------------------------------------------------------------
@given("a RemoteProjectClient with cache_ttl {ttl:g}")
def step_client_ttl(context: Context, ttl: float) -> None:
context.remote_client, _ = _build_remote_client(cache_ttl=ttl)
@then("the client cache_ttl should be {expected:g}")
def step_check_ttl(context: Context, expected: float) -> None:
assert context.remote_client.cache_ttl == expected