diff --git a/tests/unit/test_paperclip.py b/tests/unit/test_paperclip.py new file mode 100644 index 00000000..1120cb79 --- /dev/null +++ b/tests/unit/test_paperclip.py @@ -0,0 +1,569 @@ +"""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()