test: expand unit tests for vassal/dispatch.py to 96% coverage
Add 13 new tests covering: - _get_or_create_label: finds existing, creates missing, error handling, default color - _apply_label_to_issue: success, no label ID, bad status - _post_dispatch_comment: success and failure paths - _perform_gitea_dispatch: skips when disabled, skips without token, full success path Fixes #1193 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,17 @@
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -112,3 +118,244 @@ def test_dispatch_record_defaults():
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user