- Treat empty list from fetch_issues_with_spent_time as 'no entries' and exit 0 instead of crashing later in the pipeline. - Update CLI output-extension tests to use non-empty mock data so they actually reach the extension validation code path. - Add openpyxl to mypy ignore_missing_imports overrides. Closes #36
209 lines
7.2 KiB
Python
209 lines
7.2 KiB
Python
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("")
|
||
yield
|
||
Config.set_redmine_url("")
|
||
Config.set_redmine_api_key("")
|
||
|
||
|
||
@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
|
||
assert "1.5.0" 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
|