Files
Timmy-time-dashboard/tests/unit/test_paperclip.py
Claude (Opus 4.6) b5fb6a85cf
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
[claude] Fix pre-existing ruff lint errors blocking git hooks (#1247) (#1248)
2026-03-23 23:33:37 +00:00

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