Dedup with precision=datetime crashed with TypeError: redminelib returns naive created_on/updated_on while the cutoff from _compute_dedup_cutoff is aware UTC. Normalize at a single point: _parse_datetime now always returns an aware datetime (naive treated as UTC), matching its docstring. The dedup cutoff is normalized the same way, and any residual comparison TypeError is wrapped in RedmineAPIError instead of leaking raw. Also fix --commit saving last_used.to as naive local time; it now stores aware UTC (datetime.now(timezone.utc)) so the next run computes a correct aware cutoff. Closes #58
886 lines
32 KiB
Python
886 lines
32 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
|
||
|
||
|
||
@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, 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_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_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]
|