"""Unit tests for timmy.vassal.dispatch — routing and label helpers.""" from __future__ import annotations from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from timmy.vassal.backlog import AgentTarget, TriagedIssue from timmy.vassal.dispatch import ( DispatchRecord, _apply_label_to_issue, _get_or_create_label, _post_dispatch_comment, clear_dispatch_registry, get_dispatch_registry, ) def _make_triaged( number: int, title: str, agent: AgentTarget, priority: int = 50, ) -> TriagedIssue: return TriagedIssue( number=number, title=title, body="", agent_target=agent, priority_score=priority, rationale="test rationale", url=f"http://gitea/issues/{number}", ) # --------------------------------------------------------------------------- # Registry helpers # --------------------------------------------------------------------------- def test_registry_starts_empty(): clear_dispatch_registry() assert get_dispatch_registry() == {} def test_registry_returns_copy(): clear_dispatch_registry() reg = get_dispatch_registry() reg[999] = None # type: ignore[assignment] assert 999 not in get_dispatch_registry() # --------------------------------------------------------------------------- # dispatch_issue — Timmy self-dispatch (no Gitea required) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_dispatch_timmy_self_no_gitea(): """Timmy self-dispatch records without hitting Gitea.""" clear_dispatch_registry() issue = _make_triaged(1, "Fix docs typo", AgentTarget.TIMMY) from timmy.vassal.dispatch import dispatch_issue record = await dispatch_issue(issue) assert isinstance(record, DispatchRecord) assert record.issue_number == 1 assert record.agent == AgentTarget.TIMMY assert 1 in get_dispatch_registry() @pytest.mark.asyncio async def test_dispatch_claude_no_gitea_token(): """Claude dispatch gracefully degrades when Gitea token is absent.""" clear_dispatch_registry() issue = _make_triaged(2, "Refactor auth", AgentTarget.CLAUDE) from timmy.vassal.dispatch import dispatch_issue record = await dispatch_issue(issue) assert record.issue_number == 2 assert record.agent == AgentTarget.CLAUDE # label/comment not applied — no token assert record.label_applied is False assert 2 in get_dispatch_registry() @pytest.mark.asyncio async def test_dispatch_kimi_no_gitea_token(): clear_dispatch_registry() issue = _make_triaged(3, "Research embeddings", AgentTarget.KIMI) from timmy.vassal.dispatch import dispatch_issue record = await dispatch_issue(issue) assert record.agent == AgentTarget.KIMI assert record.label_applied is False # --------------------------------------------------------------------------- # DispatchRecord fields # --------------------------------------------------------------------------- def test_dispatch_record_defaults(): r = DispatchRecord( issue_number=5, issue_title="Test issue", agent=AgentTarget.TIMMY, rationale="because", ) assert r.label_applied is False assert r.comment_posted is False assert r.dispatched_at # has a timestamp # --------------------------------------------------------------------------- # _get_or_create_label # --------------------------------------------------------------------------- _HEADERS = {"Authorization": "token x"} _BASE_URL = "http://gitea" _REPO = "org/repo" def _mock_response(status_code: int, json_data=None): resp = MagicMock() resp.status_code = status_code resp.json.return_value = json_data or {} return resp @pytest.mark.asyncio async def test_get_or_create_label_finds_existing(): """Returns the ID of an existing label without creating it.""" existing = [{"name": "claude-ready", "id": 42}, {"name": "other", "id": 7}] client = AsyncMock() client.get.return_value = _mock_response(200, existing) result = await _get_or_create_label(client, _BASE_URL, _HEADERS, _REPO, "claude-ready") assert result == 42 client.post.assert_not_called() @pytest.mark.asyncio async def test_get_or_create_label_creates_when_missing(): """Creates the label when it doesn't exist in the list.""" client = AsyncMock() # GET returns empty list client.get.return_value = _mock_response(200, []) # POST creates label client.post.return_value = _mock_response(201, {"id": 99}) result = await _get_or_create_label(client, _BASE_URL, _HEADERS, _REPO, "claude-ready") assert result == 99 client.post.assert_called_once() @pytest.mark.asyncio async def test_get_or_create_label_returns_none_on_get_error(): """Returns None if the GET raises an exception.""" client = AsyncMock() client.get.side_effect = Exception("network error") result = await _get_or_create_label(client, _BASE_URL, _HEADERS, _REPO, "claude-ready") assert result is None @pytest.mark.asyncio async def test_get_or_create_label_returns_none_on_create_error(): """Returns None if POST raises an exception.""" client = AsyncMock() client.get.return_value = _mock_response(200, []) client.post.side_effect = Exception("post failed") result = await _get_or_create_label(client, _BASE_URL, _HEADERS, _REPO, "claude-ready") assert result is None @pytest.mark.asyncio async def test_get_or_create_label_uses_default_color_for_unknown(): """Unknown label name uses '#cccccc' fallback color.""" client = AsyncMock() client.get.return_value = _mock_response(200, []) client.post.return_value = _mock_response(201, {"id": 5}) await _get_or_create_label(client, _BASE_URL, _HEADERS, _REPO, "unknown-label") call_kwargs = client.post.call_args assert call_kwargs.kwargs["json"]["color"] == "#cccccc" # --------------------------------------------------------------------------- # _apply_label_to_issue # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_apply_label_to_issue_success(): """Returns True when label is found and applied.""" client = AsyncMock() client.get.return_value = _mock_response(200, [{"name": "claude-ready", "id": 10}]) client.post.return_value = _mock_response(201) result = await _apply_label_to_issue(client, _BASE_URL, _HEADERS, _REPO, 42, "claude-ready") assert result is True @pytest.mark.asyncio async def test_apply_label_to_issue_returns_false_when_no_label_id(): """Returns False when label ID cannot be obtained.""" client = AsyncMock() client.get.side_effect = Exception("unavailable") result = await _apply_label_to_issue(client, _BASE_URL, _HEADERS, _REPO, 42, "claude-ready") assert result is False @pytest.mark.asyncio async def test_apply_label_to_issue_returns_false_on_bad_status(): """Returns False when the apply POST returns a non-2xx status.""" client = AsyncMock() client.get.return_value = _mock_response(200, [{"name": "claude-ready", "id": 10}]) client.post.return_value = _mock_response(403) result = await _apply_label_to_issue(client, _BASE_URL, _HEADERS, _REPO, 42, "claude-ready") assert result is False # --------------------------------------------------------------------------- # _post_dispatch_comment # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_post_dispatch_comment_success(): """Returns True on successful comment post.""" client = AsyncMock() client.post.return_value = _mock_response(201) issue = _make_triaged(7, "Some issue", AgentTarget.CLAUDE, priority=75) result = await _post_dispatch_comment(client, _BASE_URL, _HEADERS, _REPO, issue, "claude-ready") assert result is True body = client.post.call_args.kwargs["json"]["body"] assert "Claude" in body assert "claude-ready" in body assert "75" in body @pytest.mark.asyncio async def test_post_dispatch_comment_failure(): """Returns False when comment POST returns a non-2xx status.""" client = AsyncMock() client.post.return_value = _mock_response(500) issue = _make_triaged(8, "Other issue", AgentTarget.KIMI) result = await _post_dispatch_comment(client, _BASE_URL, _HEADERS, _REPO, issue, "kimi-ready") assert result is False # --------------------------------------------------------------------------- # _perform_gitea_dispatch — settings-level gate # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_perform_gitea_dispatch_skips_when_disabled(): """Does not call Gitea when gitea_enabled is False.""" import config from timmy.vassal.dispatch import _perform_gitea_dispatch mock_settings = SimpleNamespace(gitea_enabled=False, gitea_token="tok") with patch.object(config, "settings", mock_settings): issue = _make_triaged(9, "Disabled", AgentTarget.CLAUDE) record = DispatchRecord( issue_number=9, issue_title="Disabled", agent=AgentTarget.CLAUDE, rationale="r", ) await _perform_gitea_dispatch(issue, record) assert record.label_applied is False assert record.comment_posted is False @pytest.mark.asyncio async def test_perform_gitea_dispatch_skips_when_no_token(): """Does not call Gitea when gitea_token is empty.""" import config from timmy.vassal.dispatch import _perform_gitea_dispatch mock_settings = SimpleNamespace(gitea_enabled=True, gitea_token="") with patch.object(config, "settings", mock_settings): issue = _make_triaged(10, "No token", AgentTarget.CLAUDE) record = DispatchRecord( issue_number=10, issue_title="No token", agent=AgentTarget.CLAUDE, rationale="r", ) await _perform_gitea_dispatch(issue, record) assert record.label_applied is False @pytest.mark.asyncio async def test_perform_gitea_dispatch_updates_record(): """Record is mutated to reflect label/comment success.""" import config from timmy.vassal.dispatch import _perform_gitea_dispatch mock_settings = SimpleNamespace( gitea_enabled=True, gitea_token="tok", gitea_url="http://gitea", gitea_repo="org/repo", ) mock_client = AsyncMock() # GET labels → empty list, POST create label → id 1 mock_client.get.return_value = _mock_response(200, []) mock_client.post.side_effect = [ _mock_response(201, {"id": 1}), # create label _mock_response(201), # apply label _mock_response(201), # post comment ] with ( patch.object(config, "settings", mock_settings), patch("httpx.AsyncClient") as mock_cls, ): mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) issue = _make_triaged(11, "Full dispatch", AgentTarget.CLAUDE) record = DispatchRecord( issue_number=11, issue_title="Full dispatch", agent=AgentTarget.CLAUDE, rationale="r", ) await _perform_gitea_dispatch(issue, record) assert record.label_applied is True assert record.comment_posted is True