mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-19 10:14:46 +00:00
chore: more tests
This commit is contained in:
1 parent
dc6db847ca
commit
d5c0322e2a
64 files changed
+24436
-140
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,424 @@
|
||||
"""Comprehensive tests for application/agents/tools/api_body_serializer.py
|
||||
|
||||
Covers: ContentType enum, RequestBodySerializer (JSON, form-urlencoded,
|
||||
multipart, text/plain, XML, octet-stream, unknown types), encoding rules,
|
||||
helper methods (_percent_encode, _escape_xml, _dict_to_xml).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from application.agents.tools.api_body_serializer import (
|
||||
ContentType,
|
||||
RequestBodySerializer,
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# ContentType Enum
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestContentTypeEnum:
|
||||
|
||||
def test_json_value(self):
|
||||
assert ContentType.JSON == "application/json"
|
||||
|
||||
def test_form_urlencoded_value(self):
|
||||
assert ContentType.FORM_URLENCODED == "application/x-www-form-urlencoded"
|
||||
|
||||
def test_multipart_value(self):
|
||||
assert ContentType.MULTIPART_FORM_DATA == "multipart/form-data"
|
||||
|
||||
def test_text_plain_value(self):
|
||||
assert ContentType.TEXT_PLAIN == "text/plain"
|
||||
|
||||
def test_xml_value(self):
|
||||
assert ContentType.XML == "application/xml"
|
||||
|
||||
def test_octet_stream_value(self):
|
||||
assert ContentType.OCTET_STREAM == "application/octet-stream"
|
||||
|
||||
def test_str_enum(self):
|
||||
assert isinstance(ContentType.JSON, str)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# JSON Serialization
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSerializeJson:
|
||||
|
||||
def test_basic_json(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"key": "value"}, ContentType.JSON
|
||||
)
|
||||
assert json.loads(body) == {"key": "value"}
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
def test_nested_json(self):
|
||||
data = {"user": {"name": "Alice", "age": 30}}
|
||||
body, headers = RequestBodySerializer.serialize(data, ContentType.JSON)
|
||||
assert json.loads(body) == data
|
||||
|
||||
def test_empty_body_returns_none(self):
|
||||
body, headers = RequestBodySerializer.serialize({}, ContentType.JSON)
|
||||
assert body is None
|
||||
assert headers == {}
|
||||
|
||||
def test_none_body(self):
|
||||
body, headers = RequestBodySerializer.serialize(None, ContentType.JSON)
|
||||
assert body is None
|
||||
|
||||
def test_unknown_content_type_falls_back_to_json(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"k": "v"}, "application/vnd.custom+json"
|
||||
)
|
||||
assert json.loads(body) == {"k": "v"}
|
||||
|
||||
def test_content_type_with_charset_suffix(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"k": "v"}, "application/json; charset=utf-8"
|
||||
)
|
||||
assert json.loads(body) == {"k": "v"}
|
||||
|
||||
def test_compact_json_format(self):
|
||||
body, _ = RequestBodySerializer.serialize(
|
||||
{"a": 1, "b": 2}, ContentType.JSON
|
||||
)
|
||||
# Should use compact separators
|
||||
assert " " not in body
|
||||
|
||||
def test_unicode_json(self):
|
||||
body, _ = RequestBodySerializer.serialize(
|
||||
{"name": "Heisenberg"}, ContentType.JSON
|
||||
)
|
||||
parsed = json.loads(body)
|
||||
assert parsed["name"] == "Heisenberg"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Form URL-Encoded Serialization
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSerializeFormUrlencoded:
|
||||
|
||||
def test_basic_form(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"name": "Alice", "age": "30"}, ContentType.FORM_URLENCODED
|
||||
)
|
||||
assert "name=Alice" in body
|
||||
assert "age=30" in body
|
||||
assert headers["Content-Type"] == "application/x-www-form-urlencoded"
|
||||
|
||||
def test_none_values_skipped(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"name": "Alice", "skip": None}, ContentType.FORM_URLENCODED
|
||||
)
|
||||
assert "name=Alice" in body
|
||||
assert "skip" not in body
|
||||
|
||||
def test_list_explode_true(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"tags": ["a", "b"]},
|
||||
ContentType.FORM_URLENCODED,
|
||||
encoding_rules={"tags": {"style": "form", "explode": True}},
|
||||
)
|
||||
assert "tags=a" in body
|
||||
assert "tags=b" in body
|
||||
|
||||
def test_list_explode_false(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"tags": ["a", "b"]},
|
||||
ContentType.FORM_URLENCODED,
|
||||
encoding_rules={"tags": {"style": "form", "explode": False}},
|
||||
)
|
||||
assert "tags=" in body
|
||||
assert "a" in body and "b" in body
|
||||
|
||||
def test_dict_value_json_content_type(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"metadata": {"key": "val"}},
|
||||
ContentType.FORM_URLENCODED,
|
||||
encoding_rules={"metadata": {"contentType": "application/json"}},
|
||||
)
|
||||
assert "metadata" in body
|
||||
|
||||
def test_dict_value_xml_content_type(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"data": {"name": "test"}},
|
||||
ContentType.FORM_URLENCODED,
|
||||
encoding_rules={"data": {"contentType": "application/xml"}},
|
||||
)
|
||||
assert "data" in body
|
||||
|
||||
def test_dict_value_deep_object_explode(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"filter": {"status": "active", "type": "doc"}},
|
||||
ContentType.FORM_URLENCODED,
|
||||
encoding_rules={
|
||||
"filter": {"style": "deepObject", "explode": True}
|
||||
},
|
||||
)
|
||||
assert "filter" in body
|
||||
|
||||
def test_dict_value_non_exploded(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"obj": {"a": "1", "b": "2"}},
|
||||
ContentType.FORM_URLENCODED,
|
||||
encoding_rules={"obj": {"style": "form", "explode": False}},
|
||||
)
|
||||
assert "obj" in body
|
||||
|
||||
def test_default_explode_for_form_style(self):
|
||||
"""Default explode should be True when style is 'form'."""
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"items": ["x", "y"]},
|
||||
ContentType.FORM_URLENCODED,
|
||||
encoding_rules={"items": {"style": "form"}},
|
||||
)
|
||||
# explode defaults to True for form style => separate params
|
||||
assert "items=x" in body
|
||||
assert "items=y" in body
|
||||
|
||||
def test_special_characters_encoded(self):
|
||||
body, _ = RequestBodySerializer.serialize(
|
||||
{"q": "hello world&more"}, ContentType.FORM_URLENCODED
|
||||
)
|
||||
assert "hello" in body
|
||||
assert "q=" in body
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Text Plain Serialization
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSerializeTextPlain:
|
||||
|
||||
def test_single_value(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"message": "hello"}, ContentType.TEXT_PLAIN
|
||||
)
|
||||
assert body == "hello"
|
||||
assert headers["Content-Type"] == "text/plain"
|
||||
|
||||
def test_multiple_values(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"name": "Alice", "age": 30}, ContentType.TEXT_PLAIN
|
||||
)
|
||||
assert "name: Alice" in body
|
||||
assert "age: 30" in body
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# XML Serialization
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSerializeXml:
|
||||
|
||||
def test_basic_xml(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"name": "Alice"}, ContentType.XML
|
||||
)
|
||||
assert '<?xml version="1.0"' in body
|
||||
assert "<name>Alice</name>" in body
|
||||
assert headers["Content-Type"] == "application/xml"
|
||||
|
||||
def test_nested_xml(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"user": {"name": "Alice"}}, ContentType.XML
|
||||
)
|
||||
assert "<user>" in body
|
||||
assert "<name>Alice</name>" in body
|
||||
|
||||
def test_xml_escapes_special_chars(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"data": "<script>alert('xss')</script>"}, ContentType.XML
|
||||
)
|
||||
assert "<script>" in body
|
||||
|
||||
def test_xml_with_list(self):
|
||||
body, _ = RequestBodySerializer.serialize(
|
||||
{"items": [1, 2, 3]}, ContentType.XML
|
||||
)
|
||||
assert "<item>1</item>" in body
|
||||
assert "<item>2</item>" in body
|
||||
assert "<item>3</item>" in body
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Octet Stream Serialization
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSerializeOctetStream:
|
||||
|
||||
def test_dict_body(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"key": "val"}, ContentType.OCTET_STREAM
|
||||
)
|
||||
assert isinstance(body, bytes)
|
||||
assert headers["Content-Type"] == "application/octet-stream"
|
||||
|
||||
def test_bytes_body(self):
|
||||
body, headers = RequestBodySerializer._serialize_octet_stream(b"\x00\x01")
|
||||
assert body == b"\x00\x01"
|
||||
assert headers["Content-Type"] == "application/octet-stream"
|
||||
|
||||
def test_string_body(self):
|
||||
body, headers = RequestBodySerializer._serialize_octet_stream("hello")
|
||||
assert body == b"hello"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Multipart Form Data Serialization
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSerializeMultipartFormData:
|
||||
|
||||
def test_basic_multipart(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"field": "value"}, ContentType.MULTIPART_FORM_DATA
|
||||
)
|
||||
assert isinstance(body, bytes)
|
||||
assert "multipart/form-data" in headers["Content-Type"]
|
||||
assert "boundary=" in headers["Content-Type"]
|
||||
|
||||
def test_none_values_skipped(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"field": "value", "empty": None}, ContentType.MULTIPART_FORM_DATA
|
||||
)
|
||||
body_str = body.decode("utf-8", errors="replace")
|
||||
assert "field" in body_str
|
||||
assert "empty" not in body_str
|
||||
|
||||
def test_multipart_with_bytes(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"file": b"\x00\x01\x02"}, ContentType.MULTIPART_FORM_DATA
|
||||
)
|
||||
assert isinstance(body, bytes)
|
||||
|
||||
def test_multipart_with_dict_json(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"meta": {"key": "val"}},
|
||||
ContentType.MULTIPART_FORM_DATA,
|
||||
encoding_rules={"meta": {"contentType": "application/json"}},
|
||||
)
|
||||
body_str = body.decode("utf-8", errors="replace")
|
||||
assert "meta" in body_str
|
||||
assert "application/json" in body_str
|
||||
|
||||
def test_multipart_with_dict_xml(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"data": {"name": "test"}},
|
||||
ContentType.MULTIPART_FORM_DATA,
|
||||
encoding_rules={"data": {"contentType": "application/xml"}},
|
||||
)
|
||||
body_str = body.decode("utf-8", errors="replace")
|
||||
assert "data" in body_str
|
||||
|
||||
def test_multipart_octet_stream_bytes(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"bin": b"\xff\xfe"},
|
||||
ContentType.MULTIPART_FORM_DATA,
|
||||
encoding_rules={"bin": {"contentType": "application/octet-stream"}},
|
||||
)
|
||||
body_str = body.decode("utf-8", errors="replace")
|
||||
assert "bin" in body_str
|
||||
assert "Content-Transfer-Encoding: base64" in body_str
|
||||
|
||||
def test_multipart_string_with_json_content_type(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"json_str": '{"a": 1}'},
|
||||
ContentType.MULTIPART_FORM_DATA,
|
||||
encoding_rules={"json_str": {"contentType": "application/json"}},
|
||||
)
|
||||
body_str = body.decode("utf-8", errors="replace")
|
||||
assert "json_str" in body_str
|
||||
|
||||
def test_multipart_string_with_non_text_content_type(self):
|
||||
body, headers = RequestBodySerializer.serialize(
|
||||
{"custom": "data"},
|
||||
ContentType.MULTIPART_FORM_DATA,
|
||||
encoding_rules={"custom": {"contentType": "application/custom"}},
|
||||
)
|
||||
body_str = body.decode("utf-8", errors="replace")
|
||||
assert "custom" in body_str
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Helper Methods
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHelpers:
|
||||
|
||||
def test_percent_encode_space(self):
|
||||
assert RequestBodySerializer._percent_encode("hello world") == "hello%20world"
|
||||
|
||||
def test_percent_encode_slash(self):
|
||||
assert RequestBodySerializer._percent_encode("a/b") == "a%2Fb"
|
||||
|
||||
def test_percent_encode_safe_chars(self):
|
||||
assert RequestBodySerializer._percent_encode("a/b", safe_chars="/") == "a/b"
|
||||
|
||||
def test_escape_xml_ampersand(self):
|
||||
assert "&" in RequestBodySerializer._escape_xml("&")
|
||||
|
||||
def test_escape_xml_lt(self):
|
||||
assert "<" in RequestBodySerializer._escape_xml("<")
|
||||
|
||||
def test_escape_xml_gt(self):
|
||||
assert ">" in RequestBodySerializer._escape_xml(">")
|
||||
|
||||
def test_escape_xml_quote(self):
|
||||
assert """ in RequestBodySerializer._escape_xml('"')
|
||||
|
||||
def test_escape_xml_apos(self):
|
||||
assert "'" in RequestBodySerializer._escape_xml("'")
|
||||
|
||||
def test_dict_to_xml_list(self):
|
||||
xml = RequestBodySerializer._dict_to_xml({"items": [1, 2, 3]})
|
||||
assert "<item>1</item>" in xml
|
||||
assert "<item>2</item>" in xml
|
||||
|
||||
def test_dict_to_xml_custom_root(self):
|
||||
xml = RequestBodySerializer._dict_to_xml({"key": "val"}, root_name="data")
|
||||
assert "<data>" in xml
|
||||
assert "<key>val</key>" in xml
|
||||
|
||||
def test_dict_to_xml_deeply_nested(self):
|
||||
xml = RequestBodySerializer._dict_to_xml({"a": {"b": {"c": "deep"}}})
|
||||
assert "<c>deep</c>" in xml
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Error Handling
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSerializationErrors:
|
||||
|
||||
def test_serialize_raises_on_internal_error(self):
|
||||
"""Test that serialization errors are wrapped in ValueError."""
|
||||
# Patch _serialize_json to raise
|
||||
with pytest.raises(ValueError, match="Failed to serialize"):
|
||||
RequestBodySerializer.serialize(
|
||||
{"key": object()}, # object() is not JSON-serializable
|
||||
ContentType.JSON,
|
||||
)
|
||||
@@ -0,0 +1,516 @@
|
||||
"""Comprehensive tests for application/agents/tools/api_tool.py
|
||||
|
||||
Covers: APITool initialization, all HTTP methods, path param substitution,
|
||||
SSRF validation, error handling, response parsing, body serialization.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from application.agents.tools.api_tool import APITool, DEFAULT_TIMEOUT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_tool():
|
||||
return APITool(
|
||||
config={
|
||||
"url": "https://api.example.com/data",
|
||||
"method": "GET",
|
||||
"headers": {"Accept": "application/json"},
|
||||
"query_params": {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post_tool():
|
||||
return APITool(
|
||||
config={
|
||||
"url": "https://api.example.com/items",
|
||||
"method": "POST",
|
||||
"headers": {},
|
||||
"query_params": {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Initialization
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAPIToolInit:
|
||||
|
||||
def test_default_values(self):
|
||||
tool = APITool(config={})
|
||||
assert tool.url == ""
|
||||
assert tool.method == "GET"
|
||||
assert tool.headers == {}
|
||||
assert tool.query_params == {}
|
||||
assert tool.body_content_type == "application/json"
|
||||
assert tool.body_encoding_rules == {}
|
||||
|
||||
def test_custom_config(self):
|
||||
tool = APITool(config={
|
||||
"url": "https://api.test.com",
|
||||
"method": "POST",
|
||||
"headers": {"X-Key": "val"},
|
||||
"query_params": {"page": "1"},
|
||||
"body_content_type": "application/xml",
|
||||
"body_encoding_rules": {"field": {"style": "form"}},
|
||||
})
|
||||
assert tool.url == "https://api.test.com"
|
||||
assert tool.method == "POST"
|
||||
assert tool.headers == {"X-Key": "val"}
|
||||
assert tool.query_params == {"page": "1"}
|
||||
assert tool.body_content_type == "application/xml"
|
||||
|
||||
def test_default_timeout_constant(self):
|
||||
assert DEFAULT_TIMEOUT == 90
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# HTTP Methods
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMakeApiCall:
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.get")
|
||||
def test_successful_get(self, mock_get, mock_validate, get_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.json.return_value = {"result": "ok"}
|
||||
mock_resp.content = b'{"result":"ok"}'
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
result = get_tool.execute_action("any_action")
|
||||
|
||||
assert result["status_code"] == 200
|
||||
assert result["data"] == {"result": "ok"}
|
||||
assert result["message"] == "API call successful."
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.post")
|
||||
def test_successful_post(self, mock_post, mock_validate, post_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 201
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.json.return_value = {"id": 1}
|
||||
mock_resp.content = b'{"id":1}'
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
result = post_tool.execute_action("create", name="test")
|
||||
assert result["status_code"] == 201
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.put")
|
||||
def test_put_method(self, mock_put, mock_validate):
|
||||
tool = APITool(config={"url": "https://example.com/item/1", "method": "PUT"})
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.json.return_value = {}
|
||||
mock_resp.content = b'{}'
|
||||
mock_put.return_value = mock_resp
|
||||
|
||||
result = tool.execute_action("update", name="new")
|
||||
assert result["status_code"] == 200
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.delete")
|
||||
def test_delete_method(self, mock_delete, mock_validate):
|
||||
tool = APITool(config={"url": "https://example.com/item/1", "method": "DELETE"})
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 204
|
||||
mock_resp.headers = {"Content-Type": "text/plain"}
|
||||
mock_resp.content = b''
|
||||
mock_delete.return_value = mock_resp
|
||||
|
||||
result = tool.execute_action("delete")
|
||||
assert result["status_code"] == 204
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.patch")
|
||||
def test_patch_method(self, mock_patch, mock_validate):
|
||||
tool = APITool(config={"url": "https://example.com/item/1", "method": "PATCH"})
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.json.return_value = {"patched": True}
|
||||
mock_resp.content = b'{"patched":true}'
|
||||
mock_patch.return_value = mock_resp
|
||||
|
||||
result = tool.execute_action("patch", field="val")
|
||||
assert result["status_code"] == 200
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.head")
|
||||
def test_head_method(self, mock_head, mock_validate):
|
||||
tool = APITool(config={"url": "https://example.com", "method": "HEAD"})
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.headers = {"Content-Type": "text/html"}
|
||||
mock_resp.content = b''
|
||||
mock_head.return_value = mock_resp
|
||||
|
||||
result = tool.execute_action("check")
|
||||
assert result["status_code"] == 200
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.options")
|
||||
def test_options_method(self, mock_options, mock_validate):
|
||||
tool = APITool(config={"url": "https://example.com", "method": "OPTIONS"})
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.headers = {"Content-Type": "text/plain"}
|
||||
mock_resp.content = b''
|
||||
mock_options.return_value = mock_resp
|
||||
|
||||
result = tool.execute_action("options")
|
||||
assert result["status_code"] == 200
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
def test_unsupported_method(self, mock_validate):
|
||||
tool = APITool(config={"url": "https://example.com", "method": "CUSTOM"})
|
||||
result = tool.execute_action("any")
|
||||
assert result["status_code"] is None
|
||||
assert "Unsupported" in result["message"]
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# SSRF Validation
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSSRFValidation:
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
def test_ssrf_blocked_initial_url(self, mock_validate, get_tool):
|
||||
from application.core.url_validation import SSRFError
|
||||
|
||||
mock_validate.side_effect = SSRFError("blocked")
|
||||
result = get_tool.execute_action("any")
|
||||
assert result["status_code"] is None
|
||||
assert "URL validation error" in result["message"]
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.get")
|
||||
def test_ssrf_blocked_after_param_substitution(self, mock_get, mock_validate):
|
||||
from application.core.url_validation import SSRFError
|
||||
|
||||
tool = APITool(config={
|
||||
"url": "https://api.example.com/{host}/data",
|
||||
"method": "GET",
|
||||
"query_params": {"host": "169.254.169.254"},
|
||||
})
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def side_effect(url):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 2:
|
||||
raise SSRFError("blocked after substitution")
|
||||
|
||||
mock_validate.side_effect = side_effect
|
||||
result = tool.execute_action("any")
|
||||
assert result["status_code"] is None
|
||||
assert "URL validation error" in result["message"]
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Error Handling
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestErrorHandling:
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.get")
|
||||
def test_timeout_error(self, mock_get, mock_validate, get_tool):
|
||||
mock_get.side_effect = requests.exceptions.Timeout()
|
||||
result = get_tool.execute_action("any")
|
||||
assert result["status_code"] is None
|
||||
assert "timeout" in result["message"].lower()
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.get")
|
||||
def test_connection_error(self, mock_get, mock_validate, get_tool):
|
||||
mock_get.side_effect = requests.exceptions.ConnectionError("refused")
|
||||
result = get_tool.execute_action("any")
|
||||
assert result["status_code"] is None
|
||||
assert "Connection error" in result["message"]
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.get")
|
||||
def test_http_error_with_json(self, mock_get, mock_validate, get_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 422
|
||||
mock_resp.json.return_value = {"error": "invalid_field"}
|
||||
mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
||||
response=mock_resp
|
||||
)
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
result = get_tool.execute_action("any")
|
||||
assert result["status_code"] == 422
|
||||
assert result["data"] == {"error": "invalid_field"}
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.get")
|
||||
def test_http_error_non_json_body(self, mock_get, mock_validate, get_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
mock_resp.text = "Not Found"
|
||||
mock_resp.json.side_effect = json.JSONDecodeError("", "", 0)
|
||||
mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
||||
response=mock_resp
|
||||
)
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
result = get_tool.execute_action("any")
|
||||
assert result["status_code"] == 404
|
||||
assert result["data"] == "Not Found"
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.get")
|
||||
def test_request_exception(self, mock_get, mock_validate, get_tool):
|
||||
mock_get.side_effect = requests.exceptions.RequestException("something")
|
||||
result = get_tool.execute_action("any")
|
||||
assert "API call failed" in result["message"]
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.get")
|
||||
def test_unexpected_exception(self, mock_get, mock_validate, get_tool):
|
||||
mock_get.side_effect = RuntimeError("unexpected")
|
||||
result = get_tool.execute_action("any")
|
||||
assert "Unexpected error" in result["message"]
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.post")
|
||||
def test_body_serialization_error(self, mock_post, mock_validate):
|
||||
tool = APITool(config={
|
||||
"url": "https://example.com",
|
||||
"method": "POST",
|
||||
"body_content_type": "application/json",
|
||||
})
|
||||
|
||||
with patch(
|
||||
"application.agents.tools.api_tool.RequestBodySerializer.serialize",
|
||||
side_effect=ValueError("serialize fail"),
|
||||
):
|
||||
result = tool.execute_action("any", key="val")
|
||||
assert "serialization error" in result["message"].lower()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Path Param Substitution
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPathParamSubstitution:
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.get")
|
||||
def test_path_params_substituted(self, mock_get, mock_validate):
|
||||
tool = APITool(config={
|
||||
"url": "https://api.example.com/users/{user_id}/posts/{post_id}",
|
||||
"method": "GET",
|
||||
"query_params": {"user_id": "42", "post_id": "7"},
|
||||
})
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.json.return_value = []
|
||||
mock_resp.content = b'[]'
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
tool.execute_action("get")
|
||||
|
||||
called_url = mock_get.call_args[0][0]
|
||||
assert "/users/42/posts/7" in called_url
|
||||
assert "{user_id}" not in called_url
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.get")
|
||||
def test_remaining_query_params_appended(self, mock_get, mock_validate):
|
||||
tool = APITool(config={
|
||||
"url": "https://api.example.com/items",
|
||||
"method": "GET",
|
||||
"query_params": {"page": "2", "limit": "10"},
|
||||
})
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.json.return_value = []
|
||||
mock_resp.content = b'[]'
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
tool.execute_action("get")
|
||||
|
||||
called_url = mock_get.call_args[0][0]
|
||||
assert "page=2" in called_url
|
||||
assert "limit=10" in called_url
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.get")
|
||||
def test_query_params_append_with_existing_query_string(
|
||||
self, mock_get, mock_validate
|
||||
):
|
||||
tool = APITool(config={
|
||||
"url": "https://api.example.com/items?existing=true",
|
||||
"method": "GET",
|
||||
"query_params": {"page": "1"},
|
||||
})
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.json.return_value = []
|
||||
mock_resp.content = b'[]'
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
tool.execute_action("get")
|
||||
|
||||
called_url = mock_get.call_args[0][0]
|
||||
assert "&page=1" in called_url
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.post")
|
||||
def test_empty_body_no_serialization(self, mock_post, mock_validate):
|
||||
tool = APITool(config={"url": "https://example.com", "method": "POST"})
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.json.return_value = {}
|
||||
mock_resp.content = b'{}'
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
result = tool.execute_action("create")
|
||||
assert result["status_code"] == 200
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Parse Response
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestParseResponse:
|
||||
|
||||
def test_json_response(self, get_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.json.return_value = {"key": "val"}
|
||||
mock_resp.content = b'{"key":"val"}'
|
||||
|
||||
result = get_tool._parse_response(mock_resp)
|
||||
assert result == {"key": "val"}
|
||||
|
||||
def test_json_decode_error_falls_back_to_text(self, get_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.json.side_effect = json.JSONDecodeError("", "", 0)
|
||||
mock_resp.text = "not valid json"
|
||||
mock_resp.content = b"not valid json"
|
||||
|
||||
result = get_tool._parse_response(mock_resp)
|
||||
assert result == "not valid json"
|
||||
|
||||
def test_text_response(self, get_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.headers = {"Content-Type": "text/plain"}
|
||||
mock_resp.text = "plain text"
|
||||
mock_resp.content = b"plain text"
|
||||
|
||||
result = get_tool._parse_response(mock_resp)
|
||||
assert result == "plain text"
|
||||
|
||||
def test_xml_response(self, get_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.headers = {"Content-Type": "application/xml"}
|
||||
mock_resp.text = "<root><item>1</item></root>"
|
||||
mock_resp.content = b"<root><item>1</item></root>"
|
||||
|
||||
result = get_tool._parse_response(mock_resp)
|
||||
assert "<root>" in result
|
||||
|
||||
def test_html_response(self, get_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.headers = {"Content-Type": "text/html"}
|
||||
mock_resp.text = "<html><body>Hi</body></html>"
|
||||
mock_resp.content = b"<html><body>Hi</body></html>"
|
||||
|
||||
result = get_tool._parse_response(mock_resp)
|
||||
assert "<html>" in result
|
||||
|
||||
def test_empty_content(self, get_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.content = b""
|
||||
|
||||
result = get_tool._parse_response(mock_resp)
|
||||
assert result is None
|
||||
|
||||
def test_binary_response(self, get_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.headers = {"Content-Type": "application/octet-stream"}
|
||||
mock_resp.text = "binary_text"
|
||||
mock_resp.content = b"\x00\x01\x02"
|
||||
|
||||
result = get_tool._parse_response(mock_resp)
|
||||
assert result is not None
|
||||
|
||||
def test_text_xml_content_type(self, get_tool):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.headers = {"Content-Type": "text/xml"}
|
||||
mock_resp.text = "<data/>"
|
||||
mock_resp.content = b"<data/>"
|
||||
|
||||
result = get_tool._parse_response(mock_resp)
|
||||
assert result == "<data/>"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Metadata
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAPIToolMetadata:
|
||||
|
||||
def test_actions_metadata_empty(self, get_tool):
|
||||
assert get_tool.get_actions_metadata() == []
|
||||
|
||||
def test_config_requirements_empty(self, get_tool):
|
||||
assert get_tool.get_config_requirements() == {}
|
||||
|
||||
@patch("application.agents.tools.api_tool.validate_url")
|
||||
@patch("application.agents.tools.api_tool.requests.post")
|
||||
def test_content_type_set_for_post_with_no_headers(
|
||||
self, mock_post, mock_validate
|
||||
):
|
||||
tool = APITool(config={
|
||||
"url": "https://example.com",
|
||||
"method": "POST",
|
||||
"headers": {},
|
||||
})
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.json.return_value = {}
|
||||
mock_resp.content = b'{}'
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
tool.execute_action("create")
|
||||
call_headers = mock_post.call_args[1]["headers"]
|
||||
assert "Content-Type" in call_headers
|
||||
@@ -0,0 +1,596 @@
|
||||
"""Comprehensive tests for application/agents/tools/internal_search.py
|
||||
|
||||
Covers: InternalSearchTool (search, list_files, path_filter, error handling,
|
||||
directory structure loading), build helpers, add_internal_search_tool,
|
||||
sources_have_directory_structure.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from application.agents.tools.internal_search import (
|
||||
INTERNAL_TOOL_ENTRY,
|
||||
INTERNAL_TOOL_ID,
|
||||
InternalSearchTool,
|
||||
add_internal_search_tool,
|
||||
build_internal_tool_config,
|
||||
build_internal_tool_entry,
|
||||
sources_have_directory_structure,
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# InternalSearchTool - Search
|
||||
# =====================================================================
|
||||
|
||||
|
||||
def _make_tool(**config_overrides):
|
||||
config = {"source": {}, "retriever_name": "classic", "chunks": 2}
|
||||
config.update(config_overrides)
|
||||
return InternalSearchTool(config)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestInternalSearchToolSearch:
|
||||
|
||||
def test_search_no_query_returns_error(self):
|
||||
tool = _make_tool()
|
||||
result = tool.execute_action("search", query="")
|
||||
assert "required" in result.lower()
|
||||
|
||||
def test_search_returns_formatted_docs(self):
|
||||
tool = _make_tool()
|
||||
mock_retriever = Mock()
|
||||
mock_retriever.search.return_value = [
|
||||
{
|
||||
"text": "Hello world",
|
||||
"title": "Doc1",
|
||||
"source": "test",
|
||||
"filename": "doc1.md",
|
||||
},
|
||||
]
|
||||
tool._retriever = mock_retriever
|
||||
|
||||
result = tool.execute_action("search", query="hello")
|
||||
assert "doc1.md" in result
|
||||
assert "Hello world" in result
|
||||
assert len(tool.retrieved_docs) == 1
|
||||
|
||||
def test_search_no_results(self):
|
||||
tool = _make_tool()
|
||||
mock_retriever = Mock()
|
||||
mock_retriever.search.return_value = []
|
||||
tool._retriever = mock_retriever
|
||||
|
||||
result = tool.execute_action("search", query="nonexistent")
|
||||
assert "No documents found" in result
|
||||
|
||||
def test_search_accumulates_docs(self):
|
||||
tool = _make_tool()
|
||||
mock_retriever = Mock()
|
||||
tool._retriever = mock_retriever
|
||||
|
||||
mock_retriever.search.return_value = [
|
||||
{"text": "A", "title": "D1", "source": "s1"},
|
||||
]
|
||||
tool.execute_action("search", query="first")
|
||||
|
||||
mock_retriever.search.return_value = [
|
||||
{"text": "B", "title": "D2", "source": "s2"},
|
||||
]
|
||||
tool.execute_action("search", query="second")
|
||||
|
||||
assert len(tool.retrieved_docs) == 2
|
||||
|
||||
def test_search_deduplicates_docs(self):
|
||||
tool = _make_tool()
|
||||
doc = {"text": "Same", "title": "Same", "source": "same"}
|
||||
mock_retriever = Mock()
|
||||
mock_retriever.search.return_value = [doc]
|
||||
tool._retriever = mock_retriever
|
||||
|
||||
tool.execute_action("search", query="q1")
|
||||
tool.execute_action("search", query="q2")
|
||||
|
||||
assert len(tool.retrieved_docs) == 1
|
||||
|
||||
def test_search_with_path_filter(self):
|
||||
tool = _make_tool()
|
||||
mock_retriever = Mock()
|
||||
mock_retriever.search.return_value = [
|
||||
{"text": "A", "title": "T", "source": "src/main.py", "filename": "main.py"},
|
||||
{"text": "B", "title": "T", "source": "docs/readme.md", "filename": "readme.md"},
|
||||
]
|
||||
tool._retriever = mock_retriever
|
||||
|
||||
result = tool.execute_action("search", query="code", path_filter="src/")
|
||||
assert "main.py" in result
|
||||
assert "readme.md" not in result
|
||||
|
||||
def test_search_path_filter_matches_title(self):
|
||||
tool = _make_tool()
|
||||
mock_retriever = Mock()
|
||||
mock_retriever.search.return_value = [
|
||||
{"text": "A", "title": "src/main.py", "source": "other", "filename": ""},
|
||||
]
|
||||
tool._retriever = mock_retriever
|
||||
|
||||
result = tool.execute_action("search", query="code", path_filter="src/main")
|
||||
assert "src/main.py" in result
|
||||
|
||||
def test_search_path_filter_no_match(self):
|
||||
tool = _make_tool()
|
||||
mock_retriever = Mock()
|
||||
mock_retriever.search.return_value = [
|
||||
{"text": "A", "title": "T", "source": "other/file.txt"},
|
||||
]
|
||||
tool._retriever = mock_retriever
|
||||
|
||||
result = tool.execute_action("search", query="code", path_filter="src/")
|
||||
assert "No documents found" in result
|
||||
|
||||
def test_search_retriever_error(self):
|
||||
tool = _make_tool()
|
||||
mock_retriever = Mock()
|
||||
mock_retriever.search.side_effect = Exception("Connection error")
|
||||
tool._retriever = mock_retriever
|
||||
|
||||
result = tool.execute_action("search", query="test")
|
||||
assert "failed" in result.lower() or "error" in result.lower()
|
||||
|
||||
def test_unknown_action(self):
|
||||
tool = _make_tool()
|
||||
result = tool.execute_action("nonexistent")
|
||||
assert "Unknown action" in result
|
||||
|
||||
def test_search_formats_with_separator(self):
|
||||
tool = _make_tool()
|
||||
mock_retriever = Mock()
|
||||
mock_retriever.search.return_value = [
|
||||
{"text": "A", "title": "D1", "source": "s1", "filename": "f1.md"},
|
||||
{"text": "B", "title": "D2", "source": "s2", "filename": "f2.md"},
|
||||
]
|
||||
tool._retriever = mock_retriever
|
||||
|
||||
result = tool.execute_action("search", query="test")
|
||||
assert "---" in result
|
||||
assert "[1]" in result
|
||||
assert "[2]" in result
|
||||
|
||||
def test_search_uses_title_when_no_filename(self):
|
||||
tool = _make_tool()
|
||||
mock_retriever = Mock()
|
||||
mock_retriever.search.return_value = [
|
||||
{"text": "Content", "title": "My Title", "source": "src", "filename": ""},
|
||||
]
|
||||
tool._retriever = mock_retriever
|
||||
|
||||
result = tool.execute_action("search", query="q")
|
||||
assert "My Title" in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# InternalSearchTool - List Files
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestInternalSearchToolListFiles:
|
||||
|
||||
def test_list_files_no_structure(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
tool._dir_structure_loaded = True
|
||||
tool._directory_structure = None
|
||||
|
||||
result = tool.execute_action("list_files")
|
||||
assert "No file structure" in result
|
||||
|
||||
def test_list_files_root(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
tool._dir_structure_loaded = True
|
||||
tool._directory_structure = {
|
||||
"src": {"main.py": {}},
|
||||
"README.md": {"type": "md", "token_count": 100},
|
||||
}
|
||||
|
||||
result = tool.execute_action("list_files")
|
||||
assert "src/" in result
|
||||
assert "README.md" in result
|
||||
|
||||
def test_list_files_nested_path(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
tool._dir_structure_loaded = True
|
||||
tool._directory_structure = {
|
||||
"src": {
|
||||
"utils": {"helper.py": {}},
|
||||
},
|
||||
}
|
||||
|
||||
result = tool.execute_action("list_files", path="src")
|
||||
assert "utils/" in result
|
||||
|
||||
def test_list_files_invalid_path(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
tool._dir_structure_loaded = True
|
||||
tool._directory_structure = {"src": {}}
|
||||
|
||||
result = tool.execute_action("list_files", path="nonexistent")
|
||||
assert "not found" in result
|
||||
|
||||
def test_list_files_empty_directory(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
tool._dir_structure_loaded = True
|
||||
tool._directory_structure = {"empty_dir": {}}
|
||||
|
||||
result = tool.execute_action("list_files", path="empty_dir")
|
||||
assert "(empty)" in result
|
||||
|
||||
def test_list_files_file_with_metadata(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
tool._dir_structure_loaded = True
|
||||
tool._directory_structure = {
|
||||
"data.csv": {
|
||||
"type": "text/csv",
|
||||
"size_bytes": 1024,
|
||||
"token_count": 500,
|
||||
},
|
||||
}
|
||||
|
||||
result = tool.execute_action("list_files")
|
||||
assert "data.csv" in result
|
||||
assert "500 tokens" in result
|
||||
assert "text/csv" in result
|
||||
|
||||
def test_list_files_file_is_not_directory(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
tool._dir_structure_loaded = True
|
||||
tool._directory_structure = {
|
||||
"src": {
|
||||
"main.py": "plain_file_value",
|
||||
},
|
||||
}
|
||||
|
||||
result = tool.execute_action("list_files", path="src/main.py")
|
||||
assert "is a file" in result
|
||||
|
||||
def test_list_files_deep_nested_path_with_slashes(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
tool._dir_structure_loaded = True
|
||||
tool._directory_structure = {
|
||||
"a": {"b": {"c": {"file.txt": {"type": "text"}}}},
|
||||
}
|
||||
|
||||
result = tool.execute_action("list_files", path="a/b/c")
|
||||
assert "file.txt" in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Count Files Helper
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCountFiles:
|
||||
|
||||
def test_count_files_nested(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
node = {
|
||||
"file1.txt": {"type": "text"},
|
||||
"dir": {
|
||||
"file2.txt": {"type": "text"},
|
||||
"file3.txt": "plain_value",
|
||||
},
|
||||
}
|
||||
assert tool._count_files(node) == 3
|
||||
|
||||
def test_count_files_empty(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
assert tool._count_files({}) == 0
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Directory Structure Loading
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetDirectoryStructure:
|
||||
|
||||
def test_loads_from_mongo(self):
|
||||
tool = InternalSearchTool({
|
||||
"source": {"active_docs": ["507f1f77bcf86cd799439011"]},
|
||||
})
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.find_one.return_value = {
|
||||
"_id": "507f1f77bcf86cd799439011",
|
||||
"name": "test_source",
|
||||
"directory_structure": {"src": {"main.py": {}}},
|
||||
}
|
||||
|
||||
with patch("application.core.mongo_db.MongoDB") as mock_mongo:
|
||||
mock_db = MagicMock()
|
||||
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
|
||||
mock_client = MagicMock()
|
||||
mock_client.__getitem__ = MagicMock(return_value=mock_db)
|
||||
mock_mongo.get_client.return_value = mock_client
|
||||
|
||||
result = tool._get_directory_structure()
|
||||
assert result is not None
|
||||
assert "src" in result
|
||||
|
||||
def test_returns_none_without_active_docs(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
result = tool._get_directory_structure()
|
||||
assert result is None
|
||||
assert tool._dir_structure_loaded is True
|
||||
|
||||
def test_caches_after_first_load(self):
|
||||
tool = InternalSearchTool({"source": {}})
|
||||
tool._dir_structure_loaded = True
|
||||
tool._directory_structure = {"cached": True}
|
||||
|
||||
result = tool._get_directory_structure()
|
||||
assert result == {"cached": True}
|
||||
|
||||
def test_handles_json_string_structure(self):
|
||||
tool = InternalSearchTool({
|
||||
"source": {"active_docs": ["507f1f77bcf86cd799439011"]},
|
||||
})
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.find_one.return_value = {
|
||||
"_id": "507f1f77bcf86cd799439011",
|
||||
"name": "test_source",
|
||||
"directory_structure": json.dumps({"src": {"app.py": {}}}),
|
||||
}
|
||||
|
||||
with patch("application.core.mongo_db.MongoDB") as mock_mongo:
|
||||
mock_db = MagicMock()
|
||||
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
|
||||
mock_client = MagicMock()
|
||||
mock_client.__getitem__ = MagicMock(return_value=mock_db)
|
||||
mock_mongo.get_client.return_value = mock_client
|
||||
|
||||
result = tool._get_directory_structure()
|
||||
assert result is not None
|
||||
assert "src" in result
|
||||
|
||||
def test_handles_string_active_docs(self):
|
||||
tool = InternalSearchTool({
|
||||
"source": {"active_docs": "507f1f77bcf86cd799439011"},
|
||||
})
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.find_one.return_value = {
|
||||
"_id": "507f1f77bcf86cd799439011",
|
||||
"directory_structure": {"dir": {}},
|
||||
}
|
||||
|
||||
with patch("application.core.mongo_db.MongoDB") as mock_mongo:
|
||||
mock_db = MagicMock()
|
||||
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
|
||||
mock_client = MagicMock()
|
||||
mock_client.__getitem__ = MagicMock(return_value=mock_db)
|
||||
mock_mongo.get_client.return_value = mock_client
|
||||
|
||||
result = tool._get_directory_structure()
|
||||
assert result is not None
|
||||
|
||||
def test_merges_multiple_sources(self):
|
||||
tool = InternalSearchTool({
|
||||
"source": {
|
||||
"active_docs": [
|
||||
"507f1f77bcf86cd799439011",
|
||||
"507f1f77bcf86cd799439012",
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.find_one.side_effect = [
|
||||
{"name": "src1", "directory_structure": {"a": {}}},
|
||||
{"name": "src2", "directory_structure": {"b": {}}},
|
||||
]
|
||||
|
||||
with patch("application.core.mongo_db.MongoDB") as mock_mongo:
|
||||
mock_db = MagicMock()
|
||||
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
|
||||
mock_client = MagicMock()
|
||||
mock_client.__getitem__ = MagicMock(return_value=mock_db)
|
||||
mock_mongo.get_client.return_value = mock_client
|
||||
|
||||
result = tool._get_directory_structure()
|
||||
assert "src1" in result
|
||||
assert "src2" in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Metadata
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestInternalSearchToolMetadata:
|
||||
|
||||
def test_actions_without_directory_structure(self):
|
||||
tool = InternalSearchTool({"has_directory_structure": False})
|
||||
meta = tool.get_actions_metadata()
|
||||
|
||||
action_names = [a["name"] for a in meta]
|
||||
assert "search" in action_names
|
||||
assert "list_files" not in action_names
|
||||
|
||||
search = meta[0]
|
||||
assert "path_filter" not in search["parameters"]["properties"]
|
||||
|
||||
def test_actions_with_directory_structure(self):
|
||||
tool = InternalSearchTool({"has_directory_structure": True})
|
||||
meta = tool.get_actions_metadata()
|
||||
|
||||
action_names = [a["name"] for a in meta]
|
||||
assert "search" in action_names
|
||||
assert "list_files" in action_names
|
||||
|
||||
search = next(a for a in meta if a["name"] == "search")
|
||||
assert "path_filter" in search["parameters"]["properties"]
|
||||
|
||||
def test_config_requirements_empty(self):
|
||||
tool = InternalSearchTool({})
|
||||
assert tool.get_config_requirements() == {}
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Build Helpers
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBuildHelpers:
|
||||
|
||||
def test_build_entry_without_directory_structure(self):
|
||||
entry = build_internal_tool_entry(has_directory_structure=False)
|
||||
assert entry["name"] == "internal_search"
|
||||
action_names = [a["name"] for a in entry["actions"]]
|
||||
assert "search" in action_names
|
||||
assert "list_files" not in action_names
|
||||
assert entry["actions"][0].get("active") is True
|
||||
|
||||
def test_build_entry_with_directory_structure(self):
|
||||
entry = build_internal_tool_entry(has_directory_structure=True)
|
||||
action_names = [a["name"] for a in entry["actions"]]
|
||||
assert "list_files" in action_names
|
||||
# path_filter should be in search params
|
||||
search_action = next(a for a in entry["actions"] if a["name"] == "search")
|
||||
assert "path_filter" in search_action["parameters"]["properties"]
|
||||
|
||||
def test_build_config(self):
|
||||
config = build_internal_tool_config(
|
||||
source={"active_docs": ["abc"]},
|
||||
retriever_name="semantic",
|
||||
chunks=4,
|
||||
)
|
||||
assert config["source"] == {"active_docs": ["abc"]}
|
||||
assert config["retriever_name"] == "semantic"
|
||||
assert config["chunks"] == 4
|
||||
|
||||
def test_build_config_defaults(self):
|
||||
config = build_internal_tool_config(source={"active_docs": ["abc"]})
|
||||
assert config["retriever_name"] == "classic"
|
||||
assert config["chunks"] == 2
|
||||
assert config["doc_token_limit"] == 50000
|
||||
|
||||
def test_internal_tool_id(self):
|
||||
assert INTERNAL_TOOL_ID == "internal"
|
||||
|
||||
def test_internal_tool_entry_constant(self):
|
||||
assert INTERNAL_TOOL_ENTRY["name"] == "internal_search"
|
||||
|
||||
def test_add_internal_search_tool_with_sources(self):
|
||||
tools_dict = {}
|
||||
retriever_config = {
|
||||
"source": {"active_docs": ["abc"]},
|
||||
"retriever_name": "classic",
|
||||
"chunks": 2,
|
||||
"model_id": "gpt-4",
|
||||
"llm_name": "openai",
|
||||
"api_key": "key",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"application.agents.tools.internal_search.sources_have_directory_structure",
|
||||
return_value=False,
|
||||
):
|
||||
add_internal_search_tool(tools_dict, retriever_config)
|
||||
|
||||
assert INTERNAL_TOOL_ID in tools_dict
|
||||
assert tools_dict[INTERNAL_TOOL_ID]["name"] == "internal_search"
|
||||
assert "config" in tools_dict[INTERNAL_TOOL_ID]
|
||||
|
||||
def test_add_internal_search_tool_no_sources(self):
|
||||
tools_dict = {}
|
||||
retriever_config = {"source": {}}
|
||||
|
||||
add_internal_search_tool(tools_dict, retriever_config)
|
||||
assert INTERNAL_TOOL_ID not in tools_dict
|
||||
|
||||
def test_add_internal_search_tool_empty_config(self):
|
||||
tools_dict = {}
|
||||
add_internal_search_tool(tools_dict, {})
|
||||
assert INTERNAL_TOOL_ID not in tools_dict
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# sources_have_directory_structure
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSourcesHaveDirectoryStructure:
|
||||
|
||||
def test_no_active_docs(self):
|
||||
assert sources_have_directory_structure({}) is False
|
||||
|
||||
def test_with_directory_structure(self):
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.find_one.return_value = {
|
||||
"directory_structure": {"src": {}},
|
||||
}
|
||||
|
||||
with patch("application.core.mongo_db.MongoDB") as mock_mongo:
|
||||
mock_db = MagicMock()
|
||||
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
|
||||
mock_client = MagicMock()
|
||||
mock_client.__getitem__ = MagicMock(return_value=mock_db)
|
||||
mock_mongo.get_client.return_value = mock_client
|
||||
|
||||
result = sources_have_directory_structure(
|
||||
{"active_docs": ["507f1f77bcf86cd799439011"]}
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_without_directory_structure(self):
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.find_one.return_value = {"directory_structure": None}
|
||||
|
||||
with patch("application.core.mongo_db.MongoDB") as mock_mongo:
|
||||
mock_db = MagicMock()
|
||||
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
|
||||
mock_client = MagicMock()
|
||||
mock_client.__getitem__ = MagicMock(return_value=mock_db)
|
||||
mock_mongo.get_client.return_value = mock_client
|
||||
|
||||
result = sources_have_directory_structure(
|
||||
{"active_docs": ["507f1f77bcf86cd799439011"]}
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_handles_exception_gracefully(self):
|
||||
with patch(
|
||||
"application.core.mongo_db.MongoDB.get_client",
|
||||
side_effect=Exception("DB down"),
|
||||
):
|
||||
result = sources_have_directory_structure(
|
||||
{"active_docs": ["507f1f77bcf86cd799439011"]}
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_string_active_docs(self):
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.find_one.return_value = {
|
||||
"directory_structure": {"a": {}},
|
||||
}
|
||||
|
||||
with patch("application.core.mongo_db.MongoDB") as mock_mongo:
|
||||
mock_db = MagicMock()
|
||||
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
|
||||
mock_client = MagicMock()
|
||||
mock_client.__getitem__ = MagicMock(return_value=mock_db)
|
||||
mock_mongo.get_client.return_value = mock_client
|
||||
|
||||
result = sources_have_directory_structure(
|
||||
{"active_docs": "507f1f77bcf86cd799439011"}
|
||||
)
|
||||
assert result is True
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,449 @@
|
||||
"""Comprehensive tests for application/agents/tools/memory.py
|
||||
|
||||
Covers: MemoryTool initialization, path validation, all actions
|
||||
(view, create, str_replace, insert, delete, rename), directory operations,
|
||||
error handling, and metadata.
|
||||
"""
|
||||
|
||||
import mongomock
|
||||
import pytest
|
||||
|
||||
|
||||
def _get_settings():
|
||||
from application.core.settings import settings
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory_db(monkeypatch):
|
||||
"""Set up a mongomock-based memory collection."""
|
||||
settings = _get_settings()
|
||||
mock_client = mongomock.MongoClient()
|
||||
mock_db = mock_client[settings.MONGO_DB_NAME]
|
||||
|
||||
def get_mock_client():
|
||||
return {settings.MONGO_DB_NAME: mock_db}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"application.core.mongo_db.MongoDB.get_client", get_mock_client
|
||||
)
|
||||
return mock_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_tool(mock_memory_db):
|
||||
from application.agents.tools.memory import MemoryTool
|
||||
|
||||
return MemoryTool(
|
||||
tool_config={"tool_id": "test_tool_001"},
|
||||
user_id="test_user",
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Initialization
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMemoryToolInit:
|
||||
|
||||
def test_init_with_config(self, mock_memory_db):
|
||||
from application.agents.tools.memory import MemoryTool
|
||||
|
||||
tool = MemoryTool(
|
||||
tool_config={"tool_id": "custom_id"}, user_id="user1"
|
||||
)
|
||||
assert tool.tool_id == "custom_id"
|
||||
assert tool.user_id == "user1"
|
||||
|
||||
def test_init_fallback_to_user_id(self, mock_memory_db):
|
||||
from application.agents.tools.memory import MemoryTool
|
||||
|
||||
tool = MemoryTool(tool_config={}, user_id="user1")
|
||||
assert tool.tool_id == "default_user1"
|
||||
|
||||
def test_init_no_user_no_config(self, mock_memory_db):
|
||||
from application.agents.tools.memory import MemoryTool
|
||||
|
||||
tool = MemoryTool()
|
||||
assert tool.tool_id is not None # UUID fallback
|
||||
assert tool.user_id is None
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Path Validation
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPathValidation:
|
||||
|
||||
def test_valid_path(self, memory_tool):
|
||||
assert memory_tool._validate_path("/notes.txt") == "/notes.txt"
|
||||
|
||||
def test_adds_leading_slash(self, memory_tool):
|
||||
assert memory_tool._validate_path("notes.txt") == "/notes.txt"
|
||||
|
||||
def test_empty_path_returns_none(self, memory_tool):
|
||||
assert memory_tool._validate_path("") is None
|
||||
|
||||
def test_double_dots_rejected(self, memory_tool):
|
||||
assert memory_tool._validate_path("/../../etc/passwd") is None
|
||||
|
||||
def test_double_slash_rejected(self, memory_tool):
|
||||
assert memory_tool._validate_path("//path") is None
|
||||
|
||||
def test_preserves_trailing_slash(self, memory_tool):
|
||||
result = memory_tool._validate_path("/project/")
|
||||
assert result.endswith("/")
|
||||
|
||||
def test_root_path(self, memory_tool):
|
||||
assert memory_tool._validate_path("/") == "/"
|
||||
|
||||
def test_whitespace_stripped(self, memory_tool):
|
||||
result = memory_tool._validate_path(" /notes.txt ")
|
||||
assert result == "/notes.txt"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Execute Action - No User
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNoUser:
|
||||
|
||||
def test_requires_user_id(self, mock_memory_db):
|
||||
from application.agents.tools.memory import MemoryTool
|
||||
|
||||
tool = MemoryTool(tool_config={"tool_id": "t"}, user_id=None)
|
||||
result = tool.execute_action("view", path="/")
|
||||
assert "Error" in result
|
||||
assert "user_id" in result
|
||||
|
||||
def test_unknown_action(self, memory_tool):
|
||||
result = memory_tool.execute_action("fly")
|
||||
assert "Unknown action" in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# View Action
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestViewAction:
|
||||
|
||||
def test_view_empty_directory(self, memory_tool):
|
||||
result = memory_tool.execute_action("view", path="/")
|
||||
assert "Directory: /" in result
|
||||
assert "(empty)" in result
|
||||
|
||||
def test_view_directory_with_files(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/notes.txt", file_text="content")
|
||||
memory_tool.execute_action("create", path="/todo.txt", file_text="tasks")
|
||||
|
||||
result = memory_tool.execute_action("view", path="/")
|
||||
assert "notes.txt" in result
|
||||
assert "todo.txt" in result
|
||||
|
||||
def test_view_file_content(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/hello.txt", file_text="Hello World")
|
||||
result = memory_tool.execute_action("view", path="/hello.txt")
|
||||
assert "Hello World" in result
|
||||
|
||||
def test_view_nonexistent_file(self, memory_tool):
|
||||
result = memory_tool.execute_action("view", path="/missing.txt")
|
||||
assert "Error" in result
|
||||
assert "not found" in result.lower()
|
||||
|
||||
def test_view_file_with_range(self, memory_tool):
|
||||
memory_tool.execute_action(
|
||||
"create", path="/lines.txt", file_text="line1\nline2\nline3\nline4"
|
||||
)
|
||||
result = memory_tool.execute_action(
|
||||
"view", path="/lines.txt", view_range=[2, 3]
|
||||
)
|
||||
assert "line2" in result
|
||||
assert "line3" in result
|
||||
|
||||
def test_view_file_range_out_of_bounds(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/short.txt", file_text="only")
|
||||
result = memory_tool.execute_action(
|
||||
"view", path="/short.txt", view_range=[100, 200]
|
||||
)
|
||||
assert "out of bounds" in result.lower()
|
||||
|
||||
def test_view_invalid_path(self, memory_tool):
|
||||
result = memory_tool.execute_action("view", path="")
|
||||
assert "Error" in result
|
||||
|
||||
def test_view_subdirectory(self, memory_tool):
|
||||
memory_tool.execute_action(
|
||||
"create", path="/project/src/main.py", file_text="code"
|
||||
)
|
||||
result = memory_tool.execute_action("view", path="/project/")
|
||||
assert "src/main.py" in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Create Action
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCreateAction:
|
||||
|
||||
def test_create_file(self, memory_tool):
|
||||
result = memory_tool.execute_action(
|
||||
"create", path="/test.txt", file_text="content"
|
||||
)
|
||||
assert "File created" in result
|
||||
|
||||
content = memory_tool.execute_action("view", path="/test.txt")
|
||||
assert "content" in content
|
||||
|
||||
def test_overwrite_file(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/test.txt", file_text="old")
|
||||
memory_tool.execute_action("create", path="/test.txt", file_text="new")
|
||||
|
||||
content = memory_tool.execute_action("view", path="/test.txt")
|
||||
assert "new" in content
|
||||
|
||||
def test_create_at_directory_path(self, memory_tool):
|
||||
result = memory_tool.execute_action("create", path="/dir/", file_text="text")
|
||||
assert "Error" in result
|
||||
assert "directory path" in result.lower()
|
||||
|
||||
def test_create_invalid_path(self, memory_tool):
|
||||
result = memory_tool.execute_action("create", path="", file_text="text")
|
||||
assert "Error" in result
|
||||
|
||||
def test_create_nested_path(self, memory_tool):
|
||||
result = memory_tool.execute_action(
|
||||
"create", path="/a/b/c/file.txt", file_text="deep"
|
||||
)
|
||||
assert "File created" in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# String Replace Action
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestStrReplaceAction:
|
||||
|
||||
def test_replace_text(self, memory_tool):
|
||||
memory_tool.execute_action(
|
||||
"create", path="/doc.txt", file_text="Hello World"
|
||||
)
|
||||
result = memory_tool.execute_action(
|
||||
"str_replace", path="/doc.txt", old_str="Hello", new_str="Hi"
|
||||
)
|
||||
assert "File updated" in result
|
||||
|
||||
content = memory_tool.execute_action("view", path="/doc.txt")
|
||||
assert "Hi World" in content
|
||||
|
||||
def test_replace_not_found(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/doc.txt", file_text="Hello")
|
||||
result = memory_tool.execute_action(
|
||||
"str_replace", path="/doc.txt", old_str="Missing", new_str="X"
|
||||
)
|
||||
assert "not found" in result.lower()
|
||||
|
||||
def test_replace_empty_old_str(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/doc.txt", file_text="Hello")
|
||||
result = memory_tool.execute_action(
|
||||
"str_replace", path="/doc.txt", old_str="", new_str="X"
|
||||
)
|
||||
assert "Error" in result
|
||||
|
||||
def test_replace_file_not_found(self, memory_tool):
|
||||
result = memory_tool.execute_action(
|
||||
"str_replace", path="/missing.txt", old_str="a", new_str="b"
|
||||
)
|
||||
assert "not found" in result.lower()
|
||||
|
||||
def test_replace_case_insensitive(self, memory_tool):
|
||||
memory_tool.execute_action(
|
||||
"create", path="/doc.txt", file_text="Hello World"
|
||||
)
|
||||
result = memory_tool.execute_action(
|
||||
"str_replace", path="/doc.txt", old_str="hello", new_str="Hi"
|
||||
)
|
||||
assert "File updated" in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Insert Action
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestInsertAction:
|
||||
|
||||
def test_insert_text(self, memory_tool):
|
||||
memory_tool.execute_action(
|
||||
"create", path="/doc.txt", file_text="line1\nline2"
|
||||
)
|
||||
result = memory_tool.execute_action(
|
||||
"insert", path="/doc.txt", insert_line=2, insert_text="inserted"
|
||||
)
|
||||
assert "inserted" in result.lower()
|
||||
|
||||
content = memory_tool.execute_action("view", path="/doc.txt")
|
||||
assert "inserted" in content
|
||||
|
||||
def test_insert_empty_text(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/doc.txt", file_text="line1")
|
||||
result = memory_tool.execute_action(
|
||||
"insert", path="/doc.txt", insert_line=1, insert_text=""
|
||||
)
|
||||
assert "Error" in result
|
||||
|
||||
def test_insert_file_not_found(self, memory_tool):
|
||||
result = memory_tool.execute_action(
|
||||
"insert", path="/missing.txt", insert_line=1, insert_text="text"
|
||||
)
|
||||
assert "not found" in result.lower()
|
||||
|
||||
def test_insert_invalid_line_number(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/doc.txt", file_text="line1")
|
||||
result = memory_tool.execute_action(
|
||||
"insert", path="/doc.txt", insert_line=-5, insert_text="text"
|
||||
)
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Delete Action
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteAction:
|
||||
|
||||
def test_delete_file(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/test.txt", file_text="data")
|
||||
result = memory_tool.execute_action("delete", path="/test.txt")
|
||||
assert "Deleted" in result
|
||||
|
||||
content = memory_tool.execute_action("view", path="/test.txt")
|
||||
assert "not found" in content.lower()
|
||||
|
||||
def test_delete_nonexistent_file(self, memory_tool):
|
||||
result = memory_tool.execute_action("delete", path="/missing.txt")
|
||||
assert "not found" in result.lower()
|
||||
|
||||
def test_delete_root_clears_all(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/a.txt", file_text="a")
|
||||
memory_tool.execute_action("create", path="/b.txt", file_text="b")
|
||||
|
||||
result = memory_tool.execute_action("delete", path="/")
|
||||
assert "Deleted" in result
|
||||
assert "2" in result
|
||||
|
||||
def test_delete_directory(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/dir/f1.txt", file_text="1")
|
||||
memory_tool.execute_action("create", path="/dir/f2.txt", file_text="2")
|
||||
|
||||
result = memory_tool.execute_action("delete", path="/dir/")
|
||||
assert "Deleted" in result
|
||||
|
||||
def test_delete_directory_without_trailing_slash(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/dir/f1.txt", file_text="1")
|
||||
|
||||
result = memory_tool.execute_action("delete", path="/dir")
|
||||
assert "Deleted" in result
|
||||
|
||||
def test_delete_invalid_path(self, memory_tool):
|
||||
result = memory_tool.execute_action("delete", path="")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Rename Action
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRenameAction:
|
||||
|
||||
def test_rename_file(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/old.txt", file_text="data")
|
||||
result = memory_tool.execute_action(
|
||||
"rename", old_path="/old.txt", new_path="/new.txt"
|
||||
)
|
||||
assert "Renamed" in result
|
||||
|
||||
content = memory_tool.execute_action("view", path="/new.txt")
|
||||
assert "data" in content
|
||||
|
||||
def test_rename_file_not_found(self, memory_tool):
|
||||
result = memory_tool.execute_action(
|
||||
"rename", old_path="/missing.txt", new_path="/new.txt"
|
||||
)
|
||||
assert "not found" in result.lower()
|
||||
|
||||
def test_rename_target_exists(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/a.txt", file_text="a")
|
||||
memory_tool.execute_action("create", path="/b.txt", file_text="b")
|
||||
|
||||
result = memory_tool.execute_action(
|
||||
"rename", old_path="/a.txt", new_path="/b.txt"
|
||||
)
|
||||
assert "already exists" in result.lower()
|
||||
|
||||
def test_rename_root_rejected(self, memory_tool):
|
||||
result = memory_tool.execute_action(
|
||||
"rename", old_path="/", new_path="/new/"
|
||||
)
|
||||
assert "Cannot rename root" in result
|
||||
|
||||
def test_rename_directory(self, memory_tool):
|
||||
memory_tool.execute_action("create", path="/old/f.txt", file_text="data")
|
||||
result = memory_tool.execute_action(
|
||||
"rename", old_path="/old/", new_path="/new/"
|
||||
)
|
||||
assert "Renamed" in result
|
||||
|
||||
content = memory_tool.execute_action("view", path="/new/f.txt")
|
||||
assert "data" in content
|
||||
|
||||
def test_rename_directory_not_found(self, memory_tool):
|
||||
result = memory_tool.execute_action(
|
||||
"rename", old_path="/missing/", new_path="/new/"
|
||||
)
|
||||
assert "not found" in result.lower()
|
||||
|
||||
def test_rename_invalid_path(self, memory_tool):
|
||||
result = memory_tool.execute_action(
|
||||
"rename", old_path="", new_path="/new.txt"
|
||||
)
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Metadata
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMemoryToolMetadata:
|
||||
|
||||
def test_actions_metadata(self, memory_tool):
|
||||
meta = memory_tool.get_actions_metadata()
|
||||
action_names = [a["name"] for a in meta]
|
||||
assert "view" in action_names
|
||||
assert "create" in action_names
|
||||
assert "str_replace" in action_names
|
||||
assert "insert" in action_names
|
||||
assert "delete" in action_names
|
||||
assert "rename" in action_names
|
||||
assert len(meta) == 6
|
||||
|
||||
def test_config_requirements(self, memory_tool):
|
||||
assert memory_tool.get_config_requirements() == {}
|
||||
Reference in new issue
Block a user