- 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.
298 lines
11 KiB
Python
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"
|