- Fix circular dependencies in agent/tools - Migrate from custom JSON to OpenAI tool calls format - Add async streaming (step_stream, complete_stream) - Simplify prompt system and remove token counting - Add 5 new API endpoints (/health, /v1/models, /api/memory/*) - Add 3 new tools (get_torrent_by_index, add_torrent_by_index, set_language) - Fix all 500 tests and add coverage config (80% threshold) - Add comprehensive docs (README, pytest guide) BREAKING: LLM interface changed, memory injection via get_memory()
359 lines
13 KiB
Python
359 lines
13 KiB
Python
"""Tests for API tools."""
|
|
|
|
from unittest.mock import Mock, patch
|
|
|
|
from agent.tools import api as api_tools
|
|
from infrastructure.persistence import get_memory
|
|
|
|
|
|
class TestFindMediaImdbId:
|
|
"""Tests for find_media_imdb_id tool."""
|
|
|
|
@patch("agent.tools.api.SearchMovieUseCase")
|
|
def test_success(self, mock_use_case_class, memory):
|
|
"""Should return movie info on success."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {
|
|
"status": "ok",
|
|
"imdb_id": "tt1375666",
|
|
"title": "Inception",
|
|
"media_type": "movie",
|
|
"tmdb_id": 27205,
|
|
}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
result = api_tools.find_media_imdb_id("Inception")
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["imdb_id"] == "tt1375666"
|
|
assert result["title"] == "Inception"
|
|
|
|
@patch("agent.tools.api.SearchMovieUseCase")
|
|
def test_stores_in_stm(self, mock_use_case_class, memory):
|
|
"""Should store result in STM on success."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {
|
|
"status": "ok",
|
|
"imdb_id": "tt1375666",
|
|
"title": "Inception",
|
|
"media_type": "movie",
|
|
"tmdb_id": 27205,
|
|
}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
api_tools.find_media_imdb_id("Inception")
|
|
|
|
mem = get_memory()
|
|
entity = mem.stm.get_entity("last_media_search")
|
|
assert entity is not None
|
|
assert entity["title"] == "Inception"
|
|
assert mem.stm.current_topic == "searching_media"
|
|
|
|
@patch("agent.tools.api.SearchMovieUseCase")
|
|
def test_not_found(self, mock_use_case_class, memory):
|
|
"""Should return error when not found."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {
|
|
"status": "error",
|
|
"error": "not_found",
|
|
"message": "No results found",
|
|
}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
result = api_tools.find_media_imdb_id("NonexistentMovie12345")
|
|
|
|
assert result["status"] == "error"
|
|
assert result["error"] == "not_found"
|
|
|
|
@patch("agent.tools.api.SearchMovieUseCase")
|
|
def test_does_not_store_on_error(self, mock_use_case_class, memory):
|
|
"""Should not store in STM on error."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {"status": "error"}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
api_tools.find_media_imdb_id("Test")
|
|
|
|
mem = get_memory()
|
|
assert mem.stm.get_entity("last_media_search") is None
|
|
|
|
|
|
class TestFindTorrent:
|
|
"""Tests for find_torrent tool."""
|
|
|
|
@patch("agent.tools.api.SearchTorrentsUseCase")
|
|
def test_success(self, mock_use_case_class, memory):
|
|
"""Should return torrents on success."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {
|
|
"status": "ok",
|
|
"torrents": [
|
|
{"name": "Torrent 1", "seeders": 100, "magnet": "magnet:?xt=..."},
|
|
{"name": "Torrent 2", "seeders": 50, "magnet": "magnet:?xt=..."},
|
|
],
|
|
"count": 2,
|
|
}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
result = api_tools.find_torrent("Inception 1080p")
|
|
|
|
assert result["status"] == "ok"
|
|
assert len(result["torrents"]) == 2
|
|
|
|
@patch("agent.tools.api.SearchTorrentsUseCase")
|
|
def test_stores_in_episodic(self, mock_use_case_class, memory):
|
|
"""Should store results in episodic memory."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {
|
|
"status": "ok",
|
|
"torrents": [
|
|
{"name": "Torrent 1", "magnet": "magnet:?xt=..."},
|
|
],
|
|
"count": 1,
|
|
}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
api_tools.find_torrent("Inception")
|
|
|
|
mem = get_memory()
|
|
assert mem.episodic.last_search_results is not None
|
|
assert mem.episodic.last_search_results["query"] == "Inception"
|
|
assert mem.stm.current_topic == "selecting_torrent"
|
|
|
|
@patch("agent.tools.api.SearchTorrentsUseCase")
|
|
def test_results_have_indexes(self, mock_use_case_class, memory):
|
|
"""Should add indexes to results."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {
|
|
"status": "ok",
|
|
"torrents": [
|
|
{"name": "Torrent 1"},
|
|
{"name": "Torrent 2"},
|
|
{"name": "Torrent 3"},
|
|
],
|
|
"count": 3,
|
|
}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
api_tools.find_torrent("Test")
|
|
|
|
mem = get_memory()
|
|
results = mem.episodic.last_search_results["results"]
|
|
assert results[0]["index"] == 1
|
|
assert results[1]["index"] == 2
|
|
assert results[2]["index"] == 3
|
|
|
|
@patch("agent.tools.api.SearchTorrentsUseCase")
|
|
def test_not_found(self, mock_use_case_class, memory):
|
|
"""Should return error when no torrents found."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {
|
|
"status": "error",
|
|
"error": "not_found",
|
|
}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
result = api_tools.find_torrent("NonexistentMovie12345")
|
|
|
|
assert result["status"] == "error"
|
|
|
|
|
|
class TestGetTorrentByIndex:
|
|
"""Tests for get_torrent_by_index tool."""
|
|
|
|
def test_success(self, memory_with_search_results):
|
|
"""Should return torrent at index."""
|
|
result = api_tools.get_torrent_by_index(2)
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["torrent"]["name"] == "Inception.2010.1080p.WEB-DL.x265"
|
|
|
|
def test_first_index(self, memory_with_search_results):
|
|
"""Should return first torrent."""
|
|
result = api_tools.get_torrent_by_index(1)
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["torrent"]["name"] == "Inception.2010.1080p.BluRay.x264"
|
|
|
|
def test_last_index(self, memory_with_search_results):
|
|
"""Should return last torrent."""
|
|
result = api_tools.get_torrent_by_index(3)
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["torrent"]["name"] == "Inception.2010.720p.BluRay"
|
|
|
|
def test_index_out_of_range(self, memory_with_search_results):
|
|
"""Should return error for invalid index."""
|
|
result = api_tools.get_torrent_by_index(10)
|
|
|
|
assert result["status"] == "error"
|
|
assert result["error"] == "not_found"
|
|
|
|
def test_index_zero(self, memory_with_search_results):
|
|
"""Should return error for index 0."""
|
|
result = api_tools.get_torrent_by_index(0)
|
|
|
|
assert result["status"] == "error"
|
|
assert result["error"] == "not_found"
|
|
|
|
def test_negative_index(self, memory_with_search_results):
|
|
"""Should return error for negative index."""
|
|
result = api_tools.get_torrent_by_index(-1)
|
|
|
|
assert result["status"] == "error"
|
|
assert result["error"] == "not_found"
|
|
|
|
def test_no_search_results(self, memory):
|
|
"""Should return error if no search results."""
|
|
result = api_tools.get_torrent_by_index(1)
|
|
|
|
assert result["status"] == "error"
|
|
assert result["error"] == "not_found"
|
|
assert "Search for torrents first" in result["message"]
|
|
|
|
|
|
class TestAddTorrentToQbittorrent:
|
|
"""Tests for add_torrent_to_qbittorrent tool."""
|
|
|
|
@patch("agent.tools.api.AddTorrentUseCase")
|
|
def test_success(self, mock_use_case_class, memory):
|
|
"""Should add torrent successfully."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {
|
|
"status": "ok",
|
|
"message": "Torrent added",
|
|
}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
result = api_tools.add_torrent_to_qbittorrent("magnet:?xt=urn:btih:abc123")
|
|
|
|
assert result["status"] == "ok"
|
|
|
|
@patch("agent.tools.api.AddTorrentUseCase")
|
|
def test_adds_to_active_downloads(
|
|
self, mock_use_case_class, memory_with_search_results
|
|
):
|
|
"""Should add to active downloads on success."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {"status": "ok"}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
api_tools.add_torrent_to_qbittorrent("magnet:?xt=urn:btih:abc123")
|
|
|
|
mem = get_memory()
|
|
assert len(mem.episodic.active_downloads) == 1
|
|
assert (
|
|
mem.episodic.active_downloads[0]["name"]
|
|
== "Inception.2010.1080p.BluRay.x264"
|
|
)
|
|
|
|
@patch("agent.tools.api.AddTorrentUseCase")
|
|
def test_sets_topic_and_ends_workflow(self, mock_use_case_class, memory):
|
|
"""Should set topic and end workflow."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {"status": "ok"}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
memory.stm.start_workflow("download", {"title": "Test"})
|
|
|
|
api_tools.add_torrent_to_qbittorrent("magnet:?xt=...")
|
|
|
|
mem = get_memory()
|
|
assert mem.stm.current_topic == "downloading"
|
|
assert mem.stm.current_workflow is None
|
|
|
|
@patch("agent.tools.api.AddTorrentUseCase")
|
|
def test_error(self, mock_use_case_class, memory):
|
|
"""Should return error on failure."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {
|
|
"status": "error",
|
|
"error": "connection_failed",
|
|
}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
result = api_tools.add_torrent_to_qbittorrent("magnet:?xt=...")
|
|
|
|
assert result["status"] == "error"
|
|
|
|
|
|
class TestAddTorrentByIndex:
|
|
"""Tests for add_torrent_by_index tool."""
|
|
|
|
@patch("agent.tools.api.AddTorrentUseCase")
|
|
def test_success(self, mock_use_case_class, memory_with_search_results):
|
|
"""Should add torrent by index."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {"status": "ok"}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
result = api_tools.add_torrent_by_index(1)
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["torrent_name"] == "Inception.2010.1080p.BluRay.x264"
|
|
|
|
@patch("agent.tools.api.AddTorrentUseCase")
|
|
def test_uses_correct_magnet(self, mock_use_case_class, memory_with_search_results):
|
|
"""Should use magnet from selected torrent."""
|
|
mock_response = Mock()
|
|
mock_response.to_dict.return_value = {"status": "ok"}
|
|
mock_use_case = Mock()
|
|
mock_use_case.execute.return_value = mock_response
|
|
mock_use_case_class.return_value = mock_use_case
|
|
|
|
api_tools.add_torrent_by_index(2)
|
|
|
|
mock_use_case.execute.assert_called_once_with("magnet:?xt=urn:btih:def456")
|
|
|
|
def test_invalid_index(self, memory_with_search_results):
|
|
"""Should return error for invalid index."""
|
|
result = api_tools.add_torrent_by_index(99)
|
|
|
|
assert result["status"] == "error"
|
|
assert result["error"] == "not_found"
|
|
|
|
def test_no_search_results(self, memory):
|
|
"""Should return error if no search results."""
|
|
result = api_tools.add_torrent_by_index(1)
|
|
|
|
assert result["status"] == "error"
|
|
assert result["error"] == "not_found"
|
|
|
|
def test_no_magnet_link(self, memory):
|
|
"""Should return error if torrent has no magnet."""
|
|
memory.episodic.store_search_results(
|
|
"test",
|
|
[{"name": "Torrent without magnet", "seeders": 100}],
|
|
)
|
|
|
|
result = api_tools.add_torrent_by_index(1)
|
|
|
|
assert result["status"] == "error"
|
|
assert result["error"] == "no_magnet"
|