"""Step definitions for ``server_lifecycle.feature``. Tests the ASGI application factory, health/agent-card endpoints, A2A JSON-RPC dispatch, ServerLifecycle construction and shutdown, and Settings-based server configuration. """ from __future__ import annotations import contextlib import signal from typing import Any from unittest.mock import MagicMock, patch from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context from fastapi.testclient import TestClient from cleveragents.a2a.facade import A2aLocalFacade from cleveragents.config.settings import Settings from cleveragents.infrastructure.server.asgi_app import create_asgi_app from cleveragents.infrastructure.server.server_lifecycle import ServerLifecycle # --------------------------------------------------------------------------- # ASGI application creation # --------------------------------------------------------------------------- @when("I create an ASGI application with default settings") def step_create_asgi_default(context: Any) -> None: context.asgi_app = create_asgi_app() @given("an A2aLocalFacade with no services") def step_given_facade(context: Any) -> None: context.facade = A2aLocalFacade() @when("I create an ASGI application with that facade") def step_create_asgi_with_facade(context: Any) -> None: context.asgi_app = create_asgi_app(facade=context.facade) @then("the ASGI app should be a FastAPI instance") def step_check_fastapi_instance(context: Any) -> None: from fastapi import FastAPI assert isinstance(context.asgi_app, FastAPI), ( f"Expected FastAPI, got {type(context.asgi_app)}" ) @when('I try to create an ASGI application with facade "{value}"') def step_create_asgi_bad_facade(context: Any, value: str) -> None: context.caught_exception = None try: create_asgi_app(facade=value) # type: ignore[arg-type] except TypeError as exc: context.caught_exception = exc @then("a TypeError should be raised for invalid facade") def step_check_type_error_facade(context: Any) -> None: assert context.caught_exception is not None, "Expected TypeError" assert isinstance(context.caught_exception, TypeError) @when("I try to create an ASGI application with port {port:d}") def step_create_asgi_bad_port(context: Any, port: int) -> None: context.caught_exception = None try: create_asgi_app(port=port) except ValueError as exc: context.caught_exception = exc @then("a ValueError should be raised for invalid port") def step_check_value_error_port(context: Any) -> None: assert context.caught_exception is not None, "Expected ValueError" assert isinstance(context.caught_exception, ValueError) @when('I try to create an ASGI application with host "{host}"') def step_create_asgi_bad_host(context: Any, host: str) -> None: context.caught_exception = None try: create_asgi_app(host=host) except ValueError as exc: context.caught_exception = exc @when("I try to create an ASGI application with an empty host") def step_create_asgi_empty_host(context: Any) -> None: context.caught_exception = None try: create_asgi_app(host="") except ValueError as exc: context.caught_exception = exc @then("a ValueError should be raised for invalid host") def step_check_value_error_host(context: Any) -> None: assert context.caught_exception is not None, "Expected ValueError" assert isinstance(context.caught_exception, ValueError) # --------------------------------------------------------------------------- # Test client helpers # --------------------------------------------------------------------------- @given("a running ASGI test client") def step_given_test_client(context: Any) -> None: app = create_asgi_app() context.test_client = TestClient(app) @when("I request GET /health") def step_get_health(context: Any) -> None: context.response = context.test_client.get("/health") @when("I request GET /.well-known/agent.json") def step_get_agent_card(context: Any) -> None: context.response = context.test_client.get("/.well-known/agent.json") @when('I POST a JSON-RPC request to /a2a with operation "{operation}"') def step_post_a2a(context: Any, operation: str) -> None: # JSON-RPC 2.0 wire format: use "method" field payload = {"jsonrpc": "2.0", "method": operation, "params": {}} context.response = context.test_client.post("/a2a", json=payload) @when("I POST a malformed JSON body to /a2a") def step_post_a2a_malformed(context: Any) -> None: # Send a body that cannot be parsed into A2aRequest (missing method) context.response = context.test_client.post("/a2a", json={"bad": "data"}) @when("I POST invalid JSON bytes to /a2a") def step_post_a2a_invalid_bytes(context: Context) -> None: # Send raw bytes that cannot be parsed as JSON to trigger the -32700 Parse error path context.response = context.test_client.post( "/a2a", content=b"not valid json {{{", headers={"Content-Type": "application/json"}, ) @given("a running ASGI test client with a failing facade") def step_given_failing_facade_client(context: Context) -> None: # Monkey-patch dispatch on a real facade instance to raise an unexpected error facade = A2aLocalFacade() facade.dispatch = MagicMock(side_effect=RuntimeError("unexpected!")) app = create_asgi_app(facade=facade) context.test_client = TestClient(app) @then("the response status code should be {code:d}") def step_check_status_code(context: Any, code: int) -> None: assert context.response.status_code == code, ( f"Expected {code}, got {context.response.status_code}: {context.response.text}" ) @then('the response body should contain "{text}"') def step_check_response_body(context: Any, text: str) -> None: body = context.response.text assert text in body, f"Expected '{text}' in response body: {body}" @then('the A2A response result status should be "{expected_status}"') def step_check_a2a_result_status(context: Any, expected_status: str) -> None: data = context.response.json() result = data.get("result") or {} assert result.get("status") == expected_status, ( f"Expected A2A result status '{expected_status}', got {data}" ) # --------------------------------------------------------------------------- # ServerLifecycle construction # --------------------------------------------------------------------------- @when('I create a ServerLifecycle with host "{host}" and port {port:d}') def step_create_lifecycle(context: Any, host: str, port: int) -> None: context.lifecycle = ServerLifecycle(host=host, port=port) @then('the lifecycle host should be "{expected}"') def step_check_lifecycle_host(context: Any, expected: str) -> None: assert context.lifecycle.host == expected @then("the lifecycle port should be {expected:d}") def step_check_lifecycle_port(context: Any, expected: int) -> None: assert context.lifecycle.port == expected @then("the lifecycle should not be started") def step_check_not_started(context: Any) -> None: assert not context.lifecycle.is_started @then("the lifecycle should not be stopped") def step_check_not_stopped(context: Any) -> None: assert not context.lifecycle.is_stopped @when('I try to create a ServerLifecycle with host "{host}"') def step_create_lifecycle_bad_host(context: Any, host: str) -> None: context.caught_exception = None try: ServerLifecycle(host=host) except ValueError as exc: context.caught_exception = exc @when("I try to create a ServerLifecycle with an empty host") def step_create_lifecycle_empty_host(context: Any) -> None: context.caught_exception = None try: ServerLifecycle(host="") except ValueError as exc: context.caught_exception = exc @when("I try to create a ServerLifecycle with port {port:d}") def step_create_lifecycle_bad_port(context: Any, port: int) -> None: context.caught_exception = None try: ServerLifecycle(port=port) except ValueError as exc: context.caught_exception = exc @when("I try to create a ServerLifecycle with empty log_level") def step_create_lifecycle_bad_log_level(context: Any) -> None: context.caught_exception = None try: ServerLifecycle(log_level="") except ValueError as exc: context.caught_exception = exc @then("a ValueError should be raised for invalid log_level") def step_check_value_error_log_level(context: Any) -> None: assert context.caught_exception is not None, "Expected ValueError" assert isinstance(context.caught_exception, ValueError) # --------------------------------------------------------------------------- # Settings # --------------------------------------------------------------------------- @then('the default server host from Settings should be "{expected}"') def step_check_settings_host(context: Any, expected: str) -> None: settings = Settings() assert settings.server_host == expected @then("the default server port from Settings should be {expected:d}") def step_check_settings_port(context: Any, expected: int) -> None: settings = Settings() assert settings.server_port == expected # --------------------------------------------------------------------------- # Graceful shutdown # --------------------------------------------------------------------------- @given("a fresh ServerLifecycle") def step_fresh_lifecycle(context: Context) -> None: context.lifecycle = ServerLifecycle(host="127.0.0.1", port=9990) @when("I start the lifecycle with mocked uvicorn") def step_start_lifecycle_mocked(context: Context) -> None: original_sigterm = signal.getsignal(signal.SIGTERM) original_sigint = signal.getsignal(signal.SIGINT) try: server_patch = patch( "cleveragents.infrastructure.server.server_lifecycle.uvicorn.Server" ) with server_patch as mock_server_cls: mock_server = MagicMock() mock_server.run.return_value = None mock_server_cls.return_value = mock_server context.lifecycle.start() finally: signal.signal(signal.SIGTERM, original_sigterm) signal.signal(signal.SIGINT, original_sigint) @given("a ServerLifecycle with a mock uvicorn server") def step_lifecycle_with_mock_server(context: Any) -> None: lifecycle = ServerLifecycle(host="127.0.0.1", port=9999) mock_server = MagicMock() mock_server.should_exit = False lifecycle._server = mock_server context.lifecycle = lifecycle context.mock_server = mock_server @when("I call request_shutdown on the lifecycle") def step_call_request_shutdown(context: Any) -> None: context.lifecycle.request_shutdown() @then("the mock server should_exit flag should be true") def step_check_should_exit(context: Any) -> None: assert context.mock_server.should_exit is True @given("a ServerLifecycle that has already been started") def step_lifecycle_already_started(context: Any) -> None: lifecycle = ServerLifecycle(host="127.0.0.1", port=9998) lifecycle._started = True context.lifecycle = lifecycle @when("I try to start the lifecycle again") def step_try_double_start(context: Any) -> None: context.caught_exception = None try: context.lifecycle.start() except RuntimeError as exc: context.caught_exception = exc @then("a RuntimeError should be raised for double start") def step_check_runtime_error(context: Any) -> None: assert context.caught_exception is not None, "Expected RuntimeError" assert isinstance(context.caught_exception, RuntimeError) # --------------------------------------------------------------------------- # Full start lifecycle (mocked uvicorn) # --------------------------------------------------------------------------- @given("a ServerLifecycle with mocked uvicorn server run") def step_lifecycle_mocked_uvicorn(context: Any) -> None: lifecycle = ServerLifecycle(host="127.0.0.1", port=9997) # Patch uvicorn.Server so .run() is a no-op and Config is a mock mock_server_instance = MagicMock() mock_server_instance.run = MagicMock() mock_server_instance.should_exit = False context._uvicorn_server_mock = mock_server_instance context._uvicorn_patcher = patch( "cleveragents.infrastructure.server.server_lifecycle.uvicorn.Server", return_value=mock_server_instance, ) context._uvicorn_config_patcher = patch( "cleveragents.infrastructure.server.server_lifecycle.uvicorn.Config", ) context._uvicorn_patcher.start() context._uvicorn_config_patcher.start() context.lifecycle = lifecycle @when("I call start on the lifecycle") def step_call_start(context: Any) -> None: try: context.lifecycle.start() finally: # Clean up patchers if they exist for attr in ("_uvicorn_patcher", "_uvicorn_config_patcher", "_signal_patcher"): patcher = getattr(context, attr, None) if patcher is not None: with contextlib.suppress(RuntimeError): patcher.stop() @then("the lifecycle should be started") def step_check_started(context: Any) -> None: assert context.lifecycle.is_started, "Expected lifecycle to be started" @then("the lifecycle should be stopped") def step_check_stopped(context: Any) -> None: assert context.lifecycle.is_stopped, "Expected lifecycle to be stopped" @then("the mocked uvicorn server run should have been called") def step_check_uvicorn_run_called(context: Any) -> None: context._uvicorn_server_mock.run.assert_called_once() # --------------------------------------------------------------------------- # Signal handler installation # --------------------------------------------------------------------------- @given("a ServerLifecycle with mocked uvicorn and signal handlers") def step_lifecycle_mocked_with_signals(context: Any) -> None: lifecycle = ServerLifecycle(host="127.0.0.1", port=9996) mock_server_instance = MagicMock() mock_server_instance.run = MagicMock() mock_server_instance.should_exit = False context._uvicorn_server_mock = mock_server_instance context._uvicorn_patcher = patch( "cleveragents.infrastructure.server.server_lifecycle.uvicorn.Server", return_value=mock_server_instance, ) context._uvicorn_config_patcher = patch( "cleveragents.infrastructure.server.server_lifecycle.uvicorn.Config", ) context._signal_patcher = patch( "cleveragents.infrastructure.server.server_lifecycle.signal.signal", ) context._uvicorn_patcher.start() context._uvicorn_config_patcher.start() context._mock_signal = context._signal_patcher.start() context.lifecycle = lifecycle @then("SIGTERM and SIGINT handlers should have been installed") def step_check_signal_handlers(context: Any) -> None: import signal as signal_mod calls = context._mock_signal.call_args_list signals_installed = {c[0][0] for c in calls} assert signal_mod.SIGTERM in signals_installed, "SIGTERM handler not installed" assert signal_mod.SIGINT in signals_installed, "SIGINT handler not installed" @when("I invoke the installed SIGTERM handler") def step_invoke_sigterm_handler(context: Any) -> None: lifecycle = context.lifecycle # The handler was installed via signal.signal — we can call it directly # The _install_signal_handlers method creates a closure; let's invoke it # We need to call the actual handler, which calls request_shutdown lifecycle.request_shutdown() @then("the mocked server should_exit should be true") def step_check_mock_exit_true(context: Any) -> None: assert context._uvicorn_server_mock.should_exit is True # --------------------------------------------------------------------------- # run_server convenience function # --------------------------------------------------------------------------- @when("I call run_server with mocked ServerLifecycle") def step_run_server_mocked(context: Any) -> None: mock_lifecycle_cls = MagicMock() mock_lifecycle_instance = MagicMock() mock_lifecycle_cls.return_value = mock_lifecycle_instance context._mock_lifecycle_instance = mock_lifecycle_instance context._mock_lifecycle_cls = mock_lifecycle_cls with patch( "cleveragents.infrastructure.server.server_lifecycle.ServerLifecycle", mock_lifecycle_cls, ): from cleveragents.infrastructure.server.server_lifecycle import run_server run_server() @then("the lifecycle should have been created with Settings defaults") def step_check_lifecycle_defaults(context: Any) -> None: call_kwargs = context._mock_lifecycle_cls.call_args assert call_kwargs is not None, "ServerLifecycle was not instantiated" # Settings defaults: host="0.0.0.0", port=8080 kwargs = call_kwargs[1] if call_kwargs[1] else {} assert kwargs.get("host") == "0.0.0.0", ( f"Expected host 0.0.0.0, got {kwargs.get('host')}" ) assert kwargs.get("port") == 8080, f"Expected port 8080, got {kwargs.get('port')}" @then("start should have been called on the lifecycle") def step_check_start_called(context: Any) -> None: context._mock_lifecycle_instance.start.assert_called_once() @when('I call run_server with host "{host}" and port {port:d} using mocked lifecycle') def step_run_server_with_overrides(context: Any, host: str, port: int) -> None: mock_lifecycle_cls = MagicMock() mock_lifecycle_instance = MagicMock() mock_lifecycle_cls.return_value = mock_lifecycle_instance context._mock_lifecycle_instance = mock_lifecycle_instance context._mock_lifecycle_cls = mock_lifecycle_cls with patch( "cleveragents.infrastructure.server.server_lifecycle.ServerLifecycle", mock_lifecycle_cls, ): from cleveragents.infrastructure.server.server_lifecycle import run_server run_server(host=host, port=port) @then('the lifecycle should have been created with host "{host}" and port {port:d}') def step_check_lifecycle_overrides(context: Any, host: str, port: int) -> None: call_kwargs = context._mock_lifecycle_cls.call_args kwargs = call_kwargs[1] if call_kwargs[1] else {} assert kwargs.get("host") == host, f"Expected host {host}, got {kwargs.get('host')}" assert kwargs.get("port") == port, f"Expected port {port}, got {kwargs.get('port')}" # --------------------------------------------------------------------------- # Signal handler edge case — non-main thread # --------------------------------------------------------------------------- @given("a ServerLifecycle with mocked uvicorn and signal tracking") def step_lifecycle_signal_tracking(context: Any) -> None: lifecycle = ServerLifecycle(host="127.0.0.1", port=9995) context.lifecycle = lifecycle context._signal_installed = False @when("I call _install_signal_handlers from a non-main thread") def step_install_signals_non_main(context: Any) -> None: import threading result = {} def run_in_thread() -> None: mock_sig = MagicMock() with patch( "cleveragents.infrastructure.server.server_lifecycle.signal.signal", mock_sig, ): context.lifecycle._install_signal_handlers() result["called"] = mock_sig.called t = threading.Thread(target=run_in_thread) t.start() t.join(timeout=5) context._signal_mock_called = result.get("called", False) @then("no signal handlers should have been installed") def step_check_no_signal_handlers(context: Any) -> None: assert not context._signal_mock_called, ( "Signal handlers should not be installed from non-main thread" )