diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index 427efb0..dbe6b69 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -205,8 +205,18 @@ def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int: # Затем ищем по логину try: users = redmine.user.filter(login=text) - if users: - return int(users[0].id) + # Фильтр Redmine по логину неточный (substring-поиск), поэтому + # выбираем только точные регистрозависимые совпадения логина (#60). + exact_matches = [u for u in users if getattr(u, "login", None) == text] + if len(exact_matches) == 1: + return int(exact_matches[0].id) + if len(exact_matches) > 1: + matches = ", ".join(str(u.id) for u in exact_matches[:5]) + raise RedmineAPIError( + f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID." + ) + except RedmineAPIError: + raise except Exception as exc: raise RedmineAPIError( f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}", diff --git a/tests/test_client.py b/tests/test_client.py index f8c0b66..204eb7f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -275,6 +275,7 @@ def test_fetch_resolves_user_login_to_id(mock_redmine_class): 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 = [] @@ -287,6 +288,91 @@ def test_fetch_resolves_user_login_to_id(mock_redmine_class): 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):