577 lines
23 KiB
Python
577 lines
23 KiB
Python
"""Unit tests for src/timmy/paperclip.py.
|
|
|
|
Refs #1236
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
from types import ModuleType
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
# ── Stub serpapi before any import of paperclip (it imports research_tools) ───
|
|
|
|
_serpapi_stub = ModuleType("serpapi")
|
|
_google_search_mock = MagicMock()
|
|
_serpapi_stub.GoogleSearch = _google_search_mock
|
|
sys.modules.setdefault("serpapi", _serpapi_stub)
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
# ── PaperclipTask ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPaperclipTask:
|
|
"""PaperclipTask dataclass holds task data."""
|
|
|
|
def test_task_creation(self):
|
|
from timmy.paperclip import PaperclipTask
|
|
|
|
task = PaperclipTask(id="task-123", kind="research", context={"key": "value"})
|
|
assert task.id == "task-123"
|
|
assert task.kind == "research"
|
|
assert task.context == {"key": "value"}
|
|
|
|
def test_task_creation_empty_context(self):
|
|
from timmy.paperclip import PaperclipTask
|
|
|
|
task = PaperclipTask(id="task-456", kind="other", context={})
|
|
assert task.id == "task-456"
|
|
assert task.kind == "other"
|
|
assert task.context == {}
|
|
|
|
|
|
# ── PaperclipClient ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPaperclipClient:
|
|
"""PaperclipClient interacts with the Paperclip API."""
|
|
|
|
def test_init_uses_settings(self):
|
|
from timmy.paperclip import PaperclipClient
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_url = "http://test.example:3100"
|
|
mock_settings.paperclip_api_key = "test-api-key"
|
|
mock_settings.paperclip_agent_id = "agent-123"
|
|
mock_settings.paperclip_company_id = "company-456"
|
|
mock_settings.paperclip_timeout = 45
|
|
|
|
client = PaperclipClient()
|
|
assert client.base_url == "http://test.example:3100"
|
|
assert client.api_key == "test-api-key"
|
|
assert client.agent_id == "agent-123"
|
|
assert client.company_id == "company-456"
|
|
assert client.timeout == 45
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_tasks_makes_correct_request(self):
|
|
from timmy.paperclip import PaperclipClient
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_url = "http://test.example:3100"
|
|
mock_settings.paperclip_api_key = "test-api-key"
|
|
mock_settings.paperclip_agent_id = "agent-123"
|
|
mock_settings.paperclip_company_id = "company-456"
|
|
mock_settings.paperclip_timeout = 30
|
|
|
|
client = PaperclipClient()
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{"id": "task-1", "kind": "research", "context": {"issue_number": 42}},
|
|
{"id": "task-2", "kind": "other", "context": {}},
|
|
]
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
|
tasks = await client.get_tasks()
|
|
|
|
mock_client.get.assert_called_once_with(
|
|
"http://test.example:3100/api/tasks",
|
|
headers={"Authorization": "Bearer test-api-key"},
|
|
params={
|
|
"agent_id": "agent-123",
|
|
"company_id": "company-456",
|
|
"status": "queued",
|
|
},
|
|
)
|
|
mock_response.raise_for_status.assert_called_once()
|
|
assert len(tasks) == 2
|
|
assert tasks[0].id == "task-1"
|
|
assert tasks[0].kind == "research"
|
|
assert tasks[1].id == "task-2"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_tasks_empty_response(self):
|
|
from timmy.paperclip import PaperclipClient
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_url = "http://test.example:3100"
|
|
mock_settings.paperclip_api_key = "test-api-key"
|
|
mock_settings.paperclip_agent_id = "agent-123"
|
|
mock_settings.paperclip_company_id = "company-456"
|
|
mock_settings.paperclip_timeout = 30
|
|
|
|
client = PaperclipClient()
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = []
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
|
tasks = await client.get_tasks()
|
|
|
|
assert tasks == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_tasks_raises_on_http_error(self):
|
|
from timmy.paperclip import PaperclipClient
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_url = "http://test.example:3100"
|
|
mock_settings.paperclip_api_key = "test-api-key"
|
|
mock_settings.paperclip_agent_id = "agent-123"
|
|
mock_settings.paperclip_company_id = "company-456"
|
|
mock_settings.paperclip_timeout = 30
|
|
|
|
client = PaperclipClient()
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.get = AsyncMock(side_effect=httpx.HTTPError("Connection failed"))
|
|
|
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
|
with pytest.raises(httpx.HTTPError):
|
|
await client.get_tasks()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_task_status_makes_correct_request(self):
|
|
from timmy.paperclip import PaperclipClient
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_url = "http://test.example:3100"
|
|
mock_settings.paperclip_api_key = "test-api-key"
|
|
mock_settings.paperclip_timeout = 30
|
|
|
|
client = PaperclipClient()
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.patch = AsyncMock(return_value=MagicMock())
|
|
|
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
|
await client.update_task_status("task-123", "completed", "Task result here")
|
|
|
|
mock_client.patch.assert_called_once_with(
|
|
"http://test.example:3100/api/tasks/task-123",
|
|
headers={"Authorization": "Bearer test-api-key"},
|
|
json={"status": "completed", "result": "Task result here"},
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_task_status_without_result(self):
|
|
from timmy.paperclip import PaperclipClient
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_url = "http://test.example:3100"
|
|
mock_settings.paperclip_api_key = "test-api-key"
|
|
mock_settings.paperclip_timeout = 30
|
|
|
|
client = PaperclipClient()
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.patch = AsyncMock(return_value=MagicMock())
|
|
|
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
|
await client.update_task_status("task-123", "running")
|
|
|
|
mock_client.patch.assert_called_once_with(
|
|
"http://test.example:3100/api/tasks/task-123",
|
|
headers={"Authorization": "Bearer test-api-key"},
|
|
json={"status": "running", "result": None},
|
|
)
|
|
|
|
|
|
# ── ResearchOrchestrator ───────────────────────────────────────────────────────
|
|
|
|
|
|
class TestResearchOrchestrator:
|
|
"""ResearchOrchestrator coordinates research tasks."""
|
|
|
|
def test_init_creates_instances(self):
|
|
from timmy.paperclip import ResearchOrchestrator
|
|
|
|
orchestrator = ResearchOrchestrator()
|
|
assert orchestrator is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_gitea_issue_makes_correct_request(self):
|
|
from timmy.paperclip import ResearchOrchestrator
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.gitea_repo = "owner/repo"
|
|
mock_settings.gitea_url = "http://gitea.example:3000"
|
|
mock_settings.gitea_token = "gitea-token"
|
|
|
|
orchestrator = ResearchOrchestrator()
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"number": 42, "title": "Test Issue"}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
|
issue = await orchestrator.get_gitea_issue(42)
|
|
|
|
mock_client.get.assert_called_once_with(
|
|
"http://gitea.example:3000/api/v1/repos/owner/repo/issues/42",
|
|
headers={"Authorization": "token gitea-token"},
|
|
)
|
|
mock_response.raise_for_status.assert_called_once()
|
|
assert issue["number"] == 42
|
|
assert issue["title"] == "Test Issue"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_gitea_issue_raises_on_http_error(self):
|
|
from timmy.paperclip import ResearchOrchestrator
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.gitea_repo = "owner/repo"
|
|
mock_settings.gitea_url = "http://gitea.example:3000"
|
|
mock_settings.gitea_token = "gitea-token"
|
|
|
|
orchestrator = ResearchOrchestrator()
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.get = AsyncMock(side_effect=httpx.HTTPError("Not found"))
|
|
|
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
|
with pytest.raises(httpx.HTTPError):
|
|
await orchestrator.get_gitea_issue(999)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_gitea_comment_makes_correct_request(self):
|
|
from timmy.paperclip import ResearchOrchestrator
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.gitea_repo = "owner/repo"
|
|
mock_settings.gitea_url = "http://gitea.example:3000"
|
|
mock_settings.gitea_token = "gitea-token"
|
|
|
|
orchestrator = ResearchOrchestrator()
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.post = AsyncMock(return_value=MagicMock())
|
|
|
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
|
await orchestrator.post_gitea_comment(42, "Test comment body")
|
|
|
|
mock_client.post.assert_called_once_with(
|
|
"http://gitea.example:3000/api/v1/repos/owner/repo/issues/42/comments",
|
|
headers={"Authorization": "token gitea-token"},
|
|
json={"body": "Test comment body"},
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_research_pipeline_returns_report(self):
|
|
from timmy.paperclip import ResearchOrchestrator
|
|
|
|
orchestrator = ResearchOrchestrator()
|
|
|
|
mock_search_results = "Search result 1\nSearch result 2"
|
|
mock_llm_response = MagicMock()
|
|
mock_llm_response.text = "Research report summary"
|
|
|
|
mock_llm_client = MagicMock()
|
|
mock_llm_client.completion = AsyncMock(return_value=mock_llm_response)
|
|
|
|
with patch(
|
|
"timmy.paperclip.google_web_search", new=AsyncMock(return_value=mock_search_results)
|
|
):
|
|
with patch("timmy.paperclip.get_llm_client", return_value=mock_llm_client):
|
|
report = await orchestrator.run_research_pipeline("test query")
|
|
|
|
assert report == "Research report summary"
|
|
mock_llm_client.completion.assert_called_once()
|
|
call_args = mock_llm_client.completion.call_args
|
|
# The prompt is passed as first positional arg, check it contains expected content
|
|
prompt = call_args[0][0] if call_args[0] else call_args[1].get("messages", [""])[0]
|
|
assert "Summarize" in prompt
|
|
assert "Search result 1" in prompt
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_returns_error_when_missing_issue_number(self):
|
|
from timmy.paperclip import ResearchOrchestrator
|
|
|
|
orchestrator = ResearchOrchestrator()
|
|
result = await orchestrator.run({})
|
|
assert result == "Missing issue_number in task context"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_executes_full_pipeline_with_triage_results(self):
|
|
from timmy.paperclip import ResearchOrchestrator
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.gitea_repo = "owner/repo"
|
|
mock_settings.gitea_url = "http://gitea.example:3000"
|
|
mock_settings.gitea_token = "gitea-token"
|
|
|
|
orchestrator = ResearchOrchestrator()
|
|
|
|
mock_issue = {"number": 42, "title": "Test Research Topic"}
|
|
mock_report = "Research report content"
|
|
mock_triage_results = [
|
|
{
|
|
"action_item": MagicMock(title="Action 1"),
|
|
"gitea_issue": {"number": 101},
|
|
},
|
|
{
|
|
"action_item": MagicMock(title="Action 2"),
|
|
"gitea_issue": {"number": 102},
|
|
},
|
|
]
|
|
|
|
orchestrator.get_gitea_issue = AsyncMock(return_value=mock_issue)
|
|
orchestrator.run_research_pipeline = AsyncMock(return_value=mock_report)
|
|
orchestrator.post_gitea_comment = AsyncMock()
|
|
|
|
with patch(
|
|
"timmy.paperclip.triage_research_report",
|
|
new=AsyncMock(return_value=mock_triage_results),
|
|
):
|
|
result = await orchestrator.run({"issue_number": 42})
|
|
|
|
assert "Research complete for issue #42" in result
|
|
orchestrator.get_gitea_issue.assert_called_once_with(42)
|
|
orchestrator.run_research_pipeline.assert_called_once_with("Test Research Topic")
|
|
orchestrator.post_gitea_comment.assert_called_once()
|
|
comment_body = orchestrator.post_gitea_comment.call_args[0][1]
|
|
assert "Research complete for issue #42" in comment_body
|
|
assert "#101" in comment_body
|
|
assert "#102" in comment_body
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_executes_full_pipeline_without_triage_results(self):
|
|
from timmy.paperclip import ResearchOrchestrator
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.gitea_repo = "owner/repo"
|
|
mock_settings.gitea_url = "http://gitea.example:3000"
|
|
mock_settings.gitea_token = "gitea-token"
|
|
|
|
orchestrator = ResearchOrchestrator()
|
|
|
|
mock_issue = {"number": 42, "title": "Test Research Topic"}
|
|
mock_report = "Research report content"
|
|
|
|
orchestrator.get_gitea_issue = AsyncMock(return_value=mock_issue)
|
|
orchestrator.run_research_pipeline = AsyncMock(return_value=mock_report)
|
|
orchestrator.post_gitea_comment = AsyncMock()
|
|
|
|
with patch("timmy.paperclip.triage_research_report", new=AsyncMock(return_value=[])):
|
|
result = await orchestrator.run({"issue_number": 42})
|
|
|
|
assert "Research complete for issue #42" in result
|
|
comment_body = orchestrator.post_gitea_comment.call_args[0][1]
|
|
assert "No new issues were created" in comment_body
|
|
|
|
|
|
# ── PaperclipPoller ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPaperclipPoller:
|
|
"""PaperclipPoller polls for and executes tasks."""
|
|
|
|
def test_init_creates_client_and_orchestrator(self):
|
|
from timmy.paperclip import PaperclipPoller
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_poll_interval = 60
|
|
|
|
poller = PaperclipPoller()
|
|
assert poller.client is not None
|
|
assert poller.orchestrator is not None
|
|
assert poller.poll_interval == 60
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_returns_early_when_disabled(self):
|
|
from timmy.paperclip import PaperclipPoller
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_poll_interval = 0
|
|
|
|
poller = PaperclipPoller()
|
|
poller.client.get_tasks = AsyncMock()
|
|
|
|
await poller.poll()
|
|
|
|
poller.client.get_tasks.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_processes_research_tasks(self):
|
|
from timmy.paperclip import PaperclipPoller, PaperclipTask
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_poll_interval = 1
|
|
|
|
poller = PaperclipPoller()
|
|
|
|
mock_task = PaperclipTask(id="task-1", kind="research", context={"issue_number": 42})
|
|
poller.client.get_tasks = AsyncMock(return_value=[mock_task])
|
|
poller.run_research_task = AsyncMock()
|
|
|
|
# Stop after first iteration
|
|
call_count = 0
|
|
|
|
async def mock_sleep(duration):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count >= 1:
|
|
raise asyncio.CancelledError("Stop the loop")
|
|
|
|
import asyncio
|
|
|
|
with patch("asyncio.sleep", mock_sleep):
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await poller.poll()
|
|
|
|
poller.client.get_tasks.assert_called_once()
|
|
poller.run_research_task.assert_called_once_with(mock_task)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_logs_http_error_and_continues(self, caplog):
|
|
import logging
|
|
|
|
from timmy.paperclip import PaperclipPoller
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_poll_interval = 1
|
|
|
|
poller = PaperclipPoller()
|
|
poller.client.get_tasks = AsyncMock(side_effect=httpx.HTTPError("Connection failed"))
|
|
|
|
call_count = 0
|
|
|
|
async def mock_sleep(duration):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count >= 1:
|
|
raise asyncio.CancelledError("Stop the loop")
|
|
|
|
with patch("asyncio.sleep", mock_sleep):
|
|
with caplog.at_level(logging.WARNING, logger="timmy.paperclip"):
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await poller.poll()
|
|
|
|
assert any("Error polling Paperclip" in rec.message for rec in caplog.records)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_research_task_success(self):
|
|
from timmy.paperclip import PaperclipPoller, PaperclipTask
|
|
|
|
poller = PaperclipPoller()
|
|
|
|
mock_task = PaperclipTask(id="task-1", kind="research", context={"issue_number": 42})
|
|
|
|
poller.client.update_task_status = AsyncMock()
|
|
poller.orchestrator.run = AsyncMock(return_value="Research completed successfully")
|
|
|
|
await poller.run_research_task(mock_task)
|
|
|
|
assert poller.client.update_task_status.call_count == 2
|
|
poller.client.update_task_status.assert_any_call("task-1", "running")
|
|
poller.client.update_task_status.assert_any_call(
|
|
"task-1", "completed", "Research completed successfully"
|
|
)
|
|
poller.orchestrator.run.assert_called_once_with({"issue_number": 42})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_research_task_failure(self, caplog):
|
|
import logging
|
|
|
|
from timmy.paperclip import PaperclipPoller, PaperclipTask
|
|
|
|
poller = PaperclipPoller()
|
|
|
|
mock_task = PaperclipTask(id="task-1", kind="research", context={"issue_number": 42})
|
|
|
|
poller.client.update_task_status = AsyncMock()
|
|
poller.orchestrator.run = AsyncMock(side_effect=Exception("Something went wrong"))
|
|
|
|
with caplog.at_level(logging.ERROR, logger="timmy.paperclip"):
|
|
await poller.run_research_task(mock_task)
|
|
|
|
assert poller.client.update_task_status.call_count == 2
|
|
poller.client.update_task_status.assert_any_call("task-1", "running")
|
|
poller.client.update_task_status.assert_any_call("task-1", "failed", "Something went wrong")
|
|
assert any("Error running research task" in rec.message for rec in caplog.records)
|
|
|
|
|
|
# ── start_paperclip_poller ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestStartPaperclipPoller:
|
|
"""start_paperclip_poller creates and starts the poller."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_starts_poller_when_enabled(self):
|
|
from timmy.paperclip import start_paperclip_poller
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_enabled = True
|
|
|
|
mock_poller = MagicMock()
|
|
mock_poller.poll = AsyncMock()
|
|
|
|
created_tasks = []
|
|
original_create_task = asyncio.create_task
|
|
|
|
def capture_create_task(coro):
|
|
created_tasks.append(coro)
|
|
return original_create_task(coro)
|
|
|
|
with patch("timmy.paperclip.PaperclipPoller", return_value=mock_poller):
|
|
with patch("asyncio.create_task", side_effect=capture_create_task):
|
|
await start_paperclip_poller()
|
|
|
|
assert len(created_tasks) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_does_nothing_when_disabled(self):
|
|
from timmy.paperclip import start_paperclip_poller
|
|
|
|
with patch("timmy.paperclip.settings") as mock_settings:
|
|
mock_settings.paperclip_enabled = False
|
|
|
|
with patch("timmy.paperclip.PaperclipPoller") as mock_poller_class:
|
|
with patch("asyncio.create_task") as mock_create_task:
|
|
await start_paperclip_poller()
|
|
|
|
mock_poller_class.assert_not_called()
|
|
mock_create_task.assert_not_called()
|