Skip to content

Commit a0ed437

Browse files
committed
chore: Refactor sync client tests to be grouped together in classes
These also help identify differences with the existing async client tests.
1 parent f57cbc1 commit a0ed437

File tree

1 file changed

+143
-179
lines changed

1 file changed

+143
-179
lines changed

packages/toolbox-core/tests/test_sync_client.py

Lines changed: 143 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,9 @@ def sync_client_environment():
4343
# Force reset class state before the test.
4444
# This ensures any client created will start a new loop/thread.
4545

46-
assert (
47-
not original_loop or not original_loop.is_running()
48-
), "Loop should not be running"
49-
assert (
50-
not original_thread or not original_thread.is_alive()
51-
), "Thread should not be running"
46+
# Ensure no loop/thread is running from a previous misbehaving test or setup
47+
assert original_loop is None or not original_loop.is_running()
48+
assert original_thread is None or not original_thread.is_alive()
5249

5350
ToolboxSyncClient._ToolboxSyncClient__loop = None
5451
ToolboxSyncClient._ToolboxSyncClient__thread = None
@@ -62,16 +59,12 @@ def sync_client_environment():
6259
if test_loop and test_loop.is_running():
6360
test_loop.call_soon_threadsafe(test_loop.stop)
6461
if test_thread and test_thread.is_alive():
65-
test_thread.join(timeout=2)
62+
test_thread.join(timeout=5)
6663

6764
# Explicitly set to None to ensure a clean state for the next fixture use/test.
6865
ToolboxSyncClient._ToolboxSyncClient__loop = None
6966
ToolboxSyncClient._ToolboxSyncClient__thread = None
7067

71-
# Restoring original_loop/thread could be risky if they were from a
72-
# non-test setup or a previous misbehaving test. For robust test-to-test
73-
# isolation, ensuring these are None after cleanup is generally preferred.
74-
7568

7669
@pytest.fixture
7770
def sync_client(sync_client_environment, request):
@@ -185,70 +178,7 @@ def mock_tool_invoke(
185178
aio_resp.post(url, payload=response_payload, status=status, callback=callback)
186179

187180

188-
# --- Tests for ToolboxSyncClient ---
189-
190-
191-
def test_sync_client_creation_in_isolated_env(sync_client):
192-
"""Tests that a client is initialized correctly by the sync_client fixture."""
193-
assert sync_client._ToolboxSyncClient__loop is not None, "Loop should be created"
194-
assert (
195-
sync_client._ToolboxSyncClient__thread is not None
196-
), "Thread should be created"
197-
assert sync_client._ToolboxSyncClient__thread.is_alive(), "Thread should be running"
198-
assert isinstance(
199-
sync_client._ToolboxSyncClient__async_client, ToolboxClient
200-
), "Async client should be ToolboxClient instance"
201-
202-
203-
@pytest.mark.usefixtures("sync_client_environment")
204-
def test_sync_client_close_method():
205-
"""
206-
Tests the close() method of ToolboxSyncClient when manually created.
207-
The sync_client_environment ensures loop/thread cleanup.
208-
"""
209-
mock_async_client_instance = AsyncMock(spec=ToolboxClient)
210-
mock_async_client_instance.close = AsyncMock(return_value=None)
211-
212-
with patch(
213-
"toolbox_core.sync_client.ToolboxClient",
214-
return_value=mock_async_client_instance,
215-
) as MockedAsyncClientConst:
216-
# Manually create client; sync_client_environment handles loop setup/teardown.
217-
client = ToolboxSyncClient(TEST_BASE_URL)
218-
MockedAsyncClientConst.assert_called_once_with(
219-
TEST_BASE_URL, client_headers=None
220-
)
221-
222-
client.close() # This call closes the async_client's session.
223-
mock_async_client_instance.close.assert_awaited_once()
224-
# The sync_client_environment fixture handles stopping the loop/thread.
225-
226-
227-
@pytest.mark.usefixtures("sync_client_environment")
228-
def test_sync_client_context_manager(aioresponses, tool_schema_minimal):
229-
"""
230-
Tests the context manager (__enter__ and __exit__) functionality.
231-
The sync_client_environment ensures loop/thread cleanup.
232-
"""
233-
with patch.object(
234-
ToolboxSyncClient, "close", wraps=ToolboxSyncClient.close, autospec=True
235-
) as mock_close_method:
236-
with ToolboxSyncClient(TEST_BASE_URL) as client: # Manually creating client
237-
assert isinstance(client, ToolboxSyncClient)
238-
mock_tool_load(aioresponses, "dummy_tool_ctx", tool_schema_minimal)
239-
client.load_tool("dummy_tool_ctx")
240-
mock_close_method.assert_called_once()
241-
242-
with patch.object(
243-
ToolboxSyncClient, "close", wraps=ToolboxSyncClient.close, autospec=True
244-
) as mock_close_method_exc:
245-
with pytest.raises(ValueError, match="Test exception"):
246-
with ToolboxSyncClient(
247-
TEST_BASE_URL
248-
) as client_exc: # Manually creating client
249-
raise ValueError("Test exception")
250-
mock_close_method_exc.assert_called_once()
251-
181+
# --- Tests for General ToolboxSyncClient Functionality ---
252182

253183
def test_sync_load_tool_success(aioresponses, test_tool_str_schema, sync_client):
254184
TOOL_NAME = "test_tool_sync_1"
@@ -338,120 +268,154 @@ def test_sync_load_tool_not_found_in_manifest(
338268
)
339269

340270

341-
def test_sync_add_headers_success(aioresponses, test_tool_str_schema, sync_client):
342-
tool_name = "tool_after_add_headers_sync"
343-
manifest = ManifestSchema(
344-
serverVersion="0.0.0", tools={tool_name: test_tool_str_schema}
345-
)
346-
expected_payload = {"result": "added_sync_ok"}
347-
headers_to_add = {"X-Custom-SyncHeader": "sync_value"}
348-
349-
def get_callback(url, **kwargs):
350-
# The sync_client might have default headers. Check ours are present.
351-
assert kwargs.get("headers") is not None
352-
for key, value in headers_to_add.items():
353-
assert kwargs["headers"].get(key) == value
354-
return CallbackResult(status=200, payload=manifest.model_dump())
355-
356-
aioresponses.get(f"{TEST_BASE_URL}/api/tool/{tool_name}", callback=get_callback)
357-
358-
def post_callback(url, **kwargs):
359-
assert kwargs.get("headers") is not None
360-
for key, value in headers_to_add.items():
361-
assert kwargs["headers"].get(key) == value
362-
return CallbackResult(status=200, payload=expected_payload)
363-
364-
aioresponses.post(
365-
f"{TEST_BASE_URL}/api/tool/{tool_name}/invoke", callback=post_callback
366-
)
271+
class TestSyncClientLifecycle:
272+
"""Tests for ToolboxSyncClient's specific lifecycle and internal management."""
273+
274+
def test_sync_client_creation_in_isolated_env(self, sync_client):
275+
"""Tests that a client is initialized correctly by the sync_client fixture."""
276+
assert sync_client._ToolboxSyncClient__loop is not None, "Loop should be created"
277+
assert (
278+
sync_client._ToolboxSyncClient__thread is not None
279+
), "Thread should be created"
280+
assert sync_client._ToolboxSyncClient__thread.is_alive(), "Thread should be running"
281+
assert isinstance(
282+
sync_client._ToolboxSyncClient__async_client, ToolboxClient
283+
), "Async client should be ToolboxClient instance"
284+
285+
@pytest.mark.usefixtures("sync_client_environment")
286+
def test_sync_client_close_method(self):
287+
"""
288+
Tests the close() method of ToolboxSyncClient when manually created.
289+
The sync_client_environment ensures loop/thread cleanup.
290+
"""
291+
mock_async_client_instance = AsyncMock(spec=ToolboxClient)
292+
# AsyncMock methods are already AsyncMocks
293+
# mock_async_client_instance.close = AsyncMock(return_value=None)
294+
295+
with patch(
296+
"toolbox_core.sync_client.ToolboxClient",
297+
return_value=mock_async_client_instance,
298+
) as MockedAsyncClientConst:
299+
client = ToolboxSyncClient(TEST_BASE_URL)
300+
# The sync client passes its internal loop to the async client.
301+
MockedAsyncClientConst.assert_called_once_with(
302+
TEST_BASE_URL, client_headers=None
303+
)
367304

368-
sync_client.add_headers(headers_to_add)
369-
tool = sync_client.load_tool(tool_name)
370-
result = tool(param1="test")
371-
assert result == expected_payload["result"]
305+
client.close() # This call closes the async_client's session.
306+
mock_async_client_instance.close.assert_awaited_once()
307+
# The sync_client_environment fixture handles stopping the loop/thread.
308+
309+
@pytest.mark.usefixtures("sync_client_environment")
310+
def test_sync_client_context_manager(self, aioresponses, tool_schema_minimal):
311+
"""
312+
Tests the context manager (__enter__ and __exit__) functionality.
313+
The sync_client_environment ensures loop/thread cleanup.
314+
"""
315+
with patch.object(
316+
ToolboxSyncClient, "close", wraps=ToolboxSyncClient.close, autospec=True
317+
) as mock_close_method:
318+
with ToolboxSyncClient(TEST_BASE_URL) as client:
319+
assert isinstance(client, ToolboxSyncClient)
320+
mock_tool_load(aioresponses, "dummy_tool_ctx", tool_schema_minimal)
321+
client.load_tool("dummy_tool_ctx")
322+
mock_close_method.assert_called_once()
323+
324+
with patch.object(
325+
ToolboxSyncClient, "close", wraps=ToolboxSyncClient.close, autospec=True
326+
) as mock_close_method_exc:
327+
with pytest.raises(ValueError, match="Test exception"):
328+
with ToolboxSyncClient(
329+
TEST_BASE_URL
330+
) as client_exc:
331+
raise ValueError("Test exception")
332+
mock_close_method_exc.assert_called_once()
333+
334+
@pytest.mark.usefixtures("sync_client_environment")
335+
def test_load_tool_raises_if_loop_or_thread_none(self):
336+
"""
337+
Tests that load_tool and load_toolset raise ValueError if the class-level
338+
event loop or thread is None. sync_client_environment ensures a clean
339+
slate before this test, and client creation will set up the loop/thread.
340+
"""
341+
client = ToolboxSyncClient(TEST_BASE_URL) # Loop/thread are started here.
342+
343+
original_class_loop = ToolboxSyncClient._ToolboxSyncClient__loop
344+
original_class_thread = ToolboxSyncClient._ToolboxSyncClient__thread
345+
assert (
346+
original_class_loop is not None
347+
), "Loop should have been created by client init"
348+
assert (
349+
original_class_thread is not None
350+
), "Thread should have been created by client init"
351+
352+
# Manually break the class's loop to trigger the error condition in load_tool
353+
ToolboxSyncClient._ToolboxSyncClient__loop = None
354+
with pytest.raises(ValueError, match="Background loop or thread cannot be None."):
355+
client.load_tool("any_tool_should_fail")
356+
ToolboxSyncClient._ToolboxSyncClient__loop = (
357+
original_class_loop # Restore for next check
358+
)
372359

360+
ToolboxSyncClient._ToolboxSyncClient__thread = None
361+
with pytest.raises(ValueError, match="Background loop or thread cannot be None."):
362+
client.load_toolset("any_toolset_should_fail")
363+
ToolboxSyncClient._ToolboxSyncClient__thread = original_class_thread # Restore
373364

374-
@pytest.mark.usefixtures("sync_client_environment")
375-
def test_sync_add_headers_duplicate_fail():
376-
"""
377-
Tests that adding a duplicate header via add_headers raises ValueError.
378-
Manually create client to control initial headers.
379-
"""
380-
initial_headers = {"X-Initial-Header": "initial_value"}
381-
mock_async_client = AsyncMock(spec=ToolboxClient)
382-
383-
# This mock simulates the behavior of the underlying async client's add_headers
384-
async def mock_add_headers_async_error(headers_to_add):
385-
# Simulate error if header already exists in the "async client's current headers"
386-
if (
387-
"X-Initial-Header" in headers_to_add
388-
and hasattr(mock_async_client, "_current_headers")
389-
and "X-Initial-Header" in mock_async_client._current_headers
390-
):
391-
raise ValueError("Client header(s) `X-Initial-Header` already registered")
365+
client.close() # Clean up manually created client
366+
# sync_client_environment will handle the final cleanup of original_class_loop/thread.
392367

393-
mock_async_client.add_headers = (
394-
mock_add_headers_async_error # Assign as a coroutine
395-
)
396368

397-
# Patch ToolboxClient constructor to inject initial_headers into the mock async_client state
398-
def side_effect_constructor(base_url, client_headers=None):
399-
# Store the initial headers on the mock_async_client instance for the test
400-
mock_async_client._current_headers = (
401-
client_headers.copy() if client_headers else {}
402-
)
403-
return mock_async_client
404-
405-
with patch(
406-
"toolbox_core.sync_client.ToolboxClient", side_effect=side_effect_constructor
407-
) as MockedAsyncClientConst:
408-
# Client is created with initial_headers, which are passed to the (mocked) ToolboxClient
409-
client = ToolboxSyncClient(TEST_BASE_URL, client_headers=initial_headers)
410-
MockedAsyncClientConst.assert_called_with(
411-
TEST_BASE_URL, client_headers=initial_headers
369+
class TestSyncClientHeaders:
370+
"""Additive tests for client header functionality specific to ToolboxSyncClient if any,
371+
or counterparts to async client header tests."""
372+
373+
def test_sync_add_headers_success(self, aioresponses, test_tool_str_schema, sync_client):
374+
tool_name = "tool_after_add_headers_sync"
375+
manifest = ManifestSchema(
376+
serverVersion="0.0.0", tools={tool_name: test_tool_str_schema}
412377
)
378+
expected_payload = {"result": "added_sync_ok"}
379+
headers_to_add = {"X-Custom-SyncHeader": "sync_value"}
413380

414-
with pytest.raises(
415-
ValueError,
416-
match="Client header\\(s\\) `X-Initial-Header` already registered",
417-
):
418-
# This call to client.add_headers will internally call mock_async_client.add_headers
419-
client.add_headers({"X-Initial-Header": "another_value"})
381+
def get_callback(url, **kwargs):
382+
# The sync_client might have default headers. Check ours are present.
383+
assert kwargs.get("headers") is not None
384+
for key, value in headers_to_add.items():
385+
assert kwargs["headers"].get(key) == value
386+
return CallbackResult(status=200, payload=manifest.model_dump())
420387

388+
aioresponses.get(f"{TEST_BASE_URL}/api/tool/{tool_name}", callback=get_callback)
421389

422-
@pytest.mark.usefixtures("sync_client_environment")
423-
def test_load_tool_raises_if_loop_or_thread_none():
424-
"""
425-
Tests that load_tool and load_toolset raise ValueError if the class-level
426-
event loop or thread is None. sync_client_environment ensures a clean
427-
slate before this test, and client creation will set up the loop/thread.
428-
"""
429-
client = ToolboxSyncClient(TEST_BASE_URL) # Loop/thread are started here.
430-
431-
original_class_loop = ToolboxSyncClient._ToolboxSyncClient__loop
432-
original_class_thread = ToolboxSyncClient._ToolboxSyncClient__thread
433-
assert (
434-
original_class_loop is not None
435-
), "Loop should have been created by client init"
436-
assert (
437-
original_class_thread is not None
438-
), "Thread should have been created by client init"
439-
440-
# Manually break the class's loop to trigger the error condition in load_tool
441-
ToolboxSyncClient._ToolboxSyncClient__loop = None
442-
with pytest.raises(ValueError, match="Background loop or thread cannot be None."):
443-
client.load_tool("any_tool_should_fail")
444-
ToolboxSyncClient._ToolboxSyncClient__loop = (
445-
original_class_loop # Restore for next check
446-
)
390+
def post_callback(url, **kwargs):
391+
assert kwargs.get("headers") is not None
392+
for key, value in headers_to_add.items():
393+
assert kwargs["headers"].get(key) == value
394+
return CallbackResult(status=200, payload=expected_payload)
447395

448-
ToolboxSyncClient._ToolboxSyncClient__thread = None
449-
with pytest.raises(ValueError, match="Background loop or thread cannot be None."):
450-
client.load_toolset("any_toolset_should_fail")
451-
ToolboxSyncClient._ToolboxSyncClient__thread = original_class_thread # Restore
396+
aioresponses.post(
397+
f"{TEST_BASE_URL}/api/tool/{tool_name}/invoke", callback=post_callback
398+
)
399+
400+
sync_client.add_headers(headers_to_add)
401+
tool = sync_client.load_tool(tool_name)
402+
result = tool(param1="test")
403+
assert result == expected_payload["result"]
404+
405+
@pytest.mark.usefixtures("sync_client_environment")
406+
def test_sync_add_headers_duplicate_fail(self):
407+
"""
408+
Tests that adding a duplicate header via add_headers raises ValueError.
409+
Manually create client to control initial headers.
410+
"""
411+
initial_headers = {"X-Initial-Header": "initial_value"}
452412

453-
client.close()
454-
# sync_client_environment will handle the final cleanup of original_class_loop/thread.
413+
with ToolboxSyncClient(TEST_BASE_URL, client_headers=initial_headers) as client:
414+
with pytest.raises(
415+
ValueError,
416+
match="Client header\\(s\\) `X-Initial-Header` already registered",
417+
):
418+
client.add_headers({"X-Initial-Header": "another_value"})
455419

456420

457421
class TestSyncAuth:
@@ -550,7 +514,7 @@ def test_auth_with_load_tool_fail_no_token(
550514
aioresponses.post(
551515
f"{TEST_BASE_URL}/api/tool/{tool_name_auth}/invoke",
552516
payload={"error": "Missing token"},
553-
status=400,
517+
status=401,
554518
)
555519

556520
tool = sync_client.load_tool(tool_name_auth)

0 commit comments

Comments
 (0)