test: fix cli mocks, add pytest config and coverage gaps
- tests/test_cli.py: fix 26 fetch_issues_with_spent_time mocks to return 3-element tuples (issue, hours, activities) matching the real signature - pyproject.toml: add [tool.pytest.ini_options] with testpaths and pythonpath so bare pytest works - tests/test_client.py: add pagination test (>100 time entries arriving in pages, hours aggregated across all pages) - tests/test_formatters.py: add structural tests — XLSX full header row and grand total row via openpyxl, HTML thead with all columns and tbody row count - add strict xfail trap-tests for known bugs: #58 (naive created_on vs aware dedup cutoff TypeError) and #59 (parse_date_range rejects datetime range from dynamic+datetime period) Closes #67
This commit is contained in:
@@ -91,7 +91,7 @@ class _MockIssue:
|
||||
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
||||
"""Неизвестное расширение файла -- выход 1."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
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
|
||||
@@ -103,7 +103,7 @@ def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
||||
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_fetch.return_value = [(issue, 1.0, None)]
|
||||
mock_formatter = mock.MagicMock()
|
||||
mock_get.return_value = mock_formatter
|
||||
output = str(tmp_path / "report")
|
||||
@@ -128,7 +128,7 @@ def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
|
||||
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_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])
|
||||
@@ -198,7 +198,7 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
|
||||
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
|
||||
"""--summary выводит общее время и разбивку по проектам в stderr."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
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
|
||||
@@ -210,7 +210,7 @@ def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
|
||||
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)]
|
||||
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
|
||||
@@ -264,7 +264,7 @@ def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
|
||||
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
|
||||
"""CLI --no-time передаётся в файловый форматтер."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
output = str(tmp_path / "report.xlsx")
|
||||
with mock.patch(
|
||||
@@ -495,7 +495,7 @@ class TestOutputPathResolution:
|
||||
):
|
||||
"""--output report без расширения → добавляет .xlsx."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
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:
|
||||
@@ -521,7 +521,7 @@ class TestOutputPathResolution:
|
||||
def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path):
|
||||
"""--output report.csv с явным расширением передаётся как есть."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
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()
|
||||
@@ -546,7 +546,7 @@ class TestOutputPathResolution:
|
||||
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)]
|
||||
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()
|
||||
@@ -583,7 +583,7 @@ class TestCommitFlag:
|
||||
import yaml
|
||||
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
@@ -620,7 +620,7 @@ class TestCommitFlag:
|
||||
import yaml
|
||||
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
@@ -679,7 +679,7 @@ class TestCommitFlag:
|
||||
import yaml
|
||||
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
@@ -716,7 +716,7 @@ class TestCommitFlag:
|
||||
import yaml
|
||||
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
@@ -757,7 +757,7 @@ class TestCommitFlag:
|
||||
import yaml
|
||||
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
@@ -825,7 +825,7 @@ class TestSendFlag:
|
||||
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)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
mock_formatter = mock.MagicMock()
|
||||
mock_get.return_value = mock_formatter
|
||||
|
||||
@@ -866,7 +866,7 @@ class TestSendFlag:
|
||||
):
|
||||
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
mock_formatter = mock.MagicMock()
|
||||
mock_get.return_value = mock_formatter
|
||||
|
||||
@@ -905,7 +905,7 @@ class TestSendFlag:
|
||||
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)]
|
||||
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")
|
||||
@@ -936,7 +936,7 @@ class TestSendFlag:
|
||||
from redmine_reporter.client import RedmineAPIError
|
||||
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
mock_formatter = mock.MagicMock()
|
||||
mock_get.return_value = mock_formatter
|
||||
mock_send.side_effect = RedmineAPIError(
|
||||
@@ -981,7 +981,7 @@ class TestSendFlag:
|
||||
):
|
||||
"""--send и --commit работают вместе без конфликтов."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
mock_formatter = mock.MagicMock()
|
||||
mock_get.return_value = mock_formatter
|
||||
|
||||
@@ -1038,7 +1038,7 @@ class TestSendHtmlBody:
|
||||
):
|
||||
"""--send передаёт rows в send_report для генерации HTML."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
mock_formatter = mock.MagicMock()
|
||||
mock_get.return_value = mock_formatter
|
||||
|
||||
@@ -1083,7 +1083,7 @@ class TestSendHtmlBody:
|
||||
):
|
||||
"""При email.html: false письмо отправляется без HTML-части."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
mock_formatter = mock.MagicMock()
|
||||
mock_get.return_value = mock_formatter
|
||||
|
||||
@@ -1167,7 +1167,7 @@ class TestReportNoTimeIntegration:
|
||||
import yaml
|
||||
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
@@ -1207,7 +1207,7 @@ class TestReportNoTimeIntegration:
|
||||
import yaml
|
||||
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
@@ -1247,7 +1247,7 @@ class TestReportNoTimeIntegration:
|
||||
import yaml
|
||||
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
@@ -1287,7 +1287,7 @@ class TestReportNoTimeIntegration:
|
||||
import yaml
|
||||
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
@@ -1325,7 +1325,7 @@ class TestReportNoTimeIntegration:
|
||||
import yaml
|
||||
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
@@ -1365,3 +1365,45 @@ class TestReportNoTimeIntegration:
|
||||
|
||||
_, kwargs = mock_get.call_args
|
||||
assert kwargs.get("no_time") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #59: dynamic + precision=datetime без --date
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="bug #59: parse_date_range rejects datetime range produced by "
|
||||
"dynamic period with precision=datetime when --date is omitted",
|
||||
)
|
||||
@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"
|
||||
|
||||
Reference in New Issue
Block a user