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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user