- --commit: после генерации отчёта сохраняет период в period.last_used.from/to YAML-конфига - При period.dynamic=true следующий запуск вычисляет период от last_used: полный месяц → следующий месяц, произвольный диапазон → та же длина - При period.dynamic=false перезаписывает default_from/default_to - При period.precision=datetime сохраняет timestamps с точностью до секунд - --commit без --output сохраняет отчёт по шаблону из конфига - compute_next_period() в config.py - save_period_to_config() в yaml_config.py - 23 новых теста (7 CLI, 6 config, 6 yaml, 4 date_range) Closes #44
768 lines
28 KiB
Python
768 lines
28 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")),
|
||
],
|
||
)
|
||
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",
|
||
],
|
||
)
|
||
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)]
|
||
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)]
|
||
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)]
|
||
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
|
||
|
||
|
||
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, {}, 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\n" "REDMINE_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)]
|
||
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)]
|
||
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)]
|
||
|
||
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, {}, 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, {}, 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)]
|
||
|
||
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)]
|
||
|
||
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)]
|
||
|
||
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)]
|
||
|
||
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)]
|
||
|
||
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")
|
||
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)]
|
||
|
||
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)]
|
||
|
||
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)]
|
||
|
||
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()
|