Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db069dfb4b | ||
|
|
4a9a21624d | ||
|
|
35fa585bd0 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "redmine-reporter"
|
name = "redmine-reporter"
|
||||||
version = "1.11.1"
|
version = "1.12.1"
|
||||||
description = "Redmine time-entry based issue reporter for internal use"
|
description = "Redmine time-entry based issue reporter for internal use"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.11.1"
|
__version__ = "1.12.1"
|
||||||
|
|||||||
@@ -256,6 +256,19 @@ def _save_and_maybe_send(
|
|||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
print("\n📧 Готово к отправке:")
|
||||||
|
print(f" From: {email_config.from_}")
|
||||||
|
print(f" To: {', '.join(email_config.to)}")
|
||||||
|
if email_config.cc:
|
||||||
|
print(f" Cc: {', '.join(email_config.cc)}")
|
||||||
|
if email_config.bcc:
|
||||||
|
print(f" Bcc: {', '.join(email_config.bcc)}")
|
||||||
|
print(f" File: {output_arg}")
|
||||||
|
response = input("Отправить? [y/N]: ").strip().lower()
|
||||||
|
if response not in ("y", "yes"):
|
||||||
|
print("⏭️ Отправка отменена.")
|
||||||
|
return 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_report(
|
send_report(
|
||||||
email_config,
|
email_config,
|
||||||
@@ -567,10 +580,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
dynamic = Config._app.period_dynamic if Config._app else False
|
dynamic = Config._app.period_dynamic if Config._app else False
|
||||||
|
|
||||||
if precision == "datetime":
|
if precision == "datetime":
|
||||||
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
|
# from -- фактическое начало окна отчёта; to -- дедуп-маркер (naive UTC).
|
||||||
# метки aware cutoff для дедупликации.
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
from_str = from_date
|
||||||
from_str = now
|
|
||||||
to_str = now
|
to_str = now
|
||||||
else:
|
else:
|
||||||
from_str = from_date
|
from_str = from_date
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date, timedelta
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Union
|
from typing import Dict, Union
|
||||||
|
|
||||||
@@ -440,6 +440,23 @@ class Config:
|
|||||||
if from_env:
|
if from_env:
|
||||||
return f"{from_env}--{to_env or today_str}"
|
return f"{from_env}--{to_env or today_str}"
|
||||||
|
|
||||||
|
if (
|
||||||
|
cls._app
|
||||||
|
and cls._app.period_dynamic
|
||||||
|
and (cls._app.period_precision or "date") == "datetime"
|
||||||
|
and cls._app.period_last_used_to
|
||||||
|
):
|
||||||
|
last_to = datetime.fromisoformat(
|
||||||
|
cls._app.period_last_used_to.replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
if last_to.tzinfo is None:
|
||||||
|
last_to = last_to.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
last_to = last_to.astimezone(timezone.utc)
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
fmt = "%Y-%m-%dT%H:%M:%S"
|
||||||
|
return f"{last_to.strftime(fmt)}--{now_utc.strftime(fmt)}"
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cls._app
|
cls._app
|
||||||
and cls._app.period_dynamic
|
and cls._app.period_dynamic
|
||||||
|
|||||||
@@ -715,7 +715,9 @@ class TestCommitFlag:
|
|||||||
def test_commit_with_precision_datetime_passes_datetime_strings(
|
def test_commit_with_precision_datetime_passes_datetime_strings(
|
||||||
self, mock_save, mock_fetch, tmp_path
|
self, mock_save, mock_fetch, tmp_path
|
||||||
):
|
):
|
||||||
"""При precision=datetime --commit сохраняет timestamps."""
|
"""При precision=datetime --commit сохраняет from окна и to в naive UTC."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
@@ -732,7 +734,7 @@ class TestCommitFlag:
|
|||||||
code = main(
|
code = main(
|
||||||
[
|
[
|
||||||
"--date",
|
"--date",
|
||||||
"2026-06-30--2026-06-30",
|
"2026-09-10T09:00:00--2026-09-12T18:00:00",
|
||||||
"--commit",
|
"--commit",
|
||||||
"--output",
|
"--output",
|
||||||
str(tmp_path / "report.xlsx"),
|
str(tmp_path / "report.xlsx"),
|
||||||
@@ -745,17 +747,19 @@ class TestCommitFlag:
|
|||||||
call_args = mock_save.call_args
|
call_args = mock_save.call_args
|
||||||
assert call_args is not None
|
assert call_args is not None
|
||||||
saved_from, saved_to = call_args.args[1], call_args.args[2]
|
saved_from, saved_to = call_args.args[1], call_args.args[2]
|
||||||
assert "T" in saved_from
|
assert saved_from == "2026-09-10T09:00:00"
|
||||||
assert "T" in saved_to
|
assert datetime.strptime(saved_to, "%Y-%m-%dT%H:%M:%S")
|
||||||
|
assert call_args.args[3] == "datetime"
|
||||||
|
assert call_args.args[4] is True
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
def test_commit_with_precision_datetime_saves_aware_utc(
|
def test_commit_with_precision_datetime_saves_naive_utc_to(
|
||||||
self, mock_save, mock_fetch, tmp_path
|
self, mock_save, mock_fetch, tmp_path
|
||||||
):
|
):
|
||||||
"""При precision=datetime --commit сохраняет last_used как aware UTC (#58)."""
|
"""При precision=datetime --commit сохраняет to как naive UTC (#70)."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -773,7 +777,7 @@ class TestCommitFlag:
|
|||||||
code = main(
|
code = main(
|
||||||
[
|
[
|
||||||
"--date",
|
"--date",
|
||||||
"2026-06-30--2026-06-30",
|
"2026-09-10T09:00:00--2026-09-12T18:00:00",
|
||||||
"--commit",
|
"--commit",
|
||||||
"--output",
|
"--output",
|
||||||
str(tmp_path / "report.xlsx"),
|
str(tmp_path / "report.xlsx"),
|
||||||
@@ -786,10 +790,60 @@ class TestCommitFlag:
|
|||||||
call_args = mock_save.call_args
|
call_args = mock_save.call_args
|
||||||
assert call_args is not None
|
assert call_args is not None
|
||||||
saved_from, saved_to = call_args.args[1], call_args.args[2]
|
saved_from, saved_to = call_args.args[1], call_args.args[2]
|
||||||
for saved in (saved_from, saved_to):
|
assert saved_from == "2026-09-10T09:00:00"
|
||||||
parsed = datetime.fromisoformat(saved)
|
parsed = datetime.fromisoformat(saved_to)
|
||||||
assert parsed.tzinfo is not None, f"{saved} must be timezone-aware"
|
assert parsed.tzinfo is None, f"{saved_to} must be naive UTC"
|
||||||
assert parsed.utcoffset() == timezone.utc.utcoffset(None)
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
|
def test_commit_datetime_persists_window_start_and_now(
|
||||||
|
self, mock_save, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""--commit при datetime сохраняет начало окна и текущий naive UTC (#70)."""
|
||||||
|
from datetime import datetime as real_datetime
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_datetime = mock.MagicMock()
|
||||||
|
fake_datetime.fromisoformat = real_datetime.fromisoformat
|
||||||
|
fake_datetime.strptime = real_datetime.strptime
|
||||||
|
fake_datetime.now.return_value = real_datetime(2026, 9, 15, 12, 0, 0)
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.cli.datetime", fake_datetime):
|
||||||
|
with mock.patch(
|
||||||
|
"redmine_reporter.cli.get_formatter_by_extension"
|
||||||
|
) as mock_get:
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--commit",
|
||||||
|
"--date",
|
||||||
|
"2026-09-10T09:00:00--2026-09-12T18:00:00",
|
||||||
|
"--output",
|
||||||
|
str(tmp_path / "report.xlsx"),
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
mock_save.assert_called_once_with(
|
||||||
|
str(config_path),
|
||||||
|
"2026-09-10T09:00:00",
|
||||||
|
"2026-09-15T12:00:00",
|
||||||
|
"datetime",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@@ -964,8 +1018,12 @@ class TestSendFlag:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path):
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_triggers_mailer(
|
||||||
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
"""--send с --output вызывает send_report после сохранения."""
|
"""--send с --output вызывает send_report после сохранения."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -1003,10 +1061,12 @@ class TestSendFlag:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
def test_send_without_output_saves_to_default_path(
|
def test_send_without_output_saves_to_default_path(
|
||||||
self, mock_get, mock_send, mock_fetch, tmp_path
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
):
|
):
|
||||||
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -1071,10 +1131,12 @@ class TestSendFlag:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
def test_send_smtp_error_reported_to_stderr(
|
def test_send_smtp_error_reported_to_stderr(
|
||||||
self, mock_get, mock_send, mock_fetch, tmp_path, capsys
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys
|
||||||
):
|
):
|
||||||
"""Ошибка SMTP выводится в stderr, exit code 1."""
|
"""Ошибка SMTP выводится в stderr, exit code 1."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
from redmine_reporter.client import RedmineAPIError
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
@@ -1118,10 +1180,12 @@ class TestSendFlag:
|
|||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
def test_send_with_commit_works_together(
|
def test_send_with_commit_works_together(
|
||||||
self, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
):
|
):
|
||||||
"""--send и --commit работают вместе без конфликтов."""
|
"""--send и --commit работают вместе без конфликтов."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -1175,10 +1239,12 @@ class TestSendHtmlBody:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
def test_send_passes_rows_to_send_report(
|
def test_send_passes_rows_to_send_report(
|
||||||
self, mock_get, mock_send, mock_fetch, tmp_path
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
):
|
):
|
||||||
"""--send передаёт rows в send_report для генерации HTML."""
|
"""--send передаёт rows в send_report для генерации HTML."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -1220,10 +1286,12 @@ class TestSendHtmlBody:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
def test_send_html_false_does_not_require_html_part(
|
def test_send_html_false_does_not_require_html_part(
|
||||||
self, mock_get, mock_send, mock_fetch, tmp_path
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
):
|
):
|
||||||
"""При email.html: false письмо отправляется без HTML-части."""
|
"""При email.html: false письмо отправляется без HTML-части."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -1462,8 +1530,12 @@ class TestReportNoTimeIntegration:
|
|||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
def test_send_with_report_no_time_true(self, mock_send, mock_fetch, tmp_path):
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_with_report_no_time_true(
|
||||||
|
self, mock_input, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
"""--send с report.no_time: true передаёт no_time=True в форматтер."""
|
"""--send с report.no_time: true передаёт no_time=True в форматтер."""
|
||||||
|
mock_input.return_value = "y"
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
@@ -1517,11 +1589,10 @@ class TestReportNoTimeIntegration:
|
|||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
|
def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
|
||||||
"""dynamic+datetime без --date: datetime-диапазон из last_used принимается.
|
"""dynamic+datetime без --date: скользящее окно от last_used.to до now (#70).
|
||||||
|
|
||||||
Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон
|
Ожидаемое поведение после фикса #70: дефолтный диапазон начинается с
|
||||||
с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used,
|
last_used.to и заканчивается текущим моментом, а не сдвинутым периодом.
|
||||||
отчёт строится без ошибки.
|
|
||||||
"""
|
"""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
@@ -1536,14 +1607,17 @@ def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
|
|||||||
" to: '2026-06-30T23:59:59'\n"
|
" to: '2026-06-30T23:59:59'\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
code = main(["--config-path", str(config_path)])
|
real_dt = __import__("datetime").datetime
|
||||||
|
with mock.patch("redmine_reporter.config.datetime") as dt:
|
||||||
|
dt.fromisoformat = real_dt.fromisoformat
|
||||||
|
dt.now.return_value = real_dt(2026, 7, 15, 10, 0, 0)
|
||||||
|
code = main(["--config-path", str(config_path)])
|
||||||
|
|
||||||
assert code == 0
|
assert code == 0
|
||||||
args, _ = mock_fetch.call_args
|
args, _ = mock_fetch.call_args
|
||||||
from_date, to_date = args[0], args[1]
|
from_date, to_date = args[0], args[1]
|
||||||
# Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59
|
assert from_date == "2026-06-30T23:59:59"
|
||||||
assert from_date == "2026-07-01T00:00:00"
|
assert to_date == "2026-07-15T10:00:00"
|
||||||
assert to_date == "2026-07-30T23:59:59"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1660,3 +1734,199 @@ class TestSanitizeErrorOutput:
|
|||||||
assert "raw details" not in captured.err
|
assert "raw details" not in captured.err
|
||||||
assert "raw details" not in caplog.text
|
assert "raw details" not in caplog.text
|
||||||
assert "safe message" in captured.err
|
assert "safe message" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Email confirmation prompt before --send
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendConfirmation:
|
||||||
|
"""Tests for email confirmation prompt before sending."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_confirmation_yes_calls_send_report(
|
||||||
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""Подтверждение 'y' — send_report вызывается."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
mock_input.return_value = "y"
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"email:\n"
|
||||||
|
" smtp:\n"
|
||||||
|
" host: smtp.example.com\n"
|
||||||
|
" port: 587\n"
|
||||||
|
" user: bot\n"
|
||||||
|
" password: secret\n"
|
||||||
|
" from: bot@example.com\n"
|
||||||
|
" to:\n"
|
||||||
|
" - boss@example.com\n"
|
||||||
|
" cc:\n"
|
||||||
|
" - cc@example.com\n"
|
||||||
|
" bcc:\n"
|
||||||
|
" - bcc@example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = str(tmp_path / "report.xlsx")
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
output,
|
||||||
|
"--send",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_confirmation_no_skips_send(
|
||||||
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""Ответ 'n' — send_report не вызывается."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
mock_input.return_value = "n"
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"email:\n"
|
||||||
|
" smtp:\n"
|
||||||
|
" host: smtp.example.com\n"
|
||||||
|
" port: 587\n"
|
||||||
|
" user: bot\n"
|
||||||
|
" password: secret\n"
|
||||||
|
" from: bot@example.com\n"
|
||||||
|
" to:\n"
|
||||||
|
" - boss@example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = str(tmp_path / "report.xlsx")
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
output,
|
||||||
|
"--send",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_confirmation_default_skips_send(
|
||||||
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""Пустой ввод (Enter) — send_report не вызывается."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
mock_input.return_value = ""
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"email:\n"
|
||||||
|
" smtp:\n"
|
||||||
|
" host: smtp.example.com\n"
|
||||||
|
" port: 587\n"
|
||||||
|
" user: bot\n"
|
||||||
|
" password: secret\n"
|
||||||
|
" from: bot@example.com\n"
|
||||||
|
" to:\n"
|
||||||
|
" - boss@example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = str(tmp_path / "report.xlsx")
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
output,
|
||||||
|
"--send",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
@mock.patch("builtins.input")
|
||||||
|
def test_send_confirmation_shows_recipients(
|
||||||
|
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys
|
||||||
|
):
|
||||||
|
"""Подтверждение показывает from, to, cc, bcc, file."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
mock_input.return_value = "y"
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"email:\n"
|
||||||
|
" smtp:\n"
|
||||||
|
" host: smtp.example.com\n"
|
||||||
|
" port: 587\n"
|
||||||
|
" user: bot\n"
|
||||||
|
" password: secret\n"
|
||||||
|
" from: sender@example.com\n"
|
||||||
|
" to:\n"
|
||||||
|
" - boss@example.com\n"
|
||||||
|
" cc:\n"
|
||||||
|
" - cc@example.com\n"
|
||||||
|
" bcc:\n"
|
||||||
|
" - bcc@example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = str(tmp_path / "report.xlsx")
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
output,
|
||||||
|
"--send",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "sender@example.com" in captured.out
|
||||||
|
assert "boss@example.com" in captured.out
|
||||||
|
assert "cc@example.com" in captured.out
|
||||||
|
assert "bcc@example.com" in captured.out
|
||||||
|
assert output in captured.out
|
||||||
|
mock_input.assert_called_once_with("Отправить? [y/N]: ")
|
||||||
|
|||||||
@@ -99,6 +99,25 @@ def test_get_default_date_range_fallback():
|
|||||||
assert result == f"{start.isoformat()}--{today.isoformat()}"
|
assert result == f"{start.isoformat()}--{today.isoformat()}"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_get_default_date_range_datetime_dynamic_sliding_window():
|
||||||
|
app = AppConfig(
|
||||||
|
period_precision="datetime",
|
||||||
|
period_dynamic=True,
|
||||||
|
period_last_used_to="2026-09-10T15:00:00+00:00",
|
||||||
|
)
|
||||||
|
Config._app = app
|
||||||
|
try:
|
||||||
|
real_dt = __import__("datetime").datetime
|
||||||
|
with mock.patch("redmine_reporter.config.datetime") as dt:
|
||||||
|
dt.fromisoformat = real_dt.fromisoformat
|
||||||
|
dt.now.return_value = real_dt(2026, 9, 15, 10, 0, 0)
|
||||||
|
rng = Config.get_default_date_range()
|
||||||
|
finally:
|
||||||
|
Config._app = None
|
||||||
|
assert rng == "2026-09-10T15:00:00--2026-09-15T10:00:00"
|
||||||
|
|
||||||
|
|
||||||
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) --
|
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) --
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user