feat(i18n): add browser-language localization with Jinja2 _() and locale middleware
Some checks failed
ci/woodpecker/pr/pipeline Pipeline failed
Some checks failed
ci/woodpecker/pr/pipeline Pipeline failed
Add i18n support to the blog web UI with 4 languages (en/ru/fr/de),
80 translation keys, automatic Accept-Language detection, persistent
locale cookie, and a language switcher dropdown in the header.
- Infrastructure: TranslationService, translation dicts, convenience _()
- Presentation: locale middleware, /web/lang/{locale} switcher route
- Templates: all 9 templates use {{ _(key, current_locale) }}
- Tests: 26 tests across TranslationService, locale detection helpers
- Docs: TEST_MODEL.md and FEATURE_INFRASTRUCTURE.md updated with TC-UNIT-811-821
This commit is contained in:
@@ -314,6 +314,113 @@ supports the domain and application layers.
|
||||
- **Expected:** Calls `session.rollback` once
|
||||
- **Last Verified:** 2026-05-07
|
||||
|
||||
### i18n Localization
|
||||
|
||||
#### Translation Service
|
||||
|
||||
##### TC-UNIT-811: TranslationService — Existing key returns translation
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::test_get_text_returns_translation_for_existing_key`
|
||||
- **Expected:** Returns correct localized string when key exists in requested locale
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
##### TC-UNIT-812: TranslationService — Fallback chain
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::{test_get_text_returns_english_fallback_for_missing_key, test_get_text_returns_key_when_neither_locale_nor_en_has_it, test_get_text_returns_english_fallback_for_unknown_locale}`
|
||||
- **Preconditions:** Key missing in requested locale
|
||||
- **Steps:** Call get_text with partially-available keys
|
||||
- **Expected:**
|
||||
- Falls back to English when key exists in `en` but not in requested locale
|
||||
- Falls back to raw key when neither requested locale nor `en` has it
|
||||
- Falls back to English when locale is completely unknown
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
##### TC-UNIT-813: TranslationService — English locale
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::test_get_text_returns_en_when_requested_locale_is_en`
|
||||
- **Expected:** Returns English string when locale is explicitly `en`
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
##### TC-UNIT-814: TranslationService — Singleton pattern
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::{test_singleton_returns_same_instance, test_singleton_shares_translations}`
|
||||
- **Expected:** Multiple calls to `get_instance()` return the same object with shared data
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
#### Convenience Function
|
||||
|
||||
##### TC-UNIT-815: Convenience _() function
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/infrastructure/test_i18n.py::TestConvenienceFunction::{test_convenience_function_returns_translation, test_convenience_function_defaults_to_english, test_convenience_function_returns_key_on_missing}`
|
||||
- **Expected:**
|
||||
- Returns translation when key and locale are given
|
||||
- Defaults to `DEFAULT_LOCALE` ("en") when locale omitted
|
||||
- Returns raw key when no translation exists
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
#### Locale Detection
|
||||
|
||||
##### TC-UNIT-816: Parse Accept-Language — Empty and single locale
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::{test_empty_header_returns_empty_list, test_single_locale}`
|
||||
- **Expected:**
|
||||
- Empty header returns empty list
|
||||
- Single locale returns single-element list
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
##### TC-UNIT-817: Parse Accept-Language — Multi-locale with quality
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::test_multiple_locales_with_quality`
|
||||
- **Expected:** Locales sorted by descending q-value, quality values stripped
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
##### TC-UNIT-818: Parse Accept-Language — Region codes and complex input
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::{test_region_code_strips_to_base_language, test_complex_header, test_whitespace_around_locales}`
|
||||
- **Expected:**
|
||||
- `fr-FR` normalised to `fr`
|
||||
- Realistic headers with region codes parsed correctly
|
||||
- Whitespace around locale codes handled gracefully
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
##### TC-UNIT-819: Get best locale — Cookie priority
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/infrastructure/test_i18n.py::TestGetBestLocale::{test_cookie_takes_priority, test_invalid_cookie_falls_back_to_header}`
|
||||
- **Expected:**
|
||||
- Cookie value used when it is a supported locale
|
||||
- Unsupported locale in cookie falls back to Accept-Language header
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
##### TC-UNIT-820: Get best locale — Header fallback
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/infrastructure/test_i18n.py::TestGetBestLocale::{test_no_cookie_uses_accept_language, test_no_cookie_no_header_returns_default, test_accept_language_unsupported_returns_default, test_missing_cookie_key_uses_header}`
|
||||
- **Expected:**
|
||||
- Accept-Language used when no cookie present
|
||||
- Default locale returned when neither cookie nor header matches
|
||||
- Unsupported language in header falls back to default
|
||||
- Absent `locale` cookie key treated as no preference
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
##### TC-UNIT-821: Setup locale manager — Middleware helper
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/infrastructure/test_i18n.py::TestSetupLocaleManager::{test_sets_locale_on_request_state, test_does_not_override_existing_locale, test_default_locale_when_no_match}`
|
||||
- **Expected:**
|
||||
- Sets `request.state.locale` from best-match locale
|
||||
- Does not override an already-set locale
|
||||
- Falls back to default when nothing matches
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
| Component | Cases | Status |
|
||||
@@ -322,6 +429,7 @@ supports the domain and application layers.
|
||||
| Settings & Config | 19 | ✅ Defaults, overrides, validation, env checks |
|
||||
| Keycloak Auth Client | 16 | ✅ Token introspection, userinfo, caching, errors |
|
||||
| Transaction Manager | 2 | ⚠️ Only commit/rollback; missing nested tx, error handling |
|
||||
| i18n Localization | 11 | ✅ Translation service, locale detection, middleware helper |
|
||||
|
||||
## Gaps (Not Yet Covered)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ adding new tests.
|
||||
| Pagination | 40% | — | — | 60% | P1 | ⚠️ Partial |
|
||||
| Post Edit via Web | — | — | — | 40% | P1 | ⚠️ Partial |
|
||||
| Post Delete via Web | — | — | — | 40% | P1 | ⚠️ Partial |
|
||||
| i18n Localization | 100% | — | — | — | P1 | ✅ Active |
|
||||
|
||||
Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
|
||||
|
||||
@@ -32,6 +33,7 @@ Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
|
||||
| RBAC & Access Control | [FEATURE_RBAC.md](FEATURE_RBAC.md) |
|
||||
| Domain Foundation | [FEATURE_DOMAIN_FOUNDATION.md](FEATURE_DOMAIN_FOUNDATION.md) |
|
||||
| Infrastructure & Bootstrap | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) |
|
||||
| i18n Localization | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) |
|
||||
|
||||
## Test Naming Convention
|
||||
|
||||
|
||||
196
tests/unit/infrastructure/test_i18n.py
Normal file
196
tests/unit/infrastructure/test_i18n.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Tests for i18n infrastructure.
|
||||
|
||||
Covers TranslationService, locale detection helpers, and the
|
||||
convenience _() function.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.infrastructure.i18n.translator import (
|
||||
DEFAULT_LOCALE,
|
||||
TranslationService,
|
||||
_,
|
||||
)
|
||||
from app.presentation.web.locale import (
|
||||
_get_best_locale,
|
||||
_parse_accept_language,
|
||||
setup_locale_manager,
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_request(
|
||||
cookies: dict[str, str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock FastAPI Request with controlled state, cookies and headers."""
|
||||
request = MagicMock()
|
||||
request.state = SimpleNamespace()
|
||||
request.cookies = cookies or {}
|
||||
request.headers = headers or {}
|
||||
return request
|
||||
|
||||
|
||||
class TestTranslationService:
|
||||
"""Test TranslationService get_text resolution and fallback chain."""
|
||||
|
||||
def test_get_text_returns_translation_for_existing_key(self) -> None:
|
||||
"""Test get_text returns the correct translation for a known key."""
|
||||
ts = TranslationService()
|
||||
result = ts.get_text("nav.home", "ru")
|
||||
assert result == "Главная"
|
||||
|
||||
def test_get_text_returns_english_fallback_for_missing_key(self) -> None:
|
||||
"""Test get_text falls back to English when locale lacks the key."""
|
||||
ts = TranslationService()
|
||||
result = ts.get_text("nav.home", "fr")
|
||||
assert result == "Accueil"
|
||||
|
||||
def test_get_text_returns_key_when_neither_locale_nor_en_has_it(self) -> None:
|
||||
"""Test get_text returns the key itself when no translation exists anywhere."""
|
||||
ts = TranslationService()
|
||||
result = ts.get_text("nonexistent.key", "de")
|
||||
assert result == "nonexistent.key"
|
||||
|
||||
def test_get_text_returns_english_fallback_for_unknown_locale(self) -> None:
|
||||
"""Test get_text returns English when the requested locale does not exist."""
|
||||
ts = TranslationService()
|
||||
result = ts.get_text("nav.home", "zz")
|
||||
assert result == "Home"
|
||||
|
||||
def test_get_text_returns_en_when_requested_locale_is_en(self) -> None:
|
||||
"""Test get_text returns English string when locale is en."""
|
||||
ts = TranslationService()
|
||||
result = ts.get_text("nav.home", "en")
|
||||
assert result == "Home"
|
||||
|
||||
def test_get_text_returns_en_fallback_when_requested_locale_missing_key(self) -> None:
|
||||
"""Test get_text falls back to English when locale is unknown and key exists in en."""
|
||||
ts = TranslationService()
|
||||
result = ts.get_text("about.signed_in", "zz")
|
||||
assert result == "Signed in as {username}."
|
||||
|
||||
def test_singleton_returns_same_instance(self) -> None:
|
||||
"""Test TranslationService.get_instance always returns the same instance."""
|
||||
instance_a = TranslationService.get_instance()
|
||||
instance_b = TranslationService.get_instance()
|
||||
assert instance_a is instance_b
|
||||
|
||||
def test_singleton_shares_translations(self) -> None:
|
||||
"""Test that multiple calls via singleton share the same data."""
|
||||
instance_a = TranslationService.get_instance()
|
||||
instance_b = TranslationService.get_instance()
|
||||
assert instance_a.translations is instance_b.translations
|
||||
|
||||
|
||||
class TestConvenienceFunction:
|
||||
"""Test the module-level _() convenience function."""
|
||||
|
||||
def test_convenience_function_returns_translation(self) -> None:
|
||||
"""Test _() returns correct translation for a given key."""
|
||||
result = _("header.logo", "de")
|
||||
assert result == "Blog"
|
||||
|
||||
def test_convenience_function_defaults_to_english(self) -> None:
|
||||
"""Test _() uses DEFAULT_LOCALE when no locale is given."""
|
||||
result = _("nav.posts")
|
||||
assert result == "Posts"
|
||||
assert DEFAULT_LOCALE == "en"
|
||||
|
||||
def test_convenience_function_returns_key_on_missing(self) -> None:
|
||||
"""Test _() returns key when translation does not exist."""
|
||||
result = _("utterly.fake.key", "fr")
|
||||
assert result == "utterly.fake.key"
|
||||
|
||||
|
||||
class TestParseAcceptLanguage:
|
||||
"""Test the Accept-Language header parser."""
|
||||
|
||||
def test_empty_header_returns_empty_list(self) -> None:
|
||||
"""Test empty Accept-Language returns empty list."""
|
||||
assert _parse_accept_language("") == []
|
||||
|
||||
def test_single_locale(self) -> None:
|
||||
"""Test a single locale code is returned as a single-element list."""
|
||||
assert _parse_accept_language("fr") == ["fr"]
|
||||
|
||||
def test_multiple_locales_with_quality(self) -> None:
|
||||
"""Test multiple locales with q-values are returned in order."""
|
||||
result = _parse_accept_language("fr, en;q=0.9, de;q=0.8")
|
||||
assert result == ["fr", "en", "de"]
|
||||
|
||||
def test_region_code_strips_to_base_language(self) -> None:
|
||||
"""Test fr-FR is normalised to fr."""
|
||||
assert _parse_accept_language("fr-FR") == ["fr"]
|
||||
|
||||
def test_complex_header(self) -> None:
|
||||
"""Test a realistic Accept-Language header with multiple locales."""
|
||||
result = _parse_accept_language("ru-RU, ru;q=0.9, en;q=0.5")
|
||||
assert result == ["ru", "ru", "en"]
|
||||
|
||||
def test_whitespace_around_locales(self) -> None:
|
||||
"""Test whitespace around locale codes is handled gracefully."""
|
||||
result = _parse_accept_language(" en , fr;q=0.5 ")
|
||||
assert result == ["en", "fr"]
|
||||
|
||||
|
||||
class TestGetBestLocale:
|
||||
"""Test locale detection from cookie and Accept-Language header."""
|
||||
|
||||
def test_cookie_takes_priority(self) -> None:
|
||||
"""Test cookie value is used when it is a supported locale."""
|
||||
request = _make_mock_request(cookies={"locale": "de"}, headers={"accept-language": "ru"})
|
||||
assert _get_best_locale(request) == "de"
|
||||
|
||||
def test_invalid_cookie_falls_back_to_header(self) -> None:
|
||||
"""Test unsupported locale in cookie falls back to Accept-Language."""
|
||||
request = _make_mock_request(cookies={"locale": "zz"}, headers={"accept-language": "fr"})
|
||||
assert _get_best_locale(request) == "fr"
|
||||
|
||||
def test_no_cookie_uses_accept_language(self) -> None:
|
||||
"""Test Accept-Language is used when no cookie is present."""
|
||||
request = _make_mock_request(headers={"accept-language": "de"})
|
||||
assert _get_best_locale(request) == "de"
|
||||
|
||||
def test_no_cookie_no_header_returns_default(self) -> None:
|
||||
"""Test default locale returned when neither cookie nor header matches."""
|
||||
request = _make_mock_request()
|
||||
assert _get_best_locale(request) == DEFAULT_LOCALE
|
||||
|
||||
def test_accept_language_unsupported_returns_default(self) -> None:
|
||||
"""Test unsupported language in header falls back to default."""
|
||||
request = _make_mock_request(headers={"accept-language": "zh"})
|
||||
assert _get_best_locale(request) == DEFAULT_LOCALE
|
||||
|
||||
def test_missing_cookie_key_uses_header(self) -> None:
|
||||
"""Test absent locale cookie is treated as no preference."""
|
||||
request = _make_mock_request(cookies={"other": "val"}, headers={"accept-language": "ru"})
|
||||
assert _get_best_locale(request) == "ru"
|
||||
|
||||
|
||||
class TestSetupLocaleManager:
|
||||
"""Test the middleware helper that sets locale on request state."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sets_locale_on_request_state(self) -> None:
|
||||
"""Test setup_locale_manager sets locale when not already present."""
|
||||
request = _make_mock_request(headers={"accept-language": "fr"})
|
||||
await setup_locale_manager(request)
|
||||
assert request.state.locale == "fr"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_override_existing_locale(self) -> None:
|
||||
"""Test setup_locale_manager does not override an already-set locale."""
|
||||
request = _make_mock_request(headers={"accept-language": "fr"})
|
||||
request.state.locale = "de"
|
||||
await setup_locale_manager(request)
|
||||
assert request.state.locale == "de"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_locale_when_no_match(self) -> None:
|
||||
"""Test setup_locale_manager uses default when nothing matches."""
|
||||
request = _make_mock_request()
|
||||
await setup_locale_manager(request)
|
||||
assert request.state.locale == DEFAULT_LOCALE
|
||||
Reference in New Issue
Block a user