"""Tests for gitea_client.py — the typed, sovereign API client. gitea_client.py is 539 lines with zero tests in this repo (there are tests in hermes-agent, but not here where it's actually used). These tests cover: - All 6 dataclass from_dict() constructors (User, Label, Issue, etc.) - Defensive handling of missing/null fields from Gitea API - find_unassigned_issues() filtering logic - find_agent_issues() case-insensitive matching - GiteaError formatting - _repo_path() formatting """ from __future__ import annotations import importlib.util import sys from pathlib import Path import pytest # Import gitea_client directly via importlib to avoid any sys.modules mocking # from test_tasks_core which stubs gitea_client as a MagicMock. REPO_ROOT = Path(__file__).parent.parent _spec = importlib.util.spec_from_file_location( "gitea_client_real", REPO_ROOT / "gitea_client.py", ) _gc = importlib.util.module_from_spec(_spec) sys.modules["gitea_client_real"] = _gc _spec.loader.exec_module(_gc) User = _gc.User Label = _gc.Label Issue = _gc.Issue Comment = _gc.Comment PullRequest = _gc.PullRequest PRFile = _gc.PRFile GiteaError = _gc.GiteaError GiteaClient = _gc.GiteaClient # ═══════════════════════════════════════════════════════════════════════ # DATACLASS DESERIALIZATION # ═══════════════════════════════════════════════════════════════════════ class TestUserFromDict: def test_full_user(self): u = User.from_dict({"id": 1, "login": "timmy", "full_name": "Timmy", "email": "t@t.com"}) assert u.id == 1 assert u.login == "timmy" assert u.full_name == "Timmy" assert u.email == "t@t.com" def test_minimal_user(self): """Missing fields default to empty.""" u = User.from_dict({}) assert u.id == 0 assert u.login == "" def test_extra_fields_ignored(self): """Unknown fields from Gitea are silently ignored.""" u = User.from_dict({"id": 1, "login": "x", "avatar_url": "http://..."}) assert u.login == "x" class TestLabelFromDict: def test_label(self): lb = Label.from_dict({"id": 5, "name": "bug", "color": "#ff0000"}) assert lb.id == 5 assert lb.name == "bug" assert lb.color == "#ff0000" class TestIssueFromDict: def test_full_issue(self): issue = Issue.from_dict({ "number": 42, "title": "Fix the bug", "body": "Please fix it", "state": "open", "user": {"id": 1, "login": "reporter"}, "assignees": [{"id": 2, "login": "dev"}], "labels": [{"id": 3, "name": "bug"}], "comments": 5, }) assert issue.number == 42 assert issue.user.login == "reporter" assert len(issue.assignees) == 1 assert issue.assignees[0].login == "dev" assert len(issue.labels) == 1 assert issue.comments == 5 def test_null_assignees_handled(self): """Gitea returns null for assignees sometimes — the exact bug that crashed find_unassigned_issues() before the defensive fix.""" issue = Issue.from_dict({ "number": 1, "title": "test", "body": None, "state": "open", "user": {"id": 1, "login": "x"}, "assignees": None, }) assert issue.assignees == [] assert issue.body == "" def test_null_labels_handled(self): """Labels can also be null.""" issue = Issue.from_dict({ "number": 1, "title": "test", "state": "open", "user": {}, "labels": None, }) assert issue.labels == [] def test_missing_user_defaults(self): """Issue with no user field doesn't crash.""" issue = Issue.from_dict({"number": 1, "title": "t", "state": "open"}) assert issue.user.login == "" class TestCommentFromDict: def test_comment(self): c = Comment.from_dict({ "id": 10, "body": "LGTM", "user": {"id": 1, "login": "reviewer"}, }) assert c.id == 10 assert c.body == "LGTM" assert c.user.login == "reviewer" def test_null_body(self): c = Comment.from_dict({"id": 1, "body": None, "user": {}}) assert c.body == "" class TestPullRequestFromDict: def test_full_pr(self): pr = PullRequest.from_dict({ "number": 99, "title": "Add feature", "body": "Description here", "state": "open", "user": {"id": 1, "login": "dev"}, "head": {"ref": "feature-branch"}, "base": {"ref": "main"}, "mergeable": True, "merged": False, "changed_files": 3, }) assert pr.number == 99 assert pr.head_branch == "feature-branch" assert pr.base_branch == "main" assert pr.mergeable is True def test_null_head_base(self): """Handles null head/base objects.""" pr = PullRequest.from_dict({ "number": 1, "title": "t", "state": "open", "user": {}, "head": None, "base": None, }) assert pr.head_branch == "" assert pr.base_branch == "" def test_null_merged(self): """merged can be null from Gitea.""" pr = PullRequest.from_dict({ "number": 1, "title": "t", "state": "open", "user": {}, "merged": None, }) assert pr.merged is False class TestPRFileFromDict: def test_pr_file(self): f = PRFile.from_dict({ "filename": "src/main.py", "status": "modified", "additions": 10, "deletions": 3, }) assert f.filename == "src/main.py" assert f.status == "modified" assert f.additions == 10 assert f.deletions == 3 # ═══════════════════════════════════════════════════════════════════════ # ERROR HANDLING # ═══════════════════════════════════════════════════════════════════════ class TestGiteaError: def test_error_formatting(self): err = GiteaError(404, "not found", "http://example.com/api/v1/repos/x") assert "404" in str(err) assert "not found" in str(err) def test_error_attributes(self): err = GiteaError(500, "internal") assert err.status == 500 # ═══════════════════════════════════════════════════════════════════════ # CLIENT HELPER METHODS # ═══════════════════════════════════════════════════════════════════════ class TestClientHelpers: def test_repo_path(self): """_repo_path converts owner/name to API path.""" client = GiteaClient.__new__(GiteaClient) assert client._repo_path("Timmy_Foundation/the-nexus") == "/repos/Timmy_Foundation/the-nexus" # ═══════════════════════════════════════════════════════════════════════ # FILTERING LOGIC — find_unassigned_issues, find_agent_issues # ═══════════════════════════════════════════════════════════════════════ class TestFindUnassigned: """Tests for find_unassigned_issues() filtering logic. These tests use pre-constructed Issue objects to test the filtering without making any API calls. """ def _make_issue(self, number, assignees=None, labels=None, title="test"): return Issue( number=number, title=title, body="", state="open", user=User(id=0, login=""), assignees=[User(id=0, login=a) for a in (assignees or [])], labels=[Label(id=0, name=lb) for lb in (labels or [])], ) def test_filters_assigned_issues(self): """Issues with assignees are excluded.""" from unittest.mock import patch issues = [ self._make_issue(1, assignees=["dev"]), self._make_issue(2), # unassigned ] client = GiteaClient.__new__(GiteaClient) with patch.object(client, "list_issues", return_value=issues): result = client.find_unassigned_issues("repo") assert len(result) == 1 assert result[0].number == 2 def test_excludes_by_label(self): """Issues with excluded labels are filtered.""" from unittest.mock import patch issues = [ self._make_issue(1, labels=["wontfix"]), self._make_issue(2, labels=["bug"]), ] client = GiteaClient.__new__(GiteaClient) with patch.object(client, "list_issues", return_value=issues): result = client.find_unassigned_issues("repo", exclude_labels=["wontfix"]) assert len(result) == 1 assert result[0].number == 2 def test_excludes_by_title_pattern(self): """Issues matching title patterns are filtered.""" from unittest.mock import patch issues = [ self._make_issue(1, title="[PHASE] Research AI"), self._make_issue(2, title="Fix login bug"), ] client = GiteaClient.__new__(GiteaClient) with patch.object(client, "list_issues", return_value=issues): result = client.find_unassigned_issues( "repo", exclude_title_patterns=["[PHASE]"] ) assert len(result) == 1 assert result[0].number == 2 class TestFindAgentIssues: """Tests for find_agent_issues() case-insensitive matching.""" def test_case_insensitive_match(self): from unittest.mock import patch issues = [ Issue(number=1, title="t", body="", state="open", user=User(0, ""), assignees=[User(0, "Timmy")], labels=[]), ] client = GiteaClient.__new__(GiteaClient) with patch.object(client, "list_issues", return_value=issues): result = client.find_agent_issues("repo", "timmy") assert len(result) == 1 def test_no_match_for_different_agent(self): from unittest.mock import patch issues = [ Issue(number=1, title="t", body="", state="open", user=User(0, ""), assignees=[User(0, "Timmy")], labels=[]), ] client = GiteaClient.__new__(GiteaClient) with patch.object(client, "list_issues", return_value=issues): result = client.find_agent_issues("repo", "claude") assert len(result) == 0