Skip to content

Commit 2460dcf

Browse files
committed
test: add property-based tests using Hypothesis
Add PBT tests to improve edge case coverage across multiple modules: - test_feedback.py: whitespace validation, invalid JSON patterns - test_models.py: HTTP method case variations, JSON parsing errors, account ID round-trips - test_tfidf_index.py: punctuation removal, stopword filtering, score range invariants, result ordering - test_toolset.py: auth header encoding, glob pattern matching, provider filtering case-insensitivity - test_meta_tools.py: score threshold filtering, limit constraints, hybrid alpha clamping PBT automatically generates diverse inputs to discover bugs that hardcoded test cases might miss, particularly in input validation and boundary conditions.
1 parent 2a94c8b commit 2460dcf

5 files changed

Lines changed: 593 additions & 0 deletions

File tree

tests/test_feedback.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,44 @@
55

66
import json
77
import os
8+
import string
89

910
import httpx
1011
import pytest
1112
import respx
13+
from hypothesis import given, settings
14+
from hypothesis import strategies as st
1215

1316
from stackone_ai.feedback import create_feedback_tool
1417
from stackone_ai.models import StackOneError
1518

19+
# Hypothesis strategies for PBT
20+
# Various whitespace characters including Unicode
21+
WHITESPACE_CHARS = " \t\n\r\u00a0\u2003\u2009"
22+
whitespace_strategy = st.text(alphabet=WHITESPACE_CHARS, min_size=1, max_size=20)
23+
24+
# Valid non-empty strings (stripped)
25+
valid_string_strategy = st.text(
26+
alphabet=string.ascii_letters + string.digits + "_-",
27+
min_size=1,
28+
max_size=50,
29+
).filter(lambda s: s.strip())
30+
31+
# Invalid JSON strings (strings that cannot be parsed as valid JSON at all)
32+
# Note: Python's json module accepts NaN/Infinity by default, so avoid those
33+
invalid_json_strategy = st.one_of(
34+
st.just("{incomplete"),
35+
st.just('{"missing": }'),
36+
st.just('{"key": value}'),
37+
st.just("[1, 2, 3"),
38+
st.just("{trailing}garbage"),
39+
st.just("{missing closing brace"),
40+
st.just("undefined"),
41+
st.just("not valid json"),
42+
st.just("abc123"),
43+
st.just("foo bar baz"),
44+
)
45+
1646

1747
class TestFeedbackToolValidation:
1848
"""Test suite for feedback tool input validation."""
@@ -79,6 +109,59 @@ def test_invalid_json_input(self) -> None:
79109
with pytest.raises(StackOneError, match="Invalid JSON"):
80110
tool.execute("{missing closing brace")
81111

112+
@given(whitespace=whitespace_strategy)
113+
@settings(max_examples=50)
114+
def test_whitespace_feedback_validation_pbt(self, whitespace: str) -> None:
115+
"""PBT: Test validation for various whitespace patterns in feedback."""
116+
tool = create_feedback_tool(api_key="test_key")
117+
118+
with pytest.raises(StackOneError, match="non-empty"):
119+
tool.execute({"feedback": whitespace, "account_id": "acc_123456", "tool_names": ["test_tool"]})
120+
121+
@given(whitespace=whitespace_strategy)
122+
@settings(max_examples=50)
123+
def test_whitespace_account_id_validation_pbt(self, whitespace: str) -> None:
124+
"""PBT: Test validation for various whitespace patterns in account_id."""
125+
tool = create_feedback_tool(api_key="test_key")
126+
127+
with pytest.raises(StackOneError, match="non-empty"):
128+
tool.execute({"feedback": "Great!", "account_id": whitespace, "tool_names": ["test_tool"]})
129+
130+
@given(whitespace_list=st.lists(whitespace_strategy, min_size=1, max_size=5))
131+
@settings(max_examples=50)
132+
def test_whitespace_tool_names_validation_pbt(self, whitespace_list: list[str]) -> None:
133+
"""PBT: Test validation for lists containing only whitespace tool names."""
134+
tool = create_feedback_tool(api_key="test_key")
135+
136+
with pytest.raises(StackOneError, match="At least one tool name"):
137+
tool.execute({"feedback": "Great!", "account_id": "acc_123456", "tool_names": whitespace_list})
138+
139+
@given(
140+
whitespace_list=st.lists(whitespace_strategy, min_size=1, max_size=5),
141+
)
142+
@settings(max_examples=50)
143+
def test_whitespace_account_ids_list_validation_pbt(self, whitespace_list: list[str]) -> None:
144+
"""PBT: Test validation for lists containing only whitespace account IDs."""
145+
tool = create_feedback_tool(api_key="test_key")
146+
147+
with pytest.raises(StackOneError, match="At least one valid account ID is required"):
148+
tool.execute(
149+
{
150+
"feedback": "Great tools!",
151+
"account_id": whitespace_list,
152+
"tool_names": ["test_tool"],
153+
}
154+
)
155+
156+
@given(invalid_json=invalid_json_strategy)
157+
@settings(max_examples=50)
158+
def test_invalid_json_input_pbt(self, invalid_json: str) -> None:
159+
"""PBT: Test that various invalid JSON inputs raise appropriate error."""
160+
tool = create_feedback_tool(api_key="test_key")
161+
162+
with pytest.raises(StackOneError, match="Invalid JSON"):
163+
tool.execute(invalid_json)
164+
82165
@respx.mock
83166
def test_json_string_input(self) -> None:
84167
"""Test that JSON string input is properly parsed."""

tests/test_meta_tools.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import httpx
44
import pytest
55
import respx
6+
from hypothesis import given, settings
7+
from hypothesis import strategies as st
68

