Files
duty-teller/tests/test_app.py
Nikolay Tatarinov aa89494bd5
All checks were successful
CI / lint-and-test (push) Successful in 22s
feat: enhance calendar ICS generation with event type filtering
- Added support for filtering calendar events by type in the ICS generation API endpoint, allowing users to specify whether to include only duty shifts or all event types (duty, unavailable, vacation).
- Updated the `get_duties_for_user` function to accept an optional `event_types` parameter, enabling more flexible data retrieval based on user preferences.
- Enhanced unit tests to cover the new event type filtering functionality, ensuring correct behavior and reliability of the ICS generation process.
2026-02-20 17:47:52 +03:00

416 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"
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, 4050 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, 4050 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, 4050 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",
)