feat(i18n): add browser-language localization with Jinja2 _() and locale middleware
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:
2026-05-10 16:22:06 +03:00
parent 4e6505c598
commit d32ad29abc
18 changed files with 1077 additions and 100 deletions

View File

@@ -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)

View File

@@ -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

View 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