Files
blog.pyaqa.ru/tests/unit/infrastructure/test_i18n.py
Sergey Vanyushkin d32ad29abc
Some checks failed
ci/woodpecker/pr/pipeline Pipeline failed
feat(i18n): add browser-language localization with Jinja2 _() and locale middleware
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
2026-05-10 16:22:06 +03:00

197 lines
7.9 KiB
Python

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