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")), ( "2026-06-01T00:00:00--2026-06-30T23:59:59", ("2026-06-01T00:00:00", "2026-06-30T23:59:59"), ), ], ) 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--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"] == "" @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, {}, 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 сохраняет период в YAML-конфиг.""" 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": {"precision": "date", "dynamic": 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", "--commit", "--output", str(tmp_path / "report.xlsx"), "--config-path", str(config_path), ] ) assert code == 0 mock_save.assert_called_once_with( str(config_path), "2026-06-01", "2026-06-30", "date", 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_precision_datetime_passes_datetime_strings( self, mock_save, mock_fetch, tmp_path ): """При precision=datetime --commit сохраняет timestamps.""" 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": {"precision": "datetime", "dynamic": 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-30--2026-06-30", "--commit", "--output", str(tmp_path / "report.xlsx"), "--config-path", str(config_path), ] ) assert code == 0 call_args = mock_save.call_args assert call_args is not None saved_from, saved_to = call_args.args[1], call_args.args[2] assert "T" in saved_from assert "T" in saved_to @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_precision_datetime_saves_aware_utc( self, mock_save, mock_fetch, tmp_path ): """При precision=datetime --commit сохраняет last_used как aware UTC (#58).""" from datetime import datetime, timezone 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": {"precision": "datetime", "dynamic": 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-30--2026-06-30", "--commit", "--output", str(tmp_path / "report.xlsx"), "--config-path", str(config_path), ] ) assert code == 0 call_args = mock_save.call_args assert call_args is not None saved_from, saved_to = call_args.args[1], call_args.args[2] for saved in (saved_from, saved_to): parsed = datetime.fromisoformat(saved) assert parsed.tzinfo is not None, f"{saved} must be timezone-aware" assert parsed.utcoffset() == timezone.utc.utcoffset(None) @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path): """Без записей --commit не пишет конфиг.""" mock_fetch.return_value = None config_path = tmp_path / "config.yml" config_path.write_text("period:\n precision: date\n dynamic: true\n") code = main( [ "--date", "2026-01-01--2026-01-31", "--commit", "--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") @mock.patch("redmine_reporter.cli.save_period_to_config") def test_commit_dynamic_false_overwrites_defaults( self, mock_save, mock_fetch, tmp_path ): """При dynamic=false --commit перезаписывает default_from/to.""" 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": {"precision": "date", "dynamic": False}}) ) 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), "2026-06-01", "2026-06-30", "date", 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_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": {"precision": "date", "dynamic": True}, "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": {"precision": "date", "dynamic": 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", "--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_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": {"precision": "date", "dynamic": True}}) ) code = main( [ "--date", "2026-01-01--2026-01-31", "--commit", "--config-path", str(config_path), ] ) assert code == 0 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") def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path): """--send с --output вызывает send_report после сохранения.""" 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") def test_send_without_output_saves_to_default_path( self, mock_get, mock_send, mock_fetch, tmp_path ): """--send без --output сохраняет файл по шаблону, затем отправляет.""" 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") def test_send_smtp_error_reported_to_stderr( self, mock_get, mock_send, mock_fetch, tmp_path, capsys ): """Ошибка SMTP выводится в stderr, exit code 1.""" 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") def test_send_with_commit_works_together( self, mock_save, mock_get, mock_send, mock_fetch, tmp_path ): """--send и --commit работают вместе без конфликтов.""" 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" " precision: date\n" " dynamic: true\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() @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 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") def test_send_passes_rows_to_send_report( self, mock_get, mock_send, mock_fetch, tmp_path ): """--send передаёт rows в send_report для генерации HTML.""" 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") def test_send_html_false_does_not_require_html_part( self, mock_get, mock_send, mock_fetch, tmp_path ): """При email.html: false письмо отправляется без HTML-части.""" 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": {"precision": "date", "dynamic": 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", "--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": {"precision": "date", "dynamic": 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", "--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": {"precision": "date", "dynamic": 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", "--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") def test_send_with_report_no_time_true(self, mock_send, mock_fetch, tmp_path): """--send с 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}, "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 # --------------------------------------------------------------------------- # #59: dynamic + precision=datetime без --date # --------------------------------------------------------------------------- @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path): """dynamic+datetime без --date: datetime-диапазон из last_used принимается. Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used, отчёт строится без ошибки. """ issue = _MockIssue() mock_fetch.return_value = [(issue, 1.0, None)] config_path = tmp_path / "config.yml" config_path.write_text( "period:\n" " precision: datetime\n" " dynamic: true\n" " last_used:\n" " from: '2026-06-01T00:00:00'\n" " to: '2026-06-30T23:59:59'\n" ) code = main(["--config-path", str(config_path)]) assert code == 0 args, _ = mock_fetch.call_args from_date, to_date = args[0], args[1] # Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59 assert from_date == "2026-07-01T00:00:00" assert to_date == "2026-07-30T23:59:59" # --------------------------------------------------------------------------- # #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