import os import tempfile from pathlib import Path from unittest import mock import pytest from redmine_reporter.config import AppConfig, Config, DEFAULT_REDMINE_VERIFY @mock.patch.dict( os.environ, { "REDMINE_URL": "https://red.eltex.loc/", "REDMINE_USER": "test", "REDMINE_PASSWORD": "secret", }, clear=True, ) def test_config_valid_with_password_fallback(): Config.validate() # не должно быть исключения @mock.patch.dict( os.environ, { "REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token", }, clear=True, ) def test_config_valid_with_api_key(): Config.validate() # не должно быть исключения @mock.patch.dict(os.environ, {}, clear=True) def test_config_missing_url(): with pytest.raises(ValueError, match="REDMINE_URL"): Config.validate() @mock.patch.dict(os.environ, {"REDMINE_URL": "https://red.eltex.loc/"}, clear=True) def test_config_missing_auth(): with pytest.raises(ValueError, match="REDMINE_API_KEY"): Config.validate() @mock.patch.dict(os.environ, {"REDMINE_URL": " https://red.eltex.loc/ "}, clear=True) def test_get_redmine_url_strips_spaces_and_trailing_slash(): assert Config.get_redmine_url() == "https://red.eltex.loc" @mock.patch.dict(os.environ, {"REDMINE_AUTHOR": " Иванов И.И. "}, clear=True) def test_get_author(): assert Config.get_author("") == "Иванов И.И." assert Config.get_author("Петров П.П.") == "Петров П.П." @mock.patch.dict(os.environ, {}, clear=True) def test_get_author_fallback(): """Если ни CLI, ни .env не задали автора -- возвращается пустая строка.""" assert Config.get_author("") == "" @mock.patch.dict( os.environ, { "DEFAULT_FROM_DATE": "2026-01-01", "DEFAULT_TO_DATE": "2026-01-31", }, clear=True, ) def test_get_default_date_range_from_env(): assert Config.get_default_date_range() == "2026-01-01--2026-01-31" @mock.patch.dict(os.environ, {}, clear=True) def test_get_default_date_range_fallback(): """Если даты не заданы -- используется текущий месяц.""" from datetime import date, timedelta today = date.today() start = today.replace(day=1) if today.month == 12: next_month = today.replace(year=today.year + 1, month=1, day=1) else: next_month = today.replace(month=today.month + 1, day=1) end = next_month - timedelta(days=1) result = Config.get_default_date_range() assert result == f"{start.isoformat()}--{end.isoformat()}" @mock.patch.dict(os.environ, {}, clear=True) def test_get_redmine_verify_default(): assert Config.get_redmine_verify() == DEFAULT_REDMINE_VERIFY @pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"]) def test_get_redmine_verify_false_values(value): with mock.patch.dict(os.environ, {"REDMINE_VERIFY": value}, clear=True): assert Config.get_redmine_verify() is False @pytest.mark.parametrize("value", ["1", "true", "True", "yes", "on"]) def test_get_redmine_verify_true_values(value): with mock.patch.dict(os.environ, {"REDMINE_VERIFY": value}, clear=True): assert Config.get_redmine_verify() is True @mock.patch.dict(os.environ, {"REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True) def test_get_redmine_verify_custom_path(): assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem" # -- #15: .env не должен переопределять переменные окружения -- @mock.patch("dotenv.load_dotenv") def test_env_var_takes_priority_over_dotenv(mock_load): """load_dotenv вызывается с override=False — env vars не перебиваются .env.""" import importlib from redmine_reporter import config as cfg_mod importlib.reload(cfg_mod) mock_load.assert_called_once_with(override=False) # -- #35: get_redmine_password должен делать .strip() -- @mock.patch.dict(os.environ, {"REDMINE_PASSWORD": " secret123 "}, clear=True) def test_get_redmine_password_strips_whitespace(): """Пароль обрезается от whitespace, как и все остальные геттеры.""" assert Config.get_redmine_password() == "secret123" # --------------------------------------------------------------------------- # AppConfig tests — YAML config loading # --------------------------------------------------------------------------- FULL_YAML = """\ redmine: url: https://redmine.example.com/ api_key: ${MISSING_API_KEY_FOR_TEST} author: "Тестовый Автор" verify_ssl: false period: precision: datetime default_from: "2026-06-01" default_to: "2026-06-30" dynamic: true output: dir: ~/reports filename: "{author}_{from}_{to}.{ext}" default_format: xlsx email: smtp: host: smtp.example.com port: 587 user: bot@example.com password: ${MISSING_SMTP_PASSWORD_FOR_TEST} tls: true from: bot@example.com to: - boss@example.com cc: [] bcc: [] subject: "Отчёт {author} за {period}" body_text: "Во вложении отчёт." attach: true """ class TestAppConfigFromYaml: """Tests for AppConfig.from_yaml().""" def test_loads_all_sections_from_valid_yaml(self): with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text(FULL_YAML) cfg = AppConfig.from_yaml(yaml_path) assert cfg.redmine_url == "https://redmine.example.com/" assert cfg.redmine_api_key == "" # ${MISSING_API_KEY_FOR_TEST} not set assert cfg.redmine_author == "Тестовый Автор" assert cfg.redmine_verify is False assert cfg.period_precision == "datetime" assert cfg.period_default_from == "2026-06-01" assert cfg.period_default_to == "2026-06-30" assert cfg.period_dynamic is True assert cfg.output_dir == "~/reports" assert cfg.output_filename == "{author}_{from}_{to}.{ext}" assert cfg.output_default_format == "xlsx" assert cfg.email.smtp.host == "smtp.example.com" assert cfg.email.smtp.port == 587 assert cfg.email.smtp.user == "bot@example.com" assert cfg.email.smtp.password == "" assert cfg.email.smtp.tls is True assert cfg.email.from_ == "bot@example.com" assert cfg.email.to == ["boss@example.com"] assert cfg.email.subject == "Отчёт {author} за {period}" def test_partial_yaml_uses_defaults_for_missing(self): with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text("redmine:\n url: https://x.com/\n") cfg = AppConfig.from_yaml(yaml_path) assert cfg.redmine_url == "https://x.com/" # defaults for everything else assert cfg.redmine_author == "" assert cfg.period_precision == "date" assert cfg.output_dir == "" assert cfg.email.to == [] def test_empty_file_returns_all_defaults(self): with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text("") cfg = AppConfig.from_yaml(yaml_path) assert cfg.redmine_url == "" assert cfg.redmine_api_key == "" assert cfg.period_precision == "date" def test_file_not_found_returns_all_defaults(self): cfg = AppConfig.from_yaml("/nonexistent/path/config.yml") assert cfg.redmine_url == "" @mock.patch.dict(os.environ, {"REDMINE_API_KEY": "secret-token"}, clear=True) def test_env_var_resolved_in_yaml(self): with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text("redmine:\n api_key: ${REDMINE_API_KEY}\n") cfg = AppConfig.from_yaml(yaml_path) assert cfg.redmine_api_key == "secret-token" def test_missing_env_var_logs_warning(self, caplog): import logging with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text("redmine:\n api_key: ${MISSING_KEY}\n") with caplog.at_level(logging.WARNING): cfg = AppConfig.from_yaml(yaml_path) assert cfg.redmine_api_key == "" assert "MISSING_KEY" in caplog.text def test_unknown_top_level_key_logs_warning(self, caplog): import logging with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text("unknown_section:\n foo: bar\n") with caplog.at_level(logging.WARNING): AppConfig.from_yaml(yaml_path) assert "unknown_section" in caplog.text # --------------------------------------------------------------------------- # Config priority chain tests — YAML fallback # --------------------------------------------------------------------------- class TestConfigYamlFallback: """Tests that Config.get_*() falls back to YAML when env/CLI not set.""" @mock.patch.dict(os.environ, {}, clear=True) def test_url_from_yaml_when_no_env_or_cli(self): with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n") Config._cli_url = None Config._app = AppConfig.from_yaml(yaml_path) assert Config.get_redmine_url() == "https://yaml-redmine.example.com" @mock.patch.dict(os.environ, {}, clear=True) def test_cli_beats_yaml(self): with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n") Config._app = AppConfig.from_yaml(yaml_path) Config.set_redmine_url("https://cli-override.example.com") assert Config.get_redmine_url() == "https://cli-override.example.com" @mock.patch.dict(os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True) def test_env_beats_yaml(self): with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n") Config._cli_url = None Config._app = AppConfig.from_yaml(yaml_path) assert Config.get_redmine_url() == "https://env-redmine.example.com" @mock.patch.dict(os.environ, {}, clear=True) def test_date_range_from_yaml(self): with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text( "period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n" ) Config._app = AppConfig.from_yaml(yaml_path) assert Config.get_default_date_range() == "2026-03-01--2026-03-15" @mock.patch.dict(os.environ, {}, clear=True) def test_author_from_yaml(self): with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text("redmine:\n author: 'Автор Из Ямла'\n") Config._app = AppConfig.from_yaml(yaml_path) assert Config.get_author("") == "Автор Из Ямла" @mock.patch.dict(os.environ, {}, clear=True) def test_validate_passes_with_yaml_url_and_api_key(self): with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text( "redmine:\n url: https://yaml.example.com/\n api_key: yaml-token\n" ) Config._cli_url = None Config._cli_api_key = None Config._app = AppConfig.from_yaml(yaml_path) # Не должно быть исключения Config.validate() @mock.patch.dict(os.environ, {}, clear=True) def test_default_date_range_env_beats_yaml(self): with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" yaml_path.write_text( "period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n" ) Config._app = AppConfig.from_yaml(yaml_path) with mock.patch.dict( os.environ, {"DEFAULT_FROM_DATE": "2026-07-01", "DEFAULT_TO_DATE": "2026-07-15"}, clear=True, ): assert Config.get_default_date_range() == "2026-07-01--2026-07-15"