From 829f1b73fd369e9cfa352743538a817ae8c2db0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 17 Jul 2026 13:28:32 +0700 Subject: [PATCH 1/3] fix: unify verify_ssl semantics across config sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- docs/CONFIG.md | 18 +++++++++++- redmine_reporter/config.py | 12 ++++++-- tests/test_config.py | 60 +++++++++++++++++++++++++++++++------- 4 files changed, 76 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 95db9eb..a79c6c3 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ DEFAULT_TO_DATE=2026-01-31 | `REDMINE_AUTHOR` | Нет | Имя автора для отчёта. | | `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). | | `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию (`YYYY-MM-DD`). Если не задана, а `DEFAULT_FROM_DATE` задана — используется сегодняшняя дата. | -| `REDMINE_VERIFY` | Нет | TLS-проверка: `true` / `false` / путь к CA bundle. | +| `REDMINE_VERIFY` | Нет | TLS-проверка: `true` (по умолчанию — стандартная проверка средствами requests) / `false` / путь к CA bundle. Семантика совпадает с `redmine.verify_ssl` в YAML. | ## Использование diff --git a/docs/CONFIG.md b/docs/CONFIG.md index d181886..d057356 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -65,6 +65,22 @@ email: attach: true ``` +### `redmine.verify_ssl` — проверка TLS-сертификата + +Управляет проверкой TLS-сертификата Redmine. Семантика едина с переменной +окружения `REDMINE_VERIFY`: + +| Значение | Поведение | +|---|---| +| `true` (по умолчанию) | Стандартная проверка TLS средствами requests (системные CA / certifi) | +| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM | +| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть | + +До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь +`/etc/ssl/certs/ca-certificates.crt`, который отсутствует на части +дистрибутивов. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают +одинаково — оба включают стандартную проверку без привязки к конкретному пути. + ### `period.precision` — точность периода Определяет, как вычисляется следующий период после фиксации: @@ -422,7 +438,7 @@ DEFAULT_TO_DATE=2026-06-30 ``` Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц -как период, стандартный путь сертификатов, пустой автор). +как период, стандартная проверка TLS, пустой автор). ## Безопасность diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index b41e9d6..7d5898b 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -15,7 +15,7 @@ load_dotenv(override=False) logger = logging.getLogger(__name__) -DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt" +DEFAULT_REDMINE_VERIFY: Union[bool, str] = True FALSE_VALUES = {"0", "false", "no", "off"} TRUE_VALUES = {"1", "true", "yes", "on"} @@ -157,17 +157,23 @@ class AppConfig: @classmethod def _resolve_verify(cls, raw: dict) -> Union[bool, str]: + """Семантика verify_ssl, единая с REDMINE_VERIFY из env: + + - true (bool или строка) → True — стандартная проверка TLS (requests); + - false (bool или строка) → False — проверка отключена; + - любая другая строка → путь к CA-bundle как есть. + """ value = raw.get("redmine", {}).get("verify_ssl") if value is None: return DEFAULT_REDMINE_VERIFY if isinstance(value, bool): - return DEFAULT_REDMINE_VERIFY if value else False + return value if isinstance(value, str): normalized = value.lower() if normalized in FALSE_VALUES: return False if normalized in TRUE_VALUES: - return DEFAULT_REDMINE_VERIFY + return True return resolve_env_vars(value) return DEFAULT_REDMINE_VERIFY diff --git a/tests/test_config.py b/tests/test_config.py index 079b6aa..9e2a328 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,12 +5,7 @@ from unittest import mock import pytest -from redmine_reporter.config import ( - DEFAULT_REDMINE_VERIFY, - AppConfig, - Config, - EmailConfig, -) +from redmine_reporter.config import AppConfig, Config, EmailConfig @mock.patch.dict( @@ -106,7 +101,9 @@ def test_get_default_date_range_fallback(): @mock.patch.dict(os.environ, {}, clear=True) def test_get_redmine_verify_default(): - assert Config.get_redmine_verify() == DEFAULT_REDMINE_VERIFY + """Без явной настройки проверка TLS включена (стандартные CA requests).""" + Config._app = None + assert Config.get_redmine_verify() is True @pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"]) @@ -355,16 +352,57 @@ class TestAppConfigFromYaml: assert cfg.redmine_api_key == "secret-token" - def test_verify_ssl_true_returns_default_ca_path(self): - """verify_ssl: true → DEFAULT_REDMINE_VERIFY (путь), не Python True.""" + 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 == DEFAULT_REDMINE_VERIFY - assert cfg.redmine_verify is not True # не бул! + 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: From 1df1194f587463b3bd61f74904ee82ca9b9da970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 17 Jul 2026 13:35:02 +0700 Subject: [PATCH 2/3] refactor: remove import-time side effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Импорт модулей больше не мутирует процесс: - config.py: module-level load_dotenv(override=False) мутировал os.environ при любом импорте. Вызов перенесён в Config.load_yaml() — точку явной загрузки конфигурации, семантика override=False и путь по умолчанию сохранены. --config PATH (load_dotenv(path, override=True)) не затронут. - mailer.py: глобальная регистрация email.charset.add_charset('utf-8', ...) меняла кодировку для всего процесса. Заменена на точечное применение 8bit UTF-8 к телу письма в _utf8_text_part() — payload в UTF-8 (surrogateescape) + Content-Transfer-Encoding: 8bit на конкретной части. Проверено: сериализация в байты (BytesGenerator, как в send_message) побайтово совпадает со старым поведением. Тест test_env_var_takes_priority_over_dotenv адаптирован к новой точке вызова load_dotenv; test_body_substitution переведён с as_string() на декодированный payload (str-сериализация не-ASCII части без глобальной мутации реестра теперь даёт base64; wire-формат 8bit сохранён и зафиксирован test_wire_format_is_8bit_utf8). Closes #64 --- redmine_reporter/config.py | 10 +++++-- redmine_reporter/mailer.py | 23 +++++++++----- tests/test_config.py | 8 ++--- tests/test_import_side_effects.py | 50 +++++++++++++++++++++++++++++++ tests/test_mailer.py | 32 +++++++++++++++++++- 5 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 tests/test_import_side_effects.py diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 7d5898b..55efb47 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -11,8 +11,6 @@ from dotenv import load_dotenv from .yaml_config import check_file_permissions, resolve_env_vars -load_dotenv(override=False) - logger = logging.getLogger(__name__) DEFAULT_REDMINE_VERIFY: Union[bool, str] = True @@ -249,7 +247,13 @@ class Config: @classmethod def load_yaml(cls, path: str) -> None: - """Загружает YAML-конфиг. Не бросает исключений при отсутствии файла.""" + """Загружает конфигурацию приложения. + + Сначала подгружает .env из текущей директории (override=False: + переменные окружения не перебиваются), затем читает YAML-конфиг. + Не бросает исключений при отсутствии файла. + """ + load_dotenv(override=False) cls._app = AppConfig.from_yaml(path) @classmethod diff --git a/redmine_reporter/mailer.py b/redmine_reporter/mailer.py index 2090012..ae28e99 100644 --- a/redmine_reporter/mailer.py +++ b/redmine_reporter/mailer.py @@ -1,6 +1,5 @@ """Отправка сгенерированного отчёта по email через SMTP.""" -import email.charset as _charset import os import smtplib from email.mime.application import MIMEApplication @@ -12,10 +11,6 @@ from .client import RedmineAPIError from .config import EmailConfig from .types import ReportRow -# Use 8bit transfer encoding for UTF-8 so non-ASCII text (e.g. Russian) -# appears literally in MIME output instead of base64. -_charset.add_charset("utf-8", _charset.SHORTEST, None, "utf-8") - SMTP_TIMEOUT = 30 MIME_TYPES: Dict[str, str] = { @@ -34,6 +29,20 @@ def _resolve_mime_type(file_path: str) -> str: return MIME_TYPES.get(ext, "application/octet-stream") +def _utf8_text_part(text: str, subtype: str = "plain") -> MIMEText: + """Создаёт текстовую MIME-часть с 8bit UTF-8 телом. + + Кодировка применяется точечно к части письма, без глобальной мутации + реестра email.charset: payload хранится как UTF-8 (surrogateescape), + а Content-Transfer-Encoding выставляется в 8bit, чтобы не-ASCII текст + (например, русский) передавался литерально, а не в base64. + """ + part = MIMEText("", subtype, "utf-8") + part.set_payload(text.encode("utf-8").decode("ascii", "surrogateescape")) + part.replace_header("Content-Transfer-Encoding", "8bit") + return part + + def _build_html_body(rows: List[ReportRow]) -> str: """Генерирует HTML-версию тела письма через HTMLFormatter.""" from .formatters.html import HTMLFormatter @@ -66,11 +75,11 @@ def build_message( # Тела письма: plain-text всегда, HTML по флагу body_container = MIMEMultipart("alternative") - body_container.attach(MIMEText(body, "plain", "utf-8")) + body_container.attach(_utf8_text_part(body)) if email_config.html: html_body = _build_html_body(rows) - body_container.attach(MIMEText(html_body, "html", "utf-8")) + body_container.attach(_utf8_text_part(html_body, "html")) msg.attach(body_container) diff --git a/tests/test_config.py b/tests/test_config.py index 9e2a328..f3f89bd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -216,14 +216,10 @@ class TestWarnWhenTlsVerificationDisabled: # -- #15: .env не должен переопределять переменные окружения -- -@mock.patch("dotenv.load_dotenv") +@mock.patch("redmine_reporter.config.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) + Config.load_yaml("/nonexistent/config.yml") mock_load.assert_called_once_with(override=False) diff --git a/tests/test_import_side_effects.py b/tests/test_import_side_effects.py new file mode 100644 index 0000000..a0260c1 --- /dev/null +++ b/tests/test_import_side_effects.py @@ -0,0 +1,50 @@ +"""#64: импорт модулей не должен давать side effects в процессе.""" + +import os +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _run_in_subprocess(code: str, cwd: Path) -> str: + """Выполняет code в чистом интерпретаторе с доступом к пакету.""" + env = {**os.environ, "PYTHONPATH": str(REPO_ROOT)} + result = subprocess.run( + [sys.executable, "-c", code], + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def test_config_import_does_not_mutate_os_environ(tmp_path): + """import redmine_reporter.config не подгружает .env из текущей директории.""" + (tmp_path / ".env").write_text("RR_IMPORT_PROBE=1\n") + + output = _run_in_subprocess( + "import os\n" + "import redmine_reporter.config\n" + "print('RR_IMPORT_PROBE' in os.environ)\n", + cwd=tmp_path, + ) + + assert output == "False" + + +def test_mailer_import_does_not_mutate_global_charset_registry(tmp_path): + """import redmine_reporter.mailer не меняет email.charset для всего процесса.""" + output = _run_in_subprocess( + "from email.charset import BASE64, Charset\n" + "before = Charset('utf-8').body_encoding\n" + "import redmine_reporter.mailer\n" + "after = Charset('utf-8').body_encoding\n" + "print(before == BASE64, after == BASE64)\n", + cwd=tmp_path, + ) + + assert output == "True True" diff --git a/tests/test_mailer.py b/tests/test_mailer.py index e96af64..91d343f 100644 --- a/tests/test_mailer.py +++ b/tests/test_mailer.py @@ -49,7 +49,37 @@ class TestBuildMessage: msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", []) plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"] assert len(plain_parts) == 1 - assert "Автор: Иванов, период: Q1" in plain_parts[0].as_string() + payload = plain_parts[0].get_payload(decode=True).decode("utf-8") + assert "Автор: Иванов, период: Q1" in payload + + def test_wire_format_is_8bit_utf8(self): + """Сериализация в байты (как в smtplib.send_message) — 8bit UTF-8.""" + from email.generator import BytesGenerator + from io import BytesIO + + cfg = _make_email_config(body_text="Автор: {author}", attach=False) + msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", []) + + buf = BytesIO() + BytesGenerator(buf, policy=msg.policy.clone(linesep="\r\n")).flatten(msg) + data = buf.getvalue() + + assert b"Content-Transfer-Encoding: 8bit" in data + assert "Автор: Иванов".encode("utf-8") in data + + def test_plain_part_uses_8bit_utf8(self): + """Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть.""" + cfg = _make_email_config( + body_text="Автор: {author}, период: {period}", attach=False + ) + msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", []) + + plain = [p for p in msg.walk() if p.get_content_type() == "text/plain"][0] + assert plain["Content-Transfer-Encoding"] == "8bit" + assert ( + plain.get_payload(decode=True).decode("utf-8") + == "Автор: Иванов, период: Q1" + ) def test_from_header(self): cfg = _make_email_config(from_="sender@example.com", attach=False) From 1683b0f8933dfbd0b8363145422eb3c8be7d522f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 17 Jul 2026 13:38:14 +0700 Subject: [PATCH 3/3] fix: default report period to current month MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Без --date, env и YAML отчёт строится за текущий месяц: начало периода — 1-е число текущего месяца, конец — сегодня (date.today()), согласовано с fallback default_to→today из #50. Приоритеты источников (CLI > env > .env > YAML > дефолт) и precision=datetime не затронуты. Сам fallback уже существовал в коде (хардкод устаревшего периода был убран ранее); коммит фиксирует поведение детерминированными тестами с моком date.today (RED проверен регрессионным зондом: при возврате хардкода 2025-12-19--2026-01-31 тесты падают) и обновляет README и docs/CONFIG.md: дефолт явно описан как «текущий месяц». Closes #56 --- README.md | 5 +++-- docs/CONFIG.md | 7 ++++++ tests/test_config.py | 53 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a79c6c3..ae21a33 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ DEFAULT_TO_DATE=2026-01-31 | `REDMINE_USER` | Да, если нет токена | Логин Redmine. | | `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. | | `REDMINE_AUTHOR` | Нет | Имя автора для отчёта. | -| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). | +| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). Если не задана (и нет в YAML) — 1-е число текущего месяца. | | `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию (`YYYY-MM-DD`). Если не задана, а `DEFAULT_FROM_DATE` задана — используется сегодняшняя дата. | | `REDMINE_VERIFY` | Нет | TLS-проверка: `true` (по умолчанию — стандартная проверка средствами requests) / `false` / путь к CA bundle. Семантика совпадает с `redmine.verify_ssl` в YAML. | @@ -141,7 +141,8 @@ source .venv/bin/activate ### Основные сценарии -Отчёт за период по умолчанию: +Отчёт за период по умолчанию — текущий месяц (с 1-го числа по сегодня), +если период не задан через `--date`, env или YAML: ```bash redmine-reporter diff --git a/docs/CONFIG.md b/docs/CONFIG.md index d057356..2fa65dd 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -107,6 +107,13 @@ period: Аналогично работает `DEFAULT_TO_DATE`: если переменная не задана, а `DEFAULT_FROM_DATE` задана, конец периода = сегодня. +### Период по умолчанию — текущий месяц + +Если период не задан ни одним из источников (`--date`, `DEFAULT_FROM_DATE`, +`period.default_from`), отчёт строится за текущий месяц: начало периода — +1-е число текущего месяца, конец — сегодняшняя дата. Период вычисляется +на момент запуска и не хранится в коде или конфиге. + ### `--commit` — автофиксация периода Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода. diff --git a/tests/test_config.py b/tests/test_config.py index f3f89bd..8922100 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -99,6 +99,59 @@ def test_get_default_date_range_fallback(): 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_default_from_falls_back_to_today(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 default_from: '2026-04-01'\n") + Config._app = AppConfig.from_yaml(yaml_path) + + with mock.patch("redmine_reporter.config.date") as mock_date: + mock_date.today.return_value = date(2026, 4, 20) + mock_date.side_effect = lambda *a, **kw: date(*a, **kw) + assert Config.get_default_date_range() == "2026-04-01--2026-04-20" + + @mock.patch.dict(os.environ, {"DEFAULT_FROM_DATE": "2026-02-01"}, clear=True) + def test_env_from_without_to_falls_back_to_today(self): + """DEFAULT_FROM_DATE задана, DEFAULT_TO_DATE нет → конец = сегодня.""" + from datetime import date + + Config._app = None + with mock.patch("redmine_reporter.config.date") as mock_date: + mock_date.today.return_value = date(2026, 2, 10) + mock_date.side_effect = lambda *a, **kw: date(*a, **kw) + assert Config.get_default_date_range() == "2026-02-01--2026-02-10" + + @mock.patch.dict(os.environ, {}, clear=True) def test_get_redmine_verify_default(): """Без явной настройки проверка TLS включена (стандартные CA requests)."""