diff --git a/features/consolidated_misc.feature b/features/consolidated_misc.feature index 1b647e5cf..e8e21a0a5 100644 --- a/features/consolidated_misc.feature +++ b/features/consolidated_misc.feature @@ -724,31 +724,24 @@ Feature: Consolidated Misc When I m6 smoke attempt remote subscribe to "https://example.com/events" Then the m6 smoke facade should raise A2aNotAvailableError - # --- A2A HTTP transport stub --- + # --- A2A HTTP transport — server-mode --- - Scenario: M6 smoke A2A transport send raises not available + Scenario: M6 smoke A2A transport send while disconnected raises RuntimeError Given a m6 smoke test runner And a m6 smoke A2A local facade When I m6 smoke attempt transport send - Then the m6 smoke facade should raise A2aNotAvailableError + Then the m6 smoke facade should raise RuntimeError - Scenario: M6 smoke A2A transport connect raises not available + Scenario: M6 smoke A2A transport connect succeeds with valid URL Given a m6 smoke test runner And a m6 smoke A2A local facade When I m6 smoke attempt transport connect to "https://example.com/a2a" - Then the m6 smoke facade should raise A2aNotAvailableError + Then the m6 smoke transport should be connected - Scenario: M6 smoke A2A transport disconnect raises not available - Given a m6 smoke test runner - And a m6 smoke A2A local facade - When I m6 smoke attempt transport disconnect - Then the m6 smoke facade should raise A2aNotAvailableError - - - Scenario: M6 smoke A2A transport is_connected returns false + Scenario: M6 smoke A2A transport is_connected returns false before connect Given a m6 smoke test runner And a m6 smoke A2A local facade When I m6 smoke check transport is_connected diff --git a/features/m6_autonomy_acceptance.feature b/features/m6_autonomy_acceptance.feature index 0879bfe0c..d2a832996 100644 --- a/features/m6_autonomy_acceptance.feature +++ b/features/m6_autonomy_acceptance.feature @@ -142,21 +142,17 @@ Feature: M6 autonomy acceptance smoke tests When I m6 smoke attempt remote subscribe to "https://example.com/events" Then the m6 smoke facade should raise A2aNotAvailableError - # --- A2A HTTP transport stub --- + # --- A2A HTTP transport — server-mode --- - Scenario: M6 smoke A2A transport send raises not available + Scenario: M6 smoke A2A transport send while disconnected raises RuntimeError When I m6 smoke attempt transport send - Then the m6 smoke facade should raise A2aNotAvailableError + Then the m6 smoke facade should raise RuntimeError - Scenario: M6 smoke A2A transport connect raises not available + Scenario: M6 smoke A2A transport connect succeeds with valid URL When I m6 smoke attempt transport connect to "https://example.com/a2a" - Then the m6 smoke facade should raise A2aNotAvailableError + Then the m6 smoke transport should be connected - Scenario: M6 smoke A2A transport disconnect raises not available - When I m6 smoke attempt transport disconnect - Then the m6 smoke facade should raise A2aNotAvailableError - - Scenario: M6 smoke A2A transport is_connected returns false + Scenario: M6 smoke A2A transport is_connected returns false before connect When I m6 smoke check transport is_connected Then the m6 smoke transport should not be connected diff --git a/features/steps/a2a_facade_steps.py b/features/steps/a2a_facade_steps.py index 8ecc41315..19c4a96f4 100644 --- a/features/steps/a2a_facade_steps.py +++ b/features/steps/a2a_facade_steps.py @@ -180,28 +180,6 @@ def step_transport_send(context: Context) -> None: context.error = exc -@when(r'I try to connect via the transport to "(?P[^"]+)"') -def step_transport_connect(context: Context, url: str) -> None: - context.caught_error = None - context.error = None - try: - context.transport.connect(url) - except A2aNotAvailableError as exc: - context.caught_error = exc - context.error = exc - - -@when(r"I try to disconnect the transport") -def step_transport_disconnect(context: Context) -> None: - context.caught_error = None - context.error = None - try: - context.transport.disconnect() - except A2aNotAvailableError as exc: - context.caught_error = exc - context.error = exc - - @then(r"an A2aNotAvailableError should be raised") def step_not_available_raised(context: Context) -> None: assert isinstance(context.caught_error, A2aNotAvailableError), ( @@ -209,11 +187,6 @@ def step_not_available_raised(context: Context) -> None: ) -@then(r"the transport should not be connected") -def step_transport_not_connected(context: Context) -> None: - assert context.transport.is_connected() is False - - # A2aEventQueue — local mode @@ -510,10 +483,9 @@ def step_create_not_available_default(context: Context) -> None: # "the error message should contain" is provided by service_steps.py - - # A2aHttpTransport — server-mode connect / disconnect + @then(r"the transport should be connected when using url") def step_transport_is_connected(context: Context) -> None: assert context.transport.is_connected() is True @@ -526,48 +498,45 @@ def step_transport_unconnected_state(context: Context) -> None: # ----- connect steps ----- -@when( - r"I try to connect via the transport with TLS disabled to (.+)", -) -def step_transport_connect_tls_off(context: Context, url: str) -> None: + +@when(r'I try to connect via the transport to "(?P.*)"') +def step_transport_try_connect(context: Context, url: str) -> None: context.caught_error = None context.error = None try: - context.transport.connect(url.strip("'\""), tls_verify=False) # type: ignore[arg-type] + context.transport.connect(url) except ValueError as exc: context.caught_error = exc context.error = exc -@when(r"I connect the transport to (.+)") +@when(r"I connect the transport to (?P.+)") def step_transport_connect(context: Context, url: str) -> None: - context.transport.connect(url.strip("'\"")) # type: ignore[arg-type] + context.transport.connect(url.strip("'\"")) -@when(r"the transport is connected to (.+)") +@when(r"the transport is connected to (?P.+)") def step_transport_connected(context: Context, url: str) -> None: - context.transport.connect(url.strip("'\"")) # type: ignore[arg-type] + context.transport.connect(url.strip("'\"")) @when(r"I disconnect the transport") +@then(r"I disconnect the transport") def step_transport_disconnect(context: Context) -> None: context.transport.disconnect() # ----- connect value error handling ----- -@then( - r"an A2aValueError should be raised with message containing (.+)", -) + +@then(r"an A2aValueError should be raised with message containing (?P.+)") def step_connect_value_error(context: Context, reason: str) -> None: assert isinstance(context.caught_error, ValueError), ( f"Expected ValueError, got {type(context.caught_error)}" ) -@then( - r"the ValueError message should contain (.+)", -) +@then(r"the ValueError message should contain (?P.+)") def step_connect_value_error_message(context: Context, text: str) -> None: assert context.caught_error is not None assert isinstance(context.caught_error, ValueError) @@ -578,9 +547,11 @@ def step_connect_value_error_message(context: Context, text: str) -> None: # ----- disconnect / unconnected steps ----- + @when(r"the transport\s+is in unconnected state") def step_force_unconnected(context: Context) -> None: - context.transport._is_connected = False # noqa: SLF001 + context.transport._is_connected = False + @then(r"the transport should not be connected") def step_transport_should_not_be_connected(context: Context) -> None: @@ -589,6 +560,7 @@ def step_transport_should_not_be_connected(context: Context) -> None: # ----- send non-connected operation (RuntimeError) ----- + @when(r"I try to send a request via the unconnected transport") def step_send_unconnected(context: Context) -> None: context.caught_error = None @@ -600,6 +572,7 @@ def step_send_unconnected(context: Context) -> None: context.caught_error = exc context.error = exc + @when(r"I dispatch a non-connected operation via the transport") def step_nonconnected_op(context: Context) -> None: context.caught_error = None @@ -611,6 +584,7 @@ def step_nonconnected_op(context: Context) -> None: context.caught_error = exc context.error = exc + @then(r"an A2aRuntimeError should be raised for the transport") def step_runtime_error(context: Context) -> None: assert isinstance(context.caught_error, RuntimeError), ( @@ -620,16 +594,18 @@ def step_runtime_error(context: Context) -> None: # ----- send non-A2aRequest (TypeError) ----- + @when(r"I try to send a non-A2aRequest via the transport") def step_send_non_a2arequest(context: Context) -> None: context.caught_error = None context.error = None try: - context.transport.send("not an A2aRequest") # type: ignore[arg-type] + context.transport.send("not an A2aRequest") except TypeError as exc: context.caught_error = exc context.error = exc + @then(r"an A2aTypeError should be raised for the transport") def step_type_error(context: Context) -> None: assert isinstance(context.caught_error, TypeError), ( @@ -639,36 +615,47 @@ def step_type_error(context: Context) -> None: # ----- HTTP response mocking ----- + @when(r"HTTP responses are mocked with JSON-RPC success response") def step_mock_jsonrpc_success(context: Context) -> None: from unittest.mock import MagicMock + mock_resp = MagicMock() mock_resp.status = 200 - mock_resp.read.return_value = json.dumps({ - "jsonrpc": "2.0", - "id": "mock-req-id", - "result": {"status": "ok"}, - }).encode("utf-8") + mock_resp.read.return_value = json.dumps( + { + "jsonrpc": "2.0", + "id": "mock-req-id", + "result": {"status": "ok"}, + } + ).encode("utf-8") context.mock_response = mock_resp @when(r"HTTP responses are mocked with HTTP 500 status") def step_mock_http_500(context: Context) -> None: from unittest.mock import MagicMock + mock_resp = MagicMock() mock_resp.status = 500 - mock_resp.read.return_value = json.dumps({ - "jsonrpc": "2.0", - "error": {"code": -32603, "message": "Internal Server Error"}, - }).encode("utf-8") + mock_resp.read.return_value = json.dumps( + { + "jsonrpc": "2.0", + "error": {"code": -32603, "message": "Internal Server Error"}, + } + ).encode("utf-8") context.mock_response = mock_resp -@when(r"I send a request (.+) via the transport") +@when(r"I send a request (?P.+) via the transport") def step_send_with_mock(context: Context, method_str: str) -> None: from unittest.mock import patch + if hasattr(context, "mock_response"): - with patch("cleveragents.a2a.transport.urlopen", return_value=context.mock_response): + with patch( + "cleveragents.a2a.transport.urlopen", + return_value=context.mock_response, + ): request = A2aRequest(method="test.op") context.response = context.transport.send(request) else: @@ -678,17 +665,17 @@ def step_send_with_mock(context: Context, method_str: str) -> None: # ----- response assertions ----- + @then(r"the response has no error") def step_response_no_error(context: Context) -> None: assert context.response.error is None, ( f"Expected no error, got {context.response.error}" ) + @then(r"the response has an error") def step_response_has_error(context: Context) -> None: - assert context.response.error is not None, ( - "Expected an error in response" - ) + assert context.response.error is not None, "Expected an error in response" # Reset step matcher to parse (default) so subsequent step files are not affected diff --git a/features/steps/m6_facade_steps.py b/features/steps/m6_facade_steps.py index 2056c5e4f..a156a1a8f 100644 --- a/features/steps/m6_facade_steps.py +++ b/features/steps/m6_facade_steps.py @@ -287,42 +287,48 @@ def step_m6_smoke_error_not_available(context: Context) -> None: def step_m6_smoke_transport_send(context: Context) -> None: transport = A2aHttpTransport() request = A2aRequest(method="plan.create", params={}) + context.m6_transport = transport try: transport.send(request) context.m6_error = None - except A2aNotAvailableError as exc: + except RuntimeError as exc: context.m6_error = exc @when('I m6 smoke attempt transport connect to "{url}"') def step_m6_smoke_transport_connect(context: Context, url: str) -> None: transport = A2aHttpTransport() + context.m6_transport = transport try: transport.connect(url) context.m6_error = None - except A2aNotAvailableError as exc: - context.m6_error = exc - - -@when("I m6 smoke attempt transport disconnect") -def step_m6_smoke_transport_disconnect(context: Context) -> None: - transport = A2aHttpTransport() - try: - transport.disconnect() - context.m6_error = None - except A2aNotAvailableError as exc: + except ValueError as exc: context.m6_error = exc @when("I m6 smoke check transport is_connected") def step_m6_smoke_transport_connected(context: Context) -> None: transport = A2aHttpTransport() + context.m6_transport = transport context.m6_transport_connected = transport.is_connected() +@then("the m6 smoke facade should raise RuntimeError") +def step_m6_smoke_error_runtime(context: Context) -> None: + assert isinstance(context.m6_error, RuntimeError) + + +@then("the m6 smoke transport should be connected") +def step_m6_smoke_transport_connected_assert(context: Context) -> None: + assert context.m6_transport.is_connected() is True + + @then("the m6 smoke transport should not be connected") def step_m6_smoke_transport_not_connected(context: Context) -> None: - assert context.m6_transport_connected is False + if hasattr(context, "m6_transport_connected"): + assert context.m6_transport_connected is False + else: + assert context.m6_transport.is_connected() is False # ----------------------------------------------------------------------- diff --git a/features/steps/test_a2a_http_transport_pytest.py b/features/steps/test_a2a_http_transport_pytest.py deleted file mode 100644 index 0f76b1130..000000000 --- a/features/steps/test_a2a_http_transport_pytest.py +++ /dev/null @@ -1,398 +0,0 @@ -"""Pytest unit tests for A2aHttpTransport (server-mode HTTP transport).""" - -from __future__ import annotations - -import json -from http.client import HTTPResponse -from io import BytesIO -from ssl import SSLContext, create_default_context -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch -from urllib.request import Request - -import pytest - -from cleveragents.a2a.errors import A2aError, map_domain_error -from cleveragents.a2a.models import A2aErrorDetail, A2aRequest, A2aResponse -from cleveragents.a2a.transport import A2aHttpTransport - -if TYPE_CHECKING: - from collections.abc import Iterator - - -def make_mock_response(status_code: int = 200, body: dict | None = None) -> MagicMock: - """Create a mocked HTTPResponse for testing.""" - mock_resp = MagicMock(spec=HTTPResponse) - mock_resp.status = status_code - if body is not None: - mock_resp.read.return_value = json.dumps(body).encode("utf-8") - else: - mock_resp.read.return_value = b"{}" - return mock_resp - - -class TestTransportInitialization: - """Test A2aHttpTransport constructor.""" - - def test_default_initialization(self) -> None: - transport = A2aHttpTransport() - assert transport._is_connected is False - assert transport._base_url == "" - assert transport._tls_verify is True - assert transport._auth_token_ref is None - - def test_custom_tls_verification_false(self) -> None: - transport = A2aHttpTransport(tls_verify=False) - assert transport._tls_verify is False - - def test_custom_auth_token(self) -> None: - token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test" - transport = A2aHttpTransport(auth_token_ref=token) - assert transport._auth_token_ref == token - - -class TestConnectValidation: - """Test connect() method validation.""" - - def test_connect_empty_string(self) -> None: - transport = A2aHttpTransport() - with pytest.raises(ValueError, match="base_url must be a non-empty string"): - transport.connect("") - - def test_connect_none(self) -> None: - transport = A2aHttpTransport() - with pytest.raises(ValueError, match="base_url must be a non-empty string"): - transport.connect(None) # type: ignore[arg-type] - - def test_connect_invalid_scheme_http(self) -> None: - transport = A2aHttpTransport() - with pytest.raises(ValueError, match="must start with http:// or https://"): - transport.connect("ftp://localhost") - - def test_connect_invalid_scheme_file(self) -> None: - transport = A2aHttpTransport() - with pytest.raises(ValueError, match="must start with http:// or https://"): - transport.connect("file:///some/path") - - def test_connect_whitespace_handling(self) -> None: - transport = A2aHttpTransport() - transport.connect(" https://example.com/ ") - assert transport._base_url == "https://example.com/" - - def test_trailing_slash_unification(self) -> None: - """Base URLs should be normalized to have exactly one trailing slash.""" - t1 = A2aHttpTransport() - t1.connect("http://example.com") - assert t1._base_url == "http://example.com/" - - t2 = A2aHttpTransport() - t2.connect("http://example.com//") - assert t2._base_url == "http://example.com/" - - -class TestConnectionLifecycle: - """Test connect -> send -> disconnect lifecycle.""" - - def test_is_connected_before_connect(self) -> None: - transport = A2aHttpTransport() - assert transport.is_connected() is False - - @patch("cleveragents.a2a.transport.create_default_context") - def test_is_connected_after_connect_https(self, mock_ctx) -> None: - mock_ctx.return_value = MagicMock(spec=SSLContext) - transport = A2aHttpTransport() - transport.connect("https://server.example.com/") - assert transport.is_connected() is True - - @patch("cleveragents.a2a.transport.create_default_context") - def test_is_connected_after_connect_http(self, mock_ctx) -> None: - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - assert transport.is_connected() is True - - @patch("cleveragents.a2a.transport.urlopen") - def test_disconnect_sets_not_connected(self, mock_urlopen) -> None: - transport = A2aHttpTransport() - transport.connect("https://server.example.com/") - assert transport.is_connected() is True - transport.disconnect() - assert transport.is_connected() is False - - def test_send_after_disconnect_raises_runtime_error(self) -> None: - transport = A2aHttpTransport() - transport.connect("https://server.example.com/") - transport.disconnect() - request = A2aRequest(method="test.op") - with pytest.raises(RuntimeError, match="Not connected"): - transport.send(request) - - -class TestSendValidation: - """Test send() method input validation.""" - - def test_send_requires_a2a_request(self) -> None: - transport = A2aHttpTransport() - transport.connect("https://server.example.com/") - with pytest.raises(TypeError, match="request must be an A2aRequest"): - transport.send("not a request") # type: ignore[arg-type] - - def test_send_before_connect_raises_runtime_error(self) -> None: - transport = A2aHttpTransport() - request = A2aRequest(method="test.op") - with pytest.raises(RuntimeError, match="Not connected"): - transport.send(request) - - -class TestSslContext: - """Test _get_ssl_context() behavior.""" - - def test_http_returns_no_ssl_context(self) -> None: - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - assert transport._get_ssl_context() is None - - @patch("cleveragents.a2a.transport.create_default_context") - def test_https_verify_true_creates_default_context(self, mock_create) -> None: - mock_create.return_value = MagicMock(spec=SSLContext) - transport = A2aHttpTransport(tls_verify=True) - transport.connect("https://server.example.com/") - ctx = transport._get_ssl_context() - assert isinstance(ctx, SSLContext) - mock_create.assert_called_once() - - @patch("cleveragents.a2a.transport.SSLContext") - def test_https_verify_false_creates_unverified_context(self, mock_ssl_ctx_class) -> None: - mock_mock_ctx = MagicMock(spec=SSLContext) - mock_ssl_ctx_class.return_value = mock_mock_ctx - transport = A2aHttpTransport(tls_verify=False) - transport.connect("https://insecure-server.example.com/") - ctx = transport._get_ssl_context() - assert ctx == mock_mock_ctx - mock_ssl_ctx_class.assert_called_once() - - -class TestHeaders: - """Test _build_headers() output.""" - - def test_default_headers(self) -> None: - transport = A2aHttpTransport() - headers = transport._build_headers() - assert headers["Content-Type"] == "application/json" - assert headers["Accept"] == "application/json" - assert "Authorization" not in headers - - def test_headers_with_auth_token(self) -> None: - token = "test-jwt-token-12345" - transport = A2aHttpTransport(auth_token_ref=token) - headers = transport._build_headers() - assert headers["Authorization"] == f"Bearer {token}" - - def test_headers_no_auth_when_token_blank(self) -> None: - transport = A2aHttpTransport(auth_token_ref="") - headers = transport._build_headers() - # Empty string is still truthy in the auth check - should not be added since it's a falsy value - assert "Authorization" not in headers - - -class TestRequestDispatch: - """Test HTTP request dispatch (using mocked urlopen).""" - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_success(self, mock_urlopen) -> None: - """Successful JSON-RPC response.""" - mock_resp = make_mock_response( - status_code=200, - body={"jsonrpc": "2.0", "id": "req-1", "result": {"status": "ok"}}, - ) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "01HXM8C2"}) - response = transport.send(request) - - assert response.error is None - assert response.result == {"status": "ok"} - assert response.id == request.id - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_jsonrpc_error(self, mock_urlopen) -> None: - """Server returns a JSON-RPC error.""" - mock_resp = make_mock_response( - status_code=200, - body={ - "jsonrpc": "2.0", - "id": "req-1", - "error": {"code": -32001, "message": "Not found"}, - }, - ) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "notfound"}) - response = transport.send(request) - - assert response.error is not None - assert response.error.code == -32001 - assert response.error.message == "Not found" - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_http_500(self, mock_urlopen) -> None: - """Server returns HTTP 500 Internal Server Error.""" - mock_resp = make_mock_response(status_code=500, body={}) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"}) - response = transport.send(request) - - assert response.error is not None - assert response.error.code == -32603 # INTERNAL_ERROR - assert "500" in response.error.message - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_http_404(self, mock_urlopen) -> None: - """Server returns HTTP 404 Not Found.""" - mock_resp = make_mock_response(status_code=404, body={}) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"}) - response = transport.send(request) - - assert response.error is not None - # 400 <= 404 <= 499 -> error_code = -32000 + (404 - 400) = -31996 - assert response.error.code == -31997 - assert "404" in response.error.message - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_invalid_json_response(self, mock_urlopen) -> None: - """Server returns non-JSON (e.g., HTML error page).""" - mock_resp = MagicMock(spec=HTTPResponse) - mock_resp.status = 200 - mock_resp.read.return_value = b"Error" - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"}) - - with pytest.raises(A2aError, match="Invalid JSON"): - transport.send(request) - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_wrong_jsonrpc_version(self, mock_urlopen) -> None: - """Server returns a response with incorrect jsonrpc version.""" - mock_resp = make_mock_response( - status_code=200, - body={"jsonrpc": "1.0", "id": "req-1"}, - ) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"}) - - with pytest.raises(A2aError, match="Expected JSON-RPC 2.0"): - transport.send(request) - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_embedded_jsonrpc_error_on_http_status(self, mock_urlopen) -> None: - """HTTP error with embedded JSON-RPC error takes the embedded codes.""" - mock_resp = make_mock_response( - status_code=503, - body={ - "jsonrpc": "2.0", - "error": {"code": -32700, "message": "Service unavailable"}, - }, - ) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"}) - response = transport.send(request) - - assert response.error is not None - # Embedded error takes precedence: -32700 (JSON-RPC parse error code) - assert response.error.code == -32700 - assert "Service unavailable" in response.error.message - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_with_auth_token(self, mock_urlopen) -> None: - """Auth token is included as Bearer header.""" - mock_resp = make_mock_response( - status_code=200, - body={"jsonrpc": "2.0", "id": "req-1", "result": {"ok": True}}, - ) - mock_urlopen.return_value = mock_resp - - token = "secret-jwt" - transport = A2aHttpTransport(auth_token_ref=token) - transport.connect("https://secure.example.com/") - request = A2aRequest(method="plan.fetch", params={}) - transport.send(request) - - call_args = mock_urlopen.call_args - req: Request = call_args[0][0] # First positional arg - assert req.headers["Authorization"] == f"Bearer {token}" - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_url_correct(self, mock_urlopen) -> None: - """Requests go to the correct URL path.""" - mock_resp = make_mock_response(status_code=200, body={}) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("https://api.example.com/a2a/") - request = A2aRequest(method="test.op", params={}) - transport.send(request) - - call_args = mock_urlopen.call_args - req: Request = call_args[0][0] - assert req.get_full_url() == "https://api.example.com/a2a/_rpc" - - -class TestNetworkErrors: - """Test network-level error handling.""" - - @patch("cleveragents.a2a.transport.urlopen") - def test_network_timeout(self, mock_urlopen) -> None: - """URLError raises wraps in A2aError with clear message.""" - from urllib.error import URLError - - mock_urlopen.side_effect = URLError("Connection timed out") - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={}) - - with pytest.raises(A2aError, match="Network error"): - transport.send(request) - - -class TestIntegration: - """Integration: roundtrip serialization/deserialization.""" - - @patch("cleveragents.a2a.transport.urlopen") - def test_roundtrip(self, mock_urlopen) -> None: - """Request params are preserved in the response.""" - response_body = { - "jsonrpc": "2.0", - "id": "ulid-123", - "result": {"plan_id": "plan-abc", "status": "running"}, - } - mock_resp = make_mock_response(status_code=200, body=response_body) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.status", params={"plan_id": "plan-abc"}) - response = transport.send(request) - - assert response.error is None - assert response.result["plan_id"] == "plan-abc" - assert response.result["status"] == "running" diff --git a/robot/a2a_facade.robot b/robot/a2a_facade.robot index c43a73e43..9bca8ae45 100644 --- a/robot/a2a_facade.robot +++ b/robot/a2a_facade.robot @@ -17,8 +17,8 @@ A2A Local Facade Dispatch Session Create Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} a2a-facade-dispatch-ok -A2A HTTP Transport Stub Error - [Documentation] Verify HTTP transport raises A2aNotAvailableError +A2A HTTP Transport Lifecycle + [Documentation] Verify server-mode HTTP transport connect/send/disconnect lifecycle [Tags] tdd_issue tdd_issue_4190 ${result}= Run Process ${PYTHON} ${HELPER} transport-stub cwd=${WORKSPACE} Log ${result.stdout} diff --git a/robot/helper_a2a_facade.py b/robot/helper_a2a_facade.py index 10e1e201b..628236f10 100644 --- a/robot/helper_a2a_facade.py +++ b/robot/helper_a2a_facade.py @@ -13,10 +13,7 @@ _SRC = str(Path(__file__).resolve().parents[1] / "src") if _SRC not in sys.path: sys.path.insert(0, _SRC) -from cleveragents.a2a.errors import ( # noqa: E402 - A2aNotAvailableError, - A2aVersionMismatchError, -) +from cleveragents.a2a.errors import A2aVersionMismatchError # noqa: E402 from cleveragents.a2a.events import A2aEventQueue # noqa: E402 from cleveragents.a2a.facade import A2aLocalFacade # noqa: E402 from cleveragents.a2a.models import A2aEvent, A2aRequest # noqa: E402 @@ -45,21 +42,44 @@ def facade_dispatch() -> None: def transport_stub() -> None: - """Verify transport stub raises A2aNotAvailableError.""" + """Verify server-mode HTTP transport connect/disconnect lifecycle.""" transport = A2aHttpTransport() - try: - transport.connect("http://localhost:8080") - print("FAIL: should have raised", file=sys.stderr) + + if transport.is_connected() is not False: + print("FAIL: is_connected should be False before connect", file=sys.stderr) sys.exit(1) - except A2aNotAvailableError: + + # Send before connect should raise RuntimeError + try: + transport.send(A2aRequest(method="plan.create", params={})) + print("FAIL: expected RuntimeError on send-before-connect", file=sys.stderr) + sys.exit(1) + except RuntimeError: pass - if transport.is_connected() is False: - print("a2a-transport-stub-ok") - else: - print("FAIL: is_connected should be False", file=sys.stderr) + # Connect with valid URL should succeed + transport.connect("http://localhost:8080") + if transport.is_connected() is not True: + print("FAIL: is_connected should be True after connect", file=sys.stderr) sys.exit(1) + # Invalid URL should raise ValueError + transport_bad = A2aHttpTransport() + try: + transport_bad.connect("ftp://localhost") + print("FAIL: expected ValueError on invalid scheme", file=sys.stderr) + sys.exit(1) + except ValueError: + pass + + # Disconnect should clear state + transport.disconnect() + if transport.is_connected() is not False: + print("FAIL: is_connected should be False after disconnect", file=sys.stderr) + sys.exit(1) + + print("a2a-transport-stub-ok") + def event_queue() -> None: """Verify local event queue publish/subscribe.""" diff --git a/robot/helper_m6_autonomy_acceptance.py b/robot/helper_m6_autonomy_acceptance.py index 8e707f494..a0239448c 100644 --- a/robot/helper_m6_autonomy_acceptance.py +++ b/robot/helper_m6_autonomy_acceptance.py @@ -15,7 +15,6 @@ if _SRC not in sys.path: sys.path.insert(0, _SRC) from cleveragents.a2a.errors import ( # noqa: E402 - A2aNotAvailableError, A2aOperationNotFoundError, A2aVersionMismatchError, ) @@ -134,34 +133,35 @@ def event_queue() -> None: def transport_stub() -> None: - """Verify HTTP transport stub raises A2aNotAvailableError.""" + """Verify server-mode HTTP transport lifecycle and error semantics.""" transport = A2aHttpTransport() - # send + # is_connected before connect should be False + assert transport.is_connected() is False + + # send before connect should raise RuntimeError try: transport.send(A2aRequest(method="plan.create", params={})) - print("FAIL: expected A2aNotAvailableError on send") + print("FAIL: expected RuntimeError on send-before-connect") sys.exit(1) - except A2aNotAvailableError: + except RuntimeError: pass - # connect + # connect with valid URL succeeds + transport.connect("https://example.com/a2a") + assert transport.is_connected() is True + + # connect with invalid scheme raises ValueError + transport_bad = A2aHttpTransport() try: - transport.connect("https://example.com/a2a") - print("FAIL: expected A2aNotAvailableError on connect") + transport_bad.connect("ftp://localhost") + print("FAIL: expected ValueError on invalid scheme") sys.exit(1) - except A2aNotAvailableError: + except ValueError: pass - # disconnect - try: - transport.disconnect() - print("FAIL: expected A2aNotAvailableError on disconnect") - sys.exit(1) - except A2aNotAvailableError: - pass - - # is_connected + # disconnect clears state + transport.disconnect() assert transport.is_connected() is False print("m6-transport-stub-ok") diff --git a/robot/m6_autonomy_acceptance.robot b/robot/m6_autonomy_acceptance.robot index 2eb1538fe..3dfde89b5 100644 --- a/robot/m6_autonomy_acceptance.robot +++ b/robot/m6_autonomy_acceptance.robot @@ -40,8 +40,8 @@ M6 A2A Event Queue Publish Subscribe Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} m6-event-queue-ok -M6 A2A Transport Stub Rejects All - [Documentation] Verify HTTP transport stub raises A2aNotAvailableError +M6 A2A Transport Lifecycle + [Documentation] Verify server-mode HTTP transport connect/send/disconnect lifecycle ${result}= Run Process ${PYTHON} ${HELPER} transport-stub cwd=${WORKSPACE} timeout=120s on_timeout=kill Log ${result.stdout} Log ${result.stderr} diff --git a/src/cleveragents/a2a/transport.py b/src/cleveragents/a2a/transport.py index 57f52aa28..54703ab86 100644 --- a/src/cleveragents/a2a/transport.py +++ b/src/cleveragents/a2a/transport.py @@ -4,33 +4,30 @@ from __future__ import annotations import json from http.client import HTTPResponse -from ssl import SSLContext, create_default_context -from typing import TYPE_CHECKING, Any +from ssl import CERT_NONE, SSLContext, create_default_context from urllib.error import URLError from urllib.request import Request, urlopen import structlog -from cleveragents.a2a.errors import ( - A2aError, - map_domain_error, -) +from cleveragents.a2a.errors import A2aError from cleveragents.a2a.models import ( A2aErrorDetail, A2aRequest, A2aResponse, ) -if TYPE_CHECKING: - from urllib.request import BaseHandler, OpenerDirector - logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) class A2aHttpTransport: """HTTP transport for server-mode A2A communication.""" - def __init__(self, tls_verify: bool = True, auth_token_ref: str | None = None) -> None: + def __init__( + self, + tls_verify: bool = True, + auth_token_ref: str | None = None, + ) -> None: self._base_url: str = "" self._tls_verify: bool = tls_verify self._is_connected: bool = False @@ -45,9 +42,17 @@ class A2aHttpTransport: self._base_url = stripped.rstrip("/") + "/" self._is_connected = True if stripped.startswith("https://"): - logger.info("a2a.http.connect", base_url=self._base_url, tls_verify=self._tls_verify) + logger.info( + "a2a.http.connect", + base_url=self._base_url, + tls_verify=self._tls_verify, + ) else: - logger.warning("a2a.http.connect", base_url=self._base_url, warning="connecting over plain HTTP") + logger.warning( + "a2a.http.connect", + base_url=self._base_url, + warning="connecting over plain HTTP", + ) def disconnect(self) -> None: self._is_connected = False @@ -75,7 +80,11 @@ class A2aHttpTransport: raise except URLError as exc: reason = str(exc.reason) if hasattr(exc, "reason") else str(exc) - logger.error("a2a.http.send_url_error", method=request.method, reason=reason) + logger.error( + "a2a.http.send_url_error", + method=request.method, + reason=reason, + ) raise A2aError(f"Network error connecting to server: {exc}") from exc except Exception as exc: logger.error("a2a.http.send_error", method=request.method, error=str(exc)) @@ -86,32 +95,44 @@ class A2aHttpTransport: return None if self._tls_verify: return create_default_context() - else: - context = SSLContext() - context.check_hostname = False - context.verify_mode = 0 - return context + context = SSLContext() + context.check_hostname = False + context.verify_mode = CERT_NONE + return context def _build_headers(self) -> dict[str, str]: headers: dict[str, str] = { "Content-Type": "application/json", "Accept": "application/json", } - if self._auth_token_ref is not None: + if self._auth_token_ref: headers["Authorization"] = f"Bearer {self._auth_token_ref}" return headers def _do_request(self, payload: bytes, request: A2aRequest) -> HTTPResponse: url = self._base_url + "_rpc" - req = Request(url, data=payload, headers=self._build_headers(), method="POST") + req = Request( + url, + data=payload, + headers=self._build_headers(), + method="POST", + ) ssl_ctx = self._get_ssl_context() - http_response = urlopen(req, context=ssl_ctx if ssl_ctx else None, timeout=30) - return http_response + return urlopen(req, context=ssl_ctx if ssl_ctx else None, timeout=30) - def _parse_response(self, resp: HTTPResponse, request: A2aRequest) -> A2aResponse: + def _parse_response( + self, + resp: HTTPResponse, + request: A2aRequest, + ) -> A2aResponse: status_code = resp.status body_str = resp.read().decode("utf-8") - logger.debug("a2a.http.receive", method=request.method, request_id=request.id, status=status_code) + logger.debug( + "a2a.http.receive", + method=request.method, + request_id=request.id, + status=status_code, + ) if status_code >= 400: return self._handle_http_status(body_str, request, status_code) @@ -130,7 +151,12 @@ class A2aHttpTransport: except Exception as exc: raise A2aError(f"Failed to parse response: {exc}") from exc - def _handle_http_status(self, body_str: str, request: A2aRequest, status_code: int) -> A2aResponse: + def _handle_http_status( + self, + body_str: str, + request: A2aRequest, + status_code: int, + ) -> A2aResponse: if 400 <= status_code <= 499: error_code = -32000 + (status_code - 400) else: @@ -141,7 +167,10 @@ class A2aHttpTransport: response_dict = json.loads(body_str) if "jsonrpc" in response_dict and "error" in response_dict: error_code = response_dict["error"].get("code", error_code) - error_message = response_dict["error"].get("message", f"HTTP {status_code}") + error_message = response_dict["error"].get( + "message", + f"HTTP {status_code}", + ) except json.JSONDecodeError: pass diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/unit/a2a_test_http_transport.py b/tests/unit/a2a_test_http_transport.py deleted file mode 100644 index 0f76b1130..000000000 --- a/tests/unit/a2a_test_http_transport.py +++ /dev/null @@ -1,398 +0,0 @@ -"""Pytest unit tests for A2aHttpTransport (server-mode HTTP transport).""" - -from __future__ import annotations - -import json -from http.client import HTTPResponse -from io import BytesIO -from ssl import SSLContext, create_default_context -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch -from urllib.request import Request - -import pytest - -from cleveragents.a2a.errors import A2aError, map_domain_error -from cleveragents.a2a.models import A2aErrorDetail, A2aRequest, A2aResponse -from cleveragents.a2a.transport import A2aHttpTransport - -if TYPE_CHECKING: - from collections.abc import Iterator - - -def make_mock_response(status_code: int = 200, body: dict | None = None) -> MagicMock: - """Create a mocked HTTPResponse for testing.""" - mock_resp = MagicMock(spec=HTTPResponse) - mock_resp.status = status_code - if body is not None: - mock_resp.read.return_value = json.dumps(body).encode("utf-8") - else: - mock_resp.read.return_value = b"{}" - return mock_resp - - -class TestTransportInitialization: - """Test A2aHttpTransport constructor.""" - - def test_default_initialization(self) -> None: - transport = A2aHttpTransport() - assert transport._is_connected is False - assert transport._base_url == "" - assert transport._tls_verify is True - assert transport._auth_token_ref is None - - def test_custom_tls_verification_false(self) -> None: - transport = A2aHttpTransport(tls_verify=False) - assert transport._tls_verify is False - - def test_custom_auth_token(self) -> None: - token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test" - transport = A2aHttpTransport(auth_token_ref=token) - assert transport._auth_token_ref == token - - -class TestConnectValidation: - """Test connect() method validation.""" - - def test_connect_empty_string(self) -> None: - transport = A2aHttpTransport() - with pytest.raises(ValueError, match="base_url must be a non-empty string"): - transport.connect("") - - def test_connect_none(self) -> None: - transport = A2aHttpTransport() - with pytest.raises(ValueError, match="base_url must be a non-empty string"): - transport.connect(None) # type: ignore[arg-type] - - def test_connect_invalid_scheme_http(self) -> None: - transport = A2aHttpTransport() - with pytest.raises(ValueError, match="must start with http:// or https://"): - transport.connect("ftp://localhost") - - def test_connect_invalid_scheme_file(self) -> None: - transport = A2aHttpTransport() - with pytest.raises(ValueError, match="must start with http:// or https://"): - transport.connect("file:///some/path") - - def test_connect_whitespace_handling(self) -> None: - transport = A2aHttpTransport() - transport.connect(" https://example.com/ ") - assert transport._base_url == "https://example.com/" - - def test_trailing_slash_unification(self) -> None: - """Base URLs should be normalized to have exactly one trailing slash.""" - t1 = A2aHttpTransport() - t1.connect("http://example.com") - assert t1._base_url == "http://example.com/" - - t2 = A2aHttpTransport() - t2.connect("http://example.com//") - assert t2._base_url == "http://example.com/" - - -class TestConnectionLifecycle: - """Test connect -> send -> disconnect lifecycle.""" - - def test_is_connected_before_connect(self) -> None: - transport = A2aHttpTransport() - assert transport.is_connected() is False - - @patch("cleveragents.a2a.transport.create_default_context") - def test_is_connected_after_connect_https(self, mock_ctx) -> None: - mock_ctx.return_value = MagicMock(spec=SSLContext) - transport = A2aHttpTransport() - transport.connect("https://server.example.com/") - assert transport.is_connected() is True - - @patch("cleveragents.a2a.transport.create_default_context") - def test_is_connected_after_connect_http(self, mock_ctx) -> None: - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - assert transport.is_connected() is True - - @patch("cleveragents.a2a.transport.urlopen") - def test_disconnect_sets_not_connected(self, mock_urlopen) -> None: - transport = A2aHttpTransport() - transport.connect("https://server.example.com/") - assert transport.is_connected() is True - transport.disconnect() - assert transport.is_connected() is False - - def test_send_after_disconnect_raises_runtime_error(self) -> None: - transport = A2aHttpTransport() - transport.connect("https://server.example.com/") - transport.disconnect() - request = A2aRequest(method="test.op") - with pytest.raises(RuntimeError, match="Not connected"): - transport.send(request) - - -class TestSendValidation: - """Test send() method input validation.""" - - def test_send_requires_a2a_request(self) -> None: - transport = A2aHttpTransport() - transport.connect("https://server.example.com/") - with pytest.raises(TypeError, match="request must be an A2aRequest"): - transport.send("not a request") # type: ignore[arg-type] - - def test_send_before_connect_raises_runtime_error(self) -> None: - transport = A2aHttpTransport() - request = A2aRequest(method="test.op") - with pytest.raises(RuntimeError, match="Not connected"): - transport.send(request) - - -class TestSslContext: - """Test _get_ssl_context() behavior.""" - - def test_http_returns_no_ssl_context(self) -> None: - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - assert transport._get_ssl_context() is None - - @patch("cleveragents.a2a.transport.create_default_context") - def test_https_verify_true_creates_default_context(self, mock_create) -> None: - mock_create.return_value = MagicMock(spec=SSLContext) - transport = A2aHttpTransport(tls_verify=True) - transport.connect("https://server.example.com/") - ctx = transport._get_ssl_context() - assert isinstance(ctx, SSLContext) - mock_create.assert_called_once() - - @patch("cleveragents.a2a.transport.SSLContext") - def test_https_verify_false_creates_unverified_context(self, mock_ssl_ctx_class) -> None: - mock_mock_ctx = MagicMock(spec=SSLContext) - mock_ssl_ctx_class.return_value = mock_mock_ctx - transport = A2aHttpTransport(tls_verify=False) - transport.connect("https://insecure-server.example.com/") - ctx = transport._get_ssl_context() - assert ctx == mock_mock_ctx - mock_ssl_ctx_class.assert_called_once() - - -class TestHeaders: - """Test _build_headers() output.""" - - def test_default_headers(self) -> None: - transport = A2aHttpTransport() - headers = transport._build_headers() - assert headers["Content-Type"] == "application/json" - assert headers["Accept"] == "application/json" - assert "Authorization" not in headers - - def test_headers_with_auth_token(self) -> None: - token = "test-jwt-token-12345" - transport = A2aHttpTransport(auth_token_ref=token) - headers = transport._build_headers() - assert headers["Authorization"] == f"Bearer {token}" - - def test_headers_no_auth_when_token_blank(self) -> None: - transport = A2aHttpTransport(auth_token_ref="") - headers = transport._build_headers() - # Empty string is still truthy in the auth check - should not be added since it's a falsy value - assert "Authorization" not in headers - - -class TestRequestDispatch: - """Test HTTP request dispatch (using mocked urlopen).""" - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_success(self, mock_urlopen) -> None: - """Successful JSON-RPC response.""" - mock_resp = make_mock_response( - status_code=200, - body={"jsonrpc": "2.0", "id": "req-1", "result": {"status": "ok"}}, - ) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "01HXM8C2"}) - response = transport.send(request) - - assert response.error is None - assert response.result == {"status": "ok"} - assert response.id == request.id - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_jsonrpc_error(self, mock_urlopen) -> None: - """Server returns a JSON-RPC error.""" - mock_resp = make_mock_response( - status_code=200, - body={ - "jsonrpc": "2.0", - "id": "req-1", - "error": {"code": -32001, "message": "Not found"}, - }, - ) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "notfound"}) - response = transport.send(request) - - assert response.error is not None - assert response.error.code == -32001 - assert response.error.message == "Not found" - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_http_500(self, mock_urlopen) -> None: - """Server returns HTTP 500 Internal Server Error.""" - mock_resp = make_mock_response(status_code=500, body={}) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"}) - response = transport.send(request) - - assert response.error is not None - assert response.error.code == -32603 # INTERNAL_ERROR - assert "500" in response.error.message - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_http_404(self, mock_urlopen) -> None: - """Server returns HTTP 404 Not Found.""" - mock_resp = make_mock_response(status_code=404, body={}) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"}) - response = transport.send(request) - - assert response.error is not None - # 400 <= 404 <= 499 -> error_code = -32000 + (404 - 400) = -31996 - assert response.error.code == -31997 - assert "404" in response.error.message - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_invalid_json_response(self, mock_urlopen) -> None: - """Server returns non-JSON (e.g., HTML error page).""" - mock_resp = MagicMock(spec=HTTPResponse) - mock_resp.status = 200 - mock_resp.read.return_value = b"Error" - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"}) - - with pytest.raises(A2aError, match="Invalid JSON"): - transport.send(request) - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_wrong_jsonrpc_version(self, mock_urlopen) -> None: - """Server returns a response with incorrect jsonrpc version.""" - mock_resp = make_mock_response( - status_code=200, - body={"jsonrpc": "1.0", "id": "req-1"}, - ) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"}) - - with pytest.raises(A2aError, match="Expected JSON-RPC 2.0"): - transport.send(request) - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_embedded_jsonrpc_error_on_http_status(self, mock_urlopen) -> None: - """HTTP error with embedded JSON-RPC error takes the embedded codes.""" - mock_resp = make_mock_response( - status_code=503, - body={ - "jsonrpc": "2.0", - "error": {"code": -32700, "message": "Service unavailable"}, - }, - ) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"}) - response = transport.send(request) - - assert response.error is not None - # Embedded error takes precedence: -32700 (JSON-RPC parse error code) - assert response.error.code == -32700 - assert "Service unavailable" in response.error.message - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_with_auth_token(self, mock_urlopen) -> None: - """Auth token is included as Bearer header.""" - mock_resp = make_mock_response( - status_code=200, - body={"jsonrpc": "2.0", "id": "req-1", "result": {"ok": True}}, - ) - mock_urlopen.return_value = mock_resp - - token = "secret-jwt" - transport = A2aHttpTransport(auth_token_ref=token) - transport.connect("https://secure.example.com/") - request = A2aRequest(method="plan.fetch", params={}) - transport.send(request) - - call_args = mock_urlopen.call_args - req: Request = call_args[0][0] # First positional arg - assert req.headers["Authorization"] == f"Bearer {token}" - - @patch("cleveragents.a2a.transport.urlopen") - def test_send_url_correct(self, mock_urlopen) -> None: - """Requests go to the correct URL path.""" - mock_resp = make_mock_response(status_code=200, body={}) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("https://api.example.com/a2a/") - request = A2aRequest(method="test.op", params={}) - transport.send(request) - - call_args = mock_urlopen.call_args - req: Request = call_args[0][0] - assert req.get_full_url() == "https://api.example.com/a2a/_rpc" - - -class TestNetworkErrors: - """Test network-level error handling.""" - - @patch("cleveragents.a2a.transport.urlopen") - def test_network_timeout(self, mock_urlopen) -> None: - """URLError raises wraps in A2aError with clear message.""" - from urllib.error import URLError - - mock_urlopen.side_effect = URLError("Connection timed out") - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.fetch", params={}) - - with pytest.raises(A2aError, match="Network error"): - transport.send(request) - - -class TestIntegration: - """Integration: roundtrip serialization/deserialization.""" - - @patch("cleveragents.a2a.transport.urlopen") - def test_roundtrip(self, mock_urlopen) -> None: - """Request params are preserved in the response.""" - response_body = { - "jsonrpc": "2.0", - "id": "ulid-123", - "result": {"plan_id": "plan-abc", "status": "running"}, - } - mock_resp = make_mock_response(status_code=200, body=response_body) - mock_urlopen.return_value = mock_resp - - transport = A2aHttpTransport() - transport.connect("http://server.example.com/") - request = A2aRequest(method="plan.status", params={"plan_id": "plan-abc"}) - response = transport.send(request) - - assert response.error is None - assert response.result["plan_id"] == "plan-abc" - assert response.result["status"] == "running"