"""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 sqlalchemy.orm import Session from duty_teller.api.app import app from duty_teller.api.dependencies import get_db_session @pytest.fixture def client(): return TestClient(app) def _override_get_db_session(mock_session: MagicMock): """Dependency override that returns mock_session (no real DB). Used as get_db_session override.""" def _override() -> Session: return mock_session return _override # --- 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).""" # Override get_db_session so the endpoint does not open the real DB (CI has no data/ dir). mock_session = MagicMock() mock_session.query.return_value.filter.return_value.first.return_value = None app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session) try: r = client.get("/api/admin/me") assert r.status_code == 200 assert r.json() == {"is_admin": False} finally: app.dependency_overrides.pop(get_db_session, None) @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 app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session) try: 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" }, ) finally: app.dependency_overrides.pop(get_db_session, None) 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 app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session) try: 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" }, ) finally: app.dependency_overrides.pop(get_db_session, None) 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"] == "Дежурство не найдено"