Regression from #62: verify_ssl true used to resolve to the system CA bundle path, so corporate CAs installed in the OS worked; after the unification true became requests' default (certifi), breaking setups with a corporate CA in the system store. Now verify_ssl true injects truststore, so requests verifies against the OS trust store on any platform. verify_ssl false / custom CA path behavior is unchanged. Tests mock truststore via an autouse fixture to keep the pytest process free of global ssl mutation. Refs #62
1156 lines
42 KiB
Python
1156 lines
42 KiB
Python
import os
|
||
from unittest import mock
|
||
|
||
import pytest
|
||
|
||
from redmine_reporter.client import fetch_issues_with_spent_time
|
||
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
|
||
|
||
|
||
# -- #47: Дедупликация time entries по datetime --
|
||
|
||
|
||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.client.Redmine")
|
||
def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
|
||
"""Записи с created_on/updated_on раньше dedup_before исключаются."""
|
||
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)
|
||
|
||
e1 = mock.MagicMock()
|
||
e1.issue.id = 1
|
||
e1.hours = 2.0
|
||
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||
e1.updated_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||
|
||
e2 = mock.MagicMock()
|
||
e2.issue.id = 2
|
||
e2.hours = 1.0
|
||
e2.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||
e2.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||
|
||
mock_redmine.time_entry.filter.return_value = [e1, e2]
|
||
|
||
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-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||
)
|
||
|
||
assert result is not None
|
||
assert len(result) == 1
|
||
assert result[0][0].id == 2
|
||
|
||
|
||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.client.Redmine")
|
||
def test_dedup_filters_entries_with_old_created_even_if_updated_recently(
|
||
mock_redmine_class,
|
||
):
|
||
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
|
||
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)
|
||
|
||
e1 = mock.MagicMock()
|
||
e1.issue.id = 1
|
||
e1.hours = 2.0
|
||
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||
e1.updated_on = datetime(2026, 7, 1, 15, 0, 0, tzinfo=timezone.utc)
|
||
|
||
mock_redmine.time_entry.filter.return_value = [e1]
|
||
mock_redmine.issue.filter.return_value = []
|
||
|
||
result = fetch_issues_with_spent_time(
|
||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||
)
|
||
|
||
assert result is None
|
||
|
||
|
||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.client.Redmine")
|
||
def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
|
||
"""Записи с created_on/updated_on >= dedup_before сохраняются."""
|
||
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)
|
||
|
||
e1 = mock.MagicMock()
|
||
e1.issue.id = 1
|
||
e1.hours = 2.0
|
||
e1.created_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||
e1.updated_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||
|
||
mock_redmine.time_entry.filter.return_value = [e1]
|
||
|
||
mock_issue1 = mock.MagicMock()
|
||
mock_issue1.id = 1
|
||
mock_issue1.project = "P"
|
||
mock_issue1.subject = "T"
|
||
mock_issue1.status = "New"
|
||
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||
|
||
result = fetch_issues_with_spent_time(
|
||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||
)
|
||
|
||
assert result is not None
|
||
assert len(result) == 1
|
||
|
||
|
||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.client.Redmine")
|
||
def test_dedup_entries_without_created_on_are_kept(mock_redmine_class):
|
||
"""Записи без created_on/updated_on не фильтруются (сохраняются)."""
|
||
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)
|
||
|
||
e1 = mock.MagicMock()
|
||
e1.issue.id = 1
|
||
e1.hours = 2.0
|
||
e1.created_on = None
|
||
e1.updated_on = None
|
||
|
||
mock_redmine.time_entry.filter.return_value = [e1]
|
||
|
||
mock_issue1 = mock.MagicMock()
|
||
mock_issue1.id = 1
|
||
mock_issue1.project = "P"
|
||
mock_issue1.subject = "T"
|
||
mock_issue1.status = "New"
|
||
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||
|
||
result = fetch_issues_with_spent_time(
|
||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||
)
|
||
|
||
assert result is not None
|
||
assert len(result) == 1
|
||
|
||
|
||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.client.Redmine")
|
||
def test_dedup_handles_string_created_on(mock_redmine_class):
|
||
"""created_on может быть строкой ISO — парсим корректно."""
|
||
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)
|
||
|
||
e1 = mock.MagicMock()
|
||
e1.issue.id = 1
|
||
e1.hours = 2.0
|
||
e1.created_on = "2026-07-01T10:00:00Z"
|
||
e1.updated_on = "2026-07-01T10:00:00Z"
|
||
|
||
mock_redmine.time_entry.filter.return_value = [e1]
|
||
mock_redmine.issue.filter.return_value = []
|
||
|
||
result = fetch_issues_with_spent_time(
|
||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||
)
|
||
|
||
assert result is None
|
||
|
||
|
||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.client.Redmine")
|
||
def test_no_dedup_when_cutoff_is_none(mock_redmine_class):
|
||
"""Без dedup_before фильтрация не применяется."""
|
||
mock_redmine = mock_redmine_class.return_value
|
||
_configure_current_user(mock_redmine, user_id=123)
|
||
|
||
e1 = mock.MagicMock()
|
||
e1.issue.id = 1
|
||
e1.hours = 2.0
|
||
e1.created_on = None
|
||
e1.updated_on = None
|
||
|
||
mock_redmine.time_entry.filter.return_value = [e1]
|
||
|
||
mock_issue1 = mock.MagicMock()
|
||
mock_issue1.id = 1
|
||
mock_issue1.project = "P"
|
||
mock_issue1.subject = "T"
|
||
mock_issue1.status = "New"
|
||
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||
|
||
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=None)
|
||
|
||
assert result is not None
|
||
assert len(result) == 1
|
||
|
||
|
||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.client.Redmine")
|
||
def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
|
||
"""Смешанные записи: старые исключаются, новые сохраняются, аки идут."""
|
||
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 = 10
|
||
e_old.hours = 1.0
|
||
e_old.created_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
|
||
e_old.updated_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
|
||
|
||
e_new = mock.MagicMock()
|
||
e_new.issue.id = 20
|
||
e_new.hours = 3.0
|
||
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||
|
||
e_akiy = mock.MagicMock()
|
||
e_akiy.issue.id = 30
|
||
e_akiy.hours = 5.0
|
||
e_akiy.created_on = None
|
||
e_akiy.updated_on = None
|
||
|
||
mock_redmine.time_entry.filter.return_value = [e_old, e_new, e_akiy]
|
||
|
||
mock_issue_new = mock.MagicMock()
|
||
mock_issue_new.id = 20
|
||
mock_issue_new.project = "P"
|
||
mock_issue_new.subject = "T"
|
||
mock_issue_new.status = "New"
|
||
mock_issue_akiy = mock.MagicMock()
|
||
mock_issue_akiy.id = 30
|
||
mock_issue_akiy.project = "P"
|
||
mock_issue_akiy.subject = "T2"
|
||
mock_issue_akiy.status = "New"
|
||
mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy]
|
||
|
||
result = fetch_issues_with_spent_time(
|
||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||
)
|
||
|
||
assert result is not None
|
||
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 --
|
||
|
||
|
||
@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_issue1 = mock.MagicMock()
|
||
mock_issue1.id = 1
|
||
mock_issue1.project = "P"
|
||
mock_issue1.subject = "T"
|
||
mock_issue1.status = "New"
|
||
mock_issue2 = mock.MagicMock()
|
||
mock_issue2.id = 2
|
||
mock_issue2.project = "P"
|
||
mock_issue2.subject = "T"
|
||
mock_issue2.status = "New"
|
||
# API возвращает обе задачи: issue 1 попадёт в результат, только если
|
||
# её time entry не отсечён дедупликацией. Корректный фикс #58 отсекает
|
||
# e_old (created_on/updated_on < cutoff) -> [2]; неправильный фикс
|
||
# "погасить TypeError и оставить всё" даст [1, 2] и тест упадёт.
|
||
mock_redmine.issue.filter.return_value = [mock_issue1, 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]
|
||
|
||
|
||
# -- #58: нормализация datetime к aware UTC --
|
||
|
||
|
||
class TestParseDatetimeNormalization:
|
||
"""_parse_datetime всегда возвращает aware datetime; naive трактуется как UTC."""
|
||
|
||
def test_naive_datetime_treated_as_utc(self):
|
||
from datetime import datetime, timezone
|
||
|
||
from redmine_reporter.client import _parse_datetime
|
||
|
||
result = _parse_datetime(datetime(2026, 7, 1, 10, 0, 0))
|
||
assert result == datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||
|
||
def test_aware_datetime_kept(self):
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from redmine_reporter.client import _parse_datetime
|
||
|
||
aware = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone(timedelta(hours=3)))
|
||
assert _parse_datetime(aware) is aware
|
||
|
||
def test_naive_iso_string_treated_as_utc(self):
|
||
from datetime import timezone
|
||
|
||
from redmine_reporter.client import _parse_datetime
|
||
|
||
result = _parse_datetime("2026-07-01T10:00:00")
|
||
assert result is not None
|
||
assert result.tzinfo == timezone.utc
|
||
|
||
def test_aware_iso_string_keeps_offset(self):
|
||
from datetime import timedelta
|
||
|
||
from redmine_reporter.client import _parse_datetime
|
||
|
||
result = _parse_datetime("2026-07-01T10:00:00+03:00")
|
||
assert result is not None
|
||
assert result.utcoffset() == timedelta(hours=3)
|
||
|
||
def test_none_and_garbage_return_none(self):
|
||
from redmine_reporter.client import _parse_datetime
|
||
|
||
assert _parse_datetime(None) is None
|
||
assert _parse_datetime("not a date") is None
|
||
assert _parse_datetime(123) is None
|
||
|
||
|
||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||
@mock.patch("redmine_reporter.client.Redmine")
|
||
def test_dedup_accepts_naive_cutoff(mock_redmine_class):
|
||
"""Naive dedup_before трактуется как UTC — сравнение не падает (#58)."""
|
||
from datetime import datetime
|
||
|
||
mock_redmine = mock_redmine_class.return_value
|
||
_configure_current_user(mock_redmine, user_id=123)
|
||
|
||
naive_cutoff = datetime(2026, 7, 1, 12, 0, 0) # naive == 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)
|
||
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)
|
||
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
|
||
|
||
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
|
||
|
||
mock_issue1 = mock.MagicMock()
|
||
mock_issue1.id = 1
|
||
mock_issue2 = mock.MagicMock()
|
||
mock_issue2.id = 2
|
||
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
|
||
|
||
result = fetch_issues_with_spent_time(
|
||
"2026-07-01", "2026-07-01", dedup_before=naive_cutoff
|
||
)
|
||
|
||
assert result is not None
|
||
assert [r[0].id for r in result] == [2]
|
||
|
||
|
||
# -- #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()
|