List open Redmine issues assigned to the current (or overridden) user, sorted by priority rank (P1..P5) then version due date with a stable id tie-break. Renders an OSC 8 hyperlinked console table on stdout and a counts/hours summary line on stderr; version due dates are fetched once per unique version, unavailable versions degrade to an empty due date. Also migrates the codebase to ruff 0.17.3 defaults (PEP 585/604 annotations, targeted noqa for intentional blind excepts and CLI root logging). Behavior-neutral: 408 tests pass with -W error.
2151 lines
80 KiB
Python
2151 lines
80 KiB
Python
import os
|
||
from datetime import date
|
||
from unittest import mock
|
||
|
||
import pytest
|
||
from redmine_reporter.cli import main, parse_date_range
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
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")),
|
||
# datetime-границы флорятся до даты -- окно состоит из целых дней.
|
||
(
|
||
"2026-06-01T00:00:00--2026-06-30T23:59:59",
|
||
("2026-06-01", "2026-06-30"),
|
||
),
|
||
# Смешение форматов между границами допустимо.
|
||
("2026-06-01--2026-06-30T23:59:59", ("2026-06-01", "2026-06-30")),
|
||
("2026-06-01T09:30:00--2026-06-30", ("2026-06-01", "2026-06-30")),
|
||
],
|
||
)
|
||
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",
|
||
"2026-06-01T25:00:00--2026-06-30T23:59:59",
|
||
"2026-06-01 00:00:00--2026-06-30",
|
||
"2026-06-01T00:00:00+03:00--2026-06-30T23:59:59",
|
||
"2026-06-30T23:59:59--2026-06-01T00:00:00",
|
||
],
|
||
)
|
||
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", "--config-path", "/nonexistent/config.yml"]
|
||
)
|
||
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, None)]
|
||
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")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
|
||
"""Файл без расширения — приложение расширяет default_format (#43)."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
output = str(tmp_path / "report")
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--output",
|
||
output,
|
||
"--config-path",
|
||
str(tmp_path / "nonexistent.yml"),
|
||
]
|
||
)
|
||
assert code == 0
|
||
saved_path = mock_formatter.save.call_args.args[1]
|
||
assert saved_path.endswith(".xlsx")
|
||
|
||
|
||
@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, None)]
|
||
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
|
||
|
||
|
||
@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\nREDMINE_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, None)]
|
||
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, None)]
|
||
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, None)]
|
||
|
||
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, VALID_ENV, clear=True)
|
||
def test_init_config_includes_email_html(self, tmp_path):
|
||
"""--init-config генерирует email.html: false."""
|
||
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 "html" in data["email"]
|
||
assert data["email"]["html"] is False
|
||
|
||
@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"] == ""
|
||
|
||
def test_init_config_reads_dotenv_file(self, tmp_path, monkeypatch):
|
||
"""#64: --init-config подхватывает значения из реального .env в cwd.
|
||
|
||
Регрессионный тест: после 1df1194 load_dotenv перестал выполняться
|
||
до _run_init_config, и пользователь с настройками только в .env
|
||
получал пустой YAML.
|
||
"""
|
||
import yaml
|
||
|
||
(tmp_path / ".env").write_text(
|
||
"REDMINE_URL=https://redmine.example.com\n"
|
||
"REDMINE_API_KEY=secret-from-dotenv\n"
|
||
"REDMINE_AUTHOR=ivan.ivanov\n"
|
||
"FROM_DATE=2026-01-01\n"
|
||
"SMTP_PASSWORD=smtp-secret\n"
|
||
)
|
||
monkeypatch.chdir(tmp_path)
|
||
# Целевые переменные не мокаются: значения должны прийти только из .env.
|
||
for var in (
|
||
"REDMINE_URL",
|
||
"REDMINE_API_KEY",
|
||
"REDMINE_AUTHOR",
|
||
"FROM_DATE",
|
||
"SMTP_PASSWORD",
|
||
):
|
||
monkeypatch.delenv(var, raising=False)
|
||
|
||
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"]["url"] == "https://redmine.example.com"
|
||
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
|
||
assert data["redmine"]["author"] == "ivan.ivanov"
|
||
assert data["period"]["from"] == "2026-01-01"
|
||
assert data["email"]["smtp"]["password"] == "${SMTP_PASSWORD}"
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_init_config_includes_report_section(self, tmp_path):
|
||
"""--init-config генерирует секцию report с no_time: false."""
|
||
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 "report" in data
|
||
assert data["report"]["no_time"] is False
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_init_config_includes_status_translation_example(self, tmp_path):
|
||
"""#65: --init-config дописывает закомментированный пример status_translation.
|
||
|
||
Файл остаётся валидным YAML, активной секции status_translation нет.
|
||
"""
|
||
import yaml
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
code = main(["--init-config", "--config-path", str(config_path)])
|
||
assert code == 0
|
||
|
||
text = config_path.read_text(encoding="utf-8")
|
||
assert "# status_translation:" in text
|
||
|
||
with open(config_path) as fh:
|
||
data = yaml.safe_load(fh)
|
||
|
||
assert "status_translation" not in data["report"]
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_status_translation_from_yaml_reaches_builder(mock_fetch, tmp_path):
|
||
"""#65: report.status_translation из YAML доезжает до build_grouped_report."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"report:\n"
|
||
" status_translation:\n"
|
||
' New: "Новая"\n'
|
||
' "Custom Status": "Кастом"\n'
|
||
)
|
||
|
||
with mock.patch("redmine_reporter.cli.build_grouped_report") as mock_build:
|
||
mock_build.return_value = []
|
||
code = main(
|
||
["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)]
|
||
)
|
||
|
||
assert code == 0
|
||
_, kwargs = mock_build.call_args
|
||
translation = kwargs["status_translation"]
|
||
assert translation["New"] == "Новая"
|
||
assert translation["Custom Status"] == "Кастом"
|
||
# встроенные переводы сохранились
|
||
assert translation["Closed"] == "Закрыто"
|
||
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_autoloads_yaml_config(mock_fetch, tmp_path):
|
||
"""CLI автоматически подгружает YAML-конфиг при запуске."""
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"redmine:\n url: https://yaml-redmine.example.com/\n api_key: yaml-token\n"
|
||
)
|
||
|
||
mock_fetch.return_value = None
|
||
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)])
|
||
assert code == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #43: --output path resolution tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestOutputPathResolution:
|
||
"""Tests for --output flag with path defaults."""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_bare_format_name_xlsx_uses_template_path(self, mock_fetch, tmp_path):
|
||
"""--output xlsx без пути → использует шаблон из конфига."""
|
||
import yaml
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"output": {
|
||
"dir": str(tmp_path / "reports"),
|
||
"filename": "report_{date}.{ext}",
|
||
"default_format": "xlsx",
|
||
}
|
||
}
|
||
)
|
||
)
|
||
|
||
mock_fetch.return_value = None
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--output",
|
||
"xlsx",
|
||
"--config-path",
|
||
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_output_without_extension_appends_default_format(
|
||
self, mock_fetch, tmp_path
|
||
):
|
||
"""--output report без расширения → добавляет .xlsx."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
output = str(tmp_path / "report")
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--output",
|
||
output,
|
||
"--config-path",
|
||
str(tmp_path / "nonexistent.yml"),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_formatter.save.assert_called_once()
|
||
saved_path = mock_formatter.save.call_args.args[1]
|
||
assert saved_path.endswith(".xlsx")
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path):
|
||
"""--output report.csv с явным расширением передаётся как есть."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--output",
|
||
str(tmp_path / "report.csv"),
|
||
"--config-path",
|
||
str(tmp_path / "nonexistent.yml"),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_formatter.save.assert_called_once()
|
||
saved_path = mock_formatter.save.call_args.args[1]
|
||
assert saved_path.endswith(".csv")
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_output_bare_format_invalid_is_treated_as_path(self, mock_fetch, tmp_path):
|
||
"""--output notanxlsx (no path, not known format) → treated as path + .xlsx appended."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--output",
|
||
"myreport",
|
||
"--config-path",
|
||
str(tmp_path / "nonexistent.yml"),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_formatter.save.assert_called_once()
|
||
saved_path = mock_formatter.save.call_args.args[1]
|
||
assert saved_path == "myreport.xlsx"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #44: --commit tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCommitFlag:
|
||
"""Tests for --commit flag."""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_commit_saves_period_to_config(self, mock_save, mock_fetch, tmp_path):
|
||
"""--commit пишет в period.from конец окна отчёта (день коммита)."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}}))
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_commit_period_from_equals_window_end(
|
||
self, mock_save, mock_fetch, tmp_path
|
||
):
|
||
"""Конец окна 2026-09-30 -- period.from ровно 2026-09-30, не 2026-10-01."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(yaml.dump({"period": {"from": "2026-09-01"}}))
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_get.return_value = mock.MagicMock()
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-09-01--2026-09-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
mock_save.assert_called_once_with(str(config_path), from_str="2026-09-30")
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_commit_period_from_equals_window_end_year_boundary(
|
||
self, mock_save, mock_fetch, tmp_path
|
||
):
|
||
"""Окно до 2026-12-31 -- from_str равен 2026-12-31: timedelta-математики нет."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(yaml.dump({"period": {"from": "2026-12-01"}}))
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_get.return_value = mock.MagicMock()
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-12-01--2026-12-31",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
mock_save.assert_called_once_with(str(config_path), from_str="2026-12-31")
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_commit_without_output_uses_default_path(
|
||
self, mock_save, mock_fetch, tmp_path
|
||
):
|
||
"""--commit без --output сохраняет файл по шаблону из конфига."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"period": {"from": "2026-06-01"},
|
||
"output": {
|
||
"dir": str(tmp_path / "reports"),
|
||
"filename": "report_{date}.{ext}",
|
||
"default_format": "xlsx",
|
||
},
|
||
}
|
||
)
|
||
)
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_formatter.save.assert_called_once()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_commit_prints_info_to_stderr(
|
||
self, mock_save, mock_fetch, capsys, tmp_path
|
||
):
|
||
"""--commit выводит сообщение о фиксации в stderr."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}}))
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
captured = capsys.readouterr()
|
||
assert "commit" in captured.err.lower() or "Commit" in captured.err
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_commit_no_entries_does_not_save(self, mock_save, mock_fetch, tmp_path):
|
||
"""Без записей --commit не пишет конфиг."""
|
||
mock_fetch.return_value = None
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text("period:\n from: '2026-01-01'\n")
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--commit",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_save.assert_not_called()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_commit_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path):
|
||
"""Пустой список задач — --commit не сохраняет конфиг."""
|
||
import yaml
|
||
|
||
mock_fetch.return_value = []
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(yaml.dump({"period": {"from": "2026-01-01"}}))
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--commit",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_save.assert_not_called()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_commit_save_error_does_not_advance_period(
|
||
self, mock_save, mock_fetch, tmp_path
|
||
):
|
||
"""Сбой записи отчёта не двигает окно: --commit фиксирует только успешно сохранённый отчёт."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}}))
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_formatter.save.side_effect = OSError("disk full")
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
|
||
assert code == 1
|
||
mock_save.assert_not_called()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #45: --send tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSendFlag:
|
||
"""Tests for --send flag."""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("builtins.input")
|
||
def test_send_triggers_mailer(
|
||
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""--send с --output вызывает send_report после сохранения."""
|
||
mock_input.return_value = "y"
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
)
|
||
|
||
output = str(tmp_path / "report.xlsx")
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
output,
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_send.assert_called_once()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("builtins.input")
|
||
def test_send_without_output_saves_to_default_path(
|
||
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
||
mock_input.return_value = "y"
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
"output:\n"
|
||
" dir: " + str(tmp_path / "reports") + "\n"
|
||
" filename: report_{date}.{ext}\n"
|
||
" default_format: xlsx\n"
|
||
)
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_formatter.save.assert_called_once()
|
||
mock_send.assert_called_once()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_send_without_email_config_is_error(self, mock_fetch, tmp_path, capsys):
|
||
"""--send без email-конфига — ошибка и выход 1."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text("redmine:\n url: https://x.com\n api_key: token\n")
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 1
|
||
captured = capsys.readouterr()
|
||
assert "Email не настроен" in captured.err
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("builtins.input")
|
||
def test_send_smtp_error_reported_to_stderr(
|
||
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys
|
||
):
|
||
"""Ошибка SMTP выводится в stderr, exit code 1."""
|
||
mock_input.return_value = "y"
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
mock_send.side_effect = RedmineAPIError(
|
||
"Не удалось подключиться к SMTP-серверу bad:587"
|
||
)
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: bad\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
)
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 1
|
||
captured = capsys.readouterr()
|
||
assert "SMTP" in captured.err
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
@mock.patch("builtins.input")
|
||
def test_send_with_commit_works_together(
|
||
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""--send и --commit работают вместе без конфликтов."""
|
||
mock_input.return_value = "y"
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
"period:\n"
|
||
" from: '2026-06-01'\n"
|
||
)
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--send",
|
||
"--commit",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_send.assert_called_once()
|
||
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_send_no_entries_exits_early(self, mock_fetch):
|
||
"""--send без time entries просто выходит с 0."""
|
||
mock_fetch.return_value = None
|
||
code = main(["--date", "2026-06-01--2026-06-30", "--send"])
|
||
assert code == 0
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
@mock.patch("builtins.input")
|
||
def test_send_success_advances_period(
|
||
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""Успешная отправка сдвигает period.from на конец окна отчёта."""
|
||
mock_input.return_value = "y"
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_get.return_value = mock.MagicMock()
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
)
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
@mock.patch("builtins.input")
|
||
def test_send_declined_does_not_advance_period(
|
||
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""Отказ от подтверждения не сдвигает окно."""
|
||
mock_input.return_value = "n"
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_get.return_value = mock.MagicMock()
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
)
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_send.assert_not_called()
|
||
mock_save.assert_not_called()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
@mock.patch("builtins.input")
|
||
def test_send_smtp_error_does_not_advance_period(
|
||
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""Ошибка SMTP не сдвигает окно."""
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
mock_input.return_value = "y"
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_get.return_value = mock.MagicMock()
|
||
mock_send.side_effect = RedmineAPIError("SMTP connection failed")
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
)
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 1
|
||
mock_save.assert_not_called()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
@mock.patch("builtins.input")
|
||
def test_send_commit_declined_send_still_advances(
|
||
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""--send --commit: отказ от отправки не мешает явному --commit сдвинуть окно."""
|
||
mock_input.return_value = "n"
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_get.return_value = mock.MagicMock()
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
"period:\n"
|
||
" from: '2026-06-01'\n"
|
||
)
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--send",
|
||
"--commit",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_send.assert_not_called()
|
||
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_send_commit_no_entries_does_not_save(self, mock_save, mock_fetch):
|
||
"""--send --commit без записей не трогает конфиг."""
|
||
mock_fetch.return_value = None
|
||
code = main(["--date", "2026-06-01--2026-06-30", "--send", "--commit"])
|
||
assert code == 0
|
||
mock_save.assert_not_called()
|
||
|
||
|
||
class TestSendHtmlBody:
|
||
"""Tests for email.html body generation."""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("builtins.input")
|
||
def test_send_passes_rows_to_send_report(
|
||
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""--send передаёт rows в send_report для генерации HTML."""
|
||
mock_input.return_value = "y"
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" html: true\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
)
|
||
|
||
output = str(tmp_path / "report.xlsx")
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
output,
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
_, kwargs = mock_send.call_args
|
||
assert "rows" in kwargs
|
||
assert len(kwargs["rows"]) == 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.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("builtins.input")
|
||
def test_send_html_false_does_not_require_html_part(
|
||
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""При email.html: false письмо отправляется без HTML-части."""
|
||
mock_input.return_value = "y"
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
)
|
||
|
||
output = str(tmp_path / "report.xlsx")
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
output,
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_send.assert_called_once()
|
||
_, kwargs = mock_send.call_args
|
||
assert "rows" in kwargs
|
||
|
||
|
||
class TestResolveNoTime:
|
||
"""Tests for _resolve_no_time()."""
|
||
|
||
def test_cli_flag_wins_in_manual_mode(self):
|
||
"""CLI --no-time побеждает в ручном режиме."""
|
||
from redmine_reporter.cli import _resolve_no_time
|
||
|
||
assert _resolve_no_time(True, False) is True
|
||
|
||
def test_cli_flag_wins_in_auto_mode(self):
|
||
"""CLI --no-time побеждает в автоматическом режиме."""
|
||
from redmine_reporter.cli import _resolve_no_time
|
||
|
||
assert _resolve_no_time(True, True) is True
|
||
|
||
@mock.patch("redmine_reporter.cli.Config.get_report_no_time", return_value=True)
|
||
def test_yaml_applies_in_auto_mode(self, mock_get):
|
||
"""YAML report.no_time применяется в автоматическом режиме."""
|
||
from redmine_reporter.cli import _resolve_no_time
|
||
|
||
assert _resolve_no_time(False, True) is True
|
||
mock_get.assert_called_once()
|
||
|
||
def test_default_in_auto_mode(self):
|
||
"""Без YAML report.no_time в автоматическом режиме — False."""
|
||
from redmine_reporter.cli import _resolve_no_time
|
||
|
||
assert _resolve_no_time(False, True) is False
|
||
|
||
@mock.patch("redmine_reporter.cli.Config.get_report_no_time", return_value=True)
|
||
def test_yaml_ignored_in_manual_mode(self, mock_get):
|
||
"""YAML report.no_time игнорируется в ручном режиме --output."""
|
||
from redmine_reporter.cli import _resolve_no_time
|
||
|
||
assert _resolve_no_time(False, False) is False
|
||
mock_get.assert_not_called()
|
||
|
||
|
||
class TestReportNoTimeIntegration:
|
||
"""Интеграционные тесты для report.no_time из YAML."""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_commit_with_report_no_time_true(self, mock_save, mock_fetch, tmp_path):
|
||
"""--commit с report.no_time: true передаёт no_time=True в форматтер."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||
"report": {"no_time": True},
|
||
"period": {"from": "2026-06-01"},
|
||
}
|
||
)
|
||
)
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
_, kwargs = mock_get.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")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_commit_with_report_no_time_false(self, mock_save, mock_fetch, tmp_path):
|
||
"""--commit без report.no_time передаёт no_time=False в форматтер."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||
"report": {"no_time": False},
|
||
"period": {"from": "2026-06-01"},
|
||
}
|
||
)
|
||
)
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
_, kwargs = mock_get.call_args
|
||
assert kwargs.get("no_time") is False
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||
def test_commit_cli_no_time_overrides_yaml(self, mock_save, mock_fetch, tmp_path):
|
||
"""--commit с --no-time побеждает report.no_time: false."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||
"report": {"no_time": False},
|
||
"period": {"from": "2026-06-01"},
|
||
}
|
||
)
|
||
)
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--no-time",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
_, kwargs = mock_get.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_output_ignores_report_no_time_true(self, mock_fetch, tmp_path):
|
||
"""--output (ручной режим) игнорирует report.no_time: true."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||
"report": {"no_time": True},
|
||
}
|
||
)
|
||
)
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
_, kwargs = mock_get.call_args
|
||
assert kwargs.get("no_time") is False
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("builtins.input")
|
||
def test_send_with_report_no_time_true(
|
||
self, mock_input, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""--send с report.no_time: true передаёт no_time=True в форматтер."""
|
||
mock_input.return_value = "y"
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||
"report": {"no_time": True},
|
||
"email": {
|
||
"smtp": {
|
||
"host": "smtp.example.com",
|
||
"port": 587,
|
||
"user": "bot",
|
||
"password": "secret",
|
||
},
|
||
"from": "bot@example.com",
|
||
"to": ["boss@example.com"],
|
||
},
|
||
}
|
||
)
|
||
)
|
||
|
||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--send",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
_, kwargs = mock_get.call_args
|
||
assert kwargs.get("no_time") is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #55: sanitize Redmine exception output in CLI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSanitizeErrorOutput:
|
||
"""CLI не должен выводить API-ключ из текста исключений в stderr (#55)."""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_api_key_in_url_is_masked(self, mock_fetch, capsys):
|
||
"""key=SECRET в URL внутри сообщения исключения маскируется."""
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
mock_fetch.side_effect = RedmineAPIError(
|
||
"Network error while calling Redmine: "
|
||
"HTTPSConnectionPool(host='red.example.com', port=443): "
|
||
"Failed https://red.example.com/issues.json?key=SECRET123&limit=100"
|
||
)
|
||
code = main(["--date", "2026-01-01--2026-01-31"])
|
||
captured = capsys.readouterr()
|
||
assert code == 1
|
||
assert "SECRET123" not in captured.err
|
||
assert "key=***" in captured.err
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_api_key_header_is_masked(self, mock_fetch, capsys):
|
||
"""Заголовок X-Redmine-API-Key в тексте исключения маскируется."""
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
mock_fetch.side_effect = RedmineAPIError(
|
||
"Request failed: headers {'X-Redmine-API-Key': 'SECRET123'}"
|
||
)
|
||
code = main(["--date", "2026-01-01--2026-01-31"])
|
||
captured = capsys.readouterr()
|
||
assert code == 1
|
||
assert "SECRET123" not in captured.err
|
||
assert "X-Redmine-API-Key" in captured.err
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_unexpected_exception_is_masked(self, mock_fetch, capsys):
|
||
"""Общее исключение с key=SECRET в тексте тоже санитизируется."""
|
||
mock_fetch.side_effect = RuntimeError(
|
||
"boom while requesting https://red.example.com/t.json?key=SECRET123"
|
||
)
|
||
code = main(["--date", "2026-01-01--2026-01-31"])
|
||
captured = capsys.readouterr()
|
||
assert code == 1
|
||
assert "SECRET123" not in captured.err
|
||
assert "Unexpected error" in captured.err
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_error_message_readability_preserved(self, mock_fetch, capsys):
|
||
"""Санитизация не ломает обычные сообщения об ошибках."""
|
||
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: bad key" in captured.err
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_debug_shows_full_traceback(self, mock_fetch, caplog):
|
||
"""--debug выводит полный стектрейс исходного исключения."""
|
||
import logging as _logging
|
||
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
original = ValueError("raw details https://red.example.com?key=SECRET123")
|
||
error = RedmineAPIError("safe message", original=original)
|
||
error.__cause__ = original
|
||
mock_fetch.side_effect = error
|
||
with caplog.at_level(_logging.ERROR):
|
||
code = main(["--date", "2026-01-01--2026-01-31", "--debug"])
|
||
assert code == 1
|
||
assert "raw details" in caplog.text
|
||
assert "Traceback" in caplog.text
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_verbose_shows_full_traceback(self, mock_fetch, caplog):
|
||
"""--verbose тоже даёт доступ к деталям/стектрейсу исходной ошибки."""
|
||
import logging as _logging
|
||
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
original = ValueError("raw details https://red.example.com?key=SECRET123")
|
||
error = RedmineAPIError("safe message", original=original)
|
||
error.__cause__ = original
|
||
mock_fetch.side_effect = error
|
||
with caplog.at_level(_logging.INFO):
|
||
code = main(["--date", "2026-01-01--2026-01-31", "--verbose"])
|
||
assert code == 1
|
||
assert "raw details" in caplog.text
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_no_flags_hides_original_details(self, mock_fetch, caplog, capsys):
|
||
"""Без флагов детали исходного исключения не печатаются."""
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
original = ValueError("raw details https://red.example.com?key=SECRET123")
|
||
mock_fetch.side_effect = RedmineAPIError("safe message", original=original)
|
||
code = main(["--date", "2026-01-01--2026-01-31"])
|
||
captured = capsys.readouterr()
|
||
assert code == 1
|
||
assert "raw details" not in captured.err
|
||
assert "raw details" not in caplog.text
|
||
assert "safe message" in captured.err
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Email confirmation prompt before --send
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSendConfirmation:
|
||
"""Tests for email confirmation prompt before sending."""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("builtins.input")
|
||
def test_send_confirmation_yes_calls_send_report(
|
||
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""Подтверждение 'y' — send_report вызывается."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
mock_input.return_value = "y"
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
" cc:\n"
|
||
" - cc@example.com\n"
|
||
" bcc:\n"
|
||
" - bcc@example.com\n"
|
||
)
|
||
|
||
output = str(tmp_path / "report.xlsx")
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
output,
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_send.assert_called_once()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("builtins.input")
|
||
def test_send_confirmation_no_skips_send(
|
||
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""Ответ 'n' — send_report не вызывается."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
mock_input.return_value = "n"
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
)
|
||
|
||
output = str(tmp_path / "report.xlsx")
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
output,
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_send.assert_not_called()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("builtins.input")
|
||
def test_send_confirmation_default_skips_send(
|
||
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""Пустой ввод (Enter) — send_report не вызывается."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
mock_input.return_value = ""
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
)
|
||
|
||
output = str(tmp_path / "report.xlsx")
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
output,
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_send.assert_not_called()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.send_report")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
@mock.patch("builtins.input")
|
||
def test_send_confirmation_shows_recipients(
|
||
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys
|
||
):
|
||
"""Подтверждение показывает from, to, cc, bcc, file."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
mock_input.return_value = "y"
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: sender@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
" cc:\n"
|
||
" - cc@example.com\n"
|
||
" bcc:\n"
|
||
" - bcc@example.com\n"
|
||
)
|
||
|
||
output = str(tmp_path / "report.xlsx")
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
output,
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
captured = capsys.readouterr()
|
||
assert "sender@example.com" in captured.out
|
||
assert "boss@example.com" in captured.out
|
||
assert "cc@example.com" in captured.out
|
||
assert "bcc@example.com" in captured.out
|
||
assert output in captured.out
|
||
mock_input.assert_called_once_with("Отправить? [y/N]: ")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #71: --my-tasks tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_task_issue(issue_id, subject="Задача", status="New", priority="P3"):
|
||
"""Mock-issue с явно заданными атрибутами: MagicMock-автогенерация недопустима.
|
||
|
||
build_task_rows читает nested-атрибуты (priority.name); unset-атрибут
|
||
MagicMock был бы автосконструирован и str() дал бы нестабильный текст.
|
||
"""
|
||
issue = mock.MagicMock()
|
||
issue.id = issue_id
|
||
issue.subject = subject
|
||
issue.status = status
|
||
issue.priority.name = priority
|
||
issue.fixed_version = None
|
||
issue.spent_hours = 0.0
|
||
issue.estimated_hours = None
|
||
return issue
|
||
|
||
|
||
def _expected_link(issue_id):
|
||
"""Точная OSC 8-последовательность для ссылки на задачу в stdout."""
|
||
return (
|
||
f"\x1b]8;;https://red.eltex.loc/issues/{issue_id}\x1b\\{issue_id}\x1b]8;;\x1b\\"
|
||
)
|
||
|
||
|
||
class TestMyTasksFlag:
|
||
"""Tests for --my-tasks flag (#71)."""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@pytest.mark.parametrize(
|
||
"flag_args",
|
||
[
|
||
["--date", "2026-01-01--2026-01-31"],
|
||
["--output", "/tmp/report.xlsx"],
|
||
["--compact"],
|
||
["--summary"],
|
||
["--by-activity"],
|
||
["--no-time"],
|
||
["--commit"],
|
||
["--send"],
|
||
],
|
||
)
|
||
def test_my_tasks_rejects_report_flags(self, flag_args, capsys):
|
||
"""--my-tasks несовместим с каждым отчётным флагом: выход 1 и сообщение."""
|
||
code = main(["--my-tasks"] + flag_args)
|
||
captured = capsys.readouterr()
|
||
assert code == 1
|
||
assert "--my-tasks cannot be used with report-generating flags" in captured.err
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_open_issues")
|
||
def test_my_tasks_empty_list_exits_zero(self, mock_fetch, capsys):
|
||
"""Пустой список открытых задач -- выход 0 и сообщение в stderr."""
|
||
mock_fetch.return_value = []
|
||
code = main(["--my-tasks"])
|
||
captured = capsys.readouterr()
|
||
assert code == 0
|
||
assert "Открытых задач нет" in captured.err
|
||
assert captured.out == ""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_open_issues")
|
||
def test_my_tasks_passes_user_id(self, mock_fetch):
|
||
"""--user-id передаётся в fetch_open_issues."""
|
||
mock_fetch.return_value = []
|
||
code = main(["--my-tasks", "--user-id", "42"])
|
||
assert code == 0
|
||
_, 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_open_issues")
|
||
def test_my_tasks_no_user_flag_passes_none(self, mock_fetch):
|
||
"""Без флагов пользователя в fetch_open_issues уходит user_id=None."""
|
||
mock_fetch.return_value = []
|
||
code = main(["--my-tasks"])
|
||
assert code == 0
|
||
_, kwargs = mock_fetch.call_args
|
||
assert kwargs["user_id"] is None
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_open_issues")
|
||
def test_my_tasks_happy_path(self, mock_fetch, capsys):
|
||
"""Таблица с OSC 8-ссылкой и переведённым статусом в stdout; саммари в stderr."""
|
||
issue_overdue = _make_task_issue(42, subject="Починить баг")
|
||
issue_fresh = _make_task_issue(43, subject="Добавить фичу", status="Pending")
|
||
# Срок глубоко в прошлом: overdue не зависит от даты прогона тестов.
|
||
mock_fetch.return_value = [
|
||
(issue_fresh, None),
|
||
(issue_overdue, date(2000, 1, 1)),
|
||
]
|
||
|
||
code = main(["--my-tasks"])
|
||
captured = capsys.readouterr()
|
||
assert code == 0
|
||
|
||
# Ссылка на base_url из env теста с точной escape-последовательностью.
|
||
assert _expected_link(42) in captured.out
|
||
assert _expected_link(43) in captured.out
|
||
|
||
# Переведённые статусы попадают в таблицу.
|
||
assert "В работе" in captured.out
|
||
assert "Ожидание" in captured.out
|
||
assert "Починить баг" in captured.out
|
||
|
||
# Саммари -- в stderr, таблица -- не там.
|
||
assert "Открытых: 2" in captured.err
|
||
assert "просроченных: 1" in captured.err
|
||
assert "потрачено:" in captured.err
|
||
assert "Открытых:" not in captured.out
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_open_issues")
|
||
def test_my_tasks_api_error_exits_one(self, mock_fetch, capsys):
|
||
"""RedmineAPIError -- понятное сообщение и выход 1."""
|
||
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
|
||
code = main(["--my-tasks"])
|
||
captured = capsys.readouterr()
|
||
assert code == 1
|
||
assert "Authentication failed" in captured.err
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_open_issues")
|
||
def test_my_tasks_unexpected_error_exits_one(self, mock_fetch, capsys):
|
||
"""Непредвиденное исключение -- Unexpected error и выход 1."""
|
||
mock_fetch.side_effect = RuntimeError("boom")
|
||
code = main(["--my-tasks"])
|
||
captured = capsys.readouterr()
|
||
assert code == 1
|
||
assert "Unexpected error" in captured.err
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.TaskTableFormatter")
|
||
@mock.patch("redmine_reporter.cli.fetch_open_issues")
|
||
def test_my_tasks_format_error_exits_one(
|
||
self, mock_fetch, mock_formatter_cls, capsys
|
||
):
|
||
"""Сбой рендера таблицы -- Formatting error и выход 1, без трейсбека."""
|
||
mock_fetch.return_value = [(_make_task_issue(7), None)]
|
||
mock_formatter_cls.return_value.format.side_effect = ValueError("boom")
|
||
code = main(["--my-tasks"])
|
||
captured = capsys.readouterr()
|
||
assert code == 1
|
||
assert "❌ Formatting error: boom" in captured.err
|