Files
redmine-reporter/tests/test_config.py
Кокос Артем Николаевич b1a565bc9e feat: filename template expansion with {date} placeholder
- Add expand_filename_template() to yaml_config.py
- Supports {author}, {from}, {to}, {date}, {ext} placeholders
- {date} formats as DD_MM_YYYY (e.g. 31_03_2026)
- Spaces in author replaced with underscores for safe filenames
- Unknown placeholders left as-is
- Add comprehensive CONFIG.md documentation covering YAML setup, migration, and template syntax
2026-07-03 18:13:55 +07:00

370 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import tempfile
from pathlib import Path
from unittest import mock
import pytest
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY, AppConfig, Config
@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"