Files
duty-teller/tests/test_app.py
Nikolay Tatarinov dc116270b7 feat: add calendar subscription token functionality and ICS generation
- Introduced a new database model for calendar subscription tokens, allowing users to generate unique tokens for accessing their personal calendar.
- Implemented API endpoint to return ICS files containing only the subscribing user's duties, enhancing user experience with personalized calendar access.
- Added utility functions for generating ICS files from user duties, ensuring proper formatting and timezone handling.
- Updated command handlers to support the new calendar link feature, providing users with easy access to their personal calendar subscriptions.
- Included unit tests for the new functionality, ensuring reliability and correctness of token generation and ICS file creation.
2026-02-19 17:04:22 +03:00

298 lines
11 KiB
Python

"""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_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"
@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
r = client.get("/api/calendar/ical/unknown-token-xyz.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"
)
r = client.get("/api/calendar/ical/valid-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)
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"