RED phase: 5 tests in test_collector.py (collect_milestones), 10 tests in test_display.py (render_milestones, parse_columns), 2 tests in test_exporter.py (milestones JSON), 7 tests in test_cli.py (--milestones, --columns). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
483 lines
17 KiB
Python
483 lines
17 KiB
Python
"""Tests for CLI entry point."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from gitea_dashboard.cli import main
|
|
|
|
|
|
class TestMainNominal:
|
|
"""Test main() happy path."""
|
|
|
|
@patch("gitea_dashboard.cli.render_dashboard")
|
|
@patch("gitea_dashboard.cli.collect_all")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_main_runs_full_pipeline(self, mock_client_cls, mock_collect, mock_render):
|
|
"""main() creates client, collects, and renders."""
|
|
env = {"GITEA_TOKEN": "test-token-123", "GITEA_URL": "http://localhost:3000"}
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
mock_collect.return_value = []
|
|
|
|
with patch.dict("os.environ", env, clear=False):
|
|
main([])
|
|
|
|
mock_client_cls.assert_called_once_with("http://localhost:3000", "test-token-123")
|
|
mock_collect.assert_called_once_with(mock_client, include=None, exclude=None)
|
|
mock_render.assert_called_once_with(
|
|
mock_collect.return_value, sort_key="name", show_description=True
|
|
)
|
|
|
|
@patch("gitea_dashboard.cli.render_dashboard")
|
|
@patch("gitea_dashboard.cli.collect_all")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_main_uses_default_url(self, mock_client_cls, mock_collect, mock_render):
|
|
"""main() uses default URL when GITEA_URL is not set."""
|
|
env = {"GITEA_TOKEN": "my-token"}
|
|
mock_client_cls.return_value = MagicMock()
|
|
mock_collect.return_value = []
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
main([])
|
|
|
|
mock_client_cls.assert_called_once_with("http://192.168.0.106:3000", "my-token")
|
|
|
|
|
|
class TestMainMissingToken:
|
|
"""Test main() when GITEA_TOKEN is not set."""
|
|
|
|
def test_error_message_when_token_missing(self, capsys):
|
|
"""main() exits with code 1 and prints message mentioning GITEA_TOKEN."""
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main([])
|
|
|
|
assert exc_info.value.code == 1
|
|
captured = capsys.readouterr()
|
|
assert "GITEA_TOKEN" in captured.err
|
|
|
|
|
|
class TestMainConnectionErrors:
|
|
"""Test main() error handling for network issues."""
|
|
|
|
@patch("gitea_dashboard.cli.collect_all")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_connection_error_handled(self, mock_client_cls, mock_collect):
|
|
"""ConnectionError is caught and exits with code 1."""
|
|
env = {"GITEA_TOKEN": "test-token"}
|
|
mock_client_cls.return_value = MagicMock()
|
|
mock_collect.side_effect = requests.ConnectionError("Connection refused")
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main([])
|
|
|
|
assert exc_info.value.code == 1
|
|
|
|
@patch("gitea_dashboard.cli.collect_all")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_timeout_error_handled(self, mock_client_cls, mock_collect):
|
|
"""Timeout is caught and exits with code 1."""
|
|
env = {"GITEA_TOKEN": "test-token"}
|
|
mock_client_cls.return_value = MagicMock()
|
|
mock_collect.side_effect = requests.Timeout("Request timed out")
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main([])
|
|
|
|
assert exc_info.value.code == 1
|
|
|
|
@patch("gitea_dashboard.cli.collect_all")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_request_exception_handled(self, mock_client_cls, mock_collect):
|
|
"""Generic RequestException is caught and exits with code 1."""
|
|
env = {"GITEA_TOKEN": "test-token"}
|
|
mock_client_cls.return_value = MagicMock()
|
|
mock_collect.side_effect = requests.RequestException("Something went wrong")
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main([])
|
|
|
|
assert exc_info.value.code == 1
|
|
|
|
@patch("gitea_dashboard.cli.collect_all")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_token_not_in_error_output(self, mock_client_cls, mock_collect, capsys):
|
|
"""Token must never appear in error messages, even when present in the exception."""
|
|
env = {"GITEA_TOKEN": "super-secret-token-xyz"}
|
|
mock_client_cls.return_value = MagicMock()
|
|
|
|
# Build exception message that embeds the token value from env
|
|
# to simulate a real-world leak scenario
|
|
def make_exc(environ):
|
|
leaked = environ["GITEA_TOKEN"]
|
|
return requests.RequestException(f"HTTP Error: Authorization token {leaked} rejected")
|
|
|
|
import os as _os
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
mock_collect.side_effect = make_exc(_os.environ)
|
|
with pytest.raises(SystemExit):
|
|
main([])
|
|
|
|
captured = capsys.readouterr()
|
|
assert env["GITEA_TOKEN"] not in captured.out
|
|
assert env["GITEA_TOKEN"] not in captured.err
|
|
|
|
|
|
class TestParseArgs:
|
|
"""Test parse_args function."""
|
|
|
|
def test_no_options(self):
|
|
"""No arguments returns None for both repo and exclude."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args([])
|
|
assert args.repo is None
|
|
assert args.exclude is None
|
|
|
|
def test_single_repo(self):
|
|
"""--repo foo returns repo=["foo"]."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args(["--repo", "foo"])
|
|
assert args.repo == ["foo"]
|
|
|
|
def test_multiple_repo(self):
|
|
"""--repo foo --repo bar returns repo=["foo", "bar"]."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args(["--repo", "foo", "--repo", "bar"])
|
|
assert args.repo == ["foo", "bar"]
|
|
|
|
def test_short_flags(self):
|
|
"""-r foo -x bar works like long forms."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args(["-r", "foo", "-x", "bar"])
|
|
assert args.repo == ["foo"]
|
|
assert args.exclude == ["bar"]
|
|
|
|
|
|
class TestMainWithFilters:
|
|
"""Test main() passes filters to collect_all."""
|
|
|
|
@patch("gitea_dashboard.cli.render_dashboard")
|
|
@patch("gitea_dashboard.cli.collect_all")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_main_passes_filters(self, mock_client_cls, mock_collect, mock_render):
|
|
"""main() passes include/exclude from CLI args to collect_all."""
|
|
env = {"GITEA_TOKEN": "test-token"}
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
mock_collect.return_value = []
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
main(["--repo", "dash", "--exclude", "old"])
|
|
|
|
mock_collect.assert_called_once_with(mock_client, include=["dash"], exclude=["old"])
|
|
|
|
@patch("gitea_dashboard.cli.render_dashboard")
|
|
@patch("gitea_dashboard.cli.collect_all")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_main_no_filters_passes_none(self, mock_client_cls, mock_collect, mock_render):
|
|
"""Without options, collect_all is called with include=None, exclude=None."""
|
|
env = {"GITEA_TOKEN": "test-token"}
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
mock_collect.return_value = []
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
main([])
|
|
|
|
mock_collect.assert_called_once_with(mock_client, include=None, exclude=None)
|
|
|
|
|
|
class TestParseArgsSort:
|
|
"""Test --sort argument parsing."""
|
|
|
|
def test_sort_default(self):
|
|
"""Without --sort, default is 'name'."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args([])
|
|
assert args.sort == "name"
|
|
|
|
def test_sort_issues(self):
|
|
"""--sort issues is accepted."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args(["--sort", "issues"])
|
|
assert args.sort == "issues"
|
|
|
|
def test_sort_short_flag(self):
|
|
"""-s activity is accepted."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args(["-s", "activity"])
|
|
assert args.sort == "activity"
|
|
|
|
def test_sort_invalid(self):
|
|
"""--sort invalid raises SystemExit (argparse error)."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
with pytest.raises(SystemExit):
|
|
parse_args(["--sort", "invalid"])
|
|
|
|
|
|
class TestParseArgsFormat:
|
|
"""Test --format argument parsing."""
|
|
|
|
def test_format_default(self):
|
|
"""Without --format, default is 'table'."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args([])
|
|
assert args.format == "table"
|
|
|
|
def test_format_json(self):
|
|
"""--format json is accepted."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args(["--format", "json"])
|
|
assert args.format == "json"
|
|
|
|
def test_format_short_flag(self):
|
|
"""-f json is accepted."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args(["-f", "json"])
|
|
assert args.format == "json"
|
|
|
|
def test_format_invalid(self):
|
|
"""--format invalid raises SystemExit."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
with pytest.raises(SystemExit):
|
|
parse_args(["--format", "invalid"])
|
|
|
|
|
|
class TestParseArgsHealth:
|
|
"""Test --health argument parsing."""
|
|
|
|
def test_parse_args_health(self):
|
|
"""--health sets health=True."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args(["--health"])
|
|
assert args.health is True
|
|
|
|
def test_parse_args_no_health_default(self):
|
|
"""Without --health, health is False."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args([])
|
|
assert args.health is False
|
|
|
|
|
|
class TestParseArgsNoDesc:
|
|
"""Test --no-desc argument parsing."""
|
|
|
|
def test_parse_args_no_desc(self):
|
|
"""--no-desc sets no_desc=True."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args(["--no-desc"])
|
|
assert args.no_desc is True
|
|
|
|
def test_parse_args_no_desc_default(self):
|
|
"""Without --no-desc, no_desc is False."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args([])
|
|
assert args.no_desc is False
|
|
|
|
|
|
class TestMainHealth:
|
|
"""Test main() with --health."""
|
|
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_main_health_success(self, mock_client_cls, capsys):
|
|
"""--health displays version and repo count, exits normally."""
|
|
env = {"GITEA_TOKEN": "test-token"}
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
mock_client.get_version.return_value = {"version": "1.21.0"}
|
|
mock_client.get_repos.return_value = [{"id": 1}, {"id": 2}, {"id": 3}]
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
main(["--health"])
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Gitea v1.21.0" in captured.err
|
|
assert "3 repos accessibles" in captured.err
|
|
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_main_health_connection_error(self, mock_client_cls):
|
|
"""--health with connection error exits with code 1."""
|
|
env = {"GITEA_TOKEN": "test-token"}
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
mock_client.get_version.side_effect = requests.ConnectionError("refused")
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main(["--health"])
|
|
|
|
assert exc_info.value.code == 1
|
|
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_main_health_version_ok_repos_fail(self, mock_client_cls):
|
|
"""--health : get_version reussit mais get_repos leve HTTPError -> exit 1.
|
|
|
|
Verifie le cas d'un health check partiel : l'instance Gitea repond
|
|
sur /version mais l'acces aux repos echoue (ex: token sans permissions).
|
|
"""
|
|
env = {"GITEA_TOKEN": "test-token"}
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
mock_client.get_version.return_value = {"version": "1.21.0"}
|
|
mock_client.get_repos.side_effect = requests.HTTPError("403 Forbidden")
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main(["--health"])
|
|
|
|
assert exc_info.value.code == 1
|
|
|
|
|
|
class TestMainNoDesc:
|
|
"""Test main() with --no-desc."""
|
|
|
|
@patch("gitea_dashboard.cli.render_dashboard")
|
|
@patch("gitea_dashboard.cli.collect_all")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_main_passes_no_desc_to_render(self, mock_client_cls, mock_collect, mock_render):
|
|
"""--no-desc passes show_description=False to render_dashboard."""
|
|
env = {"GITEA_TOKEN": "test-token"}
|
|
mock_client_cls.return_value = MagicMock()
|
|
mock_collect.return_value = []
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
main(["--no-desc"])
|
|
|
|
mock_render.assert_called_once_with(
|
|
mock_collect.return_value, sort_key="name", show_description=False
|
|
)
|
|
|
|
|
|
class TestMainFormatJson:
|
|
"""Test main() with --format json."""
|
|
|
|
@patch("gitea_dashboard.cli.collect_all")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_json_output(self, mock_client_cls, mock_collect, capsys):
|
|
"""--format json produces valid JSON on stdout."""
|
|
import json
|
|
|
|
env = {"GITEA_TOKEN": "test-token"}
|
|
mock_client_cls.return_value = MagicMock()
|
|
mock_collect.return_value = []
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
main(["--format", "json"])
|
|
|
|
captured = capsys.readouterr()
|
|
parsed = json.loads(captured.out)
|
|
assert isinstance(parsed, list)
|
|
|
|
|
|
class TestParseArgsMilestones:
|
|
"""Test --milestones argument parsing."""
|
|
|
|
def test_parse_args_milestones(self):
|
|
"""--milestones sets milestones=True."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args(["--milestones"])
|
|
assert args.milestones is True
|
|
|
|
def test_parse_args_milestones_default(self):
|
|
"""Without --milestones, milestones is False."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args([])
|
|
assert args.milestones is False
|
|
|
|
|
|
class TestParseArgsColumns:
|
|
"""Test --columns argument parsing."""
|
|
|
|
def test_parse_args_columns(self):
|
|
"""--columns name,issues sets columns='name,issues'."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args(["--columns", "name,issues"])
|
|
assert args.columns == "name,issues"
|
|
|
|
def test_parse_args_columns_default(self):
|
|
"""Without --columns, columns is None."""
|
|
from gitea_dashboard.cli import parse_args
|
|
|
|
args = parse_args([])
|
|
assert args.columns is None
|
|
|
|
|
|
class TestMainMilestonesMode:
|
|
"""Test main() with --milestones."""
|
|
|
|
@patch("gitea_dashboard.cli.render_milestones")
|
|
@patch("gitea_dashboard.cli.collect_milestones")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_main_milestones_mode(self, mock_client_cls, mock_collect_ms, mock_render_ms):
|
|
"""--milestones routes to collect_milestones + render_milestones."""
|
|
env = {"GITEA_TOKEN": "test-tok"}
|
|
mock_client_cls.return_value = MagicMock()
|
|
mock_collect_ms.return_value = []
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
main(["--milestones"])
|
|
|
|
mock_collect_ms.assert_called_once()
|
|
mock_render_ms.assert_called_once()
|
|
|
|
|
|
class TestMainColumnsHelp:
|
|
"""Test main() with --columns help."""
|
|
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_main_columns_help(self, mock_client_cls, capsys):
|
|
"""--columns help displays available columns and exits."""
|
|
env = {"GITEA_TOKEN": "test-tok"}
|
|
mock_client_cls.return_value = MagicMock()
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
main(["--columns", "help"])
|
|
|
|
captured = capsys.readouterr()
|
|
# Should list column names
|
|
assert "name" in captured.out or "name" in captured.err
|
|
|
|
@patch("gitea_dashboard.cli.render_dashboard")
|
|
@patch("gitea_dashboard.cli.collect_all")
|
|
@patch("gitea_dashboard.cli.GiteaClient")
|
|
def test_main_no_desc_and_columns_compat(self, mock_client_cls, mock_collect, mock_render):
|
|
"""--no-desc and --columns -commit both apply cumulatively."""
|
|
env = {"GITEA_TOKEN": "test-tok"}
|
|
mock_client_cls.return_value = MagicMock()
|
|
mock_collect.return_value = []
|
|
|
|
with patch.dict("os.environ", env, clear=True):
|
|
main(["--no-desc", "--columns", "-commit"])
|
|
|
|
# render_dashboard should be called with columns excluding both description and commit
|
|
call_kwargs = mock_render.call_args
|
|
columns = call_kwargs[1].get("columns") if call_kwargs[1] else None
|
|
if columns is not None:
|
|
assert "description" not in columns
|
|
assert "commit" not in columns
|