Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
]
dependencies = [
"pydantic>=2.10.6",
"requests>=2.32.3",
"httpx>=0.28.0",
"langchain-core>=0.1.0",
"bm25s>=0.2.2",
"numpy>=1.24.0",
Expand Down Expand Up @@ -61,7 +61,7 @@ dev = [
"pytest-asyncio>=0.25.3",
"pytest-cov>=6.0.0",
"pytest-snapshot>=0.9.0",
"responses>=0.25.8",
"respx>=0.22.0",
"ruff>=0.9.6",
"stackone-ai",
"ty>=0.0.3",
Expand Down
27 changes: 16 additions & 11 deletions stackone_ai/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@
from typing import Annotated, Any, ClassVar, cast
from urllib.parse import quote

import requests
import httpx
from langchain_core.tools import BaseTool
from pydantic import BaseModel, BeforeValidator, Field, PrivateAttr
from requests.exceptions import RequestException

# TODO: Remove when Python 3.9 support is dropped
from typing_extensions import TypeAlias
Expand Down Expand Up @@ -242,7 +241,7 @@ def execute(
if query_params:
request_kwargs["params"] = query_params

response = requests.request(**request_kwargs)
response = httpx.request(**request_kwargs)
response_status = response.status_code
response.raise_for_status()

Expand All @@ -254,15 +253,21 @@ def execute(
status = "error"
error_message = f"Invalid JSON in arguments: {exc}"
raise ValueError(error_message) from exc
except RequestException as exc:
except httpx.HTTPStatusError as exc:
status = "error"
response_body = None
if exc.response.text:
try:
response_body = exc.response.json()
except json.JSONDecodeError:
response_body = exc.response.text
raise StackOneAPIError(
str(exc),
exc.response.status_code,
response_body,
) from exc
except httpx.RequestError as exc:
status = "error"
error_message = str(exc)
if hasattr(exc, "response") and exc.response is not None:
raise StackOneAPIError(
str(exc),
exc.response.status_code,
exc.response.json() if exc.response.text else None,
) from exc
raise StackOneError(f"Request failed: {exc}") from exc
finally:
datetime.now(timezone.utc)
Expand Down
236 changes: 112 additions & 124 deletions tests/test_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@

import json
import os
from unittest.mock import Mock, patch

import httpx
import pytest
import respx

from stackone_ai.feedback import create_feedback_tool
from stackone_ai.models import StackOneError
Expand Down Expand Up @@ -55,165 +56,152 @@ def test_multiple_account_ids_validation(self) -> None:
with pytest.raises(StackOneError, match="At least one valid account ID is required"):
tool.execute({"feedback": "Great tools!", "account_id": ["", " "], "tool_names": ["test_tool"]})

@respx.mock
def test_json_string_input(self) -> None:
"""Test that JSON string input is properly parsed."""
tool = create_feedback_tool(api_key="test_key")

with patch("requests.request") as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": "Success"}
mock_response.raise_for_status = Mock()
mock_request.return_value = mock_response
route = respx.post("https://api.stackone.com/ai/tool-feedback").mock(
return_value=httpx.Response(200, json={"message": "Success"})
)

json_string = json.dumps(
{"feedback": "Great tools!", "account_id": "acc_123456", "tool_names": ["test_tool"]}
)
result = tool.execute(json_string)
assert result["message"] == "Success"
json_string = json.dumps(
{"feedback": "Great tools!", "account_id": "acc_123456", "tool_names": ["test_tool"]}
)
result = tool.execute(json_string)
assert result["message"] == "Success"
assert route.called


class TestFeedbackToolExecution:
"""Test suite for feedback tool execution."""

@respx.mock
def test_single_account_execution(self) -> None:
"""Test execution with single account ID."""
tool = create_feedback_tool(api_key="test_key")
api_response = {"message": "Feedback successfully stored", "trace_id": "test-trace-id"}

with patch("requests.request") as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = api_response
mock_response.raise_for_status = Mock()
mock_request.return_value = mock_response

result = tool.execute(
{
"feedback": "Great tools!",
"account_id": "acc_123456",
"tool_names": ["data_export", "analytics"],
}
)

assert result == api_response
mock_request.assert_called_once()
call_kwargs = mock_request.call_args[1]
assert call_kwargs["method"] == "POST"
assert call_kwargs["url"] == "https://api.stackone.com/ai/tool-feedback"
assert call_kwargs["json"]["feedback"] == "Great tools!"
assert call_kwargs["json"]["account_id"] == "acc_123456"
assert call_kwargs["json"]["tool_names"] == ["data_export", "analytics"]

route = respx.post("https://api.stackone.com/ai/tool-feedback").mock(
return_value=httpx.Response(200, json=api_response)
)

result = tool.execute(
{
"feedback": "Great tools!",
"account_id": "acc_123456",
"tool_names": ["data_export", "analytics"],
}
)

assert result == api_response
assert route.called
assert route.call_count == 1
request = route.calls[0].request
body = json.loads(request.content)
assert body["feedback"] == "Great tools!"
assert body["account_id"] == "acc_123456"
assert body["tool_names"] == ["data_export", "analytics"]

@respx.mock
def test_call_method_interface(self) -> None:
"""Test that the .call() method works correctly."""
tool = create_feedback_tool(api_key="test_key")
api_response = {"message": "Success", "trace_id": "test-trace-id"}

with patch("requests.request") as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = api_response
mock_response.raise_for_status = Mock()
mock_request.return_value = mock_response

result = tool.call(
feedback="Testing the .call() method interface.",
account_id="acc_test004",
tool_names=["meta_collect_tool_feedback"],
)
route = respx.post("https://api.stackone.com/ai/tool-feedback").mock(
return_value=httpx.Response(200, json=api_response)
)

result = tool.call(
feedback="Testing the .call() method interface.",
account_id="acc_test004",
tool_names=["meta_collect_tool_feedback"],
)

assert result == api_response
mock_request.assert_called_once()
assert result == api_response
assert route.called
assert route.call_count == 1

@respx.mock
def test_api_error_handling(self) -> None:
"""Test that API errors are handled properly."""
tool = create_feedback_tool(api_key="test_key")

with patch("requests.request") as mock_request:
mock_response = Mock()
mock_response.status_code = 401
mock_response.text = '{"error": "Unauthorized"}'
mock_response.json.return_value = {"error": "Unauthorized"}
mock_response.raise_for_status.side_effect = Exception("401 Client Error: Unauthorized")
mock_request.return_value = mock_response

with pytest.raises(StackOneError):
tool.execute(
{
"feedback": "Great tools!",
"account_id": "acc_123456",
"tool_names": ["test_tool"],
}
)
respx.post("https://api.stackone.com/ai/tool-feedback").mock(
return_value=httpx.Response(401, json={"error": "Unauthorized"})
)

def test_multiple_account_ids_execution(self) -> None:
"""Test execution with multiple account IDs - both success and mixed scenarios."""
tool = create_feedback_tool(api_key="test_key")
api_response = {"message": "Feedback successfully stored", "trace_id": "test-trace-id"}

# Test all successful case
with patch("requests.request") as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = api_response
mock_response.raise_for_status = Mock()
mock_request.return_value = mock_response

result = tool.execute(
with pytest.raises(StackOneError):
tool.execute(
{
"feedback": "Great tools!",
"account_id": ["acc_123456", "acc_789012", "acc_345678"],
"account_id": "acc_123456",
"tool_names": ["test_tool"],
}
)

assert result["message"] == "Feedback sent to 3 account(s)"
assert result["total_accounts"] == 3
assert result["successful"] == 3
assert result["failed"] == 0
assert len(result["results"]) == 3
assert mock_request.call_count == 3
@respx.mock
def test_multiple_account_ids_execution(self) -> None:
"""Test execution with multiple account IDs - both success and mixed scenarios."""
tool = create_feedback_tool(api_key="test_key")
api_response = {"message": "Feedback successfully stored", "trace_id": "test-trace-id"}

# Test mixed success/error case
def mock_request_side_effect(*args, **kwargs):
account_id = kwargs.get("json", {}).get("account_id")
# Test all successful case
route = respx.post("https://api.stackone.com/ai/tool-feedback").mock(
return_value=httpx.Response(200, json=api_response)
)

result = tool.execute(
{
"feedback": "Great tools!",
"account_id": ["acc_123456", "acc_789012", "acc_345678"],
"tool_names": ["test_tool"],
}
)

assert result["message"] == "Feedback sent to 3 account(s)"
assert result["total_accounts"] == 3
assert result["successful"] == 3
assert result["failed"] == 0
assert len(result["results"]) == 3
assert route.call_count == 3

@respx.mock
def test_multiple_account_ids_mixed_success(self) -> None:
"""Test execution with multiple account IDs - mixed success and error."""
tool = create_feedback_tool(api_key="test_key")

def custom_side_effect(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
account_id = body.get("account_id")
if account_id == "acc_123456":
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": "Success"}
mock_response.raise_for_status = Mock()
return mock_response
return httpx.Response(200, json={"message": "Success"})
else:
mock_response = Mock()
mock_response.status_code = 401
mock_response.text = '{"error": "Unauthorized"}'
mock_response.json.return_value = {"error": "Unauthorized"}
mock_response.raise_for_status.side_effect = Exception("401 Client Error: Unauthorized")
return mock_response

with patch("requests.request") as mock_request:
mock_request.side_effect = mock_request_side_effect

result = tool.execute(
{
"feedback": "Great tools!",
"account_id": ["acc_123456", "acc_unauthorized"],
"tool_names": ["test_tool"],
}
)

assert result["total_accounts"] == 2
assert result["successful"] == 1
assert result["failed"] == 1
assert len(result["results"]) == 2

success_result = next(r for r in result["results"] if r["account_id"] == "acc_123456")
assert success_result["status"] == "success"

error_result = next(r for r in result["results"] if r["account_id"] == "acc_unauthorized")
assert error_result["status"] == "error"
assert "401 Client Error: Unauthorized" in error_result["error"]
return httpx.Response(401, json={"error": "Unauthorized"})

respx.post("https://api.stackone.com/ai/tool-feedback").mock(side_effect=custom_side_effect)

result = tool.execute(
{
"feedback": "Great tools!",
"account_id": ["acc_123456", "acc_unauthorized"],
"tool_names": ["test_tool"],
}
)

assert result["total_accounts"] == 2
assert result["successful"] == 1
assert result["failed"] == 1
assert len(result["results"]) == 2

success_result = next(r for r in result["results"] if r["account_id"] == "acc_123456")
assert success_result["status"] == "success"

error_result = next(r for r in result["results"] if r["account_id"] == "acc_unauthorized")
assert error_result["status"] == "error"
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message assertion was removed from the test. The test should verify that the error result contains the expected error information to ensure proper error handling and reporting.

Copilot uses AI. Check for mistakes.
Copy link

@cubic-dev-ai cubic-dev-ai bot Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Test coverage reduced: the assertion verifying error message content was removed. The original test checked assert "401 Client Error: Unauthorized" in error_result["error"] to ensure error details were properly captured. Consider adding an assertion to verify that error_result["error"] is non-empty or contains expected error information.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test_feedback.py, line 202:

<comment>Test coverage reduced: the assertion verifying error message content was removed. The original test checked `assert &quot;401 Client Error: Unauthorized&quot; in error_result[&quot;error&quot;]` to ensure error details were properly captured. Consider adding an assertion to verify that `error_result[&quot;error&quot;]` is non-empty or contains expected error information.</comment>

<file context>
@@ -55,165 +56,150 @@ def test_multiple_account_ids_validation(self) -&gt; None:
-            assert error_result[&quot;status&quot;] == &quot;error&quot;
-            assert &quot;401 Client Error: Unauthorized&quot; in error_result[&quot;error&quot;]
+        error_result = next(r for r in result[&quot;results&quot;] if r[&quot;account_id&quot;] == &quot;acc_unauthorized&quot;)
+        assert error_result[&quot;status&quot;] == &quot;error&quot;
 
     def test_tool_integration(self) -&gt; None:
</file context>

✅ Addressed in 9b023ed

assert "error" in error_result
assert "401" in error_result["error"]

def test_tool_integration(self) -> None:
"""Test that feedback tool integrates properly with toolset."""
Expand Down
Loading