"""Tests for the sovereign Gitea API client. Validates: - Retry logic with jitter on transient errors (429, 502, 503) - Pagination across multi-page results - Defensive None handling (assignees, labels) - Error handling and GiteaError - find_unassigned_issues filtering - Token loading from config file - Backward compatibility (existing get_file/create_file/update_file API) These tests are fully self-contained — no network calls, no Gitea server, no firecrawl dependency. The gitea_client module is imported directly by file path to bypass tools/__init__.py's eager imports. """ import io import inspect import json import os import sys import tempfile import urllib.error from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch import pytest # ── Direct module import ───────────────────────────────────────────── # Import gitea_client directly by file path to bypass tools/__init__.py # which eagerly imports web_tools → firecrawl (not always installed). import importlib.util PROJECT_ROOT = Path(__file__).parent.parent.parent _spec = importlib.util.spec_from_file_location( "gitea_client_test", PROJECT_ROOT / "tools" / "gitea_client.py", ) _mod = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_mod) GiteaClient = _mod.GiteaClient GiteaError = _mod.GiteaError _load_token_config = _mod._load_token_config # Module path for patching — must target our loaded module, not tools.gitea_client _MOD_NAME = "gitea_client_test" sys.modules[_MOD_NAME] = _mod # ── Helpers ────────────────────────────────────────────────────────── def _make_response(data: Any, status: int = 200): """Create a mock HTTP response context manager.""" resp = MagicMock() resp.read.return_value = json.dumps(data).encode() resp.status = status resp.__enter__ = MagicMock(return_value=resp) resp.__exit__ = MagicMock(return_value=False) return resp def _make_http_error(code: int, msg: str): """Create a real urllib HTTPError for testing.""" return urllib.error.HTTPError( url="http://test", code=code, msg=msg, hdrs={}, # type: ignore fp=io.BytesIO(msg.encode()), ) # ── Fixtures ───────────────────────────────────────────────────────── @pytest.fixture def client(): """Client with no real credentials (won't hit network).""" return GiteaClient(base_url="http://localhost:3000", token="test_token") @pytest.fixture def mock_urlopen(): """Patch urllib.request.urlopen on our directly-loaded module.""" with patch.object(_mod.urllib.request, "urlopen") as mock: yield mock # ── Core request tests ─────────────────────────────────────────────── class TestCoreRequest: def test_successful_get(self, client, mock_urlopen): """Basic GET request returns parsed JSON.""" mock_urlopen.return_value = _make_response({"id": 1, "name": "test"}) result = client._request("GET", "/repos/org/repo") assert result == {"id": 1, "name": "test"} mock_urlopen.assert_called_once() def test_auth_header_set(self, client, mock_urlopen): """Token is included in Authorization header.""" mock_urlopen.return_value = _make_response({}) client._request("GET", "/test") req = mock_urlopen.call_args[0][0] assert req.get_header("Authorization") == "token test_token" def test_post_sends_json_body(self, client, mock_urlopen): """POST with data sends JSON-encoded body.""" mock_urlopen.return_value = _make_response({"id": 42}) client._request("POST", "/test", data={"title": "hello"}) req = mock_urlopen.call_args[0][0] assert req.data == json.dumps({"title": "hello"}).encode() assert req.get_method() == "POST" def test_params_become_query_string(self, client, mock_urlopen): """Query params are URL-encoded.""" mock_urlopen.return_value = _make_response([]) client._request("GET", "/issues", params={"state": "open", "limit": 50}) req = mock_urlopen.call_args[0][0] assert "state=open" in req.full_url assert "limit=50" in req.full_url def test_none_params_excluded(self, client, mock_urlopen): """None values in params dict are excluded from query string.""" mock_urlopen.return_value = _make_response([]) client._request("GET", "/issues", params={"state": "open", "labels": None}) req = mock_urlopen.call_args[0][0] assert "state=open" in req.full_url assert "labels" not in req.full_url # ── Retry tests ────────────────────────────────────────────────────── class TestRetry: def test_retries_on_429(self, client, mock_urlopen): """429 (rate limit) triggers retry with jitter.""" mock_urlopen.side_effect = [ _make_http_error(429, "rate limited"), _make_response({"ok": True}), ] with patch.object(_mod.time, "sleep"): result = client._request("GET", "/test") assert result == {"ok": True} assert mock_urlopen.call_count == 2 def test_retries_on_502(self, client, mock_urlopen): """502 (bad gateway) triggers retry.""" mock_urlopen.side_effect = [ _make_http_error(502, "bad gateway"), _make_response({"recovered": True}), ] with patch.object(_mod.time, "sleep"): result = client._request("GET", "/test") assert result == {"recovered": True} def test_retries_on_503(self, client, mock_urlopen): """503 (service unavailable) triggers retry.""" mock_urlopen.side_effect = [ _make_http_error(503, "unavailable"), _make_http_error(503, "unavailable"), _make_response({"third_time": True}), ] with patch.object(_mod.time, "sleep"): result = client._request("GET", "/test") assert result == {"third_time": True} assert mock_urlopen.call_count == 3 def test_non_retryable_error_raises_immediately(self, client, mock_urlopen): """404 is not retryable — raises GiteaError immediately.""" mock_urlopen.side_effect = _make_http_error(404, "not found") with pytest.raises(GiteaError) as exc_info: client._request("GET", "/nonexistent") assert exc_info.value.status_code == 404 assert mock_urlopen.call_count == 1 def test_max_retries_exhausted(self, client, mock_urlopen): """After max retries, raises the last error.""" mock_urlopen.side_effect = [ _make_http_error(503, "unavailable"), ] * 4 with patch.object(_mod.time, "sleep"): with pytest.raises(GiteaError) as exc_info: client._request("GET", "/test") assert exc_info.value.status_code == 503 # ── Pagination tests ───────────────────────────────────────────────── class TestPagination: def test_single_page(self, client, mock_urlopen): """Single page of results (fewer items than limit).""" items = [{"id": i} for i in range(10)] mock_urlopen.return_value = _make_response(items) result = client._paginate("/repos/org/repo/issues") assert len(result) == 10 assert mock_urlopen.call_count == 1 def test_multi_page(self, client, mock_urlopen): """Results spanning multiple pages.""" page1 = [{"id": i} for i in range(50)] page2 = [{"id": i} for i in range(50, 75)] mock_urlopen.side_effect = [ _make_response(page1), _make_response(page2), ] result = client._paginate("/test") assert len(result) == 75 assert mock_urlopen.call_count == 2 def test_max_items_respected(self, client, mock_urlopen): """max_items truncates results.""" page1 = [{"id": i} for i in range(50)] mock_urlopen.return_value = _make_response(page1) result = client._paginate("/test", max_items=20) assert len(result) == 20 # ── Issue methods ──────────────────────────────────────────────────── class TestIssues: def test_list_issues(self, client, mock_urlopen): """list_issues passes correct params.""" mock_urlopen.return_value = _make_response([ {"number": 1, "title": "Bug"}, {"number": 2, "title": "Feature"}, ]) result = client.list_issues("org/repo", state="open") assert len(result) == 2 req = mock_urlopen.call_args[0][0] assert "state=open" in req.full_url assert "type=issues" in req.full_url def test_create_issue_comment(self, client, mock_urlopen): """create_issue_comment sends body.""" mock_urlopen.return_value = _make_response({"id": 99, "body": "Fixed"}) result = client.create_issue_comment("org/repo", 42, "Fixed in PR #102") req = mock_urlopen.call_args[0][0] body = json.loads(req.data) assert body["body"] == "Fixed in PR #102" assert "/repos/org/repo/issues/42/comments" in req.full_url def test_find_unassigned_none_assignees(self, client, mock_urlopen): """find_unassigned_issues handles None assignees field. Gitea sometimes returns null for assignees on issues created without setting one. This was a bug found in the audit — tasks.py crashed with TypeError when iterating None. """ mock_urlopen.return_value = _make_response([ {"number": 1, "title": "Bug", "assignees": None, "labels": []}, {"number": 2, "title": "Assigned", "assignees": [{"login": "dev"}], "labels": []}, {"number": 3, "title": "Empty", "assignees": [], "labels": []}, ]) result = client.find_unassigned_issues("org/repo") assert len(result) == 2 assert result[0]["number"] == 1 assert result[1]["number"] == 3 def test_find_unassigned_excludes_labels(self, client, mock_urlopen): """find_unassigned_issues respects exclude_labels.""" mock_urlopen.return_value = _make_response([ {"number": 1, "title": "Bug", "assignees": None, "labels": [{"name": "wontfix"}]}, {"number": 2, "title": "Todo", "assignees": None, "labels": [{"name": "enhancement"}]}, ]) result = client.find_unassigned_issues( "org/repo", exclude_labels=["wontfix"] ) assert len(result) == 1 assert result[0]["number"] == 2 # ── Pull Request methods ──────────────────────────────────────────── class TestPullRequests: def test_create_pull(self, client, mock_urlopen): """create_pull sends correct data.""" mock_urlopen.return_value = _make_response( {"number": 105, "state": "open"} ) result = client.create_pull( "org/repo", title="Fix bugs", head="fix-branch", base="main", body="Fixes #42", ) req = mock_urlopen.call_args[0][0] body = json.loads(req.data) assert body["title"] == "Fix bugs" assert body["head"] == "fix-branch" assert body["base"] == "main" assert result["number"] == 105 def test_create_pull_review(self, client, mock_urlopen): """create_pull_review sends review event.""" mock_urlopen.return_value = _make_response({"id": 1}) client.create_pull_review("org/repo", 42, "LGTM", event="APPROVE") req = mock_urlopen.call_args[0][0] body = json.loads(req.data) assert body["event"] == "APPROVE" assert body["body"] == "LGTM" # ── Backward compatibility ────────────────────────────────────────── class TestBackwardCompat: """Ensure the expanded client doesn't break graph_store.py or knowledge_ingester.py which import the old 3-method interface.""" def test_get_file_signature(self, client): """get_file accepts (repo, path, ref) — same as before.""" sig = inspect.signature(client.get_file) params = list(sig.parameters.keys()) assert params == ["repo", "path", "ref"] def test_create_file_signature(self, client): """create_file accepts (repo, path, content, message, branch).""" sig = inspect.signature(client.create_file) params = list(sig.parameters.keys()) assert "repo" in params and "content" in params and "message" in params def test_update_file_signature(self, client): """update_file accepts (repo, path, content, message, sha, branch).""" sig = inspect.signature(client.update_file) params = list(sig.parameters.keys()) assert "sha" in params def test_constructor_env_var_fallback(self): """Constructor reads GITEA_URL and GITEA_TOKEN from env.""" with patch.dict(os.environ, { "GITEA_URL": "http://myserver:3000", "GITEA_TOKEN": "mytoken", }): c = GiteaClient() assert c.base_url == "http://myserver:3000" assert c.token == "mytoken" # ── Token config loading ───────────────────────────────────────────── class TestTokenConfig: def test_load_missing_file(self, tmp_path): """Missing token file returns empty dict.""" with patch.object(_mod.Path, "home", return_value=tmp_path / "nope"): config = _load_token_config() assert config == {"url": "", "token": ""} def test_load_valid_file(self, tmp_path): """Valid token file is parsed correctly.""" token_file = tmp_path / ".timmy" / "gemini_gitea_token" token_file.parent.mkdir(parents=True) token_file.write_text( 'GITEA_URL=http://143.198.27.163:3000\n' 'GITEA_TOKEN=abc123\n' ) with patch.object(_mod.Path, "home", return_value=tmp_path): config = _load_token_config() assert config["url"] == "http://143.198.27.163:3000" assert config["token"] == "abc123" # ── GiteaError ─────────────────────────────────────────────────────── class TestGiteaError: def test_error_attributes(self): err = GiteaError(404, "not found", "http://example.com/api/v1/test") assert err.status_code == 404 assert err.url == "http://example.com/api/v1/test" assert "404" in str(err) assert "not found" in str(err) def test_error_is_exception(self): """GiteaError is a proper exception that can be caught.""" with pytest.raises(GiteaError): raise GiteaError(500, "server error")