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"
|
||||
|
||||
@@ -691,3 +691,104 @@ def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
|
||||
assert len(result) == 2
|
||||
ids = {r[0].id for r in result}
|
||||
assert ids == {20, 30}
|
||||
|
||||
|
||||
# -- Пагинация time entries (>100 записей) --
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
|
||||
"""Time entries приходят страницами по 100 — часы агрегируются по всем страницам."""
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
def make_entry(issue_id, hours):
|
||||
e = mock.MagicMock()
|
||||
e.issue.id = issue_id
|
||||
e.hours = hours
|
||||
return e
|
||||
|
||||
# Эмулируем ленивую пагинацию redminelib ResourceSet: записи отдаются
|
||||
# генератором порциями ("страницами" по 100), как при догрузке с сервера.
|
||||
# Если клиент прочитает только первую страницу, агрегация будет неверной.
|
||||
pages = [
|
||||
[make_entry(1, 0.5) for _ in range(100)],
|
||||
[make_entry(2, 1.0) for _ in range(100)],
|
||||
[make_entry(1, 0.5) for _ in range(50)],
|
||||
]
|
||||
|
||||
def paged_entries():
|
||||
for page in pages:
|
||||
yield from page
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = paged_entries()
|
||||
|
||||
mock_issue1 = mock.MagicMock()
|
||||
mock_issue1.id = 1
|
||||
mock_issue1.project = "P"
|
||||
mock_issue2 = mock.MagicMock()
|
||||
mock_issue2.id = 2
|
||||
mock_issue2.project = "P"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
|
||||
|
||||
result = fetch_issues_with_spent_time("2026-01-01", "2026-03-31")
|
||||
|
||||
assert result is not None
|
||||
hours_by_id = {issue.id: hours for issue, hours, _ in result}
|
||||
# issue 1: 100×0.5 (стр. 1) + 50×0.5 (стр. 3) = 75.0
|
||||
assert hours_by_id[1] == 75.0
|
||||
# issue 2: 100×1.0 (стр. 2) = 100.0
|
||||
assert hours_by_id[2] == 100.0
|
||||
|
||||
|
||||
# -- #58: дедупликация при precision=datetime с naive created_on --
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="bug #58: naive created_on vs aware dedup cutoff raises TypeError; "
|
||||
"fix must normalize entry datetimes to aware (UTC)",
|
||||
)
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class):
|
||||
"""python-redmine отдаёт naive created_on; cutoff aware (UTC) — фильтрация не падает.
|
||||
|
||||
Ожидаемое поведение после фикса #58: naive datetime трактуется как UTC,
|
||||
старые записи отсекаются, новые сохраняются.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e_old = mock.MagicMock()
|
||||
e_old.issue.id = 1
|
||||
e_old.hours = 2.0
|
||||
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0) # naive, как из python-redmine
|
||||
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
|
||||
|
||||
e_new = mock.MagicMock()
|
||||
e_new.issue.id = 2
|
||||
e_new.hours = 1.0
|
||||
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0) # naive
|
||||
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
|
||||
|
||||
mock_issue2 = mock.MagicMock()
|
||||
mock_issue2.id = 2
|
||||
mock_issue2.project = "P"
|
||||
mock_issue2.subject = "T"
|
||||
mock_issue2.status = "New"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue2]
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert [r[0].id for r in result] == [2]
|
||||
|
||||
@@ -396,6 +396,32 @@ def test_xlsx_has_totals(fake_rows, tmp_path):
|
||||
assert ws["F2"].number_format == "0.00"
|
||||
|
||||
|
||||
def test_xlsx_has_full_header_row_and_grand_total_row(fake_rows, tmp_path):
|
||||
"""XLSX содержит полный ряд заголовков колонок и финальную строку общего итога."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
output = tmp_path / "report.xlsx"
|
||||
XLSXFormatter().save(fake_rows, str(output))
|
||||
|
||||
wb = load_workbook(str(output))
|
||||
ws = wb.active
|
||||
|
||||
headers = [ws.cell(row=1, column=c).value for c in range(1, 8)]
|
||||
assert headers == [
|
||||
"Project",
|
||||
"Version",
|
||||
"Issue ID",
|
||||
"Subject",
|
||||
"Status",
|
||||
"Hours",
|
||||
"Spent Time",
|
||||
]
|
||||
|
||||
# Последняя строка — общий итог по всем проектам
|
||||
assert ws.cell(row=ws.max_row, column=1).value == "Total"
|
||||
assert ws.cell(row=ws.max_row, column=6).value == 22.5
|
||||
|
||||
|
||||
def test_xlsx_no_time_keeps_columns_empty_and_skips_totals(fake_rows, tmp_path):
|
||||
"""XLSX с no_time: колонки времени пустые, итогов нет."""
|
||||
from openpyxl import load_workbook
|
||||
@@ -456,6 +482,25 @@ def test_html_output_has_doctype_and_charset(fake_rows):
|
||||
assert '<meta charset="utf-8">' in output
|
||||
|
||||
|
||||
def test_html_has_table_structure_with_all_columns(fake_rows):
|
||||
"""HTML-отчёт содержит thead со всеми колонками и по строке на каждую задачу."""
|
||||
output = HTMLFormatter().format(fake_rows)
|
||||
|
||||
assert "<thead>" in output
|
||||
assert "<tbody>" in output
|
||||
for header in (
|
||||
"Наименование Проекта",
|
||||
"Номер версии*",
|
||||
"Задача",
|
||||
"Статус Готовность*",
|
||||
"Затрачено за отчетный период",
|
||||
):
|
||||
assert f"<th>{header}</th>" in output
|
||||
|
||||
tbody = output.split("<tbody>", 1)[1]
|
||||
assert tbody.count("<tr>") == len(fake_rows)
|
||||
|
||||
|
||||
# -- Тесты ODT форматтера --
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user