From f4a9ff82fa30f084b6ff0b4a0d6efed670a67ef5 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Mon, 23 Feb 2026 12:54:16 -0500 Subject: [PATCH] feat(api-hooks): Implement ApiHookClient with comprehensive tests --- api_hook_client.py | 48 ++++++++++++ tests/test_api_hook_client.py | 144 ++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 api_hook_client.py create mode 100644 tests/test_api_hook_client.py diff --git a/api_hook_client.py b/api_hook_client.py new file mode 100644 index 0000000..9632f6b --- /dev/null +++ b/api_hook_client.py @@ -0,0 +1,48 @@ +import requests +import json + +class ApiHookClient: + def __init__(self, base_url="http://127.0.0.1:8999"): + self.base_url = base_url + + def _make_request(self, method, endpoint, data=None): + url = f"{self.base_url}{endpoint}" + headers = {'Content-Type': 'application/json'} + + try: + if method == 'GET': + response = requests.get(url, timeout=1) + elif method == 'POST': + response = requests.post(url, json=data, headers=headers, timeout=1) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) + return response.json() + except requests.exceptions.Timeout: + raise requests.exceptions.Timeout(f"Request to {endpoint} timed out.") + except requests.exceptions.ConnectionError: + raise requests.exceptions.ConnectionError(f"Could not connect to API hook server at {self.base_url}.") + except requests.exceptions.HTTPError as e: + raise requests.exceptions.HTTPError(f"HTTP error {e.response.status_code} for {endpoint}: {e.response.text}") + except json.JSONDecodeError: + raise ValueError(f"Failed to decode JSON from response for {endpoint}: {response.text}") + + + def get_status(self): + return self._make_request('GET', '/status') + + def get_project(self): + return self._make_request('GET', '/api/project') + + def post_project(self, project_data): + return self._make_request('POST', '/api/project', data={'project': project_data}) + + def get_session(self): + return self._make_request('GET', '/api/session') + + def post_session(self, session_entries): + return self._make_request('POST', '/api/session', data={'session': {'entries': session_entries}}) + + def post_gui(self, gui_data): + return self._make_request('POST', '/api/gui', data=gui_data) diff --git a/tests/test_api_hook_client.py b/tests/test_api_hook_client.py new file mode 100644 index 0000000..2f0c20e --- /dev/null +++ b/tests/test_api_hook_client.py @@ -0,0 +1,144 @@ +import pytest +import requests +from unittest.mock import MagicMock, patch +import threading +import time +import json + +# Import HookServer from api_hooks.py +from api_hooks import HookServer # No need for HookServerInstance, HookHandler here + +from api_hook_client import ApiHookClient + +@pytest.fixture(scope="module") +def hook_server_fixture(): + # Mock the 'app' object that HookServer expects + mock_app = MagicMock() + mock_app.test_hooks_enabled = True # Essential for the server to start + mock_app.project = {'name': 'test_project'} + mock_app.disc_entries = [{'role': 'user', 'content': 'hello'}] + mock_app._pending_gui_tasks = [] + mock_app._pending_gui_tasks_lock = threading.Lock() + + # Use an ephemeral port (0) to avoid conflicts + server = HookServer(mock_app, port=0) + server.start() + + # Wait a moment for the server thread to start and bind + time.sleep(0.1) + + # Get the actual port assigned by the OS + actual_port = server.server.server_address[1] + + # Update the base_url for the client to use the actual port + client_base_url = f"http://127.0.0.1:{actual_port}" + + yield client_base_url, mock_app # Yield the base URL and the mock_app + + server.stop() + +def test_get_status_success(hook_server_fixture): + """ + Test that get_status successfully retrieves the server status + when the HookServer is running. This is the 'Green Phase'. + """ + base_url, _ = hook_server_fixture + client = ApiHookClient(base_url=base_url) + status = client.get_status() + assert status == {'status': 'ok'} + +def test_get_project_success(hook_server_fixture): + """ + Test successful retrieval of project data. + """ + base_url, mock_app = hook_server_fixture + client = ApiHookClient(base_url=base_url) + project = client.get_project() + assert project == {'project': mock_app.project} + +def test_post_project_success(hook_server_fixture): + """Test successful posting and updating of project data.""" + base_url, mock_app = hook_server_fixture + client = ApiHookClient(base_url=base_url) + new_project_data = {'name': 'updated_project', 'version': '1.0'} + response = client.post_project(new_project_data) + assert response == {'status': 'updated'} + # Verify that the mock_app.project was updated. Note: the mock_app is reused. + # The actual server state is in the real app, but for testing client, we check mock. + # This part depends on how the actual server modifies the app.project. + # For HookHandler, it does `app.project = data.get('project', app.project)` + # So, the mock_app.project will actually be the *old* value, because the mock_app + # is not the real app instance. This test is primarily for the client-server interaction. + # To test the side effect on app.project, one would need to inspect the server's app instance, + # which is not directly exposed by the fixture in a simple way. + # For now, we focus on the client's ability to send and receive the success status. + +def test_get_session_success(hook_server_fixture): + """ + Test successful retrieval of session data. + """ + base_url, mock_app = hook_server_fixture + client = ApiHookClient(base_url=base_url) + session = client.get_session() + assert session == {'session': {'entries': mock_app.disc_entries}} + +def test_post_session_success(hook_server_fixture): + """ + Test successful posting and updating of session data. + """ + base_url, mock_app = hook_server_fixture + client = ApiHookClient(base_url=base_url) + new_session_entries = [{'role': 'agent', 'content': 'hi'}] + response = client.post_session(new_session_entries) + assert response == {'status': 'updated'} + # Similar note as post_project about mock_app.disc_entries not being updated here. + +def test_post_gui_success(hook_server_fixture): + """ + Test successful posting of GUI data. + """ + base_url, mock_app = hook_server_fixture + client = ApiHookClient(base_url=base_url) + gui_data = {'command': 'set_text', 'id': 'some_item', 'value': 'new_text'} + response = client.post_gui(gui_data) + assert response == {'status': 'queued'} + assert mock_app._pending_gui_tasks == [gui_data] # This should be updated by the server logic. + +def test_get_status_connection_error_handling(): + """ + Test that ApiHookClient correctly handles a connection error. + """ + client = ApiHookClient(base_url="http://127.0.0.1:1") # Use a port that is highly unlikely to be listening + with pytest.raises(requests.exceptions.Timeout): + client.get_status() + +def test_post_project_server_error_handling(hook_server_fixture): + """ + Test that ApiHookClient correctly handles a server-side error (e.g., 500). + This requires mocking the server\'s response within the fixture or a specific test. + For simplicity, we\'ll simulate this by causing the HookHandler to raise an exception + for a specific path, but that\'s complex with the current fixture. + A simpler way for client-side testing is to mock the requests call directly for this scenario. + """ + base_url, _ = hook_server_fixture + client = ApiHookClient(base_url=base_url) + + with patch('requests.post') as mock_post: + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("500 Server Error", response=mock_response) + mock_response.text = "Internal Server Error" + mock_post.return_value = mock_response + + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + client.post_project({'name': 'error_project'}) + assert "HTTP error 500" in str(excinfo.value) + + +def test_unsupported_method_error(): + """ + Test that calling an unsupported HTTP method raises a ValueError. + """ + client = ApiHookClient() + with pytest.raises(ValueError, match="Unsupported HTTP method"): + client._make_request('PUT', '/some_endpoint', data={'key': 'value'})