""" Tests for PATCH /admin/users/{user_id} - Admin changement de tier manuel (Story 1.7). AC1: 200 + user tier updated in DB; AC2/3: quota effect; AC4: audit log. """ import json import pytest from pathlib import Path from fastapi.testclient import TestClient REGISTER_URL = "/api/v1/auth/register" LOGIN_URL = "/api/v1/auth/login" ADMIN_LOGIN_URL = "/api/v1/admin/login" ADMIN_USERS_PATCH = "/api/v1/admin/users" # + /{user_id} @pytest.fixture def users_file(tmp_path: Path) -> Path: return tmp_path / "users.json" @pytest.fixture def client(users_file: Path, monkeypatch): """TestClient with JSON auth and rate limiting disabled.""" import services.auth_service as auth_svc from middleware.rate_limiting import RateLimitManager monkeypatch.setattr(auth_svc, "USERS_FILE", users_file) monkeypatch.setattr(auth_svc, "USE_DATABASE", False) monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False) async def _check_request_allow(self, request): return True, "ok", "test" async def _check_translation_allow(self, request, file_size_mb=0): return True, "ok" monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow) monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow) from main import app return TestClient(app, raise_server_exceptions=True) @pytest.fixture def admin_password(): return "admin-secret" @pytest.fixture def client_with_admin(client, admin_password, monkeypatch): """Same as client but with admin credentials patched in admin_routes (read at import time).""" import routes.admin_routes as admin_routes_mod monkeypatch.setattr(admin_routes_mod, "ADMIN_USERNAME", "admin") monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD", admin_password) monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD_HASH", None) return client @pytest.fixture def admin_token(client_with_admin, admin_password): """Get admin Bearer token.""" r = client_with_admin.post(ADMIN_LOGIN_URL, json={"password": admin_password}) assert r.status_code == 200, r.text return r.json()["access_token"] @pytest.fixture def registered_user_id_with_admin(client_with_admin, admin_token, users_file): """Register user then get id via admin GET /admin/users.""" payload = { "email": "patchuser@example.com", "password": "Password123!", "name": "Patch User", } client_with_admin.post(REGISTER_URL, json=payload) r = client_with_admin.get( "/api/v1/admin/users", headers={"Authorization": f"Bearer {admin_token}"} ) assert r.status_code == 200 users = r.json()["users"] assert users return users[0]["id"] # --------------------------------------------------------------------------- # 4.1 Admin PATCH valid tier → 200, user tier updated # --------------------------------------------------------------------------- def test_admin_patch_valid_tier_returns_200_and_updates_user( client_with_admin, admin_token, registered_user_id_with_admin ): user_id = registered_user_id_with_admin r = client_with_admin.patch( f"{ADMIN_USERS_PATCH}/{user_id}", json={"plan": "pro"}, headers={"Authorization": f"Bearer {admin_token}"}, ) assert r.status_code == 200, r.text body = r.json() assert "data" in body assert body["data"]["id"] == user_id assert body["data"]["plan"] == "pro" assert body["data"]["tier"] == "pro" # Verify persistence: GET /admin/users shows updated plan r2 = client_with_admin.get( "/api/v1/admin/users", headers={"Authorization": f"Bearer {admin_token}"} ) assert r2.status_code == 200 users = {u["id"]: u for u in r2.json()["users"]} assert users[user_id]["plan"] == "pro" # --------------------------------------------------------------------------- # 4.2 Admin PATCH invalid tier → 400 # --------------------------------------------------------------------------- def test_admin_patch_invalid_tier_returns_400( client_with_admin, admin_token, registered_user_id_with_admin ): """Invalid plan value: Pydantic validation returns 422; backend validation returns 400 with INVALID_PLAN.""" r = client_with_admin.patch( f"{ADMIN_USERS_PATCH}/{registered_user_id_with_admin}", json={"plan": "invalid"}, headers={"Authorization": f"Bearer {admin_token}"}, ) # Literal schema → 422 for invalid enum value assert r.status_code in (400, 422), r.text body = r.json() if r.status_code == 400: assert body.get("error") in ( "INVALID_PLAN", "VALIDATION_ERROR", "INVALID_FORMAT", ) assert "Erreur de validation" in (body.get("message") or "") else: assert "detail" in body assert any( "plan" in str(d.get("loc", [])) for d in body.get("detail", []) if isinstance(body.get("detail"), list) ) # --------------------------------------------------------------------------- # 4.3 Admin PATCH unknown user_id → 404 # --------------------------------------------------------------------------- def test_admin_patch_unknown_user_returns_404(client_with_admin, admin_token): r = client_with_admin.patch( f"{ADMIN_USERS_PATCH}/00000000-0000-0000-0000-000000000000", json={"plan": "pro"}, headers={"Authorization": f"Bearer {admin_token}"}, ) assert r.status_code == 404 # --------------------------------------------------------------------------- # 4.4 Non-admin PATCH → 401 # --------------------------------------------------------------------------- def test_admin_patch_without_token_returns_401( client_with_admin, registered_user_id_with_admin ): r = client_with_admin.patch( f"{ADMIN_USERS_PATCH}/{registered_user_id_with_admin}", json={"plan": "pro"}, ) assert r.status_code == 401 def test_admin_patch_with_invalid_token_returns_401( client_with_admin, registered_user_id_with_admin ): r = client_with_admin.patch( f"{ADMIN_USERS_PATCH}/{registered_user_id_with_admin}", json={"plan": "pro"}, headers={"Authorization": "Bearer invalid-token"}, ) assert r.status_code == 401 # --------------------------------------------------------------------------- # 4.5 After upgrade to pro, user can translate beyond 5/day; after downgrade to free, quota 5 applies # --------------------------------------------------------------------------- @pytest.fixture def app_client_for_quota(tmp_path, monkeypatch, admin_password): """Client with JSON auth and in-memory tier quota for translate tests.""" import routes.admin_routes as admin_routes_mod monkeypatch.setattr(admin_routes_mod, "ADMIN_USERNAME", "admin") monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD", admin_password) monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD_HASH", None) import services.auth_service as auth_svc from middleware import tier_quota as tier_quota_mod from middleware.tier_quota import _memory_usage from middleware.rate_limiting import RateLimitManager monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json") monkeypatch.setattr(auth_svc, "USE_DATABASE", False) monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False) monkeypatch.setattr(tier_quota_mod, "_get_async_redis", lambda: None) monkeypatch.setenv("REDIS_URL", "") _memory_usage.clear() async def _allow_request(self, request): return True, "ok", "test" async def _allow_translation(self, request, file_size_mb=0): return True, "" async def _allow_translation_limit(self, client_id, file_size_mb=0): return True monkeypatch.setattr(RateLimitManager, "check_request", _allow_request) monkeypatch.setattr(RateLimitManager, "check_translation", _allow_translation) monkeypatch.setattr( RateLimitManager, "check_translation_limit", _allow_translation_limit ) from main import app return TestClient(app, raise_server_exceptions=True) def test_after_upgrade_to_pro_user_can_translate_beyond_five( app_client_for_quota, minimal_xlsx, admin_password ): """After admin upgrades user to pro, user can translate more than 5 files (quota unlimited).""" client = app_client_for_quota client.post( REGISTER_URL, json={ "email": "quota@example.com", "password": "Password123!", "name": "Quota User", }, ) admin_r = client.post(ADMIN_LOGIN_URL, json={"password": admin_password}) admin_token = admin_r.json()["access_token"] users_r = client.get( "/api/v1/admin/users", headers={"Authorization": f"Bearer {admin_token}"} ) user_id = next( u["id"] for u in users_r.json()["users"] if u["email"] == "quota@example.com" ) client.patch( f"{ADMIN_USERS_PATCH}/{user_id}", json={"plan": "pro"}, headers={"Authorization": f"Bearer {admin_token}"}, ) login_r = client.post( LOGIN_URL, json={"email": "quota@example.com", "password": "Password123!"} ) access_token = login_r.json()["data"]["access_token"] from unittest.mock import patch from pathlib import Path def _fake_translate( input_path, output_path, target_language, source_language="auto", **kwargs ): Path(output_path).parent.mkdir(parents=True, exist_ok=True) Path(output_path).write_bytes(b"dummy") with patch( "routes.translate_routes.ExcelTranslator.translate_file", side_effect=_fake_translate, ), patch( "routes.translate_routes.ExcelTranslator.get_translation_stats", return_value={"attempted": 1, "changed": 1}, ): for _ in range(6): with open(minimal_xlsx, "rb") as f: r = client.post( "/api/v1/translate", files={ "file": ( "t.xlsx", f, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) }, data={"target_lang": "fr", "provider": "google"}, headers={"Authorization": f"Bearer {access_token}"}, ) assert r.status_code == 202, ( f"Pro user should not hit quota at request {_ + 1}: {r.text}" ) def test_after_downgrade_to_free_quota_five_applies( app_client_for_quota, minimal_xlsx, admin_password ): """After admin downgrades user to free, quota 5 applies (6th returns 429).""" client = app_client_for_quota client.post( REGISTER_URL, json={ "email": "downgrade@example.com", "password": "Password123!", "name": "Downgrade User", }, ) admin_r = client.post(ADMIN_LOGIN_URL, json={"password": admin_password}) admin_token = admin_r.json()["access_token"] users_r = client.get( "/api/v1/admin/users", headers={"Authorization": f"Bearer {admin_token}"} ) user_id = next( u["id"] for u in users_r.json()["users"] if u["email"] == "downgrade@example.com" ) client.patch( f"{ADMIN_USERS_PATCH}/{user_id}", json={"plan": "pro"}, headers={"Authorization": f"Bearer {admin_token}"}, ) login_r = client.post( LOGIN_URL, json={"email": "downgrade@example.com", "password": "Password123!"} ) access_token = login_r.json()["data"]["access_token"] from unittest.mock import patch from pathlib import Path def _fake_translate( input_path, output_path, target_language, source_language="auto", **kwargs ): Path(output_path).parent.mkdir(parents=True, exist_ok=True) Path(output_path).write_bytes(b"dummy") with patch( "routes.translate_routes.ExcelTranslator.translate_file", side_effect=_fake_translate, ), patch( "routes.translate_routes.ExcelTranslator.get_translation_stats", return_value={"attempted": 1, "changed": 1}, ): for _ in range(5): with open(minimal_xlsx, "rb") as f: client.post( "/api/v1/translate", files={ "file": ( "t.xlsx", f, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) }, data={"target_lang": "fr", "provider": "google"}, headers={"Authorization": f"Bearer {access_token}"}, ) import time time.sleep(0.5) client.patch( f"{ADMIN_USERS_PATCH}/{user_id}", json={"plan": "free"}, headers={"Authorization": f"Bearer {admin_token}"}, ) with open(minimal_xlsx, "rb") as f: r = client.post( "/api/v1/translate", files={ "file": ( "t.xlsx", f, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) }, data={"target_lang": "fr", "provider": "google"}, headers={"Authorization": f"Bearer {access_token}"}, ) assert r.status_code == 429, r.text assert "QUOTA_EXCEEDED" in (r.json().get("error") or "") @pytest.fixture def minimal_xlsx(tmp_path): try: import openpyxl wb = openpyxl.Workbook() wb.active["A1"] = "Hello" p = tmp_path / "minimal.xlsx" wb.save(p) return p except ImportError: pytest.skip("openpyxl required")