8e9aa7af48
- 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
306 lines
10 KiB
Python
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
|