All checks were successful
CI / lint-and-test (push) Successful in 22s
- Added Docker health check endpoint to the FastAPI application, returning a 200 status when the app is running. - Updated Dockerfile to include curl for health checks and modified entrypoint script to exit on errors. - Enhanced .dockerignore and .gitignore files to exclude coverage and test artifacts. - Updated docker-compose.prod.yml to specify version. - Added pytest-cov as a development dependency to improve test coverage reporting.
423 lines
16 KiB
Python
423 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_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",
|
||
)
|