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:
Кокос Артем Николаевич
2026-07-17 10:59:47 +07:00
parent 7ca58f881e
commit 3a3a7bb39c
4 changed files with 218 additions and 26 deletions

View File

@@ -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"