All checks were successful
CI / lint-and-test (push) Successful in 22s
- 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.
416 lines
16 KiB
Python
416 lines
16 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"
|
||
|
||
|
||
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",
|
||
)
|