From 3a3a7bb39c76c18113efd3e34be07a89524ede6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 17 Jul 2026 10:59:47 +0700 Subject: [PATCH] test: fix cli mocks, add pytest config and coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pyproject.toml | 4 ++ tests/test_cli.py | 94 ++++++++++++++++++++++++++---------- tests/test_client.py | 101 +++++++++++++++++++++++++++++++++++++++ tests/test_formatters.py | 45 +++++++++++++++++ 4 files changed, 218 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d2ede78..9065cce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,10 @@ include = ["redmine_reporter*"] [tool.setuptools.package-data] "redmine_reporter" = ["templates/template.odt"] +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + [tool.mypy] warn_unused_configs = true diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ec3d0e..c1085be 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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" diff --git a/tests/test_client.py b/tests/test_client.py index 3ec44cc..6337431 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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] diff --git a/tests/test_formatters.py b/tests/test_formatters.py index dc98707..05f5ffe 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -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 '' in output +def test_html_has_table_structure_with_all_columns(fake_rows): + """HTML-отчёт содержит thead со всеми колонками и по строке на каждую задачу.""" + output = HTMLFormatter().format(fake_rows) + + assert "" in output + assert "" in output + for header in ( + "Наименование Проекта", + "Номер версии*", + "Задача", + "Статус Готовность*", + "Затрачено за отчетный период", + ): + assert f"{header}" in output + + tbody = output.split("", 1)[1] + assert tbody.count("") == len(fake_rows) + + # -- Тесты ODT форматтера --