"""Tests for the Gitea webhook adapter.""" from unittest.mock import AsyncMock, patch import pytest from timmy.adapters.gitea_adapter import ( BOT_USERNAMES, _extract_actor, _is_bot, _is_pr_merge, _normalize_issue_comment, _normalize_issue_opened, _normalize_pull_request, _normalize_push, handle_webhook, ) # ── Fixtures: sample payloads ──────────────────────────────────────────────── def _sender(login: str) -> dict: return {"sender": {"login": login}} def _push_payload(actor: str = "rockachopa", ref: str = "refs/heads/main") -> dict: return { **_sender(actor), "ref": ref, "repository": {"full_name": "rockachopa/Timmy-time-dashboard"}, "commits": [ {"message": "fix: something\n\nDetails here"}, {"message": "chore: cleanup"}, ], } def _issue_payload(actor: str = "rockachopa", action: str = "opened") -> dict: return { **_sender(actor), "action": action, "repository": {"full_name": "rockachopa/Timmy-time-dashboard"}, "issue": {"number": 42, "title": "Bug in dashboard"}, } def _issue_comment_payload(actor: str = "rockachopa") -> dict: return { **_sender(actor), "action": "created", "repository": {"full_name": "rockachopa/Timmy-time-dashboard"}, "issue": {"number": 42, "title": "Bug in dashboard"}, "comment": {"body": "I think this is related to the config change"}, } def _pr_payload( actor: str = "rockachopa", action: str = "opened", merged: bool = False, ) -> dict: return { **_sender(actor), "action": action, "repository": {"full_name": "rockachopa/Timmy-time-dashboard"}, "pull_request": { "number": 99, "title": "feat: add new feature", "merged": merged, }, } # ── Unit tests: helpers ────────────────────────────────────────────────────── class TestExtractActor: def test_normal_sender(self): assert _extract_actor({"sender": {"login": "rockachopa"}}) == "rockachopa" def test_missing_sender(self): assert _extract_actor({}) == "unknown" class TestIsBot: @pytest.mark.parametrize("name", list(BOT_USERNAMES)) def test_known_bots(self, name): assert _is_bot(name) is True def test_owner_not_bot(self): assert _is_bot("rockachopa") is False def test_case_insensitive(self): assert _is_bot("Kimi") is True class TestIsPrMerge: def test_merged_pr(self): payload = _pr_payload(action="closed", merged=True) assert _is_pr_merge("pull_request", payload) is True def test_closed_not_merged(self): payload = _pr_payload(action="closed", merged=False) assert _is_pr_merge("pull_request", payload) is False def test_opened_pr(self): payload = _pr_payload(action="opened") assert _is_pr_merge("pull_request", payload) is False def test_non_pr_event(self): assert _is_pr_merge("push", {}) is False # ── Unit tests: normalizers ────────────────────────────────────────────────── class TestNormalizePush: def test_basic(self): data = _normalize_push(_push_payload(), "rockachopa") assert data["actor"] == "rockachopa" assert data["ref"] == "refs/heads/main" assert data["num_commits"] == 2 assert data["head_message"] == "fix: something" assert data["repo"] == "rockachopa/Timmy-time-dashboard" def test_empty_commits(self): payload = {**_push_payload(), "commits": []} data = _normalize_push(payload, "rockachopa") assert data["num_commits"] == 0 assert data["head_message"] == "" class TestNormalizeIssueOpened: def test_basic(self): data = _normalize_issue_opened(_issue_payload(), "rockachopa") assert data["issue_number"] == 42 assert data["title"] == "Bug in dashboard" assert data["action"] == "opened" class TestNormalizeIssueComment: def test_basic(self): data = _normalize_issue_comment(_issue_comment_payload(), "rockachopa") assert data["issue_number"] == 42 assert data["comment_body"].startswith("I think this is related") def test_long_comment_truncated(self): payload = _issue_comment_payload() payload["comment"]["body"] = "x" * 500 data = _normalize_issue_comment(payload, "rockachopa") assert len(data["comment_body"]) == 200 class TestNormalizePullRequest: def test_opened(self): data = _normalize_pull_request(_pr_payload(), "rockachopa") assert data["pr_number"] == 99 assert data["merged"] is False assert data["action"] == "opened" def test_merged(self): payload = _pr_payload(action="closed", merged=True) data = _normalize_pull_request(payload, "rockachopa") assert data["merged"] is True # ── Integration tests: handle_webhook ──────────────────────────────────────── @pytest.mark.asyncio class TestHandleWebhook: @patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock) async def test_push_emitted(self, mock_emit): result = await handle_webhook("push", _push_payload()) assert result is True mock_emit.assert_called_once() args = mock_emit.call_args assert args[0][0] == "gitea.push" assert args[1]["data"]["num_commits"] == 2 @patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock) async def test_issue_opened_emitted(self, mock_emit): result = await handle_webhook("issues", _issue_payload()) assert result is True mock_emit.assert_called_once() assert mock_emit.call_args[0][0] == "gitea.issue.opened" @patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock) async def test_issue_comment_emitted(self, mock_emit): result = await handle_webhook("issue_comment", _issue_comment_payload()) assert result is True assert mock_emit.call_args[0][0] == "gitea.issue.comment" @patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock) async def test_pull_request_emitted(self, mock_emit): result = await handle_webhook("pull_request", _pr_payload()) assert result is True assert mock_emit.call_args[0][0] == "gitea.pull_request" @patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock) async def test_unsupported_event_filtered(self, mock_emit): result = await handle_webhook("fork", {"sender": {"login": "someone"}}) assert result is False mock_emit.assert_not_called() @patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock) async def test_bot_push_filtered(self, mock_emit): result = await handle_webhook("push", _push_payload(actor="kimi")) assert result is False mock_emit.assert_not_called() @patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock) async def test_bot_issue_filtered(self, mock_emit): result = await handle_webhook("issues", _issue_payload(actor="hermes")) assert result is False mock_emit.assert_not_called() @patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock) async def test_bot_pr_merge_not_filtered(self, mock_emit): """Bot PR merges should still be emitted.""" payload = _pr_payload(actor="kimi", action="closed", merged=True) result = await handle_webhook("pull_request", payload) assert result is True mock_emit.assert_called_once() data = mock_emit.call_args[1]["data"] assert data["merged"] is True @patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock) async def test_bot_pr_close_without_merge_filtered(self, mock_emit): """Bot PR close (not merge) should be filtered.""" payload = _pr_payload(actor="manus", action="closed", merged=False) result = await handle_webhook("pull_request", payload) assert result is False mock_emit.assert_not_called() @patch("timmy.adapters.gitea_adapter.emit", new_callable=AsyncMock) async def test_owner_activity_always_emitted(self, mock_emit): result = await handle_webhook("push", _push_payload(actor="rockachopa")) assert result is True mock_emit.assert_called_once()