Files
redmine-reporter/tests/test_config.py
Кокос Артем Николаевич 829f1b73fd fix: unify verify_ssl semantics across config sources
YAML verify_ssl: true раньше подставлял захардкоженный путь
/etc/ssl/certs/ca-certificates.crt, а REDMINE_VERIFY=true — bool True.
Путь отсутствует на части дистрибутивов, семантика источников различалась.

Теперь едино для YAML и env:
- true (bool/строка) → True: стандартная проверка TLS средствами requests;
- false (bool/строка) → False (+ сохраняется warning из #57);
- иная строка → путь к CA-bundle как есть.
- дефолт (значение не задано) → True вместо захардкоженного пути.

Существующие тесты на путь-от-true переписаны под новую семантику
(изменение поведения): test_verify_ssl_true_returns_default_ca_path →
test_verify_ssl_true_returns_true.

Closes #62
2026-07-17 13:28:32 +07:00

861 lines
31 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 AppConfig, Config, EmailConfig
@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,
{"DEFAULT_FROM_DATE": "2026-01-01"},
clear=True,
)
def test_get_default_date_range_from_env_without_to():
"""Если DEFAULT_TO_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()}"
@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 = {"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("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
last_used:
from: "2026-06-30T09:00:00"
to: "2026-06-30T12:00:00"
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.period_last_used_from == "2026-06-30T09:00:00"
assert cfg.period_last_used_to == "2026-06-30T12:00:00"
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_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):
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"
class TestConfigPeriodPrecision:
"""Tests for period.precision and period.last_used."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_period_precision_defaults_to_date(self):
"""Без YAML-конфига precision == 'date'."""
Config._app = None
assert Config.get_period_precision() == "date"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_period_precision_from_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n precision: datetime\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_period_precision() == "datetime"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_last_used_from_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n last_used:\n from: '2026-06-30T09:00:00'\n to: '2026-06-30T12:00:00'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_last_used_from() == "2026-06-30T09:00:00"
assert Config.get_last_used_to() == "2026-06-30T12:00:00"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_last_used_returns_empty_when_not_set(self):
Config._app = AppConfig()
assert Config.get_last_used_from() == ""
assert Config.get_last_used_to() == ""
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_loads_last_used_field(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n last_used:\n from: '2026-07-01T08:00:00'\n to: '2026-07-01T18:00:00'\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_last_used_from == "2026-07-01T08:00:00"
assert cfg.period_last_used_to == "2026-07-01T18:00:00"
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_without_last_used_has_empty_fields(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n precision: date\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_last_used_from == ""
assert cfg.period_last_used_to == ""
class TestComputeNextPeriod:
"""Tests for compute_next_period()."""
def test_full_month_goes_to_next_full_month(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-01", "2026-06-30", "date")
assert nf == "2026-07-01"
assert nt == "2026-07-31"
def test_december_to_next_year_january(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2025-12-01", "2025-12-31", "date")
assert nf == "2026-01-01"
assert nt == "2026-01-31"
def test_february_2026_non_leap_to_march(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-02-01", "2026-02-28", "date")
assert nf == "2026-03-01"
assert nt == "2026-03-31"
def test_arbitrary_range_same_length_from_to_plus_one(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-15", "2026-06-20", "date")
assert nf == "2026-06-21"
assert nt == "2026-06-26"
def test_single_day_range_moves_one_day(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-15", "2026-06-15", "date")
assert nf == "2026-06-16"
assert nt == "2026-06-16"
def test_cross_month_range(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-25", "2026-07-05", "date")
assert nf == "2026-07-06"
assert nt == "2026-07-16"
def test_datetime_precision_moves_by_seconds(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period(
"2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime"
)
assert nf == "2026-06-30T12:00:01"
assert nt == "2026-06-30T15:00:01"
class TestDefaultDateRangeWithLastUsed:
"""Tests that get_default_date_range() uses last_used when dynamic=True."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_uses_last_used_when_dynamic_true(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: true\n"
" last_used:\n"
" from: '2026-05-01'\n"
" to: '2026-05-31'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-06-01--2026-06-30"
@mock.patch.dict(os.environ, {}, clear=True)
def test_ignores_last_used_when_dynamic_false(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-03-01'\n"
" default_to: '2026-03-15'\n"
" last_used:\n"
" from: '2026-06-01'\n"
" to: '2026-06-30'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-03-01--2026-03-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_falls_back_when_no_last_used_even_with_dynamic(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: true\n"
" default_from: '2026-04-01'\n"
" default_to: '2026-04-15'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-04-01--2026-04-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_falls_back_to_today_when_default_to_missing(self):
"""Если default_from задан, а default_to нет — конец периода сегодня."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-04-01'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
today = date.today().isoformat()
result = Config.get_default_date_range()
assert result == f"2026-04-01--{today}"
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