From 35946a5812f26fdbc9f4e5ced248b5fc13e77db9 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Sat, 21 Feb 2026 00:50:29 +0300 Subject: [PATCH] feat: add tests for admin checks and error handling - Introduced new tests for the `is_admin_async` function to verify correct behavior based on user roles. - Added tests for error handling in the `error_handler` to ensure exceptions are logged and do not propagate. - Enhanced test coverage for configuration settings by adding a test for parsing admin phone numbers from environment variables. - Updated the `.cursorrules` file to include the use of a virtual environment for better dependency management. --- .coverage | Bin 53248 -> 53248 bytes .cursorrules | 1 + tests/test_config.py | 7 ++++ tests/test_handlers_common.py | 52 ++++++++++++++++++++++++++++ tests/test_handlers_errors.py | 23 ++++++++++++ tests/test_personal_calendar_ics.py | 16 +++++++++ 6 files changed, 99 insertions(+) create mode 100644 tests/test_handlers_common.py diff --git a/.coverage b/.coverage index 1ff4a45e4c4cf7af92eeaa86e0cea4e86dec1aa4..618aa768d697da49d2111d883b2faa04866694aa 100644 GIT binary patch delta 92 zcmV-i0HgnapaX!Q1F!~w0dtcFfFL7?4t)+;4nPh#4lE8K4hjwe4eJfr4bTm@4W$j7 y4TueV4Rf;*5Gf6_b&V7O41Wm_1OW*Y9qI&d0h5!C&jEguV~;cf-oLY%k7q!?1sqoZ delta 90 zcmV-g0HyzcpaX!Q1F!~w0dwGy-0|vzd=)K!{Wu0ssI2 diff --git a/.cursorrules b/.cursorrules index 61f2508..76ff318 100644 --- a/.cursorrules +++ b/.cursorrules @@ -105,6 +105,7 @@ "styling": "Not applicable", "testing_framework": "pytest", "build_tool": "setuptools", + "use_venv": ".venv", "deployment": { "environment": "Local machine", diff --git a/tests/test_config.py b/tests/test_config.py index 6d3e6ed..988e836 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -38,6 +38,13 @@ def test_can_access_miniapp_denied(monkeypatch): assert config.can_access_miniapp("") is False +def test_settings_from_env_parse_admin_phones_adds_normalized(monkeypatch): + """Settings.from_env() parses ADMIN_PHONES and adds normalized phone to admin_phones.""" + monkeypatch.setenv("ADMIN_PHONES", "+7 900 123-45-67") + settings = config.Settings.from_env() + assert settings.admin_phones == {"79001234567"} + + def test_normalize_phone(): """Phone is normalized to digits only.""" assert config.normalize_phone("+7 900 123-45-67") == "79001234567" diff --git a/tests/test_handlers_common.py b/tests/test_handlers_common.py new file mode 100644 index 0000000..d1fa803 --- /dev/null +++ b/tests/test_handlers_common.py @@ -0,0 +1,52 @@ +"""Tests for duty_teller.handlers.common (is_admin_async).""" + +from unittest.mock import MagicMock, patch + +import pytest + +from duty_teller.handlers.common import is_admin_async + + +@pytest.mark.asyncio +async def test_is_admin_async_true_when_repository_returns_true(): + """is_admin_async returns True when is_admin_for_telegram_user returns True.""" + mock_session = MagicMock() + mock_scope = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + + with patch( + "duty_teller.handlers.common.session_scope", + mock_scope, + ): + with patch( + "duty_teller.handlers.common.is_admin_for_telegram_user", + return_value=True, + ) as mock_is_admin: + result = await is_admin_async(12345) + assert result is True + mock_scope.assert_called_once() + mock_scope.return_value.__enter__.assert_called_once() + mock_is_admin.assert_called_once_with(mock_session, 12345) + + +@pytest.mark.asyncio +async def test_is_admin_async_false_when_repository_returns_false(): + """is_admin_async returns False when is_admin_for_telegram_user returns False.""" + mock_session = MagicMock() + mock_scope = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + + with patch( + "duty_teller.handlers.common.session_scope", + mock_scope, + ): + with patch( + "duty_teller.handlers.common.is_admin_for_telegram_user", + return_value=False, + ) as mock_is_admin: + result = await is_admin_async(99999) + assert result is False + mock_scope.assert_called_once() + mock_is_admin.assert_called_once_with(mock_session, 99999) diff --git a/tests/test_handlers_errors.py b/tests/test_handlers_errors.py index f4712b8..ca2eb05 100644 --- a/tests/test_handlers_errors.py +++ b/tests/test_handlers_errors.py @@ -52,3 +52,26 @@ async def test_error_handler_update_none_does_not_crash(): context = MagicMock() context.error = Exception("test") await error_handler(None, context) + + +@pytest.mark.asyncio +async def test_error_handler_reply_text_raises_does_not_propagate(): + """error_handler: when reply_text raises, exception is caught and logged; does not propagate.""" + class FakeUpdate: + pass + + update = FakeUpdate() + update.effective_message = MagicMock() + update.effective_message.reply_text = AsyncMock( + side_effect=Exception("reply failed") + ) + update.effective_user = MagicMock() + context = MagicMock() + context.error = Exception("test error") + + with patch("duty_teller.handlers.errors.Update", FakeUpdate): + with patch("duty_teller.handlers.errors.get_lang", return_value="en"): + with patch("duty_teller.handlers.errors.t", return_value="An error occurred."): + await error_handler(update, context) + # Handler must not raise; reply_text was attempted + update.effective_message.reply_text.assert_called_once() diff --git a/tests/test_personal_calendar_ics.py b/tests/test_personal_calendar_ics.py index 0511eb4..a2089f2 100644 --- a/tests/test_personal_calendar_ics.py +++ b/tests/test_personal_calendar_ics.py @@ -58,3 +58,19 @@ def test_build_personal_ics_event_types(): summaries = [str(ev.get("summary")) for ev in events] assert "Unavailable" in summaries assert "Vacation" in summaries + + +def test_build_personal_ics_naive_datetime_treated_as_utc(): + """Duties with start_at/end_at without Z (naive datetime) are treated as UTC.""" + duties = [ + _duty(1, "2025-02-20T09:00:00", "2025-02-20T18:00:00", "duty"), + ] + ics = build_personal_ics(duties) + cal = ICalendar.from_ical(ics) + assert cal is not None + events = [c for c in cal.walk() if c.name == "VEVENT"] + assert len(events) == 1 + ev = events[0] + assert ev.get("summary") == "Duty" + assert "2025-02-20" in str(ev.get("dtstart").dt) + assert "2025-02-20" in str(ev.get("dtend").dt)