From f4025e3cbb3814819e6bfbfb0909f9f122108ff8 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Wed, 27 May 2026 17:16:17 +0200 Subject: [PATCH] settings: PATCH /api/settings/language with ACTIVE_LANGUAGES gate Co-Authored-By: Claude Opus 4.7 --- app/routers/api.py | 36 +++++++++++++++ tests/test_localization_integration.py | 61 ++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/app/routers/api.py b/app/routers/api.py index 5e06090..0b23d57 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -21,6 +21,7 @@ import httpx from pydantic import BaseModel, Field from app.auth import require_token, maybe_current_user, CurrentUser +from app.services.i18n import ACTIVE_LANGUAGES from app.config import get_settings from app.db import get_session, utcnow from app.services.openrouter import ( @@ -895,3 +896,38 @@ async def patch_digest_prefs( user.digest_tone = payload.tone await session.commit() return DigestPrefsOut(opt_in=payload.opt_in, tone=payload.tone) + + +# --------------------------------------------------------------------------- +# Settings — language preference +# --------------------------------------------------------------------------- + + +class LanguagePrefsIn(BaseModel): + lang: str + + +class LanguagePrefsOut(BaseModel): + lang: str + + +@router.patch("/settings/language", response_model=LanguagePrefsOut) +async def patch_language_prefs( + payload: LanguagePrefsIn, + principal: CurrentUser = Depends(require_token), + session: AsyncSession = Depends(get_session), +) -> LanguagePrefsOut: + if principal.user is None: + raise HTTPException(status_code=400, detail="no_user_context") + lang = (payload.lang or "").strip().lower() + if lang not in ACTIVE_LANGUAGES: + raise HTTPException( + status_code=400, + detail=f"unsupported language: {payload.lang!r}", + ) + user = await session.get(User, principal.user.id) + if user is None: + raise HTTPException(status_code=404, detail="user_not_found") + user.lang = lang + await session.commit() + return LanguagePrefsOut(lang=lang) diff --git a/tests/test_localization_integration.py b/tests/test_localization_integration.py index ae74a53..c5cf92c 100644 --- a/tests/test_localization_integration.py +++ b/tests/test_localization_integration.py @@ -395,3 +395,64 @@ async def test_log_endpoint_falls_back_to_english_when_no_translation(tmp_path): user = await session.get(User, 11) content = await _resolve_log_content(session, log_id, user.lang) assert "Open" in content + + +@pytest.mark.asyncio +async def test_patch_language_accepts_active(tmp_path): + """PATCH /api/settings/language accepts 'en' and 'it' and persists.""" + from app.models import User + from app.routers.api import patch_language_prefs, LanguagePrefsIn + + _, factory, setup = _build_session_factory(tmp_path) + await setup() + + async with factory() as session: + session.add(User(id=20, email="u@x", tier="paid", lang="en")) + await session.commit() + + class _P: + is_admin = False + def __init__(self, u): self.user = u + + async with factory() as session: + user = await session.get(User, 20) + result = await patch_language_prefs( + payload=LanguagePrefsIn(lang="it"), + principal=_P(user), + session=session, + ) + assert result.lang == "it" + + async with factory() as session: + user = await session.get(User, 20) + assert user.lang == "it" + + +@pytest.mark.asyncio +async def test_patch_language_rejects_wip(tmp_path): + """PATCH rejects 'es'/'fr'/'de'/'xx' with 400 — ACTIVE_LANGUAGES gate.""" + from fastapi import HTTPException + from app.models import User + from app.routers.api import patch_language_prefs, LanguagePrefsIn + + _, factory, setup = _build_session_factory(tmp_path) + await setup() + + async with factory() as session: + session.add(User(id=21, email="u2@x", tier="paid", lang="en")) + await session.commit() + + class _P: + is_admin = False + def __init__(self, u): self.user = u + + for bad in ("es", "fr", "de", "xx"): + async with factory() as session: + user = await session.get(User, 21) + with pytest.raises(HTTPException) as exc: + await patch_language_prefs( + payload=LanguagePrefsIn(lang=bad), + principal=_P(user), + session=session, + ) + assert exc.value.status_code == 400