79
from stackone_ai import StackOneTool, Tools
810
from stackone_ai.meta_tools import (
@@ -12,6 +14,77 @@
1214
)
1315
from stackone_ai.models import ExecuteConfig, ToolParameters
1416

17+
# Hypothesis strategies for PBT
18+
# Score threshold strategy
19+
score_threshold_strategy = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
20+
21+
# Hybrid alpha strategy (can be outside [0, 1] to test clamping)
22+
hybrid_alpha_strategy = st.floats(min_value=-10.0, max_value=10.0, allow_nan=False, allow_infinity=False)
23+
24+
# Limit strategy
25+
limit_strategy = st.integers(min_value=1, max_value=100)
26+
27+
28+
def _create_sample_tools() -> list[StackOneTool]:
29+
"""Helper function to create sample tools (for use in PBT tests)."""
30+
tools = []
31+
32+
# Create HiBob tools
33+
for action in ["create", "list", "update", "delete"]:
34+
for entity in ["employee", "department", "timeoff"]:
35+
tool_name = f"hibob_{action}_{entity}"
36+
execute_config = ExecuteConfig(
37+
name=tool_name,
38+
method="POST" if action in ["create", "update"] else "GET",
39+
url=f"https://api.example.com/hibob/{entity}",
40+
headers={},
41+
)
42+
43+
parameters = ToolParameters(
44+
type="object",
45+
properties={
46+
"id": {"type": "string", "description": "Entity ID"},
47+
"data": {"type": "object", "description": "Entity data"},
48+
},
49+
)
50+
51+
tool = StackOneTool(
52+
description=f"{action.capitalize()} {entity} in HiBob system",
53+
parameters=parameters,
54+
_execute_config=execute_config,
55+
_api_key="test_key",
56+
)
57+
tools.append(tool)
58+
59+
# Create BambooHR tools
60+
for action in ["create", "list", "search"]:
61+
for entity in ["candidate", "job", "application"]:
62+
tool_name = f"bamboohr_{action}_{entity}"
63+
execute_config = ExecuteConfig(
64+
name=tool_name,
65+
method="POST" if action == "create" else "GET",
66+
url=f"https://api.example.com/bamboohr/{entity}",
67+
headers={},
68+
)
69+
70+
parameters = ToolParameters(
71+
type="object",
72+
properties={
73+
"query": {"type": "string", "description": "Search query"},
74+
"filters": {"type": "object", "description": "Filter criteria"},
75+
},
76+
)
77+
78+
tool = StackOneTool(
79+
description=f"{action.capitalize()} {entity} in BambooHR system",
80+
parameters=parameters,
81+
_execute_config=execute_config,
82+
_api_key="test_key",
83+
)
84+
tools.append(tool)
85+
86+
return tools
87+
1588

1689
@pytest.fixture
1790
def sample_tools():
@@ -132,6 +205,36 @@ def test_search_limit(self, sample_tools):
132205

133206
assert len(results) <= 3
134207

208+
@given(min_score=score_threshold_strategy, limit=limit_strategy)
209+
@settings(max_examples=50)
210+
def test_search_with_min_score_pbt(self, min_score: float, limit: int):
211+
"""PBT: Test that min_score filtering always works correctly."""
212+
# Create tools inside test to avoid fixture issues with Hypothesis
213+
tools = _create_sample_tools()
214+
index = ToolIndex(tools)
215+
216+
results = index.search("employee", limit=limit, min_score=min_score)
217+
218+
# All results must meet the score threshold
219+
for r in results:
220+
assert r.score >= min_score, f"Score {r.score} < min_score {min_score}"
221+
222+
# Result count should not exceed limit
223+
assert len(results) <= limit
224+
225+
@given(limit=limit_strategy)
226+
@settings(max_examples=50)
227+
def test_search_limit_pbt(self, limit: int):
228+
"""PBT: Test that limit is always respected."""
229+
# Create tools inside test to avoid fixture issues with Hypothesis
230+
tools = _create_sample_tools()
231+
index = ToolIndex(tools)
232+
233+
results = index.search("employee", limit=limit)
234+
235+
assert len(results) <= limit
236+
assert len(results) <= len(tools)
237+
135238

136239
class TestMetaSearchTool:
137240
"""Test the meta_search_tools functionality"""
@@ -309,6 +412,25 @@ def test_hybrid_alpha_parameter(self, sample_tools):
309412
index_max = ToolIndex(sample_tools, hybrid_alpha=1.5)
310413
assert index_max.hybrid_alpha == 1.0
311414

415+
@given(alpha=hybrid_alpha_strategy)
416+
@settings(max_examples=100)
417+
def test_hybrid_alpha_clamping_pbt(self, alpha: float):
418+
"""PBT: Test that hybrid_alpha is always clamped to [0.0, 1.0]."""
419+
# Create tools inside test to avoid fixture issues with Hypothesis
420+
tools = _create_sample_tools()
421+
index = ToolIndex(tools, hybrid_alpha=alpha)
422+
423+
# Should always be clamped to [0.0, 1.0]
424+
assert 0.0 <= index.hybrid_alpha <= 1.0
425+
426+
# Verify clamping logic
427+
if alpha < 0.0:
428+
assert index.hybrid_alpha == 0.0
429+
elif alpha > 1.0:
430+
assert index.hybrid_alpha == 1.0
431+
else:
432+
assert index.hybrid_alpha == alpha
433+
312434
def test_hybrid_search_returns_results(self, sample_tools):
313435
"""Test that hybrid search returns meaningful results"""
314436
index = ToolIndex(sample_tools, hybrid_alpha=0.2)

0 commit comments

Comments
 (0)