test(v1.4.0-p1): add failing tests for timeout and YAML config
RED phase: 4 new tests in test_client.py for graceful timeout on pagination, 12 new tests in test_config.py for YAML config module (import fails, module not created yet). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
133
tests/test_config.py
Normal file
133
tests/test_config.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user