Files
redmine-reporter/tests/test_cli.py
Кокос Артем Николаевич c962a93f30 feat: YAML config support (~/.config/redmine-reporter/config.yml)
- Add AppConfig/SmtpConfig/EmailConfig dataclasses with from_yaml()/from_env()
- Add yaml_config.py: ${VAR} resolver, 0700/0600 permission helpers
- Config.get_*() methods gain YAML fallback in priority chain
- Priority: CLI > env > .env > YAML > code defaults
- CLI --init-config generates YAML from current environment
- --force flag allows overwriting existing config
- Secrets default to ${VAR} references, plaintext allowed
- Full backward compatibility with existing .env setups

Closes #46
2026-07-03 18:00:28 +07:00

396 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
from unittest import mock
import pytest
from redmine_reporter.cli import main, parse_date_range
VALID_ENV = {
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_API_KEY": "token",
}
@pytest.fixture(autouse=True)
def _reset_config_overrides():
"""Сбрасывает CLI-переопределения Config перед каждым тестом."""
from redmine_reporter.config import Config
Config.set_redmine_url("")
Config.set_redmine_api_key("")
Config._app = None
yield
Config.set_redmine_url("")
Config.set_redmine_api_key("")
Config._app = None
@pytest.mark.parametrize(
"date_arg, expected",
[
("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")),
(" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")),
],
)
def test_parse_date_range_valid(date_arg, expected):
assert parse_date_range(date_arg) == expected
@pytest.mark.parametrize(
"date_arg",
[
"20260101-20260131",
"2026-1-01--2026-01-31",
"2026-02-30--2026-03-01",
"2026-02-01--2026-01-31",
],
)
def test_parse_date_range_invalid(date_arg):
with pytest.raises(ValueError):
parse_date_range(date_arg)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_returns_zero_on_no_entries(mock_fetch):
"""None от fetch (нет time entries) -- выход 0."""
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31"])
assert code == 0
@mock.patch.dict(os.environ, {}, clear=True)
def test_cli_config_error():
"""Невалидный конфиг -- выход 1."""
code = main(["--date", "2026-01-01--2026-01-31"])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_invalid_date_format():
"""Неверный формат даты -- выход 1."""
code = main(["--date", "20260101-20260131"])
assert code == 1
class _MockIssue:
"""Простой mock Redmine Issue для CLI-тестов."""
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
self.id = issue_id
self.subject = subject
self.project = project
self.status = status
self.fixed_version = None
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
"""Неизвестное расширение файла -- выход 1."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xyz")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_output_without_extension(mock_fetch, tmp_path):
"""Файл без расширения -- выход 1 с подсказкой."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
"""При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_gf.return_value = None
output = str(tmp_path / "report.odt")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1
captured = capsys.readouterr()
assert "odfpy" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_version_flag(capsys):
"""--version выводит версию и завершается с 0."""
with pytest.raises(SystemExit) as exc_info:
main(["--version"])
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "redmine-reporter" in captured.out
from redmine_reporter import __version__
assert __version__ in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_verbose_and_debug_flags_accepted(mock_fetch):
"""--verbose и --debug не ломают запуск."""
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31", "--verbose"])
assert code == 0
code = main(["--date", "2026-01-01--2026-01-31", "--debug"])
assert code == 0
class _MockIssue:
"""Простой mock Redmine Issue для CLI-тестов."""
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
self.id = issue_id
self.subject = subject
self.project = project
self.status = status
self.fixed_version = None
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_url_and_api_key_override_env(mock_fetch):
"""--url и --api-key переопределяют отсутствующие/другие env-переменные."""
mock_fetch.return_value = None
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--url",
"https://other.redmine.loc",
"--api-key",
"cli-token",
]
)
assert code == 0
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_config_file_loading(mock_fetch, tmp_path):
"""--config загружает переменные из указанного .env-файла."""
config_path = tmp_path / "custom.env"
config_path.write_text(
"REDMINE_URL=https://config.redmine.loc\n" "REDMINE_API_KEY=config-token\n",
encoding="utf-8",
)
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31", "--config", str(config_path)])
assert code == 0
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
"""--summary выводит общее время и разбивку по проектам в stderr."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
main(["--date", "2026-01-01--2026-01-31", "--summary"])
captured = capsys.readouterr()
assert "Total time" in captured.err
assert "Project" in captured.err or "Project" in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert "Total issues" not in captured.out
assert "Total issues" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_auth_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при ошибке аутентификации."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "Authentication failed" in captured.err
assert "Redmine API error" not in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_forbidden_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при недостаточных правах."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Access denied: no permission")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "Access denied" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при таймауте."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Redmine request timed out after 30 seconds")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "timed out" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
"""CLI --no-time передаётся в файловый форматтер."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xlsx")
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get_formatter:
mock_formatter = mock.MagicMock()
mock_get_formatter.return_value = mock_formatter
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
_, kwargs = mock_get_formatter.call_args
assert kwargs.get("no_time") is True
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_passes_user_id_to_fetch(mock_fetch):
"""CLI --user-id передаётся в fetch_issues_with_spent_time."""
mock_fetch.return_value = None
main(["--date", "2026-01-01--2026-01-31", "--user-id", "42"])
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "42"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_passes_user_login_to_fetch(mock_fetch):
"""CLI --user-login передаётся в fetch_issues_with_spent_time."""
mock_fetch.return_value = None
main(["--date", "2026-01-01--2026-01-31", "--user-login", "ivanov"])
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "ivanov"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_rejects_multiple_user_flags():
"""CLI не принимает одновременно несколько флагов пользователя."""
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--user-id",
"42",
"--user-login",
"ivanov",
]
)
assert code == 1
# ---------------------------------------------------------------------------
# --init-config tests
# ---------------------------------------------------------------------------
class TestInitConfig:
"""Tests for --init-config flag."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_creates_yaml_file(self, tmp_path):
"""--init-config создаёт YAML файл с правильной структурой."""
import yaml
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
assert config_path.exists()
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert "redmine" in data
assert data["redmine"]["url"] == "https://red.eltex.loc"
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_existing_file_no_force(self, tmp_path, capsys):
"""Без --force существующий файл → предупреждение и выход 1."""
config_path = tmp_path / "config.yml"
config_path.write_text("existing: true")
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 1
captured = capsys.readouterr()
assert "already exists" in captured.err or "already exists" in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_force_overwrites(self, tmp_path):
"""--init-config --force перезаписывает существующий файл."""
import yaml
config_path = tmp_path / "config.yml"
config_path.write_text("existing: true")
code = main(["--init-config", "--force", "--config-path", str(config_path)])
assert code == 0
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert "redmine" in data
assert "existing" not in data
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_with_date_is_error(self, capsys):
"""--init-config с --date → ошибка, взаимоисключающие."""
code = main(["--init-config", "--date", "2026-01-01--2026-01-31"])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_writes_secrets_as_env_ref(self, tmp_path):
"""Секреты пишутся как ${VAR} когда переменная окружения существует."""
import yaml
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
@mock.patch.dict(os.environ, {}, clear=True)
def test_init_config_no_env_vars(self, tmp_path):
"""Без переменных окружения --init-config создаёт скелет."""
import yaml
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert "redmine" in data
assert data["redmine"]["url"] == ""
assert data["redmine"]["api_key"] == ""