"""Language selection and translation. French is the primary language of the site; English stays available. Source strings remain in English and act as gettext message ids, with the French wording supplied by a catalogue. A string not yet translated degrades to English rather than to a raw identifier — which is what lets the migration proceed template by template without ever leaving the site half broken. """ import pytest from app.i18n import DEFAULT_LOCALE, SUPPORTED_LOCALES, select_locale class TestDefaults: def test_french_is_the_default_language(self): assert DEFAULT_LOCALE == 'fr' def test_english_is_available(self): assert 'en' in SUPPORTED_LOCALES def test_a_visitor_without_preferences_gets_french(self, client): body = client.get('/auth/login').get_data(as_text=True) assert 'lang="fr"' in body assert 'Se connecter' in body def test_the_html_lang_attribute_follows_the_locale(self, client): english = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'}) assert 'lang="en"' in english.get_data(as_text=True) class TestBrowserNegotiation: def test_an_english_browser_is_served_english(self, client): body = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'} ).get_data(as_text=True) assert 'Sign In' in body def test_an_unsupported_language_falls_back_to_french(self, client): body = client.get('/auth/login', headers={'Accept-Language': 'de-DE,de;q=0.9'} ).get_data(as_text=True) assert 'Se connecter' in body def test_a_french_browser_is_served_french(self, client): body = client.get('/auth/login', headers={'Accept-Language': 'fr-CA,fr;q=0.9'} ).get_data(as_text=True) assert 'Se connecter' in body class TestExplicitSwitch: def test_switching_to_english_changes_the_page(self, client): client.get('/lang/en') body = client.get('/auth/login').get_data(as_text=True) assert 'Sign In' in body assert 'lang="en"' in body def test_an_explicit_choice_overrides_the_browser_header(self, client): """Someone on an English machine who picks French must keep French.""" client.get('/lang/fr') body = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'} ).get_data(as_text=True) assert 'Se connecter' in body assert 'lang="fr"' in body def test_the_choice_persists_across_requests(self, client): client.get('/lang/en') for _ in range(3): assert 'Sign In' in client.get('/auth/login').get_data(as_text=True) def test_an_unsupported_locale_is_refused(self, client): client.get('/lang/en') client.get('/lang/de') body = client.get('/auth/login').get_data(as_text=True) assert 'Sign In' in body, 'an unsupported code must not change the locale' def test_the_switcher_redirects_back_to_the_referring_page(self, client): response = client.get('/lang/en', headers={'Referer': 'http://localhost/auth/register'}, follow_redirects=False) assert response.headers['Location'].endswith('/auth/register') def test_an_external_referer_is_not_followed(self, client): """An unchecked Referer would make this an open redirect.""" response = client.get('/lang/en', headers={'Referer': 'https://evil.test/phishing'}, follow_redirects=False) assert 'evil.test' not in response.headers['Location'] class TestSwitcherAvailability: def test_the_switcher_is_visible_before_signing_in(self, client): """Someone who cannot read the current language has to be able to change it without signing in first.""" body = client.get('/auth/login').get_data(as_text=True) assert 'English' in body assert '/lang/en' in body def test_the_switcher_is_visible_once_signed_in(self, client, as_role): as_role('player') body = client.get('/users/profile').get_data(as_text=True) assert 'English' in body assert '/lang/en' in body def test_each_language_is_named_in_its_own_language(self, client): body = client.get('/auth/login').get_data(as_text=True) assert 'English' in body client.get('/lang/en') body = client.get('/auth/login').get_data(as_text=True) assert 'Français' in body class TestTranslatedContent: def test_navigation_is_translated(self, client, as_role): as_role('player') body = client.get('/users/profile').get_data(as_text=True) assert 'Tableau de bord' in body assert 'Déconnexion' in body def test_error_pages_are_translated(self, client): body = client.get('/no-such-page').get_data(as_text=True) assert 'Page introuvable' in body @pytest.mark.parametrize('locale,expected', [ ('fr', 'Ce compte a été désactivé.'), ('en', 'This account has been deactivated.'), ]) def test_flash_messages_are_translated( self, app, client, make_user, login, locale, expected ): from app.extensions import db from app.models import User user_id = make_user('player') with app.app_context(): user = db.session.get(User, user_id) user.is_active_account = False username = user.username db.session.commit() client.get(f'/lang/{locale}') body = login(username).get_data(as_text=True) assert expected in body class TestCatalogueIntegrity: """A missing compiled catalogue is invisible at runtime: the site simply serves English everywhere. Worth failing a build over.""" @pytest.mark.parametrize('locale', SUPPORTED_LOCALES) def test_the_compiled_catalogue_exists(self, locale): import os path = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'translations', locale, 'LC_MESSAGES', 'messages.mo', ) assert os.path.exists(path), ( f'{locale} catalogue is not compiled: run ' '`pybabel compile -d app/translations`' ) @pytest.mark.parametrize('locale', SUPPORTED_LOCALES) def test_every_message_is_translated(self, locale): import io import os from babel.messages.pofile import read_po path = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'translations', locale, 'LC_MESSAGES', 'messages.po', ) with io.open(path, encoding='utf-8') as handle: catalog = read_po(handle, locale=locale) untranslated = [m.id for m in catalog if m.id and not m.string] assert not untranslated, ( f'{len(untranslated)} untranslated string(s) in {locale}: ' f'{untranslated[:5]}' ) class TestSelectorUnit: def test_select_locale_returns_a_supported_code(self, app): with app.test_request_context('/'): assert select_locale() in SUPPORTED_LOCALES class TestLocaleSurvivesSessionRotation: """Login and logout both call session.clear() — the language choice is a display preference, not state belonging to the account, and used to be discarded along with everything else. Concretely: someone who read the login page in English and signed in was dropped straight back into French. """ def test_the_choice_survives_logging_in(self, client, as_role): client.get('/lang/en') as_role('player') body = client.get('/users/profile').get_data(as_text=True) assert 'lang="en"' in body assert 'Dashboard' in body def test_the_choice_survives_logging_out(self, client, as_role): client.get('/lang/en') as_role('player') client.get('/auth/logout') body = client.get('/auth/login').get_data(as_text=True) assert 'lang="en"' in body assert 'Sign In' in body def test_french_also_survives_logging_in(self, client, as_role): """The English browser case: an explicit French choice must hold.""" client.get('/lang/fr') as_role('player') body = client.get('/users/profile', headers={'Accept-Language': 'en-CA,en;q=0.9'} ).get_data(as_text=True) assert 'lang="fr"' in body def test_the_csrf_token_is_still_preserved(self, app, client, make_user, login): """Guard against fixing one preserved key by dropping the other. Seeded by hand rather than relying on Flask-WTF: the test fixture runs with CSRF disabled, so no token would exist to preserve. """ from app.extensions import db from app.models import User user_id = make_user('player') with app.app_context(): username = db.session.get(User, user_id).username with client.session_transaction() as sess: sess['csrf_token'] = 'sentinel-token' sess['locale'] = 'en' login(username) with client.session_transaction() as sess: assert sess.get('csrf_token') == 'sentinel-token' assert sess.get('locale') == 'en' class TestFlashMessagesAreTranslated: def test_an_access_refusal_is_translated(self, client, as_role): as_role('player') body = client.get('/users', follow_redirects=True).get_data(as_text=True) assert 'Seul le président peut gérer les utilisateurs.' in body def test_a_validation_message_is_translated(self, client, as_role): """From validators.py, which needs lazy_gettext: schema fields are built at import time, before any request exists.""" as_role('admin') body = client.post('/users/create', data={ 'username': 'x', 'email': 'not-an-email', 'password': 'a', 'full_name': 'X', 'role': 'coach', }, follow_redirects=True).get_data(as_text=True) assert 'Le nom d' in body and 'utilisateur doit compter' in body def test_a_message_with_a_value_keeps_it(self, client, as_role): as_role('admin') body = client.post('/users/create', data={ 'username': 'recrue', 'email': 'recrue@example.test', 'password': 'Password123', 'full_name': 'Nouvelle Recrue', 'role': 'coach', }, follow_redirects=True).get_data(as_text=True) assert 'Nouvelle Recrue' in body assert 'créé avec le rôle coach' in body