mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-27 23:28:03 +09:00
167 lines
6.1 KiB
Python
167 lines
6.1 KiB
Python
"""Per-user UI language preference (plan 79 B2).
|
|
|
|
Two things are proved here:
|
|
|
|
1. The backend's supported-language list has not drifted from the frontend
|
|
manifest, which is the canonical one. A backend that accepts a code the
|
|
browser has no bundle for stores a preference that silently does nothing.
|
|
2. `PUT /api/v1/auth/me` persists the choice, normalises regional tags, clears
|
|
back to "follow the panel default", and refuses junk.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.utils.i18n import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, normalize_language
|
|
|
|
MANIFEST = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ 'frontend' / 'src' / 'i18n' / 'languages.json'
|
|
)
|
|
|
|
|
|
def test_backend_language_list_matches_the_frontend_manifest():
|
|
"""The manifest is canonical; this list only exists so the API can validate.
|
|
|
|
If this fails, the fix is almost always to update SUPPORTED_LANGUAGES in
|
|
app/utils/i18n.py -- not to edit the manifest, which is what actually
|
|
decides which bundles ship.
|
|
"""
|
|
manifest = json.loads(MANIFEST.read_text(encoding='utf-8'))
|
|
manifest_codes = tuple(entry['code'] for entry in manifest['languages'])
|
|
|
|
assert manifest_codes == SUPPORTED_LANGUAGES, (
|
|
f'frontend manifest ships {manifest_codes}, '
|
|
f'backend accepts {SUPPORTED_LANGUAGES}'
|
|
)
|
|
|
|
|
|
def test_manifest_entries_are_complete():
|
|
manifest = json.loads(MANIFEST.read_text(encoding='utf-8'))
|
|
for entry in manifest['languages']:
|
|
assert entry['dir'] in ('ltr', 'rtl'), entry
|
|
assert entry['status'] in ('stable', 'provisional'), entry
|
|
assert entry['name'] and entry['nativeName'], entry
|
|
|
|
codes = [entry['code'] for entry in manifest['languages']]
|
|
assert DEFAULT_LANGUAGE in codes
|
|
assert len(codes) == len(set(codes)), 'duplicate language code in manifest'
|
|
|
|
|
|
@pytest.mark.parametrize('raw,expected', [
|
|
('en', 'en'),
|
|
('es', 'es'),
|
|
('ES', 'es'),
|
|
('es-MX', 'es'), # region tags resolve language-only ...
|
|
('es_419', 'es'), # ... including the underscore form
|
|
('en-GB', 'en'),
|
|
('zh', 'zh-Hans'), # legacy bare Chinese keeps Simplified behavior
|
|
('zh-CN', 'zh-Hans'),
|
|
('zh-SG', 'zh-Hans'),
|
|
('zh-Hans', 'zh-Hans'),
|
|
('zh-TW', 'zh-Hant'),
|
|
('zh-HK', 'zh-Hant'),
|
|
('zh_Hant_TW', 'zh-Hant'),
|
|
(' es ', 'es'),
|
|
('fr-CA', 'fr'),
|
|
('th-TH', 'th'),
|
|
('ar-EG', 'ar'),
|
|
('it-IT', 'it'),
|
|
('pl-PL', 'pl'),
|
|
('bn-BD', 'bn'),
|
|
('ja', None), # well-formed but not shipped
|
|
('zz-ZZ', None),
|
|
('', None),
|
|
(' ', None),
|
|
('e', None),
|
|
('en; DROP TABLE users', None),
|
|
('../../etc/passwd', None),
|
|
('a' * 40, None),
|
|
(None, None),
|
|
(12, None),
|
|
(['en'], None),
|
|
])
|
|
def test_normalize_language(raw, expected):
|
|
assert normalize_language(raw) == expected
|
|
|
|
|
|
def test_put_me_persists_language(client, auth_headers):
|
|
response = client.put('/api/v1/auth/me', json={'language': 'es'},
|
|
headers=auth_headers)
|
|
assert response.status_code == 200
|
|
assert response.get_json()['user']['language'] == 'es'
|
|
|
|
# Survives a fresh read -- the point of the column over localStorage.
|
|
response = client.get('/api/v1/auth/me', headers=auth_headers)
|
|
assert response.get_json()['user']['language'] == 'es'
|
|
|
|
|
|
def test_put_me_normalizes_a_regional_tag(client, auth_headers):
|
|
response = client.put('/api/v1/auth/me', json={'language': 'es-MX'},
|
|
headers=auth_headers)
|
|
assert response.status_code == 200
|
|
assert response.get_json()['user']['language'] == 'es'
|
|
|
|
|
|
def test_put_me_normalizes_a_chinese_region_to_its_script(client, auth_headers):
|
|
response = client.put('/api/v1/auth/me', json={'language': 'zh-TW'},
|
|
headers=auth_headers)
|
|
assert response.status_code == 200
|
|
assert response.get_json()['user']['language'] == 'zh-Hant'
|
|
|
|
|
|
def test_put_me_rejects_an_unsupported_language(client, auth_headers):
|
|
response = client.put('/api/v1/auth/me', json={'language': 'ja'},
|
|
headers=auth_headers)
|
|
assert response.status_code == 400
|
|
assert 'ja' in response.get_json()['error']
|
|
|
|
|
|
def test_put_me_clears_back_to_the_panel_default(client, auth_headers):
|
|
client.put('/api/v1/auth/me', json={'language': 'es'}, headers=auth_headers)
|
|
|
|
response = client.put('/api/v1/auth/me', json={'language': None},
|
|
headers=auth_headers)
|
|
assert response.status_code == 200
|
|
# NULL, not 'en': "follow the panel default" is its own state.
|
|
assert response.get_json()['user']['language'] is None
|
|
|
|
|
|
def test_new_user_has_no_language_preference(client, auth_headers):
|
|
response = client.get('/api/v1/auth/me', headers=auth_headers)
|
|
assert response.get_json()['user']['language'] is None
|
|
|
|
|
|
def test_setup_status_exposes_the_panel_default(client):
|
|
"""The sign-in screen renders before any authenticated call can answer."""
|
|
response = client.get('/api/v1/auth/setup-status')
|
|
assert response.status_code == 200
|
|
assert response.get_json()['default_language'] == DEFAULT_LANGUAGE
|
|
|
|
|
|
def test_preference_updates_do_not_burn_the_credential_throttle(client, auth_headers):
|
|
"""Switching language repeatedly must not 429.
|
|
|
|
PUT /me is throttled at 3/minute for credential edits. Language rides the
|
|
same route, and the client applies the change locally before the request
|
|
lands -- so a throttled write means the UI shows Spanish while the user row
|
|
still says English, and the choice quietly reverts on the next sign-in from
|
|
another browser. That is precisely the promise the column exists to keep.
|
|
"""
|
|
for code in ('es', 'en', 'es', 'en', 'es'):
|
|
response = client.put('/api/v1/auth/me', json={'language': code},
|
|
headers=auth_headers)
|
|
assert response.status_code == 200, response.get_json()
|
|
|
|
|
|
def test_credential_updates_are_still_throttled(client, auth_headers):
|
|
"""The exemption must not open the door it was carved out of."""
|
|
statuses = [
|
|
client.put('/api/v1/auth/me', json={'password': 'new-password-123'},
|
|
headers=auth_headers).status_code
|
|
for _ in range(5)
|
|
]
|
|
assert 429 in statuses, statuses
|