feat(07-01): localized emails, locale-aware templates API, language preference endpoint
- email.py: send_invite_email() adds language param (en/es/pt), sends localized subject+body - templates.py: list_templates()/get_template() accept ?locale= param, merge translations on response - portal.py: PATCH /api/portal/users/me/language endpoint persists language preference - portal.py: /api/portal/auth/verify response includes user.language field - portal.py: AuthVerifyResponse adds language field (default 'en') - test_portal_auth.py: fix _make_user mock to set language='en' (auto-fix Rule 1) - test_language_preference.py: 4 integration tests for language preference endpoint - test_templates_i18n.py: 5 integration tests for locale-aware templates (all passing)
This commit is contained in:
186
tests/integration/test_language_preference.py
Normal file
186
tests/integration/test_language_preference.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user