""" Integration tests for the language preference PATCH endpoint. Tests: - PATCH /api/portal/users/me/language with valid language returns 200 - PATCH with unsupported language returns 400 - PATCH to "pt" then GET /api/portal/auth/verify includes language="pt" - PATCH without auth returns 401 Uses the same pattern as existing integration tests: - Session override via app.dependency_overrides - X-Portal-User-Id / X-Portal-User-Role header injection for auth - db_session fixture from tests/conftest.py (Alembic migrations applied) """ from __future__ import annotations import uuid import bcrypt import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from shared.api.portal import portal_router from shared.db import get_session from shared.models.auth import PortalUser # --------------------------------------------------------------------------- # App factory # --------------------------------------------------------------------------- def make_app(session: AsyncSession) -> FastAPI: """Build a minimal FastAPI test app with portal router.""" app = FastAPI() app.include_router(portal_router) async def override_get_session(): # type: ignore[return] yield session app.dependency_overrides[get_session] = override_get_session return app # --------------------------------------------------------------------------- # Header helpers # --------------------------------------------------------------------------- def auth_headers(user_id: uuid.UUID, role: str = "customer_admin") -> dict[str, str]: return { "X-Portal-User-Id": str(user_id), "X-Portal-User-Role": role, } # --------------------------------------------------------------------------- # DB helpers # --------------------------------------------------------------------------- async def _create_user( session: AsyncSession, role: str = "customer_admin", language: str = "en", ) -> PortalUser: suffix = uuid.uuid4().hex[:8] hashed = bcrypt.hashpw(b"testpassword123", bcrypt.gensalt()).decode() user = PortalUser( id=uuid.uuid4(), email=f"langtest-{suffix}@example.com", hashed_password=hashed, name=f"Language Test User {suffix}", role=role, language=language, ) session.add(user) await session.flush() return user # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def lang_client(db_session: AsyncSession) -> AsyncClient: """HTTP client with portal router mounted.""" app = make_app(db_session) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c @pytest_asyncio.fixture async def lang_user(db_session: AsyncSession) -> PortalUser: """Create a customer_admin user with default language 'en'.""" user = await _create_user(db_session, role="customer_admin", language="en") await db_session.commit() return user # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_patch_language_valid( lang_client: AsyncClient, lang_user: PortalUser, ) -> None: """PATCH /api/portal/users/me/language with valid language returns 200 and new language.""" response = await lang_client.patch( "/api/portal/users/me/language", json={"language": "es"}, headers=auth_headers(lang_user.id), ) assert response.status_code == 200 data = response.json() assert data["language"] == "es" @pytest.mark.asyncio async def test_patch_language_invalid( lang_client: AsyncClient, lang_user: PortalUser, ) -> None: """PATCH with unsupported language 'fr' returns 400.""" response = await lang_client.patch( "/api/portal/users/me/language", json={"language": "fr"}, headers=auth_headers(lang_user.id), ) assert response.status_code == 400 assert "fr" in response.json()["detail"].lower() or "unsupported" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_patch_language_persists( lang_client: AsyncClient, lang_user: PortalUser, db_session: AsyncSession, ) -> None: """PATCH to 'pt', then GET /api/portal/auth/verify includes language='pt'.""" # First PATCH to "pt" patch_response = await lang_client.patch( "/api/portal/users/me/language", json={"language": "pt"}, headers=auth_headers(lang_user.id), ) assert patch_response.status_code == 200 assert patch_response.json()["language"] == "pt" # Verify via /auth/verify — need to pass email+password # Re-fetch user to get credentials, then call auth/verify verify_response = await lang_client.post( "/api/portal/auth/verify", json={"email": lang_user.email, "password": "testpassword123"}, ) assert verify_response.status_code == 200 verify_data = verify_response.json() assert verify_data["language"] == "pt", ( f"Expected language='pt' in auth/verify response, got: {verify_data.get('language')!r}" ) @pytest.mark.asyncio async def test_patch_language_unauthenticated( lang_client: AsyncClient, ) -> None: """PATCH without auth headers returns 401 or 422 (missing required headers).""" response = await lang_client.patch( "/api/portal/users/me/language", json={"language": "es"}, ) # FastAPI raises 422 when required headers are missing entirely (before auth guard runs). # Both 401 and 422 are acceptable rejections of unauthenticated requests. assert response.status_code in (401, 422)