204000aabc
Resolve 3 merge conflicts and renumber the automation_hooks migration to follow main's migration chain (036_add_document_translation_fields). Conflicts resolved: - app/api/__init__.py: add automation_router alongside main's new routers - app/utils/settings_service.py: add automation_hooks_enabled alongside compliance_enabled - tests/conftest.py: add AutomationHook alongside AuditLog/ComplianceTemplate imports Migration renumbered: - 027_add_automation_hooks → 037_add_automation_hooks - down_revision: 026_add_scheduled_jobs → 036_add_document_translation_fields Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
543 lines
18 KiB
Python
543 lines
18 KiB
Python
"""Tests for the i18n (internationalization) and l10n (localization) utilities."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.utils.i18n import (
|
|
DEFAULT_LANGUAGE,
|
|
SUPPORTED_LANGUAGE_CODES,
|
|
SUPPORTED_LANGUAGES,
|
|
_parse_accept_language,
|
|
detect_language,
|
|
format_date,
|
|
format_datetime,
|
|
format_number,
|
|
get_language_info,
|
|
get_suggested_languages,
|
|
reload_translations,
|
|
translate,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Translation file integrity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTranslationFiles:
|
|
"""Verify that all translation JSON files are valid and complete."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_cache(self) -> None:
|
|
"""Clear translation cache before each test."""
|
|
reload_translations()
|
|
|
|
@pytest.mark.unit
|
|
def test_all_translation_files_exist(self) -> None:
|
|
"""Every supported language must have a corresponding JSON file."""
|
|
translations_dir = Path(__file__).resolve().parent.parent / "frontend" / "translations"
|
|
for lang in SUPPORTED_LANGUAGES:
|
|
filepath = translations_dir / f"{lang['code']}.json"
|
|
assert filepath.is_file(), f"Missing translation file for {lang['code']}"
|
|
|
|
@pytest.mark.unit
|
|
def test_all_translation_files_are_valid_json(self) -> None:
|
|
"""All translation files must be parseable JSON."""
|
|
translations_dir = Path(__file__).resolve().parent.parent / "frontend" / "translations"
|
|
for lang in SUPPORTED_LANGUAGES:
|
|
filepath = translations_dir / f"{lang['code']}.json"
|
|
data = json.loads(filepath.read_text(encoding="utf-8"))
|
|
assert isinstance(data, dict), f"{lang['code']}.json must be a dict"
|
|
assert len(data) > 0, f"{lang['code']}.json must not be empty"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core translate() function
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTranslate:
|
|
"""Tests for the translate() function."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_cache(self) -> None:
|
|
reload_translations()
|
|
|
|
@pytest.mark.unit
|
|
def test_translate_known_key_returns_non_empty_string(self) -> None:
|
|
"""A valid key should resolve to a non-empty string (not the key itself)."""
|
|
result = translate("nav.dashboard", "en")
|
|
assert isinstance(result, str)
|
|
assert result != ""
|
|
assert result != "nav.dashboard"
|
|
|
|
@pytest.mark.unit
|
|
def test_translate_non_english_locale_returns_value(self) -> None:
|
|
"""Non-English locales should return a non-empty translated value."""
|
|
for locale in ("de", "fr", "zh"):
|
|
result = translate("nav.dashboard", locale)
|
|
assert isinstance(result, str)
|
|
assert result != ""
|
|
assert result != "nav.dashboard"
|
|
|
|
@pytest.mark.unit
|
|
def test_translate_fallback_to_english(self) -> None:
|
|
"""Unknown locale falls back to English translation."""
|
|
result = translate("nav.dashboard", "xx")
|
|
english = translate("nav.dashboard", "en")
|
|
assert result == english
|
|
|
|
@pytest.mark.unit
|
|
def test_translate_missing_key_returns_key(self) -> None:
|
|
"""Missing key falls back to the key itself."""
|
|
result = translate("nonexistent.key", "en")
|
|
assert result == "nonexistent.key"
|
|
|
|
@pytest.mark.unit
|
|
def test_translate_none_locale_uses_default(self) -> None:
|
|
"""None locale defaults to English."""
|
|
result = translate("nav.dashboard", None)
|
|
english = translate("nav.dashboard", "en")
|
|
assert result == english
|
|
|
|
@pytest.mark.unit
|
|
def test_translate_with_kwargs(self) -> None:
|
|
"""Placeholders should be interpolated via kwargs."""
|
|
result = translate("footer.copyright", "en", year="2025")
|
|
assert "2025" in result
|
|
assert result != "footer.copyright"
|
|
|
|
@pytest.mark.unit
|
|
def test_translate_with_kwargs_german(self) -> None:
|
|
"""Placeholder interpolation in German."""
|
|
result = translate("language.changed", "de", language="English")
|
|
assert "English" in result
|
|
assert result != "language.changed"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Accept-Language header parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseAcceptLanguage:
|
|
"""Tests for parsing the Accept-Language HTTP header."""
|
|
|
|
@pytest.mark.unit
|
|
def test_simple_language(self) -> None:
|
|
assert _parse_accept_language("de") == "de"
|
|
|
|
@pytest.mark.unit
|
|
def test_language_with_region(self) -> None:
|
|
assert _parse_accept_language("de-DE") == "de"
|
|
|
|
@pytest.mark.unit
|
|
def test_multiple_languages_quality(self) -> None:
|
|
result = _parse_accept_language("fr;q=0.9, de;q=1.0, en;q=0.8")
|
|
assert result == "de"
|
|
|
|
@pytest.mark.unit
|
|
def test_unsupported_language_fallback(self) -> None:
|
|
result = _parse_accept_language("xx, yy")
|
|
assert result is None
|
|
|
|
@pytest.mark.unit
|
|
def test_empty_header(self) -> None:
|
|
assert _parse_accept_language("") is None
|
|
|
|
@pytest.mark.unit
|
|
def test_complex_accept_language(self) -> None:
|
|
header = "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7"
|
|
result = _parse_accept_language(header)
|
|
assert result == "zh"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Language detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDetectLanguage:
|
|
"""Tests for detecting language from request context."""
|
|
|
|
@pytest.mark.unit
|
|
def test_session_preference_takes_priority(self) -> None:
|
|
request = MagicMock()
|
|
request.session = {"preferred_language": "de"}
|
|
request.cookies = {}
|
|
request.headers = {}
|
|
assert detect_language(request) == "de"
|
|
|
|
@pytest.mark.unit
|
|
def test_cookie_fallback(self) -> None:
|
|
request = MagicMock()
|
|
request.session = {}
|
|
request.cookies = {"docuelevate_lang": "fr"}
|
|
request.headers = {}
|
|
assert detect_language(request) == "fr"
|
|
|
|
@pytest.mark.unit
|
|
def test_accept_language_fallback(self) -> None:
|
|
request = MagicMock()
|
|
request.session = {}
|
|
request.cookies = {}
|
|
request.headers = {"accept-language": "es-ES,es;q=0.9"}
|
|
assert detect_language(request) == "es"
|
|
|
|
@pytest.mark.unit
|
|
def test_default_fallback(self) -> None:
|
|
request = MagicMock()
|
|
request.session = {}
|
|
request.cookies = {}
|
|
request.headers = {}
|
|
assert detect_language(request) == DEFAULT_LANGUAGE
|
|
|
|
@pytest.mark.unit
|
|
def test_invalid_session_language_ignored(self) -> None:
|
|
request = MagicMock()
|
|
request.session = {"preferred_language": "invalid"}
|
|
request.cookies = {"docuelevate_lang": "it"}
|
|
request.headers = {}
|
|
assert detect_language(request) == "it"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Localization helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestL10nFormatters:
|
|
"""Tests for locale-aware formatting functions."""
|
|
|
|
@pytest.mark.unit
|
|
def test_format_date_english(self) -> None:
|
|
d = date(2025, 3, 15)
|
|
result = format_date(d, "en")
|
|
assert "March" in result
|
|
assert "15" in result
|
|
assert "2025" in result
|
|
|
|
@pytest.mark.unit
|
|
def test_format_date_german(self) -> None:
|
|
d = date(2025, 3, 15)
|
|
result = format_date(d, "de")
|
|
assert "15." in result
|
|
assert "2025" in result
|
|
|
|
@pytest.mark.unit
|
|
def test_format_date_short(self) -> None:
|
|
d = date(2025, 3, 15)
|
|
result = format_date(d, "en", short=True)
|
|
assert result == "03/15/2025"
|
|
|
|
@pytest.mark.unit
|
|
def test_format_date_short_german(self) -> None:
|
|
d = date(2025, 3, 15)
|
|
result = format_date(d, "de", short=True)
|
|
assert result == "15.03.2025"
|
|
|
|
@pytest.mark.unit
|
|
def test_format_date_none(self) -> None:
|
|
assert format_date(None) == ""
|
|
|
|
@pytest.mark.unit
|
|
def test_format_datetime_none(self) -> None:
|
|
assert format_datetime(None) == ""
|
|
|
|
@pytest.mark.unit
|
|
def test_format_number_english(self) -> None:
|
|
result = format_number(1234567, "en")
|
|
assert result == "1,234,567"
|
|
|
|
@pytest.mark.unit
|
|
def test_format_number_german(self) -> None:
|
|
result = format_number(1234567, "de")
|
|
assert result == "1.234.567"
|
|
|
|
@pytest.mark.unit
|
|
def test_format_number_float_english(self) -> None:
|
|
result = format_number(1234.56, "en")
|
|
assert result == "1,234.56"
|
|
|
|
@pytest.mark.unit
|
|
def test_format_number_float_german(self) -> None:
|
|
result = format_number(1234.56, "de")
|
|
assert result == "1.234,56"
|
|
|
|
@pytest.mark.unit
|
|
def test_format_datetime_chinese(self) -> None:
|
|
dt = datetime(2025, 3, 15, 14, 30)
|
|
result = format_datetime(dt, "zh")
|
|
assert "2025" in result
|
|
assert "03" in result
|
|
assert "15" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_language_info()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetLanguageInfo:
|
|
"""Tests for get_language_info() utility."""
|
|
|
|
@pytest.mark.unit
|
|
def test_known_language(self) -> None:
|
|
info = get_language_info("de")
|
|
assert info is not None
|
|
assert info["name"] == "German"
|
|
assert info["native"] == "Deutsch"
|
|
|
|
@pytest.mark.unit
|
|
def test_unknown_language(self) -> None:
|
|
assert get_language_info("xx") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_suggested_languages()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetSuggestedLanguages:
|
|
"""Tests for get_suggested_languages() utility."""
|
|
|
|
@pytest.mark.unit
|
|
def test_returns_at_most_six(self) -> None:
|
|
"""Result must contain at most 6 languages."""
|
|
result = get_suggested_languages("en", "en,de;q=0.9,fr;q=0.8,es;q=0.7,it;q=0.6,pt;q=0.5,nl;q=0.4,zh;q=0.3")
|
|
assert len(result) <= 6
|
|
|
|
@pytest.mark.unit
|
|
def test_current_locale_is_first(self) -> None:
|
|
"""Currently active language must always be the first entry."""
|
|
result = get_suggested_languages("de", "")
|
|
assert result[0]["code"] == "de"
|
|
|
|
@pytest.mark.unit
|
|
def test_includes_browser_preference(self) -> None:
|
|
"""Languages from Accept-Language header should be included."""
|
|
result = get_suggested_languages("en", "fr;q=0.9,de;q=0.8")
|
|
codes = [lang["code"] for lang in result]
|
|
assert "fr" in codes
|
|
assert "de" in codes
|
|
|
|
@pytest.mark.unit
|
|
def test_fallback_to_popular_languages(self) -> None:
|
|
"""Popular languages fill remaining slots when no browser prefs given."""
|
|
result = get_suggested_languages("en", "")
|
|
codes = [lang["code"] for lang in result]
|
|
# en is current; popular fallbacks like zh, es, fr should be present
|
|
assert "en" in codes
|
|
# At least one other popular language should appear
|
|
popular = {"zh", "es", "ar", "fr", "de", "ja", "pt", "hi", "ko"}
|
|
assert popular & set(codes)
|
|
|
|
@pytest.mark.unit
|
|
def test_no_duplicates(self) -> None:
|
|
"""No language code should appear more than once."""
|
|
result = get_suggested_languages("fr", "fr;q=1.0,de;q=0.9")
|
|
codes = [lang["code"] for lang in result]
|
|
assert len(codes) == len(set(codes))
|
|
|
|
@pytest.mark.unit
|
|
def test_all_entries_are_valid_languages(self) -> None:
|
|
"""Every returned entry must be a dict with required language fields."""
|
|
result = get_suggested_languages("es", "ca;q=0.9")
|
|
for entry in result:
|
|
assert "code" in entry
|
|
assert "name" in entry
|
|
assert "native" in entry
|
|
assert "flag" in entry
|
|
|
|
@pytest.mark.unit
|
|
def test_unknown_locale_falls_back_gracefully(self) -> None:
|
|
"""An unsupported current_locale must not crash and still return results."""
|
|
result = get_suggested_languages("xx", "")
|
|
assert len(result) > 0 # popular fallbacks still returned
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SUPPORTED_LANGUAGES metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSupportedLanguages:
|
|
"""Tests for language metadata constants."""
|
|
|
|
@pytest.mark.unit
|
|
def test_ten_languages_supported(self) -> None:
|
|
assert len(SUPPORTED_LANGUAGES) == 77
|
|
|
|
@pytest.mark.unit
|
|
def test_supported_codes_set(self) -> None:
|
|
expected = {
|
|
"en",
|
|
"de",
|
|
"fr",
|
|
"es",
|
|
"it",
|
|
"pt",
|
|
"nl",
|
|
"nb",
|
|
"no",
|
|
"da",
|
|
"sv",
|
|
"fi",
|
|
"is",
|
|
"ga",
|
|
"lb",
|
|
"ca",
|
|
"cy",
|
|
"fy",
|
|
"gl",
|
|
"li",
|
|
"vls",
|
|
"nds",
|
|
"pl",
|
|
"cs",
|
|
"sk",
|
|
"hu",
|
|
"sl",
|
|
"hr",
|
|
"ro",
|
|
"bg",
|
|
"el",
|
|
"et",
|
|
"lv",
|
|
"lt",
|
|
"sr",
|
|
"tr",
|
|
"uk",
|
|
"he",
|
|
"ar",
|
|
"fa",
|
|
"af",
|
|
"zh",
|
|
"zh-TW",
|
|
"ja",
|
|
"ko",
|
|
"vi",
|
|
"pa",
|
|
"kn",
|
|
"hi",
|
|
"bn",
|
|
"gu",
|
|
"ml",
|
|
"mr",
|
|
"ta",
|
|
"te",
|
|
"ur",
|
|
"si",
|
|
"ne",
|
|
"th",
|
|
"km",
|
|
"id",
|
|
"ms",
|
|
"jv",
|
|
"tl",
|
|
"mn",
|
|
"kk",
|
|
"uz",
|
|
"az",
|
|
"hy",
|
|
"ka",
|
|
"sw",
|
|
"am",
|
|
"ha",
|
|
"yo",
|
|
"ig",
|
|
"zu",
|
|
"eo",
|
|
}
|
|
assert SUPPORTED_LANGUAGE_CODES == expected
|
|
|
|
@pytest.mark.unit
|
|
def test_default_language_is_english(self) -> None:
|
|
assert DEFAULT_LANGUAGE == "en"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API endpoint tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestI18nAPI:
|
|
"""Tests for the i18n API endpoints."""
|
|
|
|
@pytest.mark.integration
|
|
def test_list_languages(self, client: TestClient) -> None:
|
|
"""GET /api/i18n/languages should return all supported languages."""
|
|
response = client.get("/api/i18n/languages")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "languages" in data
|
|
assert len(data["languages"]) == 77
|
|
assert data["default"] == "en"
|
|
# Verify each language has required fields
|
|
for lang in data["languages"]:
|
|
assert "code" in lang
|
|
assert "name" in lang
|
|
assert "native" in lang
|
|
assert "flag" in lang
|
|
|
|
@pytest.mark.integration
|
|
def test_set_language(self, client: TestClient) -> None:
|
|
"""POST /api/i18n/language should set language preference."""
|
|
response = client.post(
|
|
"/api/i18n/language",
|
|
json={"language": "de"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["language"] == "de"
|
|
# Verify cookie was set
|
|
assert "docuelevate_lang" in response.cookies
|
|
|
|
@pytest.mark.integration
|
|
def test_set_language_invalid_falls_back_to_default(self, client: TestClient) -> None:
|
|
"""Invalid language code should fall back to default."""
|
|
response = client.post(
|
|
"/api/i18n/language",
|
|
json={"language": "invalid"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["language"] == "en"
|
|
|
|
@pytest.mark.integration
|
|
def test_set_language_persists_in_cookie(self, client: TestClient) -> None:
|
|
"""Language setting should be persisted in a cookie."""
|
|
client.post("/api/i18n/language", json={"language": "fr"})
|
|
# Subsequent requests should detect the language from cookie
|
|
response = client.get("/api/i18n/languages")
|
|
data = response.json()
|
|
assert data["current"] == "fr"
|
|
|
|
@pytest.mark.integration
|
|
def test_base_html_uses_current_locale(self, client: TestClient) -> None:
|
|
"""The base template should set lang attribute to current locale."""
|
|
# Set language to German
|
|
client.post("/api/i18n/language", json={"language": "de"})
|
|
# Load homepage
|
|
response = client.get("/", follow_redirects=True)
|
|
assert response.status_code == 200
|
|
# The lang attribute should reflect the locale
|
|
assert 'lang="de"' in response.text or 'lang="en"' in response.text
|
|
|
|
@pytest.mark.integration
|
|
def test_language_selector_in_nav(self, client: TestClient) -> None:
|
|
"""The navigation should contain the language selector with flag and search."""
|
|
response = client.get("/", follow_redirects=True)
|
|
if response.status_code == 200:
|
|
# The selector renders a flag emoji (not the old fa-globe icon) and the
|
|
# setLanguage JS helper for switching languages.
|
|
assert "setLanguage" in response.text
|
|
# The search input for filtering all languages must be present.
|
|
assert "langSearch" in response.text
|