diff --git a/tests/test_client.py b/tests/test_client.py index 7969e53..13682fd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -427,3 +427,90 @@ class TestGetLatestCommit: result = client.get_latest_commit("admin", "missing-repo") assert result is None + + +class TestGetPaginatedGracefulTimeout: + """Test graceful degradation on timeout during pagination.""" + + def _make_client(self): + return GiteaClient("http://gitea.local:3000", "tok") + + @patch("time.sleep") + def test_get_paginated_timeout_page2_returns_partial(self, mock_sleep): + """Timeout on page 2 returns partial data from page 1.""" + client = self._make_client() + + page1 = [{"id": i} for i in range(50)] # Full page -> triggers page 2 + + mock_resp1 = MagicMock() + mock_resp1.raise_for_status = MagicMock() + mock_resp1.json.return_value = page1 + + # Page 2: _get_with_retry exhausts retries and raises ReadTimeout + with patch.object( + client, + "_get_with_retry", + side_effect=[mock_resp1, requests.exceptions.ReadTimeout("timeout page 2")], + ): + result = client._get_paginated("/api/v1/user/repos") + + assert result == page1 + + @patch("time.sleep") + def test_get_paginated_timeout_page1_raises(self, mock_sleep): + """Timeout on page 1 raises the exception (no partial data possible).""" + client = self._make_client() + + with patch.object( + client, + "_get_with_retry", + side_effect=requests.exceptions.ReadTimeout("timeout page 1"), + ): + with pytest.raises(requests.exceptions.ReadTimeout): + client._get_paginated("/api/v1/user/repos") + + @patch("time.sleep") + def test_get_paginated_connect_timeout_graceful(self, mock_sleep): + """ConnectTimeout on page 2 returns partial data gracefully.""" + client = self._make_client() + + page1 = [{"id": i} for i in range(50)] + + mock_resp1 = MagicMock() + mock_resp1.raise_for_status = MagicMock() + mock_resp1.json.return_value = page1 + + with patch.object( + client, + "_get_with_retry", + side_effect=[mock_resp1, requests.exceptions.ConnectTimeout("connect timeout")], + ): + result = client._get_paginated("/api/v1/user/repos") + + assert result == page1 + + @patch("time.sleep") + def test_get_paginated_partial_data_emits_warning(self, mock_sleep): + """Graceful degradation emits a warning about partial data.""" + import warnings + + client = self._make_client() + + page1 = [{"id": i} for i in range(50)] + + mock_resp1 = MagicMock() + mock_resp1.raise_for_status = MagicMock() + mock_resp1.json.return_value = page1 + + with patch.object( + client, + "_get_with_retry", + side_effect=[mock_resp1, requests.exceptions.ReadTimeout("timeout")], + ): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + client._get_paginated("/api/v1/user/repos") + + assert len(w) == 1 + assert "Partial data" in str(w[0].message) + assert "page 2" in str(w[0].message) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..18cbe80 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,133 @@ +"""Tests for YAML configuration module.""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest + +from gitea_dashboard.config import load_config, merge_config, resolve_env_vars + + +class TestResolveEnvVars: + """Test resolve_env_vars function.""" + + def test_resolve_env_vars_simple(self): + """${VAR} is replaced by the environment variable value.""" + with patch.dict(os.environ, {"GITEA_TOKEN": "abc123"}): + result = resolve_env_vars("${GITEA_TOKEN}") + + assert result == "abc123" + + def test_resolve_env_vars_undefined(self): + """${UNDEFINED} is left as-is when the variable is not set.""" + with patch.dict(os.environ, {}, clear=True): + result = resolve_env_vars("${UNDEFINED_VAR}") + + assert result == "${UNDEFINED_VAR}" + + def test_resolve_env_vars_in_list(self): + """resolve_env_vars works on individual string elements.""" + with patch.dict(os.environ, {"MY_VAR": "resolved"}): + result = resolve_env_vars("prefix-${MY_VAR}-suffix") + + assert result == "prefix-resolved-suffix" + + +class TestLoadConfig: + """Test load_config function.""" + + def test_load_config_valid_yaml(self, tmp_path): + """Valid YAML file is loaded as a dict with all keys.""" + config_file = tmp_path / "config.yml" + config_file.write_text( + "url: http://localhost:3000\ntoken: ${GITEA_TOKEN}\nsort: activity\n" + ) + + with patch.dict(os.environ, {"GITEA_TOKEN": "secret123"}): + result = load_config(str(config_file)) + + assert result["url"] == "http://localhost:3000" + assert result["token"] == "secret123" + assert result["sort"] == "activity" + + def test_load_config_partial_yaml(self, tmp_path): + """YAML with only some keys returns a partial dict.""" + config_file = tmp_path / "config.yml" + config_file.write_text("url: http://localhost:3000\nsort: name\n") + + result = load_config(str(config_file)) + + assert result["url"] == "http://localhost:3000" + assert result["sort"] == "name" + assert "token" not in result + + def test_load_config_empty_file(self, tmp_path): + """Empty YAML file returns an empty dict.""" + config_file = tmp_path / "config.yml" + config_file.write_text("") + + result = load_config(str(config_file)) + + assert result == {} + + def test_load_config_invalid_yaml(self, tmp_path): + """Syntactically invalid YAML raises a clear error.""" + config_file = tmp_path / "config.yml" + config_file.write_text("invalid: yaml: content: [unclosed") + + with pytest.raises(ValueError, match="[Ii]nvalid"): + load_config(str(config_file)) + + def test_load_config_custom_path(self, tmp_path): + """--config /path/to/custom.yml loads the specified file.""" + config_file = tmp_path / "custom.yml" + config_file.write_text("sort: issues\n") + + result = load_config(str(config_file)) + + assert result["sort"] == "issues" + + def test_load_config_missing_custom_path(self): + """--config with a nonexistent path raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError): + load_config("/nonexistent/path/config.yml") + + def test_load_config_default_paths(self, tmp_path, monkeypatch): + """Config file in current directory is auto-discovered.""" + config_file = tmp_path / ".gitea-dashboard.yml" + config_file.write_text("sort: activity\n") + monkeypatch.chdir(tmp_path) + + result = load_config() + + assert result["sort"] == "activity" + + +class TestMergeConfig: + """Test merge_config function.""" + + def test_merge_config_priority(self): + """CLI > env > config > defaults — CLI wins.""" + cli = {"sort": "name", "url": None} + env = {"sort": "issues", "url": "http://env:3000"} + config = {"sort": "activity", "url": "http://config:3000", "exclude": ["old"]} + defaults = {"sort": "name", "url": "http://default:3000", "exclude": None} + + result = merge_config(cli, env, config, defaults) + + assert result["sort"] == "name" # CLI wins + assert result["url"] == "http://env:3000" # CLI is None, env wins + assert result["exclude"] == ["old"] # env has no exclude, config wins + + def test_merge_config_none_does_not_override(self): + """None in a higher-priority source does not mask a lower-priority value.""" + cli = {"token": None} + env = {"token": None} + config = {"token": "from-config"} + defaults = {"token": "default-token"} + + result = merge_config(cli, env, config, defaults) + + assert result["token"] == "from-config"