"""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()