"""Tests for FastAPI app /api/duties.""" import time from unittest.mock import ANY, patch import pytest from fastapi.testclient import TestClient import duty_teller.config as config from duty_teller.api.app import app from tests.helpers import make_init_data @pytest.fixture def client(): return TestClient(app) def test_health(client): """Health endpoint returns 200 and status ok for Docker HEALTHCHECK.""" r = client.get("/health") assert r.status_code == 200 assert r.json() == {"status": "ok"} def test_duties_invalid_date_format(client): r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"}) assert r.status_code == 400 detail = r.json()["detail"] assert "from" in detail.lower() and "to" in detail.lower() def test_duties_from_after_to(client): r = client.get("/api/duties", params={"from": "2025-02-01", "to": "2025-01-01"}) assert r.status_code == 400 detail = r.json()["detail"].lower() assert "from" in detail or "to" in detail or "after" in detail or "позже" in detail @patch("duty_teller.api.dependencies._is_private_client") @patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False) def test_duties_403_without_init_data_from_public_client(mock_private, client): mock_private.return_value = False r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, ) assert r.status_code == 403 @patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True) @patch("duty_teller.api.app.fetch_duties_response") def test_duties_200_when_skip_auth(mock_fetch, client): mock_fetch.return_value = [] r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, ) assert r.status_code == 200 assert r.json() == [] mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31") @patch("duty_teller.api.dependencies.validate_init_data_with_reason") @patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False) def test_duties_403_when_init_data_invalid(mock_validate, client): mock_validate.return_value = (None, None, "hash_mismatch", "en") r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, headers={"X-Telegram-Init-Data": "some=data&hash=abc"}, ) assert r.status_code == 403 detail = r.json()["detail"] assert ( "signature" in detail.lower() or "авторизации" in detail or "Неверные" in detail or "Неверная" in detail ) @patch("duty_teller.api.dependencies.get_user_by_telegram_id") @patch("duty_teller.api.dependencies.validate_init_data_with_reason") @patch("duty_teller.api.dependencies.config.can_access_miniapp") @patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False) def test_duties_403_when_username_not_allowed( mock_can_access, mock_validate, mock_get_user, client ): mock_validate.return_value = (123, "someuser", "ok", "en") mock_can_access.return_value = False mock_get_user.return_value = None # no user in DB or no phone path with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"}, ) assert r.status_code == 403 assert ( "Access denied" in r.json()["detail"] or "Доступ запрещён" in r.json()["detail"] ) mock_fetch.assert_not_called() @patch("duty_teller.api.dependencies.config.can_access_miniapp_by_phone") @patch("duty_teller.api.dependencies.get_user_by_telegram_id") @patch("duty_teller.api.dependencies.validate_init_data_with_reason") @patch("duty_teller.api.dependencies.config.can_access_miniapp") @patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False) def test_duties_403_when_no_username_and_phone_not_in_allowlist( mock_can_access, mock_validate, mock_get_user, mock_can_access_phone, client ): """No username in initData and user's phone not in ALLOWED_PHONES -> 403.""" from types import SimpleNamespace mock_validate.return_value = (456, None, "ok", "en") mock_can_access.return_value = False mock_get_user.return_value = SimpleNamespace(phone="+79001111111", full_name="User") mock_can_access_phone.return_value = False with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"}, ) assert r.status_code == 403 mock_fetch.assert_not_called() @patch("duty_teller.api.dependencies.config.can_access_miniapp_by_phone") @patch("duty_teller.api.dependencies.get_user_by_telegram_id") @patch("duty_teller.api.dependencies.validate_init_data_with_reason") @patch("duty_teller.api.dependencies.config.can_access_miniapp") @patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False) def test_duties_200_when_no_username_but_phone_in_allowlist( mock_can_access, mock_validate, mock_get_user, mock_can_access_phone, client ): """No username in initData but user's phone in ALLOWED_PHONES -> 200.""" from types import SimpleNamespace mock_validate.return_value = (789, None, "ok", "en") mock_can_access.return_value = False mock_get_user.return_value = SimpleNamespace( phone="+7 900 123-45-67", full_name="Иван" ) mock_can_access_phone.return_value = True with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: mock_fetch.return_value = [] r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"}, ) assert r.status_code == 200 assert r.json() == [] mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31") @patch("duty_teller.api.dependencies.validate_init_data_with_reason") @patch("duty_teller.api.dependencies.config.can_access_miniapp") @patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True) def test_duties_200_with_valid_init_data_and_skip_auth_not_in_allowlist( mock_can_access, mock_validate, client ): """With MINI_APP_SKIP_AUTH=True, valid initData grants access without allowlist check.""" mock_validate.return_value = ("outsider_user", "ok", "en") mock_can_access.return_value = False with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: mock_fetch.return_value = [] r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"}, ) assert r.status_code == 200 assert r.json() == [] mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31") mock_can_access.assert_not_called() mock_validate.assert_not_called() @patch("duty_teller.api.dependencies.validate_init_data_with_reason") @patch("duty_teller.api.dependencies.config.can_access_miniapp") def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client): mock_validate.return_value = (1, "alloweduser", "ok", "en") mock_can_access.return_value = True with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: mock_fetch.return_value = [ { "id": 1, "user_id": 10, "start_at": "2025-01-15T09:00:00Z", "end_at": "2025-01-15T18:00:00Z", "full_name": "Иван Иванов", } ] r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"}, ) assert r.status_code == 200 assert len(r.json()) == 1 assert r.json()[0]["full_name"] == "Иван Иванов" mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31") def test_duties_e2e_auth_real_validation(client, monkeypatch): test_token = "123:ABC" test_username = "e2euser" monkeypatch.setattr(config, "BOT_TOKEN", test_token) monkeypatch.setattr(config, "ALLOWED_USERNAMES", {test_username}) monkeypatch.setattr(config, "ADMIN_USERNAMES", set()) monkeypatch.setattr(config, "INIT_DATA_MAX_AGE_SECONDS", 0) init_data = make_init_data( {"id": 1, "username": test_username}, test_token, auth_date=int(time.time()), ) with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: mock_fetch.return_value = [] r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, headers={"X-Telegram-Init-Data": init_data}, ) assert r.status_code == 200 assert r.json() == [] mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31") @patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True) def test_duties_200_with_unknown_event_type_mapped_to_duty(client): from types import SimpleNamespace fake_duty = SimpleNamespace( id=1, user_id=10, start_at="2025-01-15T09:00:00Z", end_at="2025-01-15T18:00:00Z", event_type="unknown", ) def fake_get_duties(session, from_date, to_date): return [(fake_duty, "User A")] with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties): r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, ) assert r.status_code == 200 data = r.json() assert len(data) == 1 assert data[0]["event_type"] == "duty" assert data[0]["full_name"] == "User A" def test_calendar_ical_404_invalid_token_format(client): """GET /api/calendar/ical/{token}.ics with invalid token format returns 404 without DB call.""" # Token format must be base64url, 40–50 chars; short or invalid chars → 404 r = client.get("/api/calendar/ical/short.ics") assert r.status_code == 404 assert "not found" in r.text.lower() r2 = client.get("/api/calendar/ical/" + "x" * 60 + ".ics") assert r2.status_code == 404 r3 = client.get("/api/calendar/ical/../../../etc/passwd.ics") assert r3.status_code == 404 @patch("duty_teller.api.app.get_user_by_calendar_token") def test_calendar_ical_404_unknown_token(mock_get_user, client): """GET /api/calendar/ical/{token}.ics with unknown token returns 404.""" mock_get_user.return_value = None # Use a token that passes format validation (base64url, 40–50 chars) valid_format_token = "A" * 43 r = client.get(f"/api/calendar/ical/{valid_format_token}.ics") assert r.status_code == 404 assert "not found" in r.text.lower() mock_get_user.assert_called_once() @patch("duty_teller.api.app.build_personal_ics") @patch("duty_teller.api.app.get_duties_for_user") @patch("duty_teller.api.app.get_user_by_calendar_token") def test_calendar_ical_200_returns_only_that_users_duties( mock_get_user, mock_get_duties, mock_build_ics, client ): """GET /api/calendar/ical/{token}.ics returns ICS with only the token owner's duties.""" from types import SimpleNamespace mock_user = SimpleNamespace(id=1, full_name="User A") mock_get_user.return_value = mock_user duty = SimpleNamespace( id=10, user_id=1, start_at="2026-06-15T09:00:00Z", end_at="2026-06-15T18:00:00Z", event_type="duty", ) mock_get_duties.return_value = [(duty, "User A")] mock_build_ics.return_value = ( b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR" ) # Token must pass format validation (base64url, 40–50 chars) token = "x" * 43 r = client.get(f"/api/calendar/ical/{token}.ics") assert r.status_code == 200 assert r.headers.get("content-type", "").startswith("text/calendar") assert b"BEGIN:VCALENDAR" in r.content mock_get_user.assert_called_once() mock_get_duties.assert_called_once_with( ANY, 1, from_date=ANY, to_date=ANY, event_types=["duty"] ) mock_build_ics.assert_called_once() # Only User A's duty was passed to build_personal_ics duties_arg = mock_build_ics.call_args[0][0] assert len(duties_arg) == 1 assert duties_arg[0][0].user_id == 1 assert duties_arg[0][1] == "User A" @patch("duty_teller.api.app.build_personal_ics") @patch("duty_teller.api.app.get_duties_for_user") @patch("duty_teller.api.app.get_user_by_calendar_token") def test_calendar_ical_events_all_returns_all_event_types( mock_get_user, mock_get_duties, mock_build_ics, client ): """GET /api/calendar/ical/{token}.ics?events=all returns ICS with duty, unavailable, vacation.""" from types import SimpleNamespace mock_user = SimpleNamespace(id=1, full_name="User A") mock_get_user.return_value = mock_user duty = SimpleNamespace( id=10, user_id=1, start_at="2026-06-15T09:00:00Z", end_at="2026-06-15T18:00:00Z", event_type="duty", ) unavailable = SimpleNamespace( id=11, user_id=1, start_at="2026-06-16T09:00:00Z", end_at="2026-06-16T18:00:00Z", event_type="unavailable", ) vacation = SimpleNamespace( id=12, user_id=1, start_at="2026-06-17T09:00:00Z", end_at="2026-06-17T18:00:00Z", event_type="vacation", ) mock_get_duties.return_value = [ (duty, "User A"), (unavailable, "User A"), (vacation, "User A"), ] mock_build_ics.return_value = b"BEGIN:VCALENDAR\r\nVEVENT\r\nEND:VCALENDAR" token = "y" * 43 r = client.get(f"/api/calendar/ical/{token}.ics", params={"events": "all"}) assert r.status_code == 200 assert r.headers.get("content-type", "").startswith("text/calendar") mock_get_duties.assert_called_once_with( ANY, 1, from_date=ANY, to_date=ANY, event_types=None ) duties_arg = mock_build_ics.call_args[0][0] assert len(duties_arg) == 3 assert duties_arg[0][0].event_type == "duty" assert duties_arg[1][0].event_type == "unavailable" assert duties_arg[2][0].event_type == "vacation" # --- /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", )