Files
redmine-reporter/tests/test_client.py
Кокос Артем Николаевич f00c6705ba feat: filter Wishlist from --my-tasks; document the mode (#71)
Tasks in the "Wishlist" bucket version are now excluded from the list
and summary entirely (no due date, noise backlog); exact case match,
tasks without a version are unaffected. Document --my-tasks in README
and the user guide (sorting, summary metrics, OSC 8 links, stream
separation); all examples use synthetic data. Declare ruff
known-first-party in pyproject so the lint result is CWD-independent.
2026-10-28 15:53:50 +07:00

1006 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
from datetime import date
from unittest import mock
import pytest
from redmine_reporter.client import fetch_issues_with_spent_time, fetch_open_issues
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
PASSWORD_ENV = {
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_USER": "user",
"REDMINE_PASSWORD": "password",
}
def _configure_current_user(mock_redmine, user_id=1):
mock_user = mock.MagicMock()
mock_user.id = user_id
mock_redmine.user.get.return_value = mock_user
@pytest.fixture(autouse=True)
def mock_truststore():
"""Мок truststore для всех тестов клиента.
При verify=True _create_redmine() вызывает truststore.inject_into_ssl(),
который глобально подменяет ssl.SSLContext без восстановления (#62).
Не даём реальной инъекции выполниться в pytest-процессе.
"""
with mock.patch("redmine_reporter.client.truststore") as m:
yield m
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
"""Два time entry на одну задачу -- часы суммируются."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_entry1 = mock.MagicMock()
mock_entry1.issue.id = 101
mock_entry1.hours = 2.0
mock_entry2 = mock.MagicMock()
mock_entry2.issue.id = 101
mock_entry2.hours = 1.5
mock_redmine.time_entry.filter.return_value = [mock_entry1, mock_entry2]
mock_issue = mock.MagicMock()
mock_issue.id = 101
mock_issue.project = "Проект X"
mock_issue.subject = "Тестовая задача"
mock_issue.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
assert len(result) == 1
_issue, total_hours, _activities = result[0]
assert total_hours == 3.5
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_returns_none_when_no_entries(mock_redmine_class):
"""Нет time entries -- возвращается None."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_skips_entries_without_issue(mock_redmine_class):
"""Time entry без привязки к задаче игнорируется."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
# entry без issue атрибута
entry_no_issue = mock.MagicMock(spec=["hours"]) # нет .issue
entry_no_issue.hours = 1.0
mock_redmine.time_entry.filter.return_value = [entry_no_issue]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_multiple_issues(mock_redmine_class):
"""Несколько задач -- каждая с правильным суммарным временем."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
def make_entry(issue_id, hours):
e = mock.MagicMock()
e.issue.id = issue_id
e.hours = hours
return e
mock_redmine.time_entry.filter.return_value = [
make_entry(1, 1.0),
make_entry(2, 2.0),
make_entry(1, 0.5),
]
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-01-31")
assert result is not None
assert len(result) == 2
hours_by_id = {issue.id: hours for issue, hours, _ in result}
assert hours_by_id[1] == 1.5
assert hours_by_id[2] == 2.0
@mock.patch.dict(
os.environ,
{
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_API_KEY": "api-token",
"REDMINE_USER": "user",
"REDMINE_PASSWORD": "password",
},
clear=True,
)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_api_key_when_present(mock_redmine_class):
"""Если задан API key, он используется вместо логина/пароля."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
_, kwargs = mock_redmine_class.call_args
assert kwargs["key"] == "api-token"
assert kwargs["requests"]["verify"] == DEFAULT_REDMINE_VERIFY
assert "username" not in kwargs
assert "password" not in kwargs
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
"""Если API key не задан, остаётся старая схема логин/пароль."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
_, kwargs = mock_redmine_class.call_args
assert kwargs["username"] == "user"
assert kwargs["password"] == "password"
assert kwargs["requests"]["verify"] == DEFAULT_REDMINE_VERIFY
assert "key" not in kwargs
@mock.patch.dict(
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_custom_verify_path(mock_redmine_class):
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "true"}, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_true_injects_system_trust_store(mock_redmine_class, mock_truststore):
"""verify=True → truststore.inject_into_ssl() вызван, verify=True в Redmine (#62)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_called_once_with()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is True
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "false"}, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_false_skips_truststore_injection(mock_redmine_class, mock_truststore):
"""verify=False → truststore.inject_into_ssl() не вызывается."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_not_called()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is False
@mock.patch.dict(
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_custom_path_skips_truststore_injection(
mock_redmine_class, mock_truststore
):
"""verify=<путь к CA-bundle> → truststore.inject_into_ssl() не вызывается."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_not_called()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_default_injects_system_trust_store(mock_redmine_class, mock_truststore):
"""Без REDMINE_VERIFY verify по умолчанию True → inject вызван (#62)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_called_once_with()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is True
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
"""AuthError превращается в RedmineAPIError с понятным сообщением."""
from redminelib.exceptions import AuthError
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = AuthError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Authentication failed"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_forbidden(mock_redmine_class):
"""ForbiddenError превращается в RedmineAPIError с понятным сообщением."""
from redminelib.exceptions import ForbiddenError
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = ForbiddenError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Access denied"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_timeout(mock_redmine_class):
"""requests Timeout превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = requests.exceptions.Timeout("timeout")
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="timed out"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_connection_error(mock_redmine_class):
"""ConnectionError превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = requests.exceptions.ConnectionError("no route")
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Cannot connect"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_http_500(mock_redmine_class):
"""HTTP 500 превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
response = requests.Response()
response.status_code = 500
mock_redmine.user.get.side_effect = requests.exceptions.HTTPError(
"server error", response=response
)
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="server error"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_specified_user_id(mock_redmine_class):
"""user_id передаётся в time_entry.filter вместо текущего пользователя."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id=42)
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 42
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_resolves_user_login_to_id(mock_redmine_class):
"""Строковый логин разрешается в числовой ID."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_user = mock.MagicMock()
mock_user.id = 7
mock_user.login = "ivanov"
mock_redmine.user.filter.return_value = [mock_user]
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
login_filter_call = mock_redmine.user.filter.call_args_list[0]
assert login_filter_call.kwargs == {"login": "ivanov"}
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 7
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_inexact_login_match(mock_redmine_class):
"""Фильтр Redmine по логину неточный (#60): без точного совпадения — ошибка."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
near_match = mock.MagicMock()
near_match.id = 8
near_match.login = "ivanov2"
mock_redmine.user.filter.side_effect = [
[near_match], # неточное совпадение по логину
[], # по имени никого не найдено
]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="not found"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_resolves_exact_login_among_near_matches(mock_redmine_class):
"""Среди неточных результатов фильтра выбирается точное совпадение логина (#60)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
near = mock.MagicMock()
near.id = 8
near.login = "ivanov2"
exact = mock.MagicMock()
exact.id = 7
exact.login = "ivanov"
mock_redmine.user.filter.return_value = [near, exact]
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 7
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_multiple_exact_login_matches(mock_redmine_class):
"""Несколько пользователей с точным логином — ошибка неоднозначности (#60)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
user1 = mock.MagicMock()
user1.id = 7
user1.login = "ivanov"
user2 = mock.MagicMock()
user2.id = 9
user2.login = "ivanov"
mock_redmine.user.filter.return_value = [user1, user2]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Multiple users match"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_login_exact_match_is_case_sensitive(mock_redmine_class):
"""Точное совпадение логина регистрозависимое (#60)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
other_case = mock.MagicMock()
other_case.id = 8
other_case.login = "Ivanov"
mock_redmine.user.filter.side_effect = [
[other_case], # логин отличается регистром
[], # по имени никого не найдено
]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="not found"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_resolves_user_name_to_id(mock_redmine_class):
"""Строковое имя разрешается в числовой ID, если найден ровно один пользователь."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.user.filter.side_effect = [
[], # поиск по логину не дал результатов
[mock.MagicMock(id=9)], # поиск по имени нашёл одного
]
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="Иванов И.И.")
name_filter_call = mock_redmine.user.filter.call_args_list[1]
assert name_filter_call.kwargs == {"name": "Иванов И.И."}
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 9
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_ambiguous_user_name(mock_redmine_class):
"""Если по имени найдено несколько пользователей — ошибка с понятным сообщением."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.user.filter.side_effect = [
[], # поиск по логину не дал результатов
[mock.MagicMock(id=1), mock.MagicMock(id=2)], # по имени — несколько
]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Multiple users match"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="Иванов")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_unknown_user_login(mock_redmine_class):
"""Если пользователь не найден — ошибка с предложением использовать --user-id."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.user.filter.return_value = []
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="not found"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="unknown")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_numeric_string_user_id(mock_redmine_class):
"""Строка из цифр интерпретируется как числовой ID."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="42")
assert mock_redmine.user.filter.call_count == 0
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 42
# -- #24: Таймаут и retry --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_sets_timeout_in_requests(mock_redmine_class):
"""В requests dict передаётся timeout (#24)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
_, kwargs = mock_redmine_class.call_args
assert "timeout" in kwargs["requests"]
assert kwargs["requests"]["timeout"] == 30
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_mounts_retry_adapter(mock_redmine_class):
"""На сессию монтируется HTTPAdapter с retry для временных ошибок (#24)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
# Проверяем, что engine.session.mount был вызван для http:// и https://
mount_calls = mock_redmine.engine.session.mount.call_args_list
prefixes = [call.args[0] for call in mount_calls]
assert "https://" in prefixes
assert "http://" in prefixes
# Проверяем retry-конфигурацию адаптера
https_adapter = next(
call.args[1] for call in mount_calls if call.args[0] == "https://"
)
max_retries = https_adapter.max_retries
assert max_retries.total == 3
assert 429 in max_retries.status_forcelist
# -- #21: Чанкирование issue_ids --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_chunks_large_issue_count(mock_redmine_class):
"""При >100 задач запросы разбиваются на чанки (#21)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
# 250 time entries → 250 уникальных issue_id
entries = []
for i in range(1, 251):
e = mock.MagicMock()
e.issue.id = i
e.hours = 1.0
entries.append(e)
mock_redmine.time_entry.filter.return_value = entries
# issue.filter вызывается с чанками по 100 ID
call_chunks = []
def issue_filter_side_effect(**kwargs):
ids_str = kwargs.get("issue_id", "")
call_chunks.append(ids_str)
ids = [int(x) for x in ids_str.split(",")]
return [
mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids
]
mock_redmine.issue.filter.side_effect = issue_filter_side_effect
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
# Должно быть 3 вызова (100 + 100 + 50)
assert len(call_chunks) == 3
assert len(call_chunks[0].split(",")) == 100
assert len(call_chunks[1].split(",")) == 100
assert len(call_chunks[2].split(",")) == 50
assert result is not None
assert len(result) == 250
# -- Пагинация 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
# -- #61: предупреждения о молчаливой потере данных --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_warns_about_missing_issues(mock_redmine_class, capsys):
"""Задачи, не вернувшиеся из issue.filter, попадают в warning в stderr (#61).
Отчёт при этом строится по доступным данным — это предупреждение, не ошибка.
"""
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
mock_redmine.time_entry.filter.return_value = [
make_entry(1, 1.0),
make_entry(2, 2.5),
make_entry(3, 0.75),
]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
# Задачи 2 и 3 не вернулись (нет прав / удалены)
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
assert [r[0].id for r in result] == [1]
captured = capsys.readouterr()
assert "2" in captured.err
assert "3" in captured.err
assert "3.25" in captured.err # сумма потерянных часов 2.5 + 0.75
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_no_warning_when_all_issues_returned(mock_redmine_class, capsys):
"""Если все задачи вернулись, stderr остаётся чистым (#61)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
e = mock.MagicMock()
e.issue.id = 1
e.hours = 1.0
mock_redmine.time_entry.filter.return_value = [e]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
captured = capsys.readouterr()
assert captured.err == ""
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_warns_when_activities_lookup_fails(mock_redmine_class, capsys):
"""Сбой справочника активностей — warning в stderr, отчёт строится (#61)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.enumeration.filter.side_effect = Exception("enumeration boom")
e = mock.MagicMock()
e.issue.id = 1
e.hours = 1.0
e.activity.id = 9
e.activity.name = "Dev"
mock_redmine.time_entry.filter.return_value = [e]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31", by_activity=True)
assert result is not None
assert result[0][2] == {"Dev": 1.0} # fallback на имя из самого entry
captured = capsys.readouterr()
assert "activit" in captured.err.lower()
def test_load_time_entry_activities_returns_empty_on_failure(capsys):
"""_load_time_entry_activities возвращает {} и пишет warning при сбое (#61)."""
from redmine_reporter.client import _load_time_entry_activities
redmine = mock.MagicMock()
redmine.enumeration.filter.side_effect = Exception("enumeration boom")
assert _load_time_entry_activities(redmine) == {}
captured = capsys.readouterr()
assert "activit" in captured.err.lower()
# -- Границы периода передаются в API как есть --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_passes_date_boundaries_to_api_unchanged(mock_redmine_class):
"""Флоринг границ выполняет cli-парсер, клиент отдаёт даты в filter как есть."""
mock_redmine = mock_redmine_class.return_value
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-09-30", "2026-10-31", user_id=458)
mock_redmine.time_entry.filter.assert_called_once_with(
user_id=458, from_date="2026-09-30", to_date="2026-10-31"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_works_without_dedup_argument(mock_redmine_class):
"""dedup-слой удалён: без dedup-аргумента все записи периода агрегируются."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
entry = mock.MagicMock()
entry.issue.id = 1
entry.hours = 2.0
entry.created_on = None
entry.updated_on = None
mock_redmine.time_entry.filter.return_value = [entry]
mock_issue = mock.MagicMock()
mock_issue.id = 1
mock_issue.project = "P"
mock_issue.subject = "T"
mock_issue.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue]
result = fetch_issues_with_spent_time("2026-09-30", "2026-09-30", user_id=123)
assert result is not None
assert [r[0].id for r in result] == [1]
assert result[0][1] == 2.0
# -- #71: fetch_open_issues --
def _make_open_issue(issue_id, version_id=None):
"""Создаёт mock открытой задачи; без version_id эмулирует отсутствие fixed_version.
Спека без fixed_version нужна, чтобы getattr(issue, "fixed_version", None)
вернул None, а не auto-атрибут MagicMock.
"""
if version_id is None:
issue = mock.MagicMock(spec=["id", "subject", "project"])
issue.id = issue_id
return issue
issue = mock.MagicMock()
issue.id = issue_id
issue.fixed_version.id = version_id
return issue
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_filters_by_user_and_open_status(mock_redmine_class):
"""issue.filter вызван ровно с assigned_to_id и status_id="open" (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=123, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_default_user_is_current(mock_redmine_class):
"""Без user_id резолвится текущий пользователь через user.get("current") (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=77)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.user.get.assert_called_once_with("current")
_, kwargs = mock_redmine.issue.filter.call_args
assert kwargs["assigned_to_id"] == 77
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_numeric_user_id_skips_user_lookup(mock_redmine_class):
"""Числовой user_id: user.get не вызывается, фильтр по переданному ID (#71)."""
mock_redmine = mock_redmine_class.return_value
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id=42)
mock_redmine.user.get.assert_not_called()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=42, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_resolves_login_to_id(mock_redmine_class):
"""Строковый логин разрешается в числовой ID как в fetch_issues_with_spent_time (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_user = mock.MagicMock()
mock_user.id = 7
mock_user.login = "ivanov"
mock_redmine.user.filter.return_value = [mock_user]
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id="ivanov")
login_filter_call = mock_redmine.user.filter.call_args_list[0]
assert login_filter_call.kwargs == {"login": "ivanov"}
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=7, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_fetches_each_unique_version_once(mock_redmine_class):
"""Две задачи с одной версией -> один version.get на версию (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2, version_id=5),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert mock_redmine.version.get.call_count == 2
version_ids = [call.args[0] for call in mock_redmine.version.get.call_args_list]
assert version_ids == [5, 6]
assert len(result) == 3
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_attaches_version_due_date(mock_redmine_class):
"""due_date из version.get попадает в кортеж задачи (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
assert result is not None
issue, due = result[0]
assert issue.id == 1
assert due == date(2026, 11, 1)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_issue_without_version_has_empty_due(mock_redmine_class):
"""Задача без fixed_version: version.get для неё не вызывается, due None (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
mock_redmine.version.get.assert_called_once_with(5)
assert result is not None
assert result[1][0].id == 2
assert result[1][1] is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_version_failure_warns_and_continues(mock_redmine_class, capsys):
"""Сбой version.get: warning в stderr, due None, общий результат не валится (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.side_effect = Exception("version boom")
result = fetch_open_issues()
assert result is not None
assert result[0][1] is None
captured = capsys.readouterr()
assert "Could not load version 5" in captured.err
assert "version boom" in captured.err
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_result_length_matches_issues(mock_redmine_class):
"""Результат по длине и порядку соответствует списку задач из API (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert result is not None
assert len(result) == 3
assert [issue.id for issue, _ in result] == [1, 2, 3]
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_wraps_api_errors(mock_redmine_class):
"""Сбой issue.filter оборачивается в RedmineAPIError с понятным сообщением (#71)."""
from redminelib.exceptions import AuthError
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.side_effect = AuthError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Authentication failed"):
fetch_open_issues()