Files
team-tryouts/tests/test_i18n.py
T
GGThedandClaude Opus 5 8e3865f557 chore(lint): elargir les regles ruff et rendre le format bloquant en CI
QUA-002, seconde moitie. Le depot etant formate, l elargissement porte sur
des defauts et non sur du brassage.

Ajoute a la selection : B (bugbear), C4, RET, SIM, UP. Le lot entier n a
produit que 24 signalements sur 76 fichiers -- le code etait plus propre
que l audit ne le craignait. Neuf corriges automatiquement, quinze a la
main.

SIM108 est ignore : forcer un ternaire se lit moins bien que le if/else
qu il remplace, au seul endroit ou il se declenche.

isort (I) n est PAS active. Il reordonnerait les imports de 48 fichiers,
soit une seconde passe de pur brassage juste apres le commit de formatage.
A faire, mais seul.

Deux vrais defauts trouves par les nouvelles regles
  - team_matches.edit_match faisait `except ValueError: pass` sur l heure de
    debut et l heure de fin, trois lignes sous un champ date qui, lui,
    signale et redirige. Une heure mal saisie etait donc acceptee par le
    formulaire, jetee, l ancienne valeur conservee -- et la page annoncait
    la reussite. Meme traitement que la date desormais.
  - backup.py levait BackupError depuis deux blocs `except` sans `from`,
    ce qui perdait la cause d origine dans la trace.

Ainsi que : un `return` explicite dans force_https, `%`-formatage remplace
dans log_auth_event (operations de chaine avant journalisation, pas des
gabarits de logger -- la redaction n est pas affectee), une compréhension
inutile, un `set(...)` en compréhension d ensemble, `open(..., 'r')`, une
variable de boucle inutilisee, et `contextlib.suppress` dans conftest.

CI : `ruff format --check` remplace le commentaire qui expliquait pourquoi
il etait absent.

263 tests passent. Les deux nouveaux messages sont traduits ; attention,
pybabel les avait apparies en `fuzzy` avec des entrees « date » existantes,
et une entree fuzzy est ignoree a l execution.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:59:40 -04:00

314 lines
11 KiB
Python

"""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 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 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}: {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.post('/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': '[email protected]',
'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