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

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