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.
974 lines
37 KiB
Python
974 lines
37 KiB
Python
import contextlib
|
||
import os
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import ClassVar
|
||
from unittest import mock
|
||
|
||
import pytest
|
||
from redmine_reporter.config import AppConfig, Config, EmailConfig
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def mock_today(fake_today):
|
||
"""Подменяет date.today() в config, сохраняя реальные date() и fromisoformat()."""
|
||
from datetime import date as real_date
|
||
|
||
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||
mock_date.today.return_value = fake_today
|
||
mock_date.side_effect = lambda *a, **kw: real_date(*a, **kw)
|
||
mock_date.fromisoformat.side_effect = real_date.fromisoformat
|
||
yield
|
||
|
||
|
||
@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, {"FROM_DATE": "2026-01-01"}, clear=True)
|
||
def test_get_default_date_range_from_env():
|
||
"""FROM_DATE задаёт начало периода, конец всегда -- сегодня."""
|
||
from datetime import date
|
||
|
||
today = date.today().isoformat()
|
||
assert Config.get_default_date_range() == f"2026-01-01--{today}"
|
||
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_get_default_date_range_fallback():
|
||
"""Если даты не заданы -- используется текущий месяц с начала до сегодня."""
|
||
from datetime import date
|
||
|
||
today = date.today()
|
||
start = today.replace(day=1)
|
||
|
||
result = Config.get_default_date_range()
|
||
assert result == f"{start.isoformat()}--{today.isoformat()}"
|
||
|
||
|
||
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) --
|
||
|
||
|
||
class TestDefaultPeriodIsCurrentMonth:
|
||
"""Без --date, env и YAML период = с 1-го числа текущего месяца по сегодня."""
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_mid_month(self):
|
||
from datetime import date
|
||
|
||
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||
mock_date.today.return_value = date(2026, 3, 14)
|
||
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||
Config._app = None
|
||
assert Config.get_default_date_range() == "2026-03-01--2026-03-14"
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_first_day_of_month(self):
|
||
from datetime import date
|
||
|
||
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||
mock_date.today.return_value = date(2026, 5, 1)
|
||
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||
Config._app = None
|
||
assert Config.get_default_date_range() == "2026-05-01--2026-05-01"
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_yaml_period_from_runs_to_today(self):
|
||
"""period.from задан -- конец периода всегда сегодня."""
|
||
from datetime import date
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("period:\n from: '2026-04-01'\n")
|
||
Config._app = AppConfig.from_yaml(yaml_path)
|
||
|
||
with mock_today(date(2026, 4, 20)):
|
||
assert Config.get_default_date_range() == "2026-04-01--2026-04-20"
|
||
|
||
|
||
class TestDefaultDateRangePeriodFrom:
|
||
"""Приоритет окна: env FROM_DATE → YAML period.from → текущий месяц."""
|
||
|
||
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-02-01"}, clear=True)
|
||
def test_env_from_runs_to_today(self):
|
||
"""FROM_DATE задаёт начало, конец всегда сегодня."""
|
||
from datetime import date
|
||
|
||
Config._app = None
|
||
with mock_today(date(2026, 2, 10)):
|
||
assert Config.get_default_date_range() == "2026-02-01--2026-02-10"
|
||
|
||
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True)
|
||
def test_env_from_wins_over_yaml_period_from(self):
|
||
"""env FROM_DATE имеет приоритет над YAML period.from."""
|
||
from datetime import date
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("period:\n from: '2026-03-01'\n")
|
||
Config._app = AppConfig.from_yaml(yaml_path)
|
||
|
||
with mock_today(date(2026, 7, 15)):
|
||
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
|
||
|
||
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-12-31"}, clear=True)
|
||
def test_env_from_in_future_clamps_to_today(self):
|
||
"""from позже сегодня -- окно 'сегодня--сегодня', без инверсии."""
|
||
from datetime import date
|
||
|
||
Config._app = None
|
||
with mock_today(date(2026, 9, 15)):
|
||
assert Config.get_default_date_range() == "2026-09-15--2026-09-15"
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_yaml_period_from_in_future_clamps_to_today(self):
|
||
"""YAML period.from позже сегодня -- окно 'сегодня--сегодня'."""
|
||
from datetime import date
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("period:\n from: '2026-12-31'\n")
|
||
Config._app = AppConfig.from_yaml(yaml_path)
|
||
|
||
with mock_today(date(2026, 9, 15)):
|
||
assert Config.get_default_date_range() == "2026-09-15--2026-09-15"
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_malformed_yaml_period_from_warns_and_uses_current_month(self, caplog):
|
||
"""period.from не дата -- warning в лог, окно = текущий месяц."""
|
||
import logging
|
||
from datetime import date
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("period:\n from: 'not-a-date'\n")
|
||
Config._app = AppConfig.from_yaml(yaml_path)
|
||
|
||
with caplog.at_level(logging.WARNING), mock_today(date(2026, 9, 15)):
|
||
rng = Config.get_default_date_range()
|
||
|
||
assert rng == "2026-09-01--2026-09-15"
|
||
assert "not-a-date" in caplog.text
|
||
|
||
@mock.patch.dict(os.environ, {"FROM_DATE": "garbage"}, clear=True)
|
||
def test_malformed_env_from_warns_and_uses_current_month(self, caplog):
|
||
"""FROM_DATE не дата -- warning в лог, окно = текущий месяц."""
|
||
import logging
|
||
from datetime import date
|
||
|
||
Config._app = None
|
||
with caplog.at_level(logging.WARNING), mock_today(date(2026, 9, 15)):
|
||
rng = Config.get_default_date_range()
|
||
|
||
assert rng == "2026-09-01--2026-09-15"
|
||
assert "garbage" in caplog.text
|
||
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_get_redmine_verify_default():
|
||
"""Без явной настройки проверка TLS включена (стандартные CA requests)."""
|
||
Config._app = None
|
||
assert Config.get_redmine_verify() is True
|
||
|
||
|
||
@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"
|
||
|
||
|
||
# -- #54: REDMINE_URL обязан использовать HTTPS --
|
||
|
||
|
||
class TestConfigRequiresHttpsUrl:
|
||
"""Config.validate() отклоняет URL без TLS: API-ключ идёт в заголовках."""
|
||
|
||
@mock.patch.dict(
|
||
os.environ,
|
||
{"REDMINE_URL": "http://red.eltex.loc/", "REDMINE_API_KEY": "token"},
|
||
clear=True,
|
||
)
|
||
def test_http_url_rejected(self):
|
||
with pytest.raises(ValueError, match="HTTPS"):
|
||
Config.validate()
|
||
|
||
@mock.patch.dict(
|
||
os.environ,
|
||
{"REDMINE_URL": "red.eltex.loc", "REDMINE_API_KEY": "token"},
|
||
clear=True,
|
||
)
|
||
def test_url_without_scheme_rejected(self):
|
||
with pytest.raises(ValueError, match="HTTPS"):
|
||
Config.validate()
|
||
|
||
@mock.patch.dict(
|
||
os.environ,
|
||
{"REDMINE_URL": "ftp://red.eltex.loc/", "REDMINE_API_KEY": "token"},
|
||
clear=True,
|
||
)
|
||
def test_non_http_scheme_rejected(self):
|
||
with pytest.raises(ValueError, match="HTTPS"):
|
||
Config.validate()
|
||
|
||
@mock.patch.dict(
|
||
os.environ,
|
||
{"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"},
|
||
clear=True,
|
||
)
|
||
def test_https_url_accepted(self):
|
||
Config.validate()
|
||
|
||
@mock.patch.dict(
|
||
os.environ,
|
||
{"REDMINE_URL": "HTTPS://red.eltex.loc/", "REDMINE_API_KEY": "token"},
|
||
clear=True,
|
||
)
|
||
def test_uppercase_scheme_accepted(self):
|
||
Config.validate()
|
||
|
||
|
||
# -- #57: предупреждение при отключённой проверке TLS --
|
||
|
||
|
||
class TestWarnWhenTlsVerificationDisabled:
|
||
"""verify_ssl: false должен давать видимый warning в stderr (риск MITM)."""
|
||
|
||
_ENV: ClassVar[dict[str, str]] = {
|
||
"REDMINE_URL": "https://red.eltex.loc/",
|
||
"REDMINE_API_KEY": "token",
|
||
}
|
||
|
||
def _validate_env(self, verify: str):
|
||
return {**self._ENV, "REDMINE_VERIFY": verify}
|
||
|
||
def test_verify_false_warns_in_stderr(self, capsys):
|
||
with mock.patch.dict(os.environ, self._validate_env("false"), clear=True):
|
||
Config.validate()
|
||
|
||
err = capsys.readouterr().err
|
||
assert "⚠️" in err
|
||
assert "TLS" in err or "SSL" in err
|
||
|
||
def test_verify_true_no_warning(self, capsys):
|
||
with mock.patch.dict(os.environ, self._validate_env("true"), clear=True):
|
||
Config.validate()
|
||
|
||
assert capsys.readouterr().err == ""
|
||
|
||
def test_verify_ca_path_no_warning(self, capsys):
|
||
with mock.patch.dict(
|
||
os.environ, self._validate_env("/tmp/redmine-ca.pem"), clear=True
|
||
):
|
||
Config.validate()
|
||
|
||
assert capsys.readouterr().err == ""
|
||
|
||
def test_verify_default_no_warning(self, capsys):
|
||
with mock.patch.dict(os.environ, self._ENV, clear=True):
|
||
Config.validate()
|
||
|
||
assert capsys.readouterr().err == ""
|
||
|
||
|
||
# -- #15: .env не должен переопределять переменные окружения --
|
||
|
||
|
||
@mock.patch("redmine_reporter.config.load_dotenv")
|
||
def test_env_var_takes_priority_over_dotenv(mock_load):
|
||
"""load_dotenv вызывается с override=False — env vars не перебиваются .env."""
|
||
Config.load_yaml("/nonexistent/config.yml")
|
||
|
||
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:
|
||
from: "2026-06-01"
|
||
|
||
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_from == "2026-06-01"
|
||
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_from == ""
|
||
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_from == ""
|
||
|
||
def test_period_from_loaded_from_yaml(self):
|
||
"""period.from читается из YAML как единственный ключ периода."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("period:\n from: '2026-06-01'\n")
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.period_from == "2026-06-01"
|
||
|
||
def test_old_period_keys_are_ignored(self):
|
||
"""Устаревшие ключи period молча игнорируются -- читается только from."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text(
|
||
"period:\n"
|
||
" precision: datetime\n"
|
||
" dynamic: true\n"
|
||
" default_from: '2026-01-01'\n"
|
||
" default_to: '2026-01-31'\n"
|
||
" last_used:\n"
|
||
" from: '2026-01-31T09:00:00'\n"
|
||
" to: '2026-01-31T12:00:00'\n"
|
||
)
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.period_from == ""
|
||
|
||
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_verify_ssl_true_returns_true(self):
|
||
"""verify_ssl: true → bool True (стандартная проверка TLS)."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("redmine:\n verify_ssl: true\n")
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.redmine_verify is True
|
||
|
||
@pytest.mark.parametrize("value", ["true", "True", "yes", "on", "1"])
|
||
def test_verify_ssl_true_string_returns_true(self, value):
|
||
"""Строковые true-значения в YAML → bool True, как и из env."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.redmine_verify is True
|
||
|
||
def test_verify_ssl_custom_path_returns_path(self):
|
||
"""Явный путь к CA-bundle в YAML передаётся как есть."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("redmine:\n verify_ssl: /etc/ssl/my-ca.pem\n")
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.redmine_verify == "/etc/ssl/my-ca.pem"
|
||
|
||
@pytest.mark.parametrize("value", ["false", "False", "no", "off", "0"])
|
||
def test_verify_ssl_false_string_returns_false(self, value):
|
||
"""Строковые false-значения в YAML → bool False."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.redmine_verify is False
|
||
|
||
def test_verify_ssl_missing_returns_true(self):
|
||
"""Без verify_ssl в YAML — проверка TLS включена (True)."""
|
||
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_verify is True
|
||
|
||
def test_verify_ssl_false_returns_false(self):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("redmine:\n verify_ssl: false\n")
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.redmine_verify is False
|
||
|
||
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):
|
||
from datetime import date
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("period:\n from: '2026-03-01'\n")
|
||
|
||
Config._app = AppConfig.from_yaml(yaml_path)
|
||
|
||
with mock_today(date(2026, 3, 15)):
|
||
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):
|
||
from datetime import date
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("period:\n from: '2026-03-01'\n")
|
||
|
||
Config._app = AppConfig.from_yaml(yaml_path)
|
||
|
||
with (
|
||
mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True),
|
||
mock_today(date(2026, 7, 15)),
|
||
):
|
||
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
|
||
|
||
|
||
class TestGetEmailConfig:
|
||
"""Tests for Config.get_email_config()."""
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_get_email_config_from_yaml(self):
|
||
"""get_email_config() возвращает EmailConfig из YAML."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: smtp.example.com\n"
|
||
" port: 587\n"
|
||
" user: bot@example.com\n"
|
||
" password: secret\n"
|
||
" tls: true\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
)
|
||
|
||
Config._app = AppConfig.from_yaml(yaml_path)
|
||
cfg = Config.get_email_config()
|
||
|
||
assert cfg is not None
|
||
assert cfg.smtp.host == "smtp.example.com"
|
||
assert cfg.smtp.port == 587
|
||
assert cfg.smtp.user == "bot@example.com"
|
||
assert cfg.smtp.password == "secret"
|
||
assert cfg.smtp.tls is True
|
||
assert cfg.from_ == "bot@example.com"
|
||
assert cfg.to == ["boss@example.com"]
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_get_email_config_returns_none_when_no_yaml(self):
|
||
"""Без YAML-конфига get_email_config() возвращает None."""
|
||
Config._app = None
|
||
assert Config.get_email_config() is None
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_get_email_config_returns_none_when_no_host(self):
|
||
"""С YAML но без smtp.host — возвращает None."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("email:\n smtp:\n host: ''\n")
|
||
|
||
Config._app = AppConfig.from_yaml(yaml_path)
|
||
assert Config.get_email_config() is None
|
||
|
||
|
||
class TestEmailConfigHtml:
|
||
"""Tests for EmailConfig.html field."""
|
||
|
||
def test_email_config_html_from_yaml(self):
|
||
"""email.html: true загружается из YAML."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text(
|
||
"email:\n html: true\n smtp:\n host: smtp.example.com\n"
|
||
)
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
assert cfg.email.html is True
|
||
|
||
def test_email_config_html_defaults_to_false(self):
|
||
"""email.html по умолчанию False."""
|
||
assert EmailConfig().html is False
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("email:\n smtp:\n host: smtp.example.com\n")
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
assert cfg.email.html is False
|
||
|
||
def test_email_config_html_false_from_yaml(self):
|
||
"""email.html: false явно загружается из YAML."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text(
|
||
"email:\n html: false\n smtp:\n host: smtp.example.com\n"
|
||
)
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
assert cfg.email.html is False
|
||
|
||
def test_email_config_html_invalid_string_defaults_to_false(self):
|
||
"""email.html со строкой 'invalid' приводится к False."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text(
|
||
"email:\n html: 'invalid'\n smtp:\n host: smtp.example.com\n"
|
||
)
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
assert cfg.email.html is False
|
||
|
||
|
||
class TestReportNoTime:
|
||
"""Tests for Config.get_report_no_time()."""
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_get_report_no_time_from_yaml(self):
|
||
"""report.no_time: true загружается из YAML."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("report:\n no_time: true\n")
|
||
|
||
Config._app = AppConfig.from_yaml(yaml_path)
|
||
assert Config.get_report_no_time() is True
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_get_report_no_time_defaults_to_false(self):
|
||
"""Без YAML-конфига report.no_time по умолчанию False."""
|
||
Config._app = None
|
||
assert Config.get_report_no_time() is False
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_get_report_no_time_from_yaml_false(self):
|
||
"""report.no_time: false явно загружается из YAML."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("report:\n no_time: false\n")
|
||
|
||
Config._app = AppConfig.from_yaml(yaml_path)
|
||
assert Config.get_report_no_time() is False
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_get_report_no_time_invalid_type_defaults_to_false(self):
|
||
"""report.no_time со строковым значением приводится к False."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("report:\n no_time: 'invalid'\n")
|
||
|
||
Config._app = AppConfig.from_yaml(yaml_path)
|
||
assert Config.get_report_no_time() is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #65: report.status_translation — перевод статусов из YAML
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestStatusTranslationFromYaml:
|
||
"""Tests for report.status_translation loading in AppConfig.from_yaml()."""
|
||
|
||
def test_status_translation_loaded_from_yaml(self):
|
||
"""report.status_translation заполняет поле AppConfig (override + новый)."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text(
|
||
"report:\n"
|
||
" status_translation:\n"
|
||
' New: "Новая"\n'
|
||
' "Custom Status": "Кастом"\n'
|
||
)
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.report_status_translation == {
|
||
"New": "Новая",
|
||
"Custom Status": "Кастом",
|
||
}
|
||
|
||
def test_status_translation_missing_defaults_to_empty(self):
|
||
"""Без секции report.status_translation поле пустое."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("report:\n no_time: true\n")
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.report_status_translation == {}
|
||
|
||
def test_status_translation_non_mapping_warns_and_ignored(self, caplog):
|
||
"""status_translation не-mapping (строка) → warning в лог, поле {}."""
|
||
import logging
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("report:\n status_translation: 'just a string'\n")
|
||
|
||
with caplog.at_level(logging.WARNING):
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.report_status_translation == {}
|
||
assert "status_translation" in caplog.text
|
||
|
||
@mock.patch.dict(os.environ, {"STATUS_RU": "На проверке"}, clear=True)
|
||
def test_status_translation_env_var_resolved(self):
|
||
"""${VAR} в значении перевода резолвится из окружения."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text(
|
||
"report:\n status_translation:\n Testing: ${STATUS_RU}\n"
|
||
)
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.report_status_translation == {"Testing": "На проверке"}
|
||
|
||
def test_status_translation_null_value_skipped_with_warning(self, caplog):
|
||
"""Значение null пропускается с warning, а не превращается в 'None'."""
|
||
import logging
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text(
|
||
"report:\n status_translation:\n New:\n Closed: Закрыто\n"
|
||
)
|
||
|
||
with caplog.at_level(logging.WARNING):
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.report_status_translation == {"Closed": "Закрыто"}
|
||
assert "status_translation" in caplog.text
|
||
assert "New" in caplog.text
|
||
|
||
def test_status_translation_nested_dict_skipped_with_warning(self, caplog):
|
||
"""Вложенный dict в значении пропускается с warning, а не в repr."""
|
||
import logging
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text(
|
||
"report:\n"
|
||
" status_translation:\n"
|
||
" New:\n"
|
||
" nested: value\n"
|
||
" Closed: Закрыто\n"
|
||
)
|
||
|
||
with caplog.at_level(logging.WARNING):
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.report_status_translation == {"Closed": "Закрыто"}
|
||
assert "status_translation" in caplog.text
|
||
assert "New" in caplog.text
|
||
|
||
def test_status_translation_int_value_coerced_to_str(self):
|
||
"""Scalar-значения (int) по-прежнему приводятся к str."""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
yaml_path = Path(tmp) / "config.yml"
|
||
yaml_path.write_text("report:\n status_translation:\n New: 42\n")
|
||
|
||
cfg = AppConfig.from_yaml(yaml_path)
|
||
|
||
assert cfg.report_status_translation == {"New": "42"}
|
||
|
||
|
||
class TestGetStatusTranslation:
|
||
"""Tests for Config.get_status_translation()."""
|
||
|
||
def test_no_yaml_returns_builtin(self):
|
||
"""Без YAML-конфига возвращается встроенный словарь."""
|
||
from redmine_reporter.report_builder import STATUS_TRANSLATION
|
||
|
||
Config._app = None
|
||
try:
|
||
assert Config.get_status_translation() == STATUS_TRANSLATION
|
||
finally:
|
||
Config._app = None
|
||
|
||
def test_empty_overrides_returns_builtin(self):
|
||
"""Пустые overrides — результат равен встроенному словарю."""
|
||
from redmine_reporter.report_builder import STATUS_TRANSLATION
|
||
|
||
Config._app = AppConfig()
|
||
try:
|
||
assert Config.get_status_translation() == STATUS_TRANSLATION
|
||
finally:
|
||
Config._app = None
|
||
|
||
def test_override_and_extend(self):
|
||
"""Override существующего статуса + добавление нового."""
|
||
Config._app = AppConfig(
|
||
report_status_translation={"New": "Новая", "Custom Status": "Кастом"}
|
||
)
|
||
try:
|
||
translation = Config.get_status_translation()
|
||
finally:
|
||
Config._app = None
|
||
|
||
assert translation["New"] == "Новая"
|
||
assert translation["Custom Status"] == "Кастом"
|
||
# остальные встроенные переводы не тронуты
|
||
assert translation["Closed"] == "Закрыто"
|
||
|
||
def test_result_is_a_copy(self):
|
||
"""Мутация результата не трогает модульный STATUS_TRANSLATION."""
|
||
from redmine_reporter.report_builder import STATUS_TRANSLATION
|
||
|
||
Config._app = AppConfig(report_status_translation={"New": "Новая"})
|
||
try:
|
||
translation = Config.get_status_translation()
|
||
translation["New"] = "ИЗМЕНЕНО"
|
||
translation["Junk"] = "мусор"
|
||
finally:
|
||
Config._app = None
|
||
|
||
assert STATUS_TRANSLATION["New"] == "В работе"
|
||
assert "Junk" not in STATUS_TRANSLATION
|