fix(i18n): persist language preference server-side, fix dropdown rendering and avatar 404

- Fix language dropdown in base.html by moving tojson data out of HTML
  attribute into a script tag (prevents quote conflicts breaking Alpine.js)
- Fix avatar placeholder 404 by correcting filename reference from
  avatar-placeholder.svg to default-avatar.svg
- Add session hydration from DB in _inject_global_context() so
  detect_language() uses the stored preference on every request
- Sync session and cookie in PATCH /api/profile when language changes
- Reload page after language change in profile to reflect new locale
- Add tests for session/cookie sync and DB hydration

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-14 10:30:50 +00:00
parent c3e389d957
commit 1416d901d0
5 changed files with 178 additions and 11 deletions
+93 -5
View File
@@ -224,9 +224,10 @@ class TestUpdateProfileHandler:
req = MagicMock()
req.session = {"user": {"preferred_username": "carol", "email": "carol@example.com"}}
resp = MagicMock()
body = ProfileUpdateRequest(display_name="Carol Smith")
result = await update_profile(body, req, prof_session)
result = await update_profile(body, req, resp, prof_session)
assert result.display_name == "Carol Smith"
@pytest.mark.asyncio
@@ -236,11 +237,31 @@ class TestUpdateProfileHandler:
req = MagicMock()
req.session = {"user": {"preferred_username": "dave", "email": "dave@example.com"}}
resp = MagicMock()
body = ProfileUpdateRequest(preferred_language="de")
result = await update_profile(body, req, prof_session)
result = await update_profile(body, req, resp, prof_session)
assert result.preferred_language == "de"
@pytest.mark.asyncio
async def test_updates_language_syncs_session_and_cookie(self, prof_session):
"""update_profile syncs language to session and cookie."""
from app.api.profile import ProfileUpdateRequest, update_profile
session = {"user": {"preferred_username": "dave2", "email": "dave2@example.com"}}
req = MagicMock()
req.session = session
resp = MagicMock()
body = ProfileUpdateRequest(preferred_language="fr")
await update_profile(body, req, resp, prof_session)
assert session["preferred_language"] == "fr"
resp.set_cookie.assert_called_once()
cookie_kwargs = resp.set_cookie.call_args
assert cookie_kwargs.kwargs["key"] == "docuelevate_lang"
assert cookie_kwargs.kwargs["value"] == "fr"
@pytest.mark.asyncio
async def test_updates_theme(self, prof_session):
"""update_profile updates preferred_theme."""
@@ -248,9 +269,10 @@ class TestUpdateProfileHandler:
req = MagicMock()
req.session = {"user": {"preferred_username": "eve", "email": "eve@example.com"}}
resp = MagicMock()
body = ProfileUpdateRequest(preferred_theme="light")
result = await update_profile(body, req, prof_session)
result = await update_profile(body, req, resp, prof_session)
assert result.preferred_theme == "light"
@pytest.mark.asyncio
@@ -260,10 +282,11 @@ class TestUpdateProfileHandler:
req = MagicMock()
req.session = {"user": {"preferred_username": "frank", "email": "frank@example.com"}}
resp = MagicMock()
body = ProfileUpdateRequest(preferred_language="xx")
with pytest.raises(HTTPException) as exc:
await update_profile(body, req, prof_session)
await update_profile(body, req, resp, prof_session)
assert exc.value.status_code == 422
@pytest.mark.asyncio
@@ -273,13 +296,78 @@ class TestUpdateProfileHandler:
req = MagicMock()
req.session = {"user": {"preferred_username": "grace", "email": "grace@example.com"}}
resp = MagicMock()
body = ProfileUpdateRequest(preferred_theme="rainbow")
with pytest.raises(HTTPException) as exc:
await update_profile(body, req, prof_session)
await update_profile(body, req, resp, prof_session)
assert exc.value.status_code == 422
# ---------------------------------------------------------------------------
# Unit tests — _hydrate_language_from_db (views/base.py)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestHydrateLanguageFromDb:
"""Tests for session hydration of preferred_language from DB."""
def test_hydrates_language_into_session(self, prof_session):
"""When DB has a preferred_language, it should be set in the session."""
from app.views.base import _hydrate_language_from_db
# Create a profile with a preferred_language
profile = UserProfile(user_id="hydrate_user", preferred_language="fr")
prof_session.add(profile)
prof_session.commit()
req = MagicMock()
req.session = {}
session_user = {"preferred_username": "hydrate_user"}
# Patch SessionLocal to return our test session
from unittest.mock import patch
with patch("app.views.base.SessionLocal", return_value=prof_session):
_hydrate_language_from_db(req, session_user)
assert req.session.get("preferred_language") == "fr"
def test_no_hydration_when_no_db_preference(self, prof_session):
"""When DB has no preferred_language, session should remain empty."""
from app.views.base import _hydrate_language_from_db
profile = UserProfile(user_id="no_pref_user")
prof_session.add(profile)
prof_session.commit()
req = MagicMock()
req.session = {}
session_user = {"preferred_username": "no_pref_user"}
from unittest.mock import patch
with patch("app.views.base.SessionLocal", return_value=prof_session):
_hydrate_language_from_db(req, session_user)
assert "preferred_language" not in req.session
def test_no_hydration_when_no_user_id(self, prof_session):
"""When session_user has no identifiable user_id, do nothing."""
from app.views.base import _hydrate_language_from_db
req = MagicMock()
req.session = {}
from unittest.mock import patch
with patch("app.views.base.SessionLocal", return_value=prof_session):
_hydrate_language_from_db(req, {})
assert "preferred_language" not in req.session
# ---------------------------------------------------------------------------
# Unit tests — POST /api/profile/avatar handler
# ---------------------------------------------------------------------------