diff --git a/.coverage b/.coverage index 3824ee1..c3b43dc 100644 Binary files a/.coverage and b/.coverage differ diff --git a/duty_teller.egg-info/PKG-INFO b/duty_teller.egg-info/PKG-INFO index cf5c757..017bc7f 100644 --- a/duty_teller.egg-info/PKG-INFO +++ b/duty_teller.egg-info/PKG-INFO @@ -14,7 +14,7 @@ Requires-Dist: pydantic<3.0,>=2.0 Requires-Dist: icalendar<6.0,>=5.0 Provides-Extra: dev Requires-Dist: pytest<9.0,>=8.0; extra == "dev" -Requires-Dist: pytest-asyncio<1.0,>=0.24; extra == "dev" +Requires-Dist: pytest-asyncio<2.0,>=1.0; extra == "dev" Requires-Dist: pytest-cov<7.0,>=6.0; extra == "dev" Requires-Dist: httpx<1.0,>=0.27; extra == "dev" Provides-Extra: docs @@ -62,7 +62,7 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co 5. **Miniapp access (calendar)** Set `ALLOWED_USERNAMES` (and optionally `ADMIN_USERNAMES`) to allow access to the calendar miniapp; if both are empty, no one can open it. Users can also be allowed by phone via `ALLOWED_PHONES` / `ADMIN_PHONES` after setting a phone with `/set_phone`. **Mini App URL:** When configuring the bot's menu button or Web App URL (e.g. in @BotFather or via `setChatMenuButton`), use the URL **with a trailing slash**, e.g. `https://your-domain.com/app/`. A redirect from `/app` to `/app/` can cause the browser to drop the fragment that Telegram sends, which breaks authorization. - **How to open:** Users must open the calendar **via the bot's menu button** (⋮ → «Календарь» or the configured label) or a **Web App inline button**. If they use «Open in browser» or a direct link, Telegram may not send user data (`tgWebAppData`), and access will be denied. + **How to open:** Users must open the calendar **via the bot's menu button** (⋮ → "Calendar" or the configured label) or a **Web App inline button**. If they use "Open in browser" or a direct link, Telegram may not send user data (`tgWebAppData`), and access will be denied. **BOT_TOKEN:** The server that serves `/api/duties` (e.g. your production host) must have in `.env` the **same** bot token as the bot from which users open the Mini App. If the token differs (e.g. test vs production bot), validation returns "hash_mismatch" and access is denied. 6. **Other options** @@ -87,7 +87,7 @@ The bot runs in polling mode. Send `/start` or `/help` to your bot in Telegram t - **`/start`** — Greeting and user registration in the database. - **`/help`** — Help on available commands. - **`/set_phone [number]`** — Set or clear phone number (private chat only); used for access via `ALLOWED_PHONES` / `ADMIN_PHONES`. -- **`/import_duty_schedule`** — Import duty schedule (only for `ADMIN_USERNAMES` / `ADMIN_PHONES`); see «Импорт расписания» below for the two-step flow. +- **`/import_duty_schedule`** — Import duty schedule (only for `ADMIN_USERNAMES` / `ADMIN_PHONES`); see **Duty schedule import** below for the two-step flow. - **`/pin_duty`** — Pin the current duty message in a group (reply to the bot’s duty message); time/timezone for the pinned message come from `DUTY_DISPLAY_TZ`. ## Run with Docker @@ -145,16 +145,16 @@ High-level architecture (components, data flow, package relationships) is descri To add commands, define async handlers in `duty_teller/handlers/commands.py` (or a new module) and register them in `duty_teller/handlers/__init__.py`. -## Импорт расписания дежурств (duty-schedule) +## Duty schedule import (duty-schedule) -Команда **`/import_duty_schedule`** доступна только пользователям из `ADMIN_USERNAMES` или `ADMIN_PHONES`. Импорт выполняется в два шага: +The **`/import_duty_schedule`** command is available only to users in `ADMIN_USERNAMES` or `ADMIN_PHONES`. Import is done in two steps: -1. **Время пересменки** — бот просит указать время и при необходимости часовой пояс (например `09:00 Europe/Moscow` или `06:00 UTC`). Время приводится к UTC и используется для границ смен при создании записей. -2. **Файл JSON** — отправьте файл в формате duty-schedule. +1. **Handover time** — The bot asks for the shift handover time and optional timezone (e.g. `09:00 Europe/Moscow` or `06:00 UTC`). This is converted to UTC and used as the boundary between duty periods when creating records. +2. **JSON file** — Send a file in duty-schedule format. -Формат: в корне JSON — объект **meta** с полем `start_date` (YYYY-MM-DD) и массив **schedule** с объектами `name` (ФИО) и `duty` (строка с разделителем `;`, символы **в/В/б/Б** — дежурство, **Н** — недоступен, **О** — отпуск). Количество дней задаётся длиной строки `duty`. При повторном импорте дежурства в том же диапазоне дат для каждого пользователя заменяются новыми. +Format: at the root of the JSON — a **meta** object with `start_date` (YYYY-MM-DD) and a **schedule** array of objects with `name` (full name) and `duty` (string with separator `;`; characters **в/В/б/Б** = duty, **Н** = unavailable, **О** = vacation). The number of days is given by the length of the `duty` string. On re-import, duties in the same date range for each user are replaced by the new data. -**Полное описание формата и пример JSON:** [docs/import-format.md](docs/import-format.md). +**Full format description and example JSON:** [docs/import-format.md](docs/import-format.md). ## Tests @@ -168,3 +168,13 @@ pytest Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config` (is_admin, can_access_miniapp), and the API (date validation, 403/200 with mocked auth, plus an E2E auth test without auth mocks). **CI (Gitea Actions):** Lint (ruff), tests (pytest), security (bandit). If the workflow uses `PYTHONPATH: src` or `bandit -r src`, update it to match the repo layout (no `src/`). + +## Contributing + +- **Commits:** Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, etc. +- **Branches:** Follow [Gitea Flow](https://docs.gitea.io/en-us/workflow-branching/): main branch `main`, features and fixes in separate branches. +- **Changes:** Via **Pull Request** in Gitea; run linters and tests (`ruff check .`, `pytest`) before merge. + +## Logs and rotation + +To meet the 7-day log retention policy, configure log rotation at deploy time: e.g. [logrotate](https://manpages.ubuntu.com/logrotate), systemd logging settings, or Docker (size/time retention limits). Keep application logs for no more than 7 days. diff --git a/duty_teller.egg-info/SOURCES.txt b/duty_teller.egg-info/SOURCES.txt index 3a2a281..7569ec9 100644 --- a/duty_teller.egg-info/SOURCES.txt +++ b/duty_teller.egg-info/SOURCES.txt @@ -37,13 +37,24 @@ duty_teller/utils/__init__.py duty_teller/utils/dates.py duty_teller/utils/handover.py duty_teller/utils/user.py +tests/test_api_dependencies.py tests/test_app.py +tests/test_calendar_ics.py tests/test_calendar_token_repository.py tests/test_config.py +tests/test_db_session.py tests/test_duty_schedule_parser.py +tests/test_group_duty_pin_service.py +tests/test_handlers_commands.py +tests/test_handlers_errors.py +tests/test_handlers_group_duty_pin.py +tests/test_handlers_init.py tests/test_i18n.py tests/test_import_duty_schedule_integration.py +tests/test_import_service.py +tests/test_package_init.py tests/test_personal_calendar_ics.py tests/test_repository_duty_range.py +tests/test_run.py tests/test_telegram_auth.py tests/test_utils.py \ No newline at end of file diff --git a/duty_teller.egg-info/requires.txt b/duty_teller.egg-info/requires.txt index 3ac4aa3..65444e7 100644 --- a/duty_teller.egg-info/requires.txt +++ b/duty_teller.egg-info/requires.txt @@ -9,7 +9,7 @@ icalendar<6.0,>=5.0 [dev] pytest<9.0,>=8.0 -pytest-asyncio<1.0,>=0.24 +pytest-asyncio<2.0,>=1.0 pytest-cov<7.0,>=6.0 httpx<1.0,>=0.27 diff --git a/pyproject.toml b/pyproject.toml index dda92d8..3a4819a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ duty-teller = "duty_teller.run:main" [project.optional-dependencies] dev = [ "pytest>=8.0,<9.0", - "pytest-asyncio>=0.24,<1.0", + "pytest-asyncio>=1.0,<2.0", "pytest-cov>=6.0,<7.0", "httpx>=0.27,<1.0", ] @@ -49,8 +49,11 @@ line-length = 120 target-version = ["py311"] [tool.pytest.ini_options] -addopts = "--cov=duty_teller --cov-report=term-missing --cov-fail-under=51" +addopts = "--cov=duty_teller --cov-report=term-missing --cov-fail-under=80" asyncio_mode = "auto" +filterwarnings = [ + "ignore::DeprecationWarning:pytest_asyncio.plugin", +] [tool.pylint.messages_control] disable = ["C0114", "C0115", "C0116"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 5e37342..a9bb33e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -r requirements.txt pytest>=8.0,<9.0 -pytest-asyncio>=0.24,<1.0 +pytest-asyncio>=1.0,<2.0 httpx>=0.27,<1.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c951720 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +"""Pytest configuration and shared fixtures. + +Disposes the global DB engine after each test to avoid ResourceWarning +from unclosed sqlite3 connections when tests touch duty_teller.db.session. +""" + +import pytest + + +@pytest.fixture(autouse=True) +def _dispose_db_engine_after_test(): + """After each test, dispose the global engine so connections are closed.""" + yield + try: + import duty_teller.db.session as session_mod + + if session_mod._engine is not None: + session_mod._engine.dispose() + session_mod._engine = None + session_mod._SessionLocal = None + except Exception: + pass diff --git a/tests/test_api_dependencies.py b/tests/test_api_dependencies.py new file mode 100644 index 0000000..784376d --- /dev/null +++ b/tests/test_api_dependencies.py @@ -0,0 +1,99 @@ +"""Tests for duty_teller.api.dependencies (lang, auth error, date validation, private client).""" + +from unittest.mock import patch + +import pytest +from fastapi import HTTPException + +import duty_teller.api.dependencies as deps +import duty_teller.config as config + + +class TestLangFromAcceptLanguage: + """Tests for _lang_from_accept_language.""" + + def test_none_returns_default(self): + assert deps._lang_from_accept_language(None) == config.DEFAULT_LANGUAGE + + def test_empty_string_returns_default(self): + assert deps._lang_from_accept_language("") == config.DEFAULT_LANGUAGE + assert deps._lang_from_accept_language(" ") == config.DEFAULT_LANGUAGE + + def test_ru_ru_returns_ru(self): + assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == "ru" + + def test_en_us_returns_en(self): + assert deps._lang_from_accept_language("en-US") == "en" + + def test_invalid_fallback_to_en(self): + assert deps._lang_from_accept_language("zz") == "en" + assert deps._lang_from_accept_language("x") == "en" + + +class TestAuthErrorDetail: + """Tests for _auth_error_detail.""" + + def test_hash_mismatch_uses_bad_signature_key(self): + with patch("duty_teller.api.dependencies.t") as mock_t: + mock_t.return_value = "Bad signature" + result = deps._auth_error_detail("hash_mismatch", "en") + assert result == "Bad signature" + mock_t.assert_called_once_with("en", "api.auth_bad_signature") + + def test_other_reason_uses_auth_invalid_key(self): + with patch("duty_teller.api.dependencies.t") as mock_t: + mock_t.return_value = "Invalid auth" + result = deps._auth_error_detail("expired", "ru") + assert result == "Invalid auth" + mock_t.assert_called_once_with("ru", "api.auth_invalid") + + +class TestValidateDutyDates: + """Tests for _validate_duty_dates.""" + + def test_valid_range_no_exception(self): + deps._validate_duty_dates("2025-01-01", "2025-01-31", "en") + + def test_bad_format_raises_400_with_i18n(self): + with patch("duty_teller.api.dependencies.t") as mock_t: + mock_t.return_value = "Bad format message" + with pytest.raises(HTTPException) as exc_info: + deps._validate_duty_dates("01-01-2025", "2025-01-31", "en") + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Bad format message" + mock_t.assert_called_with("en", "dates.bad_format") + + def test_from_after_to_raises_400_with_i18n(self): + with patch("duty_teller.api.dependencies.t") as mock_t: + mock_t.return_value = "From after to message" + with pytest.raises(HTTPException) as exc_info: + deps._validate_duty_dates("2025-02-01", "2025-01-01", "ru") + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "From after to message" + mock_t.assert_called_with("ru", "dates.from_after_to") + + +class TestIsPrivateClient: + """Tests for _is_private_client.""" + + def test_loopback_true(self): + assert deps._is_private_client("127.0.0.1") is True + assert deps._is_private_client("::1") is True + + def test_rfc1918_private_true(self): + assert deps._is_private_client("10.0.0.1") is True + assert deps._is_private_client("192.168.1.1") is True + assert deps._is_private_client("172.16.0.1") is True + assert deps._is_private_client("172.31.255.255") is True + + def test_public_ip_false(self): + assert deps._is_private_client("8.8.8.8") is False + + def test_non_ip_false(self): + assert deps._is_private_client("example.com") is False + assert deps._is_private_client("") is False + assert deps._is_private_client(None) is False + + def test_172_non_private_octet_false(self): + assert deps._is_private_client("172.15.0.1") is False + assert deps._is_private_client("172.32.0.1") is False diff --git a/tests/test_app.py b/tests/test_app.py index 604011b..04b47f9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -311,3 +311,47 @@ def test_calendar_ical_200_returns_only_that_users_duties( assert len(duties_arg) == 1 assert duties_arg[0][0].user_id == 1 assert duties_arg[0][1] == "User A" + + +# --- /api/calendar-events --- + + +@patch("duty_teller.api.app.config.EXTERNAL_CALENDAR_ICS_URL", "") +@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True) +def test_calendar_events_empty_url_returns_empty_list(client): + """When EXTERNAL_CALENDAR_ICS_URL is empty, GET /api/calendar-events returns [] without fetch.""" + with patch("duty_teller.api.app.get_calendar_events") as mock_get: + r = client.get( + "/api/calendar-events", + params={"from": "2025-01-01", "to": "2025-01-31"}, + ) + assert r.status_code == 200 + assert r.json() == [] + mock_get.assert_not_called() + + +@patch("duty_teller.api.app.config.EXTERNAL_CALENDAR_ICS_URL", "https://example.com/cal.ics") +@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True) +def test_calendar_events_200_returns_list_with_date_summary(client): + """GET /api/calendar-events with auth and URL set returns list of {date, summary}.""" + with patch("duty_teller.api.app.get_calendar_events") as mock_get: + mock_get.return_value = [ + {"date": "2025-01-15", "summary": "Team meeting"}, + {"date": "2025-01-20", "summary": "Review"}, + ] + r = client.get( + "/api/calendar-events", + params={"from": "2025-01-01", "to": "2025-01-31"}, + ) + assert r.status_code == 200 + data = r.json() + assert len(data) == 2 + assert data[0]["date"] == "2025-01-15" + assert data[0]["summary"] == "Team meeting" + assert data[1]["date"] == "2025-01-20" + assert data[1]["summary"] == "Review" + mock_get.assert_called_once_with( + "https://example.com/cal.ics", + from_date="2025-01-01", + to_date="2025-01-31", + ) diff --git a/tests/test_calendar_ics.py b/tests/test_calendar_ics.py new file mode 100644 index 0000000..1f625ef --- /dev/null +++ b/tests/test_calendar_ics.py @@ -0,0 +1,153 @@ +"""Tests for duty_teller.api.calendar_ics (_to_date, _event_date_range, _get_events_from_ics, get_calendar_events).""" + +from datetime import date, datetime +from unittest.mock import patch + +import pytest +from icalendar import Calendar, Event + +from duty_teller.api import calendar_ics as mod + + +class TestToDate: + """Tests for _to_date.""" + + def test_datetime_returns_date(self): + dt = datetime(2025, 3, 15, 10, 0, 0) + assert mod._to_date(dt) == date(2025, 3, 15) + + def test_date_returns_same(self): + d = date(2025, 3, 15) + assert mod._to_date(d) == date(2025, 3, 15) + + def test_invalid_returns_none(self): + assert mod._to_date("2025-03-15") is None + assert mod._to_date(None) is None + assert mod._to_date(123) is None + + +class TestEventDateRange: + """Tests for _event_date_range. DTEND is exclusive in iCalendar.""" + + def _vevent(self, dtstart_value, dtend_value=None): + """Build a minimal VEVENT component using icalendar.Event.""" + cal = Calendar() + ev = Event() + ev.add("dtstart", dtstart_value) + if dtend_value is not None: + ev.add("dtend", dtend_value) + cal.add_component(ev) + for c in cal.walk(): + if c.name == "VEVENT": + return c + raise AssertionError("no vevent") + + def test_dtstart_and_dtend_exclusive_end(self): + start = date(2025, 1, 10) + end_exclusive = date(2025, 1, 13) # last day of event = 2025-01-12 + comp = self._vevent(start, end_exclusive) + s, e = mod._event_date_range(comp) + assert s == date(2025, 1, 10) + assert e == date(2025, 1, 12) + + def test_only_dtstart_returns_same_for_start_and_end(self): + start = date(2025, 2, 1) + comp = self._vevent(start) + s, e = mod._event_date_range(comp) + assert s == date(2025, 2, 1) + assert e == date(2025, 2, 1) + + def test_no_dtstart_returns_none_none(self): + cal = Calendar() + ev = Event() + ev.add("summary", "No dates") + cal.add_component(ev) + for c in cal.walk(): + if c.name == "VEVENT": + s, e = mod._event_date_range(c) + assert s is None + assert e is None + return + pytest.fail("no vevent") + + +class TestGetEventsFromIcs: + """Tests for _get_events_from_ics.""" + + def test_minimal_ics_one_event(self): + cal = Calendar() + ev = Event() + ev.add("dtstart", date(2025, 1, 15)) + ev.add("summary", "Meeting") + cal.add_component(ev) + raw = cal.to_ical() + result = mod._get_events_from_ics(raw, "2025-01-01", "2025-01-31") + assert len(result) == 1 + assert result[0]["date"] == "2025-01-15" + assert result[0]["summary"] == "Meeting" + + def test_event_filtered_by_date_range(self): + cal = Calendar() + ev = Event() + ev.add("dtstart", date(2025, 1, 10)) + ev.add("dtend", date(2025, 1, 14)) # 10,11,12,13 + ev.add("summary", "Multi") + cal.add_component(ev) + raw = cal.to_ical() + result = mod._get_events_from_ics(raw, "2025-01-12", "2025-01-13") + assert len(result) == 2 + assert result[0]["date"] == "2025-01-12" + assert result[1]["date"] == "2025-01-13" + + def test_empty_ics_returns_empty_list(self): + cal = Calendar() + raw = cal.to_ical() + result = mod._get_events_from_ics(raw, "2025-01-01", "2025-01-31") + assert result == [] + + def test_broken_ics_returns_empty_list(self): + result = mod._get_events_from_ics(b"not ical at all", "2025-01-01", "2025-01-31") + assert result == [] + + def test_recurring_events_skipped(self): + cal = Calendar() + ev = Event() + ev.add("dtstart", date(2025, 1, 15)) + ev.add("rrule", {"freq": "daily", "count": 3}) + ev.add("summary", "Recurring") + cal.add_component(ev) + raw = cal.to_ical() + result = mod._get_events_from_ics(raw, "2025-01-01", "2025-01-31") + assert result == [] + + +class TestGetCalendarEvents: + """Tests for get_calendar_events (with mocked _fetch_ics, no real HTTP).""" + + def test_empty_url_returns_empty(self): + assert mod.get_calendar_events("", "2025-01-01", "2025-01-31") == [] + + def test_from_after_to_returns_empty(self): + assert mod.get_calendar_events("https://example.com/a.ics", "2025-02-01", "2025-01-01") == [] + + @patch.object(mod, "_fetch_ics", return_value=None) + def test_fetch_returns_none_returns_empty(self, mock_fetch): + result = mod.get_calendar_events("https://example.com/a.ics", "2025-01-01", "2025-01-31") + assert result == [] + mock_fetch.assert_called_once() + + @patch.object(mod, "_fetch_ics") + def test_success_returns_events(self, mock_fetch): + cal = Calendar() + ev = Event() + ev.add("dtstart", date(2025, 1, 20)) + ev.add("summary", "Test event") + cal.add_component(ev) + mock_fetch.return_value = cal.to_ical() + result = mod.get_calendar_events( + "https://example.com/cal.ics", "2025-01-01", "2025-01-31" + ) + assert len(result) == 1 + assert result[0]["date"] == "2025-01-20" + assert result[0]["summary"] == "Test event" + mock_fetch.assert_called_once_with("https://example.com/cal.ics") diff --git a/tests/test_calendar_token_repository.py b/tests/test_calendar_token_repository.py index 4c0e40c..34ed7e3 100644 --- a/tests/test_calendar_token_repository.py +++ b/tests/test_calendar_token_repository.py @@ -25,6 +25,7 @@ def session(): yield s finally: s.close() + engine.dispose() @pytest.fixture diff --git a/tests/test_config.py b/tests/test_config.py index 9109656..6d3e6ed 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,6 @@ -"""Tests for config.is_admin and config.can_access_miniapp.""" +"""Tests for config.is_admin, config.can_access_miniapp, and require_bot_token.""" + +import pytest import duty_teller.config as config @@ -67,3 +69,18 @@ def test_is_admin_by_phone(monkeypatch): assert config.is_admin_by_phone("+7 900 111-11-11") is True assert config.is_admin_by_phone("79001234567") is False assert config.is_admin_by_phone(None) is False + + +def test_require_bot_token_raises_system_exit_when_empty(monkeypatch): + """require_bot_token() raises SystemExit with message about BOT_TOKEN when empty.""" + monkeypatch.setattr(config, "BOT_TOKEN", "") + with pytest.raises(SystemExit) as exc_info: + config.require_bot_token() + assert "BOT_TOKEN" in str(exc_info.value) + assert exc_info.value.code != 0 + + +def test_require_bot_token_does_not_raise_when_set(monkeypatch): + """require_bot_token() does nothing when BOT_TOKEN is set.""" + monkeypatch.setattr(config, "BOT_TOKEN", "123:ABC") + config.require_bot_token() diff --git a/tests/test_db_session.py b/tests/test_db_session.py new file mode 100644 index 0000000..8b7e084 --- /dev/null +++ b/tests/test_db_session.py @@ -0,0 +1,39 @@ +"""Tests for duty_teller.db.session (session_scope rollback/close, get_engine).""" + +from unittest.mock import MagicMock, patch + +import pytest + +from duty_teller.db import session as session_mod + + +def test_session_scope_rollback_on_exception(): + """session_scope: on exception inside block, rollback and close are called.""" + mock_session = MagicMock() + with patch.object(session_mod, "get_session", return_value=mock_session): + with pytest.raises(ValueError): + with session_mod.session_scope("sqlite:///:memory:") as s: + assert s is mock_session + raise ValueError("test") + mock_session.rollback.assert_called_once() + mock_session.close.assert_called_once() + + +def test_session_scope_close_on_success(): + """session_scope: on normal exit, close is called (no rollback).""" + mock_session = MagicMock() + with patch.object(session_mod, "get_session", return_value=mock_session): + with session_mod.session_scope("sqlite:///:memory:") as s: + assert s is mock_session + mock_session.rollback.assert_not_called() + mock_session.close.assert_called_once() + + +def test_get_engine_same_url_returns_same_instance(): + """get_engine: same URL returns the same engine instance.""" + session_mod._engine = None + session_mod._SessionLocal = None + url = "sqlite:///:memory:" + e1 = session_mod.get_engine(url) + e2 = session_mod.get_engine(url) + assert e1 is e2 diff --git a/tests/test_group_duty_pin_service.py b/tests/test_group_duty_pin_service.py new file mode 100644 index 0000000..69d4a4c --- /dev/null +++ b/tests/test_group_duty_pin_service.py @@ -0,0 +1,196 @@ +"""Tests for duty_teller.services.group_duty_pin_service.""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from duty_teller.db.models import Base, Duty, GroupDutyPin, User +from duty_teller.services import group_duty_pin_service as svc + + +@pytest.fixture +def session(): + """In-memory SQLite session with all models.""" + engine = create_engine( + "sqlite:///:memory:", connect_args={"check_same_thread": False} + ) + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine, autocommit=False, autoflush=False) + s = Session() + try: + yield s + finally: + s.close() + engine.dispose() + + +@pytest.fixture +def user(session): + """Create a user in DB.""" + u = User( + telegram_user_id=123, + full_name="Test User", + username="testuser", + first_name="Test", + last_name="User", + phone="+79001234567", + ) + session.add(u) + session.commit() + session.refresh(u) + return u + + +@pytest.fixture +def duty(session, user): + """Create a duty in DB (event_type='duty').""" + d = Duty( + user_id=user.id, + start_at="2025-02-20T06:00:00Z", + end_at="2025-02-21T06:00:00Z", + event_type="duty", + ) + session.add(d) + session.commit() + session.refresh(d) + return d + + +class TestFormatDutyMessage: + """Tests for format_duty_message.""" + + def test_none_duty_returns_no_duty(self): + user = SimpleNamespace(full_name="U") + with patch("duty_teller.services.group_duty_pin_service.t") as mock_t: + mock_t.return_value = "No duty" + result = svc.format_duty_message(None, user, "Europe/Moscow", "en") + assert result == "No duty" + mock_t.assert_called_with("en", "duty.no_duty") + + def test_none_user_returns_no_duty(self): + duty = SimpleNamespace(start_at="2025-01-15T09:00:00Z", end_at="2025-01-15T18:00:00Z") + with patch("duty_teller.services.group_duty_pin_service.t") as mock_t: + mock_t.return_value = "No duty" + result = svc.format_duty_message(duty, None, "Europe/Moscow", "en") + assert result == "No duty" + + def test_with_duty_and_user_returns_formatted(self): + duty = SimpleNamespace( + start_at="2025-01-15T09:00:00Z", + end_at="2025-01-15T18:00:00Z", + ) + user = SimpleNamespace( + full_name="Иван Иванов", + phone="+79001234567", + username="ivan", + ) + with patch("duty_teller.services.group_duty_pin_service.t") as mock_t: + mock_t.side_effect = lambda lang, key: ( + "Duty" if key == "duty.label" else "" + ) + result = svc.format_duty_message(duty, user, "Europe/Moscow", "ru") + assert "Иван Иванов" in result + assert "+79001234567" in result or "79001234567" in result + assert "@ivan" in result + assert "Duty" in result + + +class TestGetDutyMessageText: + """Tests for get_duty_message_text.""" + + def test_no_current_duty_returns_no_duty(self, session): + with patch( + "duty_teller.services.group_duty_pin_service.get_current_duty", + return_value=None, + ): + with patch("duty_teller.services.group_duty_pin_service.t") as mock_t: + mock_t.return_value = "No duty" + result = svc.get_duty_message_text(session, "Europe/Moscow", "en") + assert result == "No duty" + + def test_with_current_duty_returns_formatted(self, session, duty, user): + with patch( + "duty_teller.services.group_duty_pin_service.get_current_duty", + return_value=(duty, user), + ): + with patch("duty_teller.services.group_duty_pin_service.t") as mock_t: + mock_t.side_effect = lambda lang, key: ( + "Duty" if key == "duty.label" else "No duty" + ) + result = svc.get_duty_message_text(session, "Europe/Moscow", "en") + assert "Test User" in result + assert "Duty" in result + + +class TestGetNextShiftEndUtc: + """Tests for get_next_shift_end_utc.""" + + def test_no_next_shift_returns_none(self, session): + with patch( + "duty_teller.services.group_duty_pin_service.get_next_shift_end", + return_value=None, + ): + result = svc.get_next_shift_end_utc(session) + assert result is None + + def test_has_next_shift_returns_naive_utc(self, session): + naive = datetime(2025, 2, 21, 6, 0, 0) + with patch( + "duty_teller.services.group_duty_pin_service.get_next_shift_end", + return_value=naive, + ): + result = svc.get_next_shift_end_utc(session) + assert result == naive + + +class TestSavePin: + """Tests for save_pin.""" + + def test_save_pin_creates_record(self, session): + svc.save_pin(session, chat_id=100, message_id=42) + mid = svc.get_message_id(session, 100) + assert mid == 42 + + def test_save_pin_updates_existing(self, session): + svc.save_pin(session, chat_id=100, message_id=42) + svc.save_pin(session, chat_id=100, message_id=99) + mid = svc.get_message_id(session, 100) + assert mid == 99 + + +class TestDeletePin: + """Tests for delete_pin.""" + + def test_delete_pin_removes_record(self, session): + svc.save_pin(session, chat_id=200, message_id=1) + assert svc.get_message_id(session, 200) == 1 + svc.delete_pin(session, chat_id=200) + assert svc.get_message_id(session, 200) is None + + +class TestGetMessageId: + """Tests for get_message_id.""" + + def test_no_pin_returns_none(self, session): + assert svc.get_message_id(session, 999) is None + + def test_after_save_returns_message_id(self, session): + svc.save_pin(session, chat_id=300, message_id=77) + assert svc.get_message_id(session, 300) == 77 + + +class TestGetAllPinChatIds: + """Tests for get_all_pin_chat_ids.""" + + def test_empty_returns_empty_list(self, session): + assert svc.get_all_pin_chat_ids(session) == [] + + def test_returns_all_chat_ids_with_pins(self, session): + svc.save_pin(session, chat_id=10, message_id=1) + svc.save_pin(session, chat_id=20, message_id=2) + chat_ids = svc.get_all_pin_chat_ids(session) + assert set(chat_ids) == {10, 20} diff --git a/tests/test_handlers_commands.py b/tests/test_handlers_commands.py new file mode 100644 index 0000000..bbef8bf --- /dev/null +++ b/tests/test_handlers_commands.py @@ -0,0 +1,249 @@ +"""Tests for duty_teller.handlers.commands (start, set_phone, calendar_link, help_cmd).""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from duty_teller.handlers.commands import ( + start, + set_phone, + calendar_link, + help_cmd, +) + + +def _make_update(message=None, effective_user=None, effective_chat=None): + """Build a minimal Update with message and effective_user/chat.""" + update = MagicMock() + update.message = message + update.effective_user = effective_user + update.effective_chat = effective_chat + return update + + +def _make_user( + user_id=1, + first_name="Test", + last_name="User", + username="testuser", +): + """Build a minimal Telegram user.""" + u = MagicMock() + u.id = user_id + u.first_name = first_name + u.last_name = last_name + u.username = username + return u + + +def _make_chat(chat_type="private"): + """Build a minimal chat.""" + ch = MagicMock() + ch.type = chat_type + return ch + + +@pytest.mark.asyncio +async def test_start_calls_get_or_create_user_and_reply_text(): + """start: calls get_or_create_user with expected fields and reply_text with start.greeting.""" + message = MagicMock() + message.reply_text = AsyncMock() + user = _make_user(42, first_name="Alice", last_name="B") + update = _make_update(message=message, effective_user=user) + + with patch( + "duty_teller.handlers.commands.session_scope", + ) as mock_scope: + mock_session = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + with patch( + "duty_teller.handlers.commands.get_or_create_user", + ) as mock_get_user: + with patch("duty_teller.handlers.commands.t") as mock_t: + mock_t.return_value = "Hi! Use /help." + with patch("duty_teller.handlers.commands.get_lang", return_value="en"): + await start(update, MagicMock()) + mock_get_user.assert_called_once() + call_kw = mock_get_user.call_args[1] + assert call_kw["telegram_user_id"] == 42 + assert call_kw["full_name"] == "Alice B" + assert call_kw["username"] == "testuser" + message.reply_text.assert_called_once_with("Hi! Use /help.") + mock_t.assert_called_with("en", "start.greeting") + + +@pytest.mark.asyncio +async def test_start_no_message_returns_early(): + """start: if update.message is None, does not call get_or_create_user or reply.""" + update = _make_update(message=None, effective_user=_make_user()) + with patch("duty_teller.handlers.commands.session_scope") as mock_scope: + with patch("duty_teller.handlers.commands.get_or_create_user") as mock_get_user: + await start(update, MagicMock()) + mock_get_user.assert_not_called() + mock_scope.assert_not_called() + + +@pytest.mark.asyncio +async def test_set_phone_private_with_number_replies_saved(): + """set_phone in private chat with number -> reply 'saved'.""" + message = MagicMock() + message.reply_text = AsyncMock() + user = _make_user() + chat = _make_chat("private") + update = _make_update(message=message, effective_user=user, effective_chat=chat) + context = MagicMock() + context.args = ["+79001234567"] + + with patch("duty_teller.handlers.commands.session_scope") as mock_scope: + mock_session = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + with patch("duty_teller.handlers.commands.get_or_create_user"): + with patch( + "duty_teller.handlers.commands.set_user_phone", + return_value=MagicMock(), + ): + with patch("duty_teller.handlers.commands.get_lang", return_value="en"): + with patch("duty_teller.handlers.commands.t") as mock_t: + mock_t.return_value = "Phone saved: +79001234567" + await set_phone(update, context) + message.reply_text.assert_called_once_with("Phone saved: +79001234567") + + +@pytest.mark.asyncio +async def test_set_phone_private_no_number_replies_cleared(): + """set_phone in private chat without number -> reply 'cleared'.""" + message = MagicMock() + message.reply_text = AsyncMock() + user = _make_user() + chat = _make_chat("private") + update = _make_update(message=message, effective_user=user, effective_chat=chat) + context = MagicMock() + context.args = [] + + with patch("duty_teller.handlers.commands.session_scope") as mock_scope: + mock_session = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + with patch("duty_teller.handlers.commands.get_or_create_user"): + with patch( + "duty_teller.handlers.commands.set_user_phone", + return_value=MagicMock(), + ): + with patch("duty_teller.handlers.commands.get_lang", return_value="en"): + with patch("duty_teller.handlers.commands.t") as mock_t: + mock_t.return_value = "Phone cleared" + await set_phone(update, context) + message.reply_text.assert_called_once_with("Phone cleared") + + +@pytest.mark.asyncio +async def test_set_phone_group_replies_private_only(): + """set_phone in group chat -> reply private_only.""" + message = MagicMock() + message.reply_text = AsyncMock() + user = _make_user() + chat = _make_chat("group") + update = _make_update(message=message, effective_user=user, effective_chat=chat) + context = MagicMock() + context.args = ["+79001234567"] + + with patch("duty_teller.handlers.commands.get_lang", return_value="en"): + with patch("duty_teller.handlers.commands.t") as mock_t: + mock_t.return_value = "Private only" + await set_phone(update, context) + message.reply_text.assert_called_once_with("Private only") + mock_t.assert_called_with("en", "set_phone.private_only") + + +@pytest.mark.asyncio +async def test_calendar_link_with_user_and_token_replies_with_url(): + """calendar_link with allowed user and token -> reply with link.""" + message = MagicMock() + message.reply_text = AsyncMock() + user = _make_user() + chat = _make_chat("private") + update = _make_update(message=message, effective_user=user, effective_chat=chat) + + with patch("duty_teller.handlers.commands.session_scope") as mock_scope: + mock_session = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + with patch("duty_teller.handlers.commands.get_or_create_user") as mock_get_user: + mock_user = MagicMock() + mock_user.id = 10 + mock_user.phone = None + mock_get_user.return_value = mock_user + with patch("duty_teller.handlers.commands.config") as mock_cfg: + mock_cfg.can_access_miniapp.return_value = True + mock_cfg.can_access_miniapp_by_phone.return_value = False + mock_cfg.MINI_APP_BASE_URL = "https://example.com" + with patch( + "duty_teller.handlers.commands.create_calendar_token", + return_value="abc43token", + ): + with patch("duty_teller.handlers.commands.get_lang", return_value="en"): + with patch("duty_teller.handlers.commands.t") as mock_t: + mock_t.side_effect = lambda lang, key, **kw: ( + f"URL: {kw.get('url', '')}" if "success" in key else "Hint" + ) + await calendar_link(update, MagicMock()) + message.reply_text.assert_called_once() + call_args = message.reply_text.call_args[0][0] + assert "abc43token" in call_args or "example.com" in call_args + + +@pytest.mark.asyncio +async def test_calendar_link_denied_replies_access_denied(): + """calendar_link when user not in allowlist -> reply access_denied.""" + message = MagicMock() + message.reply_text = AsyncMock() + user = _make_user() + chat = _make_chat("private") + update = _make_update(message=message, effective_user=user, effective_chat=chat) + + with patch("duty_teller.handlers.commands.session_scope") as mock_scope: + mock_session = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + with patch("duty_teller.handlers.commands.get_or_create_user") as mock_get_user: + mock_user = MagicMock() + mock_user.id = 10 + mock_user.phone = None + mock_get_user.return_value = mock_user + with patch("duty_teller.handlers.commands.config") as mock_cfg: + mock_cfg.can_access_miniapp.return_value = False + mock_cfg.can_access_miniapp_by_phone.return_value = False + with patch("duty_teller.handlers.commands.get_lang", return_value="en"): + with patch("duty_teller.handlers.commands.t") as mock_t: + mock_t.return_value = "Access denied" + await calendar_link(update, MagicMock()) + message.reply_text.assert_called_once_with("Access denied") + mock_t.assert_called_with("en", "calendar_link.access_denied") + + +@pytest.mark.asyncio +async def test_help_cmd_replies_with_help_text(): + """help_cmd: reply_text called with help content (title, start, help, etc.).""" + message = MagicMock() + message.reply_text = AsyncMock() + user = _make_user() + update = _make_update(message=message, effective_user=user) + + with patch("duty_teller.handlers.commands.session_scope") as mock_scope: + mock_session = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + with patch( + "duty_teller.handlers.commands.is_admin_for_telegram_user", + return_value=False, + ): + with patch("duty_teller.handlers.commands.get_lang", return_value="en"): + with patch("duty_teller.handlers.commands.t") as mock_t: + mock_t.side_effect = lambda lang, key: f"[{key}]" + await help_cmd(update, MagicMock()) + message.reply_text.assert_called_once() + text = message.reply_text.call_args[0][0] + assert "help.title" in text or "[help.title]" in text + assert "help.start" in text or "[help.start]" in text diff --git a/tests/test_handlers_errors.py b/tests/test_handlers_errors.py new file mode 100644 index 0000000..0c7b807 --- /dev/null +++ b/tests/test_handlers_errors.py @@ -0,0 +1,53 @@ +"""Tests for duty_teller.handlers.errors (error_handler).""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from duty_teller.handlers.errors import error_handler + + +@pytest.mark.asyncio +async def test_error_handler_replies_with_generic_message(): + """error_handler: when update has effective_message, reply_text with errors.generic.""" + # Handler checks isinstance(update, Update); patch Update so our mock passes. + class FakeUpdate: + pass + + update = FakeUpdate() + update.effective_message = MagicMock() + update.effective_message.reply_text = AsyncMock() + 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") as mock_t: + mock_t.return_value = "An error occurred." + await error_handler(update, context) + update.effective_message.reply_text.assert_called_once_with("An error occurred.") + mock_t.assert_called_with("en", "errors.generic") + + +@pytest.mark.asyncio +async def test_error_handler_no_effective_message_does_not_send(): + """error_handler: when update has no effective_message, does not call reply_text.""" + update = MagicMock() + update.effective_message = None + update.effective_user = MagicMock() + context = MagicMock() + context.error = Exception("test") + + with patch("duty_teller.handlers.errors.get_lang", return_value="en"): + with patch("duty_teller.handlers.errors.t") as mock_t: + await error_handler(update, context) + mock_t.assert_not_called() + + +@pytest.mark.asyncio +async def test_error_handler_update_none_does_not_crash(): + """error_handler: when update is None, does not crash (no reply).""" + context = MagicMock() + context.error = Exception("test") + await error_handler(None, context) diff --git a/tests/test_handlers_group_duty_pin.py b/tests/test_handlers_group_duty_pin.py new file mode 100644 index 0000000..a1100e0 --- /dev/null +++ b/tests/test_handlers_group_duty_pin.py @@ -0,0 +1,145 @@ +"""Tests for duty_teller.handlers.group_duty_pin (sync wrappers, update_group_pin, pin_duty_cmd).""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from duty_teller.handlers import group_duty_pin as mod + + +class TestSyncWrappers: + """Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync.""" + + def test_get_duty_message_text_sync(self): + with patch.object(mod, "session_scope") as mock_scope: + mock_session = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + with patch.object(mod, "get_duty_message_text", return_value="Duty text"): + result = mod._get_duty_message_text_sync("en") + assert result == "Duty text" + mock_scope.assert_called_once() + + def test_sync_save_pin(self): + with patch.object(mod, "session_scope") as mock_scope: + mock_session = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + with patch.object(mod, "save_pin") as mock_save: + mod._sync_save_pin(100, 42) + mock_save.assert_called_once_with(mock_session, 100, 42) + + def test_sync_delete_pin(self): + with patch.object(mod, "session_scope") as mock_scope: + mock_session = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + with patch.object(mod, "delete_pin") as mock_delete: + mod._sync_delete_pin(200) + mock_delete.assert_called_once_with(mock_session, 200) + + def test_sync_get_message_id(self): + with patch.object(mod, "session_scope") as mock_scope: + mock_session = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + with patch.object(mod, "get_message_id", return_value=99): + result = mod._sync_get_message_id(300) + assert result == 99 + + def test_get_all_pin_chat_ids_sync(self): + with patch.object(mod, "session_scope") as mock_scope: + mock_session = MagicMock() + mock_scope.return_value.__enter__.return_value = mock_session + mock_scope.return_value.__exit__.return_value = None + with patch.object(mod, "get_all_pin_chat_ids", return_value=[10, 20]): + result = mod._get_all_pin_chat_ids_sync() + assert result == [10, 20] + + +@pytest.mark.asyncio +async def test_update_group_pin_edits_message_and_schedules_next(): + """update_group_pin: with message_id and text, edits message and schedules next update.""" + context = MagicMock() + context.job = MagicMock() + context.job.data = {"chat_id": 123} + context.bot = MagicMock() + context.bot.edit_message_text = AsyncMock() + context.application = MagicMock() + context.application.job_queue = MagicMock() + context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[]) + context.application.job_queue.run_once = MagicMock() + + with patch.object( + mod, "_sync_get_message_id", return_value=1 + ): + with patch.object( + mod, "_get_duty_message_text_sync", return_value="Current duty" + ): + with patch.object( + mod, "_get_next_shift_end_sync", return_value=None + ): + with patch.object(mod, "_schedule_next_update", AsyncMock()): + await mod.update_group_pin(context) + context.bot.edit_message_text.assert_called_once_with( + chat_id=123, message_id=1, text="Current duty" + ) + + +@pytest.mark.asyncio +async def test_update_group_pin_no_message_id_skips(): + """update_group_pin: when no pin record (message_id None), does not edit.""" + context = MagicMock() + context.job = MagicMock() + context.job.data = {"chat_id": 456} + context.bot = MagicMock() + context.bot.edit_message_text = AsyncMock() + + with patch.object(mod, "_sync_get_message_id", return_value=None): + await mod.update_group_pin(context) + context.bot.edit_message_text.assert_not_called() + + +@pytest.mark.asyncio +async def test_pin_duty_cmd_group_only_reply(): + """pin_duty_cmd in private chat -> reply group_only.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "private" + update.effective_chat.id = 1 + update.effective_user = MagicMock() + context = MagicMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "Group only" + await mod.pin_duty_cmd(update, context) + update.message.reply_text.assert_called_once_with("Group only") + mock_t.assert_called_with("en", "pin_duty.group_only") + + +@pytest.mark.asyncio +async def test_pin_duty_cmd_group_pins_and_replies_pinned(): + """pin_duty_cmd in group with existing pin record -> pin and reply pinned.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "group" + update.effective_chat.id = 100 + update.effective_user = MagicMock() + context = MagicMock() + context.bot = MagicMock() + context.bot.pin_chat_message = AsyncMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch.object(mod, "_sync_get_message_id", return_value=5): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "Pinned" + await mod.pin_duty_cmd(update, context) + context.bot.pin_chat_message.assert_called_once_with( + chat_id=100, message_id=5, disable_notification=True + ) + update.message.reply_text.assert_called_once_with("Pinned") diff --git a/tests/test_handlers_init.py b/tests/test_handlers_init.py new file mode 100644 index 0000000..70bff27 --- /dev/null +++ b/tests/test_handlers_init.py @@ -0,0 +1,13 @@ +"""Tests for duty_teller.handlers.register_handlers.""" + +from unittest.mock import MagicMock + +from duty_teller.handlers import register_handlers + + +def test_register_handlers_adds_all_handlers(): + """register_handlers: adds command, import, group pin handlers and error handler.""" + mock_app = MagicMock() + register_handlers(mock_app) + assert mock_app.add_handler.call_count >= 9 + assert mock_app.add_error_handler.call_count == 1 diff --git a/tests/test_import_duty_schedule_integration.py b/tests/test_import_duty_schedule_integration.py index 0278dab..7b16bb5 100644 --- a/tests/test_import_duty_schedule_integration.py +++ b/tests/test_import_duty_schedule_integration.py @@ -20,17 +20,23 @@ def db_url(): return "sqlite:///:memory:" +def _dispose_global_engine(session_module): + """Dispose global engine so connections are closed, then clear cache.""" + if session_module._engine is not None: + session_module._engine.dispose() + session_module._engine = None + session_module._SessionLocal = None + + @pytest.fixture(autouse=True) def _reset_db_session(db_url): """Ensure each test uses a fresh engine for :memory: (clear global cache for test URL).""" import duty_teller.db.session as session_module - session_module._engine = None - session_module._SessionLocal = None + _dispose_global_engine(session_module) init_db(db_url) yield - session_module._engine = None - session_module._SessionLocal = None + _dispose_global_engine(session_module) def test_import_creates_users_and_duties(db_url): diff --git a/tests/test_import_service.py b/tests/test_import_service.py new file mode 100644 index 0000000..8cfeb0b --- /dev/null +++ b/tests/test_import_service.py @@ -0,0 +1,47 @@ +"""Tests for duty_teller.services.import_service (_consecutive_date_ranges).""" + +from datetime import date + +import pytest + +from duty_teller.services.import_service import _consecutive_date_ranges + + +class TestConsecutiveDateRanges: + """Unit tests for _consecutive_date_ranges.""" + + def test_empty_returns_empty(self): + assert _consecutive_date_ranges([]) == [] + + def test_single_day_one_range(self): + d = date(2025, 2, 10) + assert _consecutive_date_ranges([d]) == [(d, d)] + + def test_consecutive_days_one_range(self): + dates = [date(2025, 1, 10), date(2025, 1, 11), date(2025, 1, 12)] + assert _consecutive_date_ranges(dates) == [ + (date(2025, 1, 10), date(2025, 1, 12)), + ] + + def test_two_ranges_with_gap(self): + dates = [ + date(2025, 1, 5), + date(2025, 1, 6), + date(2025, 1, 10), + date(2025, 1, 11), + ] + assert _consecutive_date_ranges(dates) == [ + (date(2025, 1, 5), date(2025, 1, 6)), + (date(2025, 1, 10), date(2025, 1, 11)), + ] + + def test_unsorted_with_duplicates_normalized(self): + dates = [ + date(2025, 1, 12), + date(2025, 1, 10), + date(2025, 1, 11), + date(2025, 1, 10), + ] + assert _consecutive_date_ranges(dates) == [ + (date(2025, 1, 10), date(2025, 1, 12)), + ] diff --git a/tests/test_package_init.py b/tests/test_package_init.py new file mode 100644 index 0000000..19dcad2 --- /dev/null +++ b/tests/test_package_init.py @@ -0,0 +1,16 @@ +"""Tests for duty_teller package __init__ (version fallback).""" + +import importlib +from importlib.metadata import PackageNotFoundError +from unittest.mock import patch + +import duty_teller + + +def test_version_fallback_when_package_not_installed(): + """When version('duty-teller') raises PackageNotFoundError, __version__ is '0.1.0'.""" + with patch("importlib.metadata.version", side_effect=PackageNotFoundError): + importlib.reload(duty_teller) + assert duty_teller.__version__ == "0.1.0" + # Restore so other tests see normal version + importlib.reload(duty_teller) diff --git a/tests/test_repository_duty_range.py b/tests/test_repository_duty_range.py index c655997..127430c 100644 --- a/tests/test_repository_duty_range.py +++ b/tests/test_repository_duty_range.py @@ -27,6 +27,7 @@ def session(): yield s finally: s.close() + engine.dispose() @pytest.fixture diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..2d502ef --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,119 @@ +"""Tests for duty_teller.run (main entry point with mocks).""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from duty_teller.run import main, _run_uvicorn, _set_default_menu_button_webapp + + +def test_main_builds_app_and_starts_thread(): + """main: calls require_bot_token, builds Application, register_handlers, starts uvicorn thread, run_polling.""" + mock_app = MagicMock() + mock_app.run_polling = MagicMock(side_effect=KeyboardInterrupt()) + mock_builder = MagicMock() + mock_builder.token.return_value = mock_builder + mock_builder.post_init.return_value = mock_builder + mock_builder.build.return_value = mock_app + + # Avoid loading real DB when main() imports api.app (prevents unclosed connection warnings). + 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.run.require_bot_token"): + with patch("duty_teller.run.ApplicationBuilder", return_value=mock_builder): + with patch("duty_teller.run.register_handlers") as mock_register: + with patch("duty_teller.run.threading.Thread") as mock_thread_class: + with patch("duty_teller.db.session.session_scope", mock_scope): + mock_thread = MagicMock() + mock_thread_class.return_value = mock_thread + with pytest.raises(KeyboardInterrupt): + main() + mock_register.assert_called_once_with(mock_app) + mock_builder.token.assert_called_once() + mock_thread.start.assert_called_once() + mock_app.run_polling.assert_called_once() + + +def test_run_uvicorn_creates_server(): + """_run_uvicorn: creates uvicorn config and runs server (mocked).""" + with patch("uvicorn.Server") as mock_server_class: + mock_server = MagicMock() + # run_until_complete needs a real coroutine + mock_server.serve = MagicMock(return_value=asyncio.sleep(0)) + mock_server_class.return_value = mock_server + _run_uvicorn(MagicMock(), 8080) + mock_server_class.assert_called_once() + mock_server.serve.assert_called_once() + + +def test_set_default_menu_button_skips_when_no_base_url(): + """_set_default_menu_button_webapp: returns early when MINI_APP_BASE_URL or BOT_TOKEN not set.""" + with patch("duty_teller.run.config") as mock_cfg: + mock_cfg.MINI_APP_BASE_URL = "" + mock_cfg.BOT_TOKEN = "token" + _set_default_menu_button_webapp() + mock_cfg.MINI_APP_BASE_URL = "https://example.com" + mock_cfg.BOT_TOKEN = "" + _set_default_menu_button_webapp() + # No urlopen should be called + with patch("duty_teller.run.config") as mock_cfg: + mock_cfg.MINI_APP_BASE_URL = None + mock_cfg.BOT_TOKEN = "x" + _set_default_menu_button_webapp() + + +def test_set_default_menu_button_skips_when_not_https(): + """_set_default_menu_button_webapp: returns early when menu_url does not start with https.""" + with patch("duty_teller.run.config") as mock_cfg: + mock_cfg.MINI_APP_BASE_URL = "http://example.com" + mock_cfg.BOT_TOKEN = "token" + _set_default_menu_button_webapp() + # No urlopen + + +def test_set_default_menu_button_calls_telegram_api(): + """_set_default_menu_button_webapp: when URL is https, calls Telegram API (mocked).""" + with patch("duty_teller.run.config") as mock_cfg: + mock_cfg.MINI_APP_BASE_URL = "https://example.com/" + mock_cfg.BOT_TOKEN = "123:ABC" + with patch("urllib.request.urlopen") as mock_urlopen: + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + _set_default_menu_button_webapp() + mock_urlopen.assert_called_once() + req = mock_urlopen.call_args[0][0] + assert "setChatMenuButton" in req.full_url + assert "123:ABC" in req.full_url + + +def test_set_default_menu_button_handles_urlopen_error(): + """_set_default_menu_button_webapp: on urlopen error, logs and does not raise.""" + with patch("duty_teller.run.config") as mock_cfg: + mock_cfg.MINI_APP_BASE_URL = "https://example.com/" + mock_cfg.BOT_TOKEN = "123:ABC" + with patch("urllib.request.urlopen") as mock_urlopen: + mock_urlopen.side_effect = OSError("network error") + _set_default_menu_button_webapp() + mock_urlopen.assert_called_once() + + +def test_set_default_menu_button_handles_non_200_response(): + """_set_default_menu_button_webapp: on non-200 response, logs warning.""" + with patch("duty_teller.run.config") as mock_cfg: + mock_cfg.MINI_APP_BASE_URL = "https://example.com/" + mock_cfg.BOT_TOKEN = "123:ABC" + with patch("urllib.request.urlopen") as mock_urlopen: + mock_resp = MagicMock() + mock_resp.status = 400 + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + _set_default_menu_button_webapp() + mock_urlopen.assert_called_once() diff --git a/tests/test_utils.py b/tests/test_utils.py index 28906d3..2a9f02e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -97,3 +97,8 @@ def test_parse_handover_invalid(): assert parse_handover_time("not a time") is None # 25:00 is normalized to 1:00 by hour % 24; use non-matching string assert parse_handover_time("12") is None + + +def test_parse_handover_invalid_timezone_returns_none(): + """Invalid IANA timezone string -> ZoneInfo raises, returns None.""" + assert parse_handover_time("09:00 NotAReal/Timezone") is None