mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 18:13:03 +00:00
549 lines
19 KiB
Python
549 lines
19 KiB
Python
"""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() == {}
|
|
|
|
|
|
# =====================================================================
|
|
# Coverage gap tests (lines 254, 257, 271, 275, 279)
|
|
# =====================================================================
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMemoryToolValidatePath:
|
|
|
|
def test_validate_path_with_traversal_returns_none(self, memory_tool):
|
|
"""Cover line 244-245: path with .. returns None."""
|
|
result = memory_tool._validate_path("/some/../etc/passwd")
|
|
assert result is None
|
|
|
|
def test_validate_path_with_directory_trailing_slash(self, memory_tool):
|
|
"""Cover line 257-258: trailing slash is preserved."""
|
|
result = memory_tool._validate_path("/some/dir/")
|
|
assert result is not None
|
|
assert result.endswith("/")
|
|
|
|
def test_validate_path_empty_returns_none(self, memory_tool):
|
|
"""Cover: empty path returns None."""
|
|
result = memory_tool._validate_path("")
|
|
assert result is None
|
|
|
|
def test_validate_path_none_returns_none(self, memory_tool):
|
|
"""Cover: None path returns None."""
|
|
result = memory_tool._validate_path(None)
|
|
assert result is None
|
|
|
|
def test_validate_path_relative_gets_prefixed(self, memory_tool):
|
|
"""Cover line 241: relative path gets / prepended."""
|
|
result = memory_tool._validate_path("relative/path")
|
|
assert result == "/relative/path"
|
|
|
|
def test_validate_path_double_slash_returns_none(self, memory_tool):
|
|
"""Cover line 244: double slash returns None."""
|
|
result = memory_tool._validate_path("//etc/passwd")
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMemoryToolViewDirectory:
|
|
|
|
def test_view_with_directory_path(self, memory_tool):
|
|
"""Cover line 271-275: _view with directory path delegates to _view_directory."""
|
|
result = memory_tool._view("/")
|
|
assert isinstance(result, str)
|
|
|
|
def test_view_with_file_path(self, memory_tool):
|
|
"""Cover line 279: _view with non-directory path delegates to _view_file."""
|
|
# _view on a non-existent file path still exercises the _view_file path
|
|
result = memory_tool._view("/nonexistent.txt")
|
|
assert "Error" in result or "not found" in result.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Coverage — additional uncovered lines: 254, 257, 271, 275, 279
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMemoryToolValidatePathCoverage:
|
|
|
|
def test_validate_path_traversal_returns_none(self, memory_tool):
|
|
"""Cover line 244: path with directory traversal returns None."""
|
|
result = memory_tool._validate_path("/etc/../passwd")
|
|
assert result is None
|
|
|
|
def test_validate_path_directory_appends_slash(self, memory_tool):
|
|
"""Cover line 257: path ending with / preserves trailing slash."""
|
|
result = memory_tool._validate_path("/some/dir/")
|
|
assert result is not None
|
|
assert result.endswith("/")
|
|
|
|
def test_validate_path_root_directory(self, memory_tool):
|
|
"""Cover line 257: root directory preserved as-is."""
|
|
result = memory_tool._validate_path("/")
|
|
assert result == "/"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMemoryToolViewCoverage:
|
|
|
|
def test_view_invalid_path_returns_error(self, memory_tool):
|
|
"""Cover line 271: _view with invalid path returns error."""
|
|
result = memory_tool._view("//bad//path")
|
|
assert "Error" in result
|
|
|
|
def test_view_root_directory(self, memory_tool):
|
|
"""Cover line 275: _view with root directory."""
|
|
result = memory_tool._view("/")
|
|
assert isinstance(result, str)
|
|
|
|
def test_view_file_path(self, memory_tool):
|
|
"""Cover line 279: _view with file path delegates to _view_file."""
|
|
result = memory_tool._view("/some/file.txt")
|
|
assert isinstance(result, str)
|