Skip to content

Commit 787ba5d

Browse files
authored
chore: Add unit tests for ToolboxSyncTool (#224)
* chore: Add unit test cases * chore: Delint * feat: Warn on insecure tool invocation with authentication This change introduces a warning that is displayed immediately before a tool invocation if: 1. The invocation includes an authentication header. 2. The connection is being made over non-secure HTTP. > [!IMPORTANT] The purpose of this warning is to alert the user to the security risk of sending credentials over an unencrypted channel and to encourage the use of HTTPS. * fix!: Warn about https only during tool initialization * chore: Add unit tests for ToolboxSyncTool * fix: Fix unittest failing due to mock attaching wrong spec signature while asserting * fix: Use correct name property while creating sync tool qualname * chore: Add unit tests for @Property methods
1 parent d331e3c commit 787ba5d

File tree

3 files changed

+395
-1
lines changed

3 files changed

+395
-1
lines changed

packages/toolbox-core/src/toolbox_core/sync_tool.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
1516
import asyncio
1617
from asyncio import AbstractEventLoop
1718
from inspect import Signature
@@ -64,7 +65,9 @@ def __init__(
6465
# itself is being processed during module import or class definition.
6566
# Defining __qualname__ as a property leads to a TypeError because the class object needs
6667
# a string value immediately, not a descriptor that evaluates later.
67-
self.__qualname__ = f"{self.__class__.__qualname__}.{self.__async_tool._name}"
68+
self.__qualname__ = (
69+
f"{self.__class__.__qualname__}.{self.__async_tool.__name__}"
70+
)
6871

6972
@property
7073
def __name__(self) -> str:
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
import asyncio
17+
from inspect import Parameter, Signature
18+
from threading import Thread
19+
from typing import Any, Callable, Mapping, Union
20+
from unittest.mock import MagicMock, Mock, create_autospec, patch
21+
22+
import pytest
23+
24+
from toolbox_core.sync_tool import ToolboxSyncTool
25+
from toolbox_core.tool import ToolboxTool
26+
27+
28+
@pytest.fixture
29+
def mock_async_tool() -> MagicMock:
30+
"""Fixture for an auto-specced MagicMock simulating a ToolboxTool instance."""
31+
tool = create_autospec(ToolboxTool, instance=True)
32+
tool.__name__ = "mock_async_tool_name"
33+
tool.__doc__ = "Mock async tool documentation."
34+
35+
# Create a simple signature for the mock tool
36+
param_a = Parameter("a", Parameter.POSITIONAL_OR_KEYWORD, annotation=str)
37+
param_b = Parameter(
38+
"b", Parameter.POSITIONAL_OR_KEYWORD, annotation=int, default=10
39+
)
40+
tool.__signature__ = Signature(parameters=[param_a, param_b])
41+
42+
tool.__annotations__ = {"a": str, "b": int, "return": str}
43+
44+
tool.add_auth_token_getters.return_value = create_autospec(
45+
ToolboxTool, instance=True
46+
)
47+
tool.bind_params.return_value = create_autospec(ToolboxTool, instance=True)
48+
49+
return tool
50+
51+
52+
@pytest.fixture
53+
def event_loop() -> asyncio.AbstractEventLoop:
54+
"""Fixture for an event loop."""
55+
# Using asyncio.get_event_loop() might be problematic if no loop is set.
56+
# For this test setup, we'll mock `run_coroutine_threadsafe` directly.
57+
return Mock(spec=asyncio.AbstractEventLoop)
58+
59+
60+
@pytest.fixture
61+
def mock_thread() -> MagicMock:
62+
"""Fixture for a mock Thread."""
63+
return MagicMock(spec=Thread)
64+
65+
66+
@pytest.fixture
67+
def toolbox_sync_tool(
68+
mock_async_tool: MagicMock,
69+
event_loop: asyncio.AbstractEventLoop,
70+
mock_thread: MagicMock,
71+
) -> ToolboxSyncTool:
72+
"""Fixture for a ToolboxSyncTool instance."""
73+
return ToolboxSyncTool(mock_async_tool, event_loop, mock_thread)
74+
75+
76+
def test_toolbox_sync_tool_init_success(
77+
mock_async_tool: MagicMock,
78+
event_loop: asyncio.AbstractEventLoop,
79+
mock_thread: MagicMock,
80+
):
81+
"""Tests successful initialization of ToolboxSyncTool."""
82+
tool = ToolboxSyncTool(mock_async_tool, event_loop, mock_thread)
83+
assert tool._ToolboxSyncTool__async_tool is mock_async_tool
84+
assert tool._ToolboxSyncTool__loop is event_loop
85+
assert tool._ToolboxSyncTool__thread is mock_thread
86+
assert tool.__qualname__ == f"ToolboxSyncTool.{mock_async_tool.__name__}"
87+
88+
89+
def test_toolbox_sync_tool_init_type_error():
90+
"""Tests TypeError if async_tool is not a ToolboxTool instance."""
91+
with pytest.raises(
92+
TypeError, match="async_tool must be an instance of ToolboxTool"
93+
):
94+
ToolboxSyncTool("not_a_toolbox_tool", Mock(), Mock())
95+
96+
97+
def test_toolbox_sync_tool_name_property(
98+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
99+
):
100+
"""Tests the __name__ property."""
101+
assert toolbox_sync_tool.__name__ == mock_async_tool.__name__
102+
103+
104+
def test_toolbox_sync_tool_doc_property(
105+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
106+
):
107+
"""Tests the __doc__ property."""
108+
assert toolbox_sync_tool.__doc__ == mock_async_tool.__doc__
109+
110+
# Test with __doc__ = None
111+
mock_async_tool.__doc__ = None
112+
sync_tool_no_doc = ToolboxSyncTool(mock_async_tool, Mock(), Mock())
113+
assert sync_tool_no_doc.__doc__ is None
114+
115+
116+
def test_toolbox_sync_tool_signature_property(
117+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
118+
):
119+
"""Tests the __signature__ property."""
120+
assert toolbox_sync_tool.__signature__ is mock_async_tool.__signature__
121+
122+
123+
def test_toolbox_sync_tool_annotations_property(
124+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
125+
):
126+
"""Tests the __annotations__ property."""
127+
assert toolbox_sync_tool.__annotations__ is mock_async_tool.__annotations__
128+
129+
130+
def test_toolbox_sync_tool_underscore_name_property(
131+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
132+
):
133+
"""Tests the _name property."""
134+
assert toolbox_sync_tool._name == mock_async_tool._name
135+
136+
137+
def test_toolbox_sync_tool_underscore_description_property(
138+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
139+
):
140+
"""Tests the _description property."""
141+
assert toolbox_sync_tool._description == mock_async_tool._description
142+
143+
144+
def test_toolbox_sync_tool_underscore_params_property(
145+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
146+
):
147+
"""Tests the _params property."""
148+
assert toolbox_sync_tool._params == mock_async_tool._params
149+
150+
151+
def test_toolbox_sync_tool_underscore_bound_params_property(
152+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
153+
):
154+
"""Tests the _bound_params property."""
155+
assert toolbox_sync_tool._bound_params == mock_async_tool._bound_params
156+
157+
158+
def test_toolbox_sync_tool_underscore_required_auth_params_property(
159+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
160+
):
161+
"""Tests the _required_auth_params property."""
162+
assert (
163+
toolbox_sync_tool._required_auth_params == mock_async_tool._required_auth_params
164+
)
165+
166+
167+
def test_toolbox_sync_tool_underscore_auth_service_token_getters_property(
168+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
169+
):
170+
"""Tests the _auth_service_token_getters property."""
171+
assert (
172+
toolbox_sync_tool._auth_service_token_getters
173+
is mock_async_tool._auth_service_token_getters
174+
)
175+
176+
177+
def test_toolbox_sync_tool_underscore_client_headers_property(
178+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
179+
):
180+
"""Tests the _client_headers property."""
181+
assert toolbox_sync_tool._client_headers is mock_async_tool._client_headers
182+
183+
184+
@patch("asyncio.run_coroutine_threadsafe")
185+
def test_toolbox_sync_tool_call(
186+
mock_run_coroutine_threadsafe: MagicMock,
187+
toolbox_sync_tool: ToolboxSyncTool,
188+
mock_async_tool: MagicMock,
189+
event_loop: asyncio.AbstractEventLoop,
190+
):
191+
"""Tests the __call__ method."""
192+
mock_future = MagicMock()
193+
expected_result = "call_result"
194+
mock_future.result.return_value = expected_result
195+
mock_run_coroutine_threadsafe.return_value = mock_future
196+
197+
args_tuple = ("test_arg",)
198+
kwargs_dict = {"kwarg1": "value1"}
199+
200+
# Create a mock coroutine to be returned by async_tool.__call__
201+
mock_coro = MagicMock(name="mock_coro_returned_by_async_tool")
202+
mock_async_tool.return_value = mock_coro
203+
204+
result = toolbox_sync_tool(*args_tuple, **kwargs_dict)
205+
206+
mock_async_tool.assert_called_once_with(*args_tuple, **kwargs_dict)
207+
mock_run_coroutine_threadsafe.assert_called_once_with(mock_coro, event_loop)
208+
mock_future.result.assert_called_once_with()
209+
assert result == expected_result
210+
211+
212+
def test_toolbox_sync_tool_add_auth_token_getters(
213+
toolbox_sync_tool: ToolboxSyncTool,
214+
mock_async_tool: MagicMock,
215+
event_loop: asyncio.AbstractEventLoop,
216+
mock_thread: MagicMock,
217+
):
218+
"""Tests the add_auth_token_getters method."""
219+
auth_getters: Mapping[str, Callable[[], str]] = {"service1": lambda: "token1"}
220+
221+
new_mock_async_tool = mock_async_tool.add_auth_token_getters.return_value
222+
new_mock_async_tool.__name__ = "new_async_tool_with_auth"
223+
224+
new_sync_tool = toolbox_sync_tool.add_auth_token_getters(auth_getters)
225+
226+
mock_async_tool.add_auth_token_getters.assert_called_once_with(auth_getters)
227+
228+
assert isinstance(new_sync_tool, ToolboxSyncTool)
229+
assert new_sync_tool is not toolbox_sync_tool
230+
assert new_sync_tool._ToolboxSyncTool__async_tool is new_mock_async_tool
231+
assert new_sync_tool._ToolboxSyncTool__loop is event_loop # Should be the same loop
232+
assert (
233+
new_sync_tool._ToolboxSyncTool__thread is mock_thread
234+
) # Should be the same thread
235+
assert (
236+
new_sync_tool.__qualname__ == f"ToolboxSyncTool.{new_mock_async_tool.__name__}"
237+
)
238+
239+
240+
def test_toolbox_sync_tool_bind_params(
241+
toolbox_sync_tool: ToolboxSyncTool,
242+
mock_async_tool: MagicMock,
243+
event_loop: asyncio.AbstractEventLoop,
244+
mock_thread: MagicMock,
245+
):
246+
"""Tests the bind_params method."""
247+
bound_params: Mapping[str, Union[Callable[[], Any], Any]] = {
248+
"param1": "value1",
249+
"param2": lambda: "value2",
250+
}
251+
252+
new_mock_async_tool = mock_async_tool.bind_params.return_value
253+
new_mock_async_tool.__name__ = "new_async_tool_with_bound_params"
254+
255+
new_sync_tool = toolbox_sync_tool.bind_params(bound_params)
256+
257+
mock_async_tool.bind_params.assert_called_once_with(bound_params)
258+
259+
assert isinstance(new_sync_tool, ToolboxSyncTool)
260+
assert new_sync_tool is not toolbox_sync_tool
261+
assert new_sync_tool._ToolboxSyncTool__async_tool is new_mock_async_tool
262+
assert new_sync_tool._ToolboxSyncTool__loop is event_loop
263+
assert new_sync_tool._ToolboxSyncTool__thread is mock_thread
264+
assert (
265+
new_sync_tool.__qualname__ == f"ToolboxSyncTool.{new_mock_async_tool.__name__}"
266+
)
267+
268+
269+
def test_toolbox_sync_tool_bind_param(
270+
toolbox_sync_tool: ToolboxSyncTool,
271+
mock_async_tool: MagicMock,
272+
event_loop: asyncio.AbstractEventLoop,
273+
mock_thread: MagicMock,
274+
):
275+
"""Tests the bind_param method."""
276+
param_name = "my_param"
277+
param_value = "my_value"
278+
279+
new_mock_async_tool = mock_async_tool.bind_params.return_value
280+
new_mock_async_tool.__name__ = "new_async_tool_with_single_bound_param"
281+
282+
new_sync_tool = toolbox_sync_tool.bind_param(param_name, param_value)
283+
284+
mock_async_tool.bind_params.assert_called_once_with({param_name: param_value})
285+
286+
assert isinstance(new_sync_tool, ToolboxSyncTool)
287+
assert new_sync_tool is not toolbox_sync_tool
288+
assert new_sync_tool._ToolboxSyncTool__async_tool is new_mock_async_tool
289+
assert new_sync_tool._ToolboxSyncTool__loop is event_loop
290+
assert new_sync_tool._ToolboxSyncTool__thread is mock_thread
291+
assert (
292+
new_sync_tool.__qualname__ == f"ToolboxSyncTool.{new_mock_async_tool.__name__}"
293+
)

0 commit comments

Comments
 (0)