diff --git a/.coverage b/.coverage index 1ff4a45..618aa76 100644 Binary files a/.coverage and b/.coverage differ 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)