List open Redmine issues assigned to the current (or overridden) user, sorted by priority rank (P1..P5) then version due date with a stable id tie-break. Renders an OSC 8 hyperlinked console table on stdout and a counts/hours summary line on stderr; version due dates are fetched once per unique version, unavailable versions degrade to an empty due date. Also migrates the codebase to ruff 0.17.3 defaults (PEP 585/604 annotations, targeted noqa for intentional blind excepts and CLI root logging). Behavior-neutral: 408 tests pass with -W error.
1005 lines
38 KiB
Python
1005 lines
38 KiB
Python
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()
|