Dedup with precision=datetime crashed with TypeError: redminelib returns naive created_on/updated_on while the cutoff from _compute_dedup_cutoff is aware UTC. Normalize at a single point: _parse_datetime now always returns an aware datetime (naive treated as UTC), matching its docstring. The dedup cutoff is normalized the same way, and any residual comparison TypeError is wrapped in RedmineAPIError instead of leaking raw. Also fix --commit saving last_used.to as naive local time; it now stores aware UTC (datetime.now(timezone.utc)) so the next run computes a correct aware cutoff. Closes #58
1455 lines
52 KiB
Python
1455 lines
52 KiB
Python
import os
|
||
from unittest import mock
|
||
|
||
import pytest
|
||
|
||
from redmine_reporter.cli import main, parse_date_range
|
||
|
||
VALID_ENV = {
|
||
"REDMINE_URL": "https://red.eltex.loc",
|
||
"REDMINE_API_KEY": "token",
|
||
}
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_config_overrides():
|
||
"""Сбрасывает CLI-переопределения Config перед каждым тестом."""
|
||
from redmine_reporter.config import Config
|
||
|
||
Config.set_redmine_url("")
|
||
Config.set_redmine_api_key("")
|
||
Config._app = None
|
||
yield
|
||
Config.set_redmine_url("")
|
||
Config.set_redmine_api_key("")
|
||
Config._app = None
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"date_arg, expected",
|
||
[
|
||
("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")),
|
||
(" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")),
|
||
(
|
||
"2026-06-01T00:00:00--2026-06-30T23:59:59",
|
||
("2026-06-01T00:00:00", "2026-06-30T23:59:59"),
|
||
),
|
||
],
|
||
)
|
||
def test_parse_date_range_valid(date_arg, expected):
|
||
assert parse_date_range(date_arg) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"date_arg",
|
||
[
|
||
"20260101-20260131",
|
||
"2026-1-01--2026-01-31",
|
||
"2026-02-30--2026-03-01",
|
||
"2026-02-01--2026-01-31",
|
||
"2026-06-01T25:00:00--2026-06-30T23:59:59",
|
||
"2026-06-01--2026-06-30T23:59:59",
|
||
"2026-06-30T23:59:59--2026-06-01T00:00:00",
|
||
],
|
||
)
|
||
def test_parse_date_range_invalid(date_arg):
|
||
with pytest.raises(ValueError):
|
||
parse_date_range(date_arg)
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_returns_zero_on_no_entries(mock_fetch):
|
||
"""None от fetch (нет time entries) -- выход 0."""
|
||
mock_fetch.return_value = None
|
||
code = main(["--date", "2026-01-01--2026-01-31"])
|
||
assert code == 0
|
||
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_cli_config_error():
|
||
"""Невалидный конфиг -- выход 1."""
|
||
code = main(
|
||
["--date", "2026-01-01--2026-01-31", "--config-path", "/nonexistent/config.yml"]
|
||
)
|
||
assert code == 1
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_cli_invalid_date_format():
|
||
"""Неверный формат даты -- выход 1."""
|
||
code = main(["--date", "20260101-20260131"])
|
||
assert code == 1
|
||
|
||
|
||
class _MockIssue:
|
||
"""Простой mock Redmine Issue для CLI-тестов."""
|
||
|
||
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
|
||
self.id = issue_id
|
||
self.subject = subject
|
||
self.project = project
|
||
self.status = status
|
||
self.fixed_version = None
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
||
"""Неизвестное расширение файла -- выход 1."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
output = str(tmp_path / "report.xyz")
|
||
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
||
assert code == 1
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
|
||
"""Файл без расширения — приложение расширяет default_format (#43)."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
output = str(tmp_path / "report")
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--output",
|
||
output,
|
||
"--config-path",
|
||
str(tmp_path / "nonexistent.yml"),
|
||
]
|
||
)
|
||
assert code == 0
|
||
saved_path = mock_formatter.save.call_args.args[1]
|
||
assert saved_path.endswith(".xlsx")
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||
def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
|
||
"""При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_gf.return_value = None
|
||
output = str(tmp_path / "report.odt")
|
||
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
||
assert code == 1
|
||
captured = capsys.readouterr()
|
||
assert "odfpy" in captured.err
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_cli_version_flag(capsys):
|
||
"""--version выводит версию и завершается с 0."""
|
||
with pytest.raises(SystemExit) as exc_info:
|
||
main(["--version"])
|
||
assert exc_info.value.code == 0
|
||
captured = capsys.readouterr()
|
||
assert "redmine-reporter" in captured.out
|
||
from redmine_reporter import __version__
|
||
|
||
assert __version__ in captured.out
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_verbose_and_debug_flags_accepted(mock_fetch):
|
||
"""--verbose и --debug не ломают запуск."""
|
||
mock_fetch.return_value = None
|
||
code = main(["--date", "2026-01-01--2026-01-31", "--verbose"])
|
||
assert code == 0
|
||
code = main(["--date", "2026-01-01--2026-01-31", "--debug"])
|
||
assert code == 0
|
||
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_url_and_api_key_override_env(mock_fetch):
|
||
"""--url и --api-key переопределяют отсутствующие/другие env-переменные."""
|
||
mock_fetch.return_value = None
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--url",
|
||
"https://other.redmine.loc",
|
||
"--api-key",
|
||
"cli-token",
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_config_file_loading(mock_fetch, tmp_path):
|
||
"""--config загружает переменные из указанного .env-файла."""
|
||
config_path = tmp_path / "custom.env"
|
||
config_path.write_text(
|
||
"REDMINE_URL=https://config.redmine.loc\nREDMINE_API_KEY=config-token\n",
|
||
encoding="utf-8",
|
||
)
|
||
mock_fetch.return_value = None
|
||
code = main(["--date", "2026-01-01--2026-01-31", "--config", str(config_path)])
|
||
assert code == 0
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
|
||
"""--summary выводит общее время и разбивку по проектам в stderr."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
main(["--date", "2026-01-01--2026-01-31", "--summary"])
|
||
captured = capsys.readouterr()
|
||
assert "Total time" in captured.err
|
||
assert "Project" in captured.err or "Project" in captured.out
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
|
||
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
main(["--date", "2026-01-01--2026-01-31"])
|
||
captured = capsys.readouterr()
|
||
assert "Total issues" not in captured.out
|
||
assert "Total issues" in captured.err
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_prints_readable_auth_error(mock_fetch, capsys):
|
||
"""CLI выводит понятное сообщение при ошибке аутентификации."""
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
|
||
code = main(["--date", "2026-01-01--2026-01-31"])
|
||
captured = capsys.readouterr()
|
||
assert code == 1
|
||
assert "Authentication failed" in captured.err
|
||
assert "Redmine API error" not in captured.err
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_prints_readable_forbidden_error(mock_fetch, capsys):
|
||
"""CLI выводит понятное сообщение при недостаточных правах."""
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
mock_fetch.side_effect = RedmineAPIError("Access denied: no permission")
|
||
code = main(["--date", "2026-01-01--2026-01-31"])
|
||
captured = capsys.readouterr()
|
||
assert code == 1
|
||
assert "Access denied" in captured.err
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
|
||
"""CLI выводит понятное сообщение при таймауте."""
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
mock_fetch.side_effect = RedmineAPIError(
|
||
"Redmine request timed out after 30 seconds"
|
||
)
|
||
code = main(["--date", "2026-01-01--2026-01-31"])
|
||
captured = capsys.readouterr()
|
||
assert code == 1
|
||
assert "timed out" in captured.err
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
|
||
"""CLI --no-time передаётся в файловый форматтер."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
output = str(tmp_path / "report.xlsx")
|
||
with mock.patch(
|
||
"redmine_reporter.cli.get_formatter_by_extension"
|
||
) as mock_get_formatter:
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get_formatter.return_value = mock_formatter
|
||
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
|
||
|
||
_, kwargs = mock_get_formatter.call_args
|
||
assert kwargs.get("no_time") is True
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_passes_user_id_to_fetch(mock_fetch):
|
||
"""CLI --user-id передаётся в fetch_issues_with_spent_time."""
|
||
mock_fetch.return_value = None
|
||
main(["--date", "2026-01-01--2026-01-31", "--user-id", "42"])
|
||
_, kwargs = mock_fetch.call_args
|
||
assert kwargs["user_id"] == "42"
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_passes_user_login_to_fetch(mock_fetch):
|
||
"""CLI --user-login передаётся в fetch_issues_with_spent_time."""
|
||
mock_fetch.return_value = None
|
||
main(["--date", "2026-01-01--2026-01-31", "--user-login", "ivanov"])
|
||
_, kwargs = mock_fetch.call_args
|
||
assert kwargs["user_id"] == "ivanov"
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_cli_rejects_multiple_user_flags():
|
||
"""CLI не принимает одновременно несколько флагов пользователя."""
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--user-id",
|
||
"42",
|
||
"--user-login",
|
||
"ivanov",
|
||
]
|
||
)
|
||
assert code == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# --init-config tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestInitConfig:
|
||
"""Tests for --init-config flag."""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_init_config_creates_yaml_file(self, tmp_path):
|
||
"""--init-config создаёт YAML файл с правильной структурой."""
|
||
import yaml
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
code = main(["--init-config", "--config-path", str(config_path)])
|
||
assert code == 0
|
||
assert config_path.exists()
|
||
|
||
with open(config_path) as fh:
|
||
data = yaml.safe_load(fh)
|
||
|
||
assert "redmine" in data
|
||
assert data["redmine"]["url"] == "https://red.eltex.loc"
|
||
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_init_config_existing_file_no_force(self, tmp_path, capsys):
|
||
"""Без --force существующий файл → предупреждение и выход 1."""
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text("existing: true")
|
||
|
||
code = main(["--init-config", "--config-path", str(config_path)])
|
||
assert code == 1
|
||
captured = capsys.readouterr()
|
||
assert "already exists" in captured.err or "already exists" in captured.out
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_init_config_force_overwrites(self, tmp_path):
|
||
"""--init-config --force перезаписывает существующий файл."""
|
||
import yaml
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text("existing: true")
|
||
|
||
code = main(["--init-config", "--force", "--config-path", str(config_path)])
|
||
assert code == 0
|
||
|
||
with open(config_path) as fh:
|
||
data = yaml.safe_load(fh)
|
||
|
||
assert "redmine" in data
|
||
assert "existing" not in data
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_init_config_with_date_is_error(self, capsys):
|
||
"""--init-config с --date → ошибка, взаимоисключающие."""
|
||
code = main(["--init-config", "--date", "2026-01-01--2026-01-31"])
|
||
assert code == 1
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_init_config_writes_secrets_as_env_ref(self, tmp_path):
|
||
"""Секреты пишутся как ${VAR} когда переменная окружения существует."""
|
||
import yaml
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
code = main(["--init-config", "--config-path", str(config_path)])
|
||
assert code == 0
|
||
|
||
with open(config_path) as fh:
|
||
data = yaml.safe_load(fh)
|
||
|
||
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_init_config_includes_email_html(self, tmp_path):
|
||
"""--init-config генерирует email.html: false."""
|
||
import yaml
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
code = main(["--init-config", "--config-path", str(config_path)])
|
||
assert code == 0
|
||
|
||
with open(config_path) as fh:
|
||
data = yaml.safe_load(fh)
|
||
|
||
assert "html" in data["email"]
|
||
assert data["email"]["html"] is False
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_init_config_no_env_vars(self, tmp_path):
|
||
"""Без переменных окружения --init-config создаёт скелет."""
|
||
import yaml
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
code = main(["--init-config", "--config-path", str(config_path)])
|
||
assert code == 0
|
||
|
||
with open(config_path) as fh:
|
||
data = yaml.safe_load(fh)
|
||
|
||
assert "redmine" in data
|
||
assert data["redmine"]["url"] == ""
|
||
assert data["redmine"]["api_key"] == ""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
def test_init_config_includes_report_section(self, tmp_path):
|
||
"""--init-config генерирует секцию report с no_time: false."""
|
||
import yaml
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
code = main(["--init-config", "--config-path", str(config_path)])
|
||
assert code == 0
|
||
|
||
with open(config_path) as fh:
|
||
data = yaml.safe_load(fh)
|
||
|
||
assert "report" in data
|
||
assert data["report"]["no_time"] is False
|
||
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_autoloads_yaml_config(mock_fetch, tmp_path):
|
||
"""CLI автоматически подгружает YAML-конфиг при запуске."""
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"redmine:\n url: https://yaml-redmine.example.com/\n api_key: yaml-token\n"
|
||
)
|
||
|
||
mock_fetch.return_value = None
|
||
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)])
|
||
assert code == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #43: --output path resolution tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestOutputPathResolution:
|
||
"""Tests for --output flag with path defaults."""
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_bare_format_name_xlsx_uses_template_path(self, mock_fetch, tmp_path):
|
||
"""--output xlsx без пути → использует шаблон из конфига."""
|
||
import yaml
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"output": {
|
||
"dir": str(tmp_path / "reports"),
|
||
"filename": "report_{date}.{ext}",
|
||
"default_format": "xlsx",
|
||
}
|
||
}
|
||
)
|
||
)
|
||
|
||
mock_fetch.return_value = None
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--output",
|
||
"xlsx",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_output_without_extension_appends_default_format(
|
||
self, mock_fetch, tmp_path
|
||
):
|
||
"""--output report без расширения → добавляет .xlsx."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
output = str(tmp_path / "report")
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--output",
|
||
output,
|
||
"--config-path",
|
||
str(tmp_path / "nonexistent.yml"),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_formatter.save.assert_called_once()
|
||
saved_path = mock_formatter.save.call_args.args[1]
|
||
assert saved_path.endswith(".xlsx")
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path):
|
||
"""--output report.csv с явным расширением передаётся как есть."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--output",
|
||
str(tmp_path / "report.csv"),
|
||
"--config-path",
|
||
str(tmp_path / "nonexistent.yml"),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_formatter.save.assert_called_once()
|
||
saved_path = mock_formatter.save.call_args.args[1]
|
||
assert saved_path.endswith(".csv")
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_output_bare_format_invalid_is_treated_as_path(self, mock_fetch, tmp_path):
|
||
"""--output notanxlsx (no path, not known format) → treated as path + .xlsx appended."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--output",
|
||
"myreport",
|
||
"--config-path",
|
||
str(tmp_path / "nonexistent.yml"),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_formatter.save.assert_called_once()
|
||
saved_path = mock_formatter.save.call_args.args[1]
|
||
assert saved_path == "myreport.xlsx"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #44: --commit tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCommitFlag:
|
||
"""Tests for --commit flag."""
|
||
|
||
@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_saves_period_to_config(self, mock_save, mock_fetch, tmp_path):
|
||
"""--commit сохраняет период в YAML-конфиг."""
|
||
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": "date", "dynamic": True}})
|
||
)
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
mock_save.assert_called_once_with(
|
||
str(config_path), "2026-06-01", "2026-06-30", "date", 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.save_period_to_config")
|
||
def test_commit_with_precision_datetime_passes_datetime_strings(
|
||
self, mock_save, mock_fetch, tmp_path
|
||
):
|
||
"""При precision=datetime --commit сохраняет timestamps."""
|
||
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}})
|
||
)
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-06-30--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
call_args = mock_save.call_args
|
||
assert call_args is not None
|
||
saved_from, saved_to = call_args.args[1], call_args.args[2]
|
||
assert "T" in saved_from
|
||
assert "T" in saved_to
|
||
|
||
@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_with_precision_datetime_saves_aware_utc(
|
||
self, mock_save, mock_fetch, tmp_path
|
||
):
|
||
"""При precision=datetime --commit сохраняет last_used как aware UTC (#58)."""
|
||
from datetime import datetime, timezone
|
||
|
||
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}})
|
||
)
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-06-30--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
call_args = mock_save.call_args
|
||
assert call_args is not None
|
||
saved_from, saved_to = call_args.args[1], call_args.args[2]
|
||
for saved in (saved_from, saved_to):
|
||
parsed = datetime.fromisoformat(saved)
|
||
assert parsed.tzinfo is not None, f"{saved} must be timezone-aware"
|
||
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")
|
||
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
|
||
"""Без записей --commit не пишет конфиг."""
|
||
mock_fetch.return_value = None
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text("period:\n precision: date\n dynamic: true\n")
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--commit",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
@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_dynamic_false_overwrites_defaults(
|
||
self, mock_save, mock_fetch, tmp_path
|
||
):
|
||
"""При dynamic=false --commit перезаписывает default_from/to."""
|
||
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": "date", "dynamic": False}})
|
||
)
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
mock_save.assert_called_once_with(
|
||
str(config_path), "2026-06-01", "2026-06-30", "date", False
|
||
)
|
||
|
||
@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_without_output_uses_default_path(
|
||
self, mock_save, mock_fetch, tmp_path
|
||
):
|
||
"""--commit без --output сохраняет файл по шаблону из конфига."""
|
||
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": "date", "dynamic": True},
|
||
"output": {
|
||
"dir": str(tmp_path / "reports"),
|
||
"filename": "report_{date}.{ext}",
|
||
"default_format": "xlsx",
|
||
},
|
||
}
|
||
)
|
||
)
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_formatter.save.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.save_period_to_config")
|
||
def test_commit_prints_info_to_stderr(
|
||
self, mock_save, mock_fetch, capsys, tmp_path
|
||
):
|
||
"""--commit выводит сообщение о фиксации в stderr."""
|
||
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": "date", "dynamic": True}})
|
||
)
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
captured = capsys.readouterr()
|
||
assert "commit" in captured.err.lower() or "Commit" in captured.err
|
||
|
||
@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_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path):
|
||
"""Пустой список задач — --commit не сохраняет конфиг."""
|
||
import yaml
|
||
|
||
mock_fetch.return_value = []
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump({"period": {"precision": "date", "dynamic": True}})
|
||
)
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-01-01--2026-01-31",
|
||
"--commit",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_save.assert_not_called()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #45: --send tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSendFlag:
|
||
"""Tests for --send flag."""
|
||
|
||
@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")
|
||
def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path):
|
||
"""--send с --output вызывает send_report после сохранения."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
|
||
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_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")
|
||
def test_send_without_output_saves_to_default_path(
|
||
self, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
|
||
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:\n"
|
||
" dir: " + str(tmp_path / "reports") + "\n"
|
||
" filename: report_{date}.{ext}\n"
|
||
" default_format: xlsx\n"
|
||
)
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_formatter.save.assert_called_once()
|
||
mock_send.assert_called_once()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_send_without_email_config_is_error(self, mock_fetch, tmp_path, capsys):
|
||
"""--send без email-конфига — ошибка и выход 1."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text("redmine:\n url: https://x.com\n api_key: token\n")
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 1
|
||
captured = capsys.readouterr()
|
||
assert "Email не настроен" in captured.err
|
||
|
||
@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")
|
||
def test_send_smtp_error_reported_to_stderr(
|
||
self, mock_get, mock_send, mock_fetch, tmp_path, capsys
|
||
):
|
||
"""Ошибка SMTP выводится в stderr, exit code 1."""
|
||
from redmine_reporter.client import RedmineAPIError
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
mock_send.side_effect = RedmineAPIError(
|
||
"Не удалось подключиться к SMTP-серверу bad:587"
|
||
)
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" smtp:\n"
|
||
" host: bad\n"
|
||
" port: 587\n"
|
||
" user: bot\n"
|
||
" password: secret\n"
|
||
" from: bot@example.com\n"
|
||
" to:\n"
|
||
" - boss@example.com\n"
|
||
)
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--send",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 1
|
||
captured = capsys.readouterr()
|
||
assert "SMTP" in captured.err
|
||
|
||
@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("redmine_reporter.cli.save_period_to_config")
|
||
def test_send_with_commit_works_together(
|
||
self, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""--send и --commit работают вместе без конфликтов."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
|
||
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"
|
||
"period:\n"
|
||
" precision: date\n"
|
||
" dynamic: true\n"
|
||
)
|
||
|
||
code = main(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--send",
|
||
"--commit",
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
mock_send.assert_called_once()
|
||
mock_save.assert_called_once()
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_send_no_entries_exits_early(self, mock_fetch):
|
||
"""--send без time entries просто выходит с 0."""
|
||
mock_fetch.return_value = None
|
||
code = main(["--date", "2026-06-01--2026-06-30", "--send"])
|
||
assert code == 0
|
||
|
||
|
||
class TestSendHtmlBody:
|
||
"""Tests for email.html body generation."""
|
||
|
||
@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")
|
||
def test_send_passes_rows_to_send_report(
|
||
self, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""--send передаёт rows в send_report для генерации HTML."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"email:\n"
|
||
" html: true\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
|
||
|
||
_, kwargs = mock_send.call_args
|
||
assert "rows" in kwargs
|
||
assert len(kwargs["rows"]) == 1
|
||
|
||
@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")
|
||
def test_send_html_false_does_not_require_html_part(
|
||
self, mock_get, mock_send, mock_fetch, tmp_path
|
||
):
|
||
"""При email.html: false письмо отправляется без HTML-части."""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
mock_formatter = mock.MagicMock()
|
||
mock_get.return_value = mock_formatter
|
||
|
||
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_called_once()
|
||
_, kwargs = mock_send.call_args
|
||
assert "rows" in kwargs
|
||
|
||
|
||
class TestResolveNoTime:
|
||
"""Tests for _resolve_no_time()."""
|
||
|
||
def test_cli_flag_wins_in_manual_mode(self):
|
||
"""CLI --no-time побеждает в ручном режиме."""
|
||
from redmine_reporter.cli import _resolve_no_time
|
||
|
||
assert _resolve_no_time(True, False) is True
|
||
|
||
def test_cli_flag_wins_in_auto_mode(self):
|
||
"""CLI --no-time побеждает в автоматическом режиме."""
|
||
from redmine_reporter.cli import _resolve_no_time
|
||
|
||
assert _resolve_no_time(True, True) is True
|
||
|
||
@mock.patch("redmine_reporter.cli.Config.get_report_no_time", return_value=True)
|
||
def test_yaml_applies_in_auto_mode(self, mock_get):
|
||
"""YAML report.no_time применяется в автоматическом режиме."""
|
||
from redmine_reporter.cli import _resolve_no_time
|
||
|
||
assert _resolve_no_time(False, True) is True
|
||
mock_get.assert_called_once()
|
||
|
||
def test_default_in_auto_mode(self):
|
||
"""Без YAML report.no_time в автоматическом режиме — False."""
|
||
from redmine_reporter.cli import _resolve_no_time
|
||
|
||
assert _resolve_no_time(False, True) is False
|
||
|
||
@mock.patch("redmine_reporter.cli.Config.get_report_no_time", return_value=True)
|
||
def test_yaml_ignored_in_manual_mode(self, mock_get):
|
||
"""YAML report.no_time игнорируется в ручном режиме --output."""
|
||
from redmine_reporter.cli import _resolve_no_time
|
||
|
||
assert _resolve_no_time(False, False) is False
|
||
mock_get.assert_not_called()
|
||
|
||
|
||
class TestReportNoTimeIntegration:
|
||
"""Интеграционные тесты для report.no_time из YAML."""
|
||
|
||
@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_with_report_no_time_true(self, mock_save, mock_fetch, tmp_path):
|
||
"""--commit с report.no_time: true передаёт no_time=True в форматтер."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||
"report": {"no_time": True},
|
||
"period": {"precision": "date", "dynamic": True},
|
||
}
|
||
)
|
||
)
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
_, kwargs = mock_get.call_args
|
||
assert kwargs.get("no_time") is 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.save_period_to_config")
|
||
def test_commit_with_report_no_time_false(self, mock_save, mock_fetch, tmp_path):
|
||
"""--commit без report.no_time передаёт no_time=False в форматтер."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||
"report": {"no_time": False},
|
||
"period": {"precision": "date", "dynamic": True},
|
||
}
|
||
)
|
||
)
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
_, kwargs = mock_get.call_args
|
||
assert kwargs.get("no_time") is False
|
||
|
||
@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_cli_no_time_overrides_yaml(self, mock_save, mock_fetch, tmp_path):
|
||
"""--commit с --no-time побеждает report.no_time: false."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||
"report": {"no_time": False},
|
||
"period": {"precision": "date", "dynamic": True},
|
||
}
|
||
)
|
||
)
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--commit",
|
||
"--no-time",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
_, kwargs = mock_get.call_args
|
||
assert kwargs.get("no_time") is True
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_output_ignores_report_no_time_true(self, mock_fetch, tmp_path):
|
||
"""--output (ручной режим) игнорирует report.no_time: true."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||
"report": {"no_time": True},
|
||
}
|
||
)
|
||
)
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
_, kwargs = mock_get.call_args
|
||
assert kwargs.get("no_time") is False
|
||
|
||
@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")
|
||
def test_send_with_report_no_time_true(self, mock_send, mock_fetch, tmp_path):
|
||
"""--send с report.no_time: true передаёт no_time=True в форматтер."""
|
||
import yaml
|
||
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
yaml.dump(
|
||
{
|
||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||
"report": {"no_time": True},
|
||
"email": {
|
||
"smtp": {
|
||
"host": "smtp.example.com",
|
||
"port": 587,
|
||
"user": "bot",
|
||
"password": "secret",
|
||
},
|
||
"from": "bot@example.com",
|
||
"to": ["boss@example.com"],
|
||
},
|
||
}
|
||
)
|
||
)
|
||
|
||
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(
|
||
[
|
||
"--date",
|
||
"2026-06-01--2026-06-30",
|
||
"--send",
|
||
"--output",
|
||
str(tmp_path / "report.xlsx"),
|
||
"--config-path",
|
||
str(config_path),
|
||
]
|
||
)
|
||
assert code == 0
|
||
|
||
_, kwargs = mock_get.call_args
|
||
assert kwargs.get("no_time") is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #59: dynamic + precision=datetime без --date
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||
def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
|
||
"""dynamic+datetime без --date: datetime-диапазон из last_used принимается.
|
||
|
||
Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон
|
||
с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used,
|
||
отчёт строится без ошибки.
|
||
"""
|
||
issue = _MockIssue()
|
||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||
|
||
config_path = tmp_path / "config.yml"
|
||
config_path.write_text(
|
||
"period:\n"
|
||
" precision: datetime\n"
|
||
" dynamic: true\n"
|
||
" last_used:\n"
|
||
" from: '2026-06-01T00:00:00'\n"
|
||
" to: '2026-06-30T23:59:59'\n"
|
||
)
|
||
|
||
code = main(["--config-path", str(config_path)])
|
||
|
||
assert code == 0
|
||
args, _ = mock_fetch.call_args
|
||
from_date, to_date = args[0], args[1]
|
||
# Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59
|
||
assert from_date == "2026-07-01T00:00:00"
|
||
assert to_date == "2026-07-30T23:59:59"
|