feat: implement admin panel functionality in Mini App
- Added new API endpoints for admin features: `GET /api/admin/me`, `GET /api/admin/users`, and `PATCH /api/admin/duties/:id` to manage user duties. - Introduced `UserForAdmin` and `AdminDutyReassignBody` schemas for handling admin-related data. - Updated documentation to include Mini App design guidelines and admin panel functionalities. - Enhanced tests for admin API to ensure proper access control and functionality. - Improved error handling and localization for admin actions.
This commit is contained in:
360
tests/test_admin_api.py
Normal file
360
tests/test_admin_api.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""Tests for admin API: GET /api/admin/me, GET /api/admin/users, PATCH /api/admin/duties/:id."""
|
||||
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from duty_teller.api.app import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# --- GET /api/admin/me ---
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_admin_me_skip_auth_returns_is_admin_false(client):
|
||||
"""With MINI_APP_SKIP_AUTH, GET /api/admin/me returns is_admin: false (no real user)."""
|
||||
r = client.get("/api/admin/me")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_admin": False}
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@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.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_me_returns_is_admin_true_when_admin(
|
||||
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||
):
|
||||
"""When user is admin, GET /api/admin/me returns is_admin: true."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (100, "user", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
r = client.get("/api/admin/me", headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_admin": True}
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@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.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_me_returns_is_admin_false_when_not_admin(
|
||||
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||
):
|
||||
"""When user is not admin, GET /api/admin/me returns is_admin: false."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (200, "user", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="User", username="user")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = False
|
||||
r = client.get("/api/admin/me", headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A200%7D&hash=x"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_admin": False}
|
||||
|
||||
|
||||
# --- GET /api/admin/users ---
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_users_403_without_init_data(client):
|
||||
"""GET /api/admin/users without initData returns 403."""
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@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.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_users_403_when_not_admin(
|
||||
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||
):
|
||||
"""GET /api/admin/users when not admin returns 403 with admin_only message."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (100, "u", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="U", username="u")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = False # not admin
|
||||
r = client.get(
|
||||
"/api/admin/users",
|
||||
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
detail = r.json()["detail"]
|
||||
assert "admin" in detail.lower() or "администратор" in detail or "only" in detail
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.get_users_for_admin")
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@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.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_users_200_returns_list(
|
||||
mock_validate, mock_get_user, mock_can_access, mock_is_admin, mock_get_users, client
|
||||
):
|
||||
"""GET /api/admin/users returns list of id, full_name, username, role_id."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
mock_get_users.return_value = [
|
||||
SimpleNamespace(id=1, full_name="Alice", username="alice", role_id=1),
|
||||
SimpleNamespace(id=2, full_name="Bob", username=None, role_id=2),
|
||||
]
|
||||
r = client.get(
|
||||
"/api/admin/users",
|
||||
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["id"] == 1
|
||||
assert data[0]["full_name"] == "Alice"
|
||||
assert data[0]["username"] == "alice"
|
||||
assert data[0]["role_id"] == 1
|
||||
assert data[1]["id"] == 2
|
||||
assert data[1]["full_name"] == "Bob"
|
||||
assert data[1]["username"] is None
|
||||
assert data[1]["role_id"] == 2
|
||||
|
||||
|
||||
# --- PATCH /api/admin/duties/:id ---
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_403_without_auth(client):
|
||||
"""PATCH /api/admin/duties/1 without auth returns 403."""
|
||||
r = client.patch(
|
||||
"/api/admin/duties/1",
|
||||
json={"user_id": 2},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.require_admin_telegram_id")
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_403_when_not_admin(mock_require_admin, client):
|
||||
"""PATCH /api/admin/duties/1 when not admin returns 403."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from duty_teller.i18n import t
|
||||
|
||||
mock_require_admin.side_effect = HTTPException(
|
||||
status_code=403, detail=t("en", "import.admin_only")
|
||||
)
|
||||
r = client.patch(
|
||||
"/api/admin/duties/1",
|
||||
json={"user_id": 2},
|
||||
headers={"X-Telegram-Init-Data": "x"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||
@patch("duty_teller.api.app.update_duty_user")
|
||||
@patch("duty_teller.api.app.get_duty_by_id")
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@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.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_404_when_duty_missing(
|
||||
mock_validate,
|
||||
mock_get_user,
|
||||
mock_can_access,
|
||||
mock_is_admin,
|
||||
mock_get_duty,
|
||||
mock_update,
|
||||
mock_invalidate,
|
||||
client,
|
||||
):
|
||||
"""PATCH /api/admin/duties/999 returns 404 when duty not found."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
mock_get_duty.return_value = None
|
||||
r = client.patch(
|
||||
"/api/admin/duties/999",
|
||||
json={"user_id": 2},
|
||||
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
mock_update.assert_not_called()
|
||||
mock_invalidate.assert_not_called()
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||
@patch("duty_teller.api.app.update_duty_user")
|
||||
@patch("duty_teller.api.app.get_duty_by_id")
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@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.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_400_when_user_not_found(
|
||||
mock_validate,
|
||||
mock_get_user,
|
||||
mock_can_access,
|
||||
mock_is_admin,
|
||||
mock_get_duty,
|
||||
mock_update,
|
||||
mock_invalidate,
|
||||
client,
|
||||
):
|
||||
"""PATCH /api/admin/duties/1 returns 400 when user_id does not exist."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
mock_get_duty.return_value = SimpleNamespace(
|
||||
id=1, user_id=10, start_at="2026-01-15T09:00:00Z", end_at="2026-01-15T18:00:00Z"
|
||||
)
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = None # User not found
|
||||
with patch("duty_teller.api.app.get_db_session") as mock_db:
|
||||
mock_db.return_value.__enter__.return_value = mock_session
|
||||
mock_db.return_value.__exit__.return_value = None
|
||||
r = client.patch(
|
||||
"/api/admin/duties/1",
|
||||
json={"user_id": 999},
|
||||
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
mock_update.assert_not_called()
|
||||
mock_invalidate.assert_not_called()
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||
@patch("duty_teller.api.app.update_duty_user")
|
||||
@patch("duty_teller.api.app.get_duty_by_id")
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@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.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_200_updates_and_invalidates(
|
||||
mock_validate,
|
||||
mock_get_user,
|
||||
mock_can_access,
|
||||
mock_is_admin,
|
||||
mock_get_duty,
|
||||
mock_update_duty_user,
|
||||
mock_invalidate,
|
||||
client,
|
||||
):
|
||||
"""PATCH /api/admin/duties/1 with valid body returns 200 and invalidates caches."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
duty = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=10,
|
||||
start_at="2026-01-15T09:00:00Z",
|
||||
end_at="2026-01-15T18:00:00Z",
|
||||
)
|
||||
updated_duty = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=2,
|
||||
start_at="2026-01-15T09:00:00Z",
|
||||
end_at="2026-01-15T18:00:00Z",
|
||||
)
|
||||
mock_get_duty.return_value = duty
|
||||
mock_update_duty_user.return_value = updated_duty
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = SimpleNamespace(id=2) # User exists
|
||||
with patch("duty_teller.api.app.get_db_session") as mock_db:
|
||||
mock_db.return_value.__enter__.return_value = mock_session
|
||||
mock_db.return_value.__exit__.return_value = None
|
||||
r = client.patch(
|
||||
"/api/admin/duties/1",
|
||||
json={"user_id": 2},
|
||||
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["id"] == 1
|
||||
assert data["user_id"] == 2
|
||||
assert data["start_at"] == "2026-01-15T09:00:00Z"
|
||||
assert data["end_at"] == "2026-01-15T18:00:00Z"
|
||||
mock_update_duty_user.assert_called_once_with(ANY, 1, 2, commit=True)
|
||||
mock_invalidate.assert_called_once()
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_admin_users_403_when_skip_auth(client):
|
||||
"""GET /api/admin/users with MINI_APP_SKIP_AUTH returns 403 (admin routes disabled)."""
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 403
|
||||
detail = r.json()["detail"]
|
||||
assert "admin" in detail.lower() or "администратор" in detail
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_admin_reassign_403_when_skip_auth(client):
|
||||
"""PATCH /api/admin/duties/1 with MINI_APP_SKIP_AUTH returns 403."""
|
||||
r = client.patch(
|
||||
"/api/admin/duties/1",
|
||||
json={"user_id": 2},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.get_duty_by_id")
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@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.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_404_uses_accept_language_for_detail(
|
||||
mock_validate,
|
||||
mock_get_user,
|
||||
mock_can_access,
|
||||
mock_is_admin,
|
||||
mock_get_duty,
|
||||
client,
|
||||
):
|
||||
"""PATCH with Accept-Language: ru returns 404 detail in Russian."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
mock_get_duty.return_value = None
|
||||
with patch("duty_teller.api.app._lang_from_accept_language") as mock_lang:
|
||||
mock_lang.return_value = "ru"
|
||||
r = client.patch(
|
||||
"/api/admin/duties/999",
|
||||
json={"user_id": 2},
|
||||
headers={
|
||||
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x",
|
||||
"Accept-Language": "ru",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"] == "Дежурство не найдено"
|
||||
@@ -9,9 +9,12 @@ from duty_teller.db.repository import (
|
||||
delete_duties_in_range,
|
||||
get_duties,
|
||||
get_duties_for_user,
|
||||
get_duty_by_id,
|
||||
get_or_create_user,
|
||||
get_or_create_user_by_full_name,
|
||||
get_users_for_admin,
|
||||
insert_duty,
|
||||
update_duty_user,
|
||||
update_user_display_name,
|
||||
)
|
||||
|
||||
@@ -217,6 +220,52 @@ def test_get_or_create_user_keeps_name_when_flag_true_updates_username(session):
|
||||
assert u2.username == "new_username"
|
||||
|
||||
|
||||
def test_get_duty_by_id_returns_duty(session, user_a):
|
||||
"""get_duty_by_id returns the duty when it exists."""
|
||||
duty = insert_duty(
|
||||
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
|
||||
)
|
||||
found = get_duty_by_id(session, duty.id)
|
||||
assert found is not None
|
||||
assert found.id == duty.id
|
||||
assert found.user_id == user_a.id
|
||||
assert found.start_at == "2026-02-01T09:00:00Z"
|
||||
|
||||
|
||||
def test_get_duty_by_id_returns_none_when_missing(session):
|
||||
"""get_duty_by_id returns None for non-existent id."""
|
||||
assert get_duty_by_id(session, 99999) is None
|
||||
|
||||
|
||||
def test_update_duty_user_changes_user(session, user_a):
|
||||
"""update_duty_user updates user_id and returns the duty."""
|
||||
user_b = get_or_create_user_by_full_name(session, "User B")
|
||||
duty = insert_duty(
|
||||
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
|
||||
)
|
||||
updated = update_duty_user(session, duty.id, user_b.id, commit=True)
|
||||
assert updated is not None
|
||||
assert updated.id == duty.id
|
||||
assert updated.user_id == user_b.id
|
||||
session.refresh(duty)
|
||||
assert duty.user_id == user_b.id
|
||||
|
||||
|
||||
def test_update_duty_user_returns_none_when_duty_missing(session, user_a):
|
||||
"""update_duty_user returns None when duty does not exist."""
|
||||
assert update_duty_user(session, 99999, user_a.id, commit=True) is None
|
||||
|
||||
|
||||
def test_get_users_for_admin_returns_all_ordered_by_full_name(session, user_a):
|
||||
"""get_users_for_admin returns all users ordered by full_name."""
|
||||
get_or_create_user_by_full_name(session, "Alice")
|
||||
get_or_create_user_by_full_name(session, "Борис")
|
||||
users = get_users_for_admin(session)
|
||||
assert len(users) >= 3
|
||||
full_names = [u.full_name for u in users]
|
||||
assert full_names == sorted(full_names)
|
||||
|
||||
|
||||
def test_update_user_display_name_sets_flag_then_get_or_create_user_keeps_name(session):
|
||||
"""update_user_display_name sets name and flag; get_or_create_user then does not overwrite name."""
|
||||
get_or_create_user(
|
||||
|
||||
Reference in New Issue
Block a user