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]