Files
pingu-concerts/app/tests/test_i18n.py
T
kai b2af866cf0 Add bilingual UI and improve event actions layout
Add persistent German/English switching, translated UI messages and English 12-hour times. Align desktop event actions while preserving mobile layouts and record bilingual project guidance.

Include pending diary deletion exclusions. Validate both languages with nine tests.
2026-09-14 15:31:56 +02:00

144 lines
8.9 KiB
Python

"""Run with: python -m unittest discover -s tests (from app/). No database needed."""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
import unittest
from unittest.mock import patch
from fastapi.testclient import TestClient
import main
from i18n import ENGLISH, current_language, gettext, safe_return_path, format_time, format_datetime
class TranslationTests(unittest.TestCase):
def setUp(self):
self.token = current_language.set('de')
def tearDown(self):
current_language.reset(self.token)
def test_time_formats_include_midnight_and_noon(self):
for hour, minute, english, german in (
(0, 0, '12:00 AM', '00:00'),
(0, 5, '12:05 AM', '00:05'),
(9, 7, '9:07 AM', '09:07'),
(12, 0, '12:00 PM', '12:00'),
(20, 30, '8:30 PM', '20:30'),
(23, 59, '11:59 PM', '23:59'),
):
value = datetime(2026, 9, 14, hour, minute)
for language, expected in [('en', english), ('de', german)]:
with self.subTest(language=language, hour=hour, minute=minute):
current_language.set(language)
self.assertEqual(format_time(value), expected)
self.assertEqual(format_datetime(value), f'14.09.2026 {expected}')
def test_event_card_uses_selected_time_format(self):
start = datetime(2026, 9, 14, 20, 30)
row = (1, 'Band', start, None, 'Venue', 'Hagen', 'Deutschland', 'concert', None, 'public', False)
for language, expected in [('en', '8:30 PM'), ('de', '20:30')]:
current_language.set(language)
card = main.serialize_concert_card(row)
self.assertEqual(card['time'], expected)
self.assertEqual(card['_start_datetime'], start)
def test_templates_compile_and_catalog_covers_literal_translations(self):
for path in Path(main.BASE_DIR, 'templates').glob('*.html'):
with self.subTest(template=path.name):
main.templates.get_template(path.name)
tree = main.templates.parse(path.read_text())
from jinja2 import nodes
for node in tree.find_all(nodes.Call):
if isinstance(node.node, nodes.Name) and node.node.name == '_' and node.args and isinstance(node.args[0], nodes.Const):
self.assertIn(node.args[0].value, ENGLISH)
def test_language_persists_and_can_be_changed_before_login(self):
with patch.object(main, 'COOKIE_SECURE', False):
client = TestClient(main.app)
response = client.get('/login')
self.assertIn('<html lang="de">', response.text)
self.assertIn('Anmelden', response.text)
response = client.get('/language/en?next=/login')
self.assertEqual(response.status_code, 200)
self.assertIn('<html lang="en">', response.text)
self.assertIn('Log in', response.text)
self.assertNotIn('Anmelden', response.text)
self.assertIn('HttpOnly', response.history[0].headers['set-cookie'])
self.assertEqual(response.headers['content-language'], 'en')
self.assertIn('Cookie', response.headers['vary'])
for path, title in [('/datenschutz', 'Privacy policy'), ('/impressum', 'Legal notice')]:
page = client.get(path)
self.assertIn(title, page.text)
self.assertIn('aria-label="English" aria-current="true"', page.text)
response = client.get('/language/de?next=/login')
self.assertIn('Anmelden', response.text)
self.assertIn('<html lang="de">', response.text)
def test_language_validation_and_safe_return(self):
with patch.object(main, 'COOKIE_SECURE', False):
client = TestClient(main.app)
self.assertEqual(client.get('/language/fr').status_code, 400)
for target in ['https://evil.example', '//evil.example', '/\\evil.example', '/\nevil', '/language/en']:
self.assertEqual(safe_return_path(target), '/')
target = '/events/past?q=Iron+Maiden&category=concert'
response = client.get('/language/en', params={'next': target}, follow_redirects=False)
self.assertEqual(response.headers['location'], target)
def test_translations_are_request_local(self):
def read(language):
with patch.object(main, 'COOKIE_SECURE', False):
client = TestClient(main.app)
client.cookies.set('metalcircle_language', language)
return client.get('/login').text
with ThreadPoolExecutor(max_workers=4) as pool:
languages = ['de', 'en'] * 8
for language, page in zip(languages, pool.map(read, languages)):
self.assertIn(f'<html lang="{language}">', page)
self.assertIn('Log in' if language == 'en' else 'Anmelden', page)
self.assertEqual(gettext('Anmelden'), 'Anmelden')
def test_user_content_is_escaped_and_never_translated(self):
current_language.set('en')
page = main.templates.get_template('_user_menu.html').render(user={
'display_name': '<script>Nachrichten</script>', 'is_admin': True, 'notification_count': 2,
})
self.assertIn('&lt;script&gt;Nachrichten&lt;/script&gt;', page)
self.assertIn('My profile', page)
self.assertIn('new message(s) or request(s)', page)
def test_every_page_renders_with_populated_content_in_both_languages(self):
from jinja2 import ChainableUndefined
import os
env = main.templates.overlay(undefined=ChainableUndefined)
member = dict(id=1, username='sample', display_name='Nachrichten <test>', email='sample@example.invalid', is_admin=True, notification_count=2, created_at='14.09.2026')
event = dict(id=1, concert_id=1, artist='Nachrichten <test>', title='Nachrichten <test>', venue='Venue', city='Hagen', country='Deutschland', date='14.09.2026', time='20:00', event_type='concert', event_type_label='Konzert', visibility='friends', is_past=True, is_verified=True, concert_count=2, name='Venue', rating=4, favorite_song='Nachrichten <test>', notes='Nachrichten <test>', photos=[dict(id=1, path='/photo.jpg')], bands=[])
badge = dict(code='founder', name='Gründer', description='Von Anfang an dabei und MetalCircle aufgebaut', earned=True, icon='⚔️', image_path='/patch.jpg')
event['badges'] = [badge]
event['linked_events'] = []
profile = dict(member, attended_concerts=1)
context = dict(user=member, users=[member], managed_user=member, concert=event, profile=profile, filters={}, stats={}, connections=dict(friends=[member], incoming=[member], outgoing=[member], blocked=[member]), badges=[badge], patch_groups=[dict(label='⚔️ Spezial-Patches', patches=[badge])], venues=[event, dict(event, id=2)], entries=[event], upcoming_concerts=[event], past_concerts=[event], events=[event], followed_bands=[dict(name='Nachrichten <test>', key='test')], followed_venues=[event], linkable_events=[event], invitable_users=[member], invited_user_ids=[1], attending_users=[member], maybe_users=[member], ticket_seekers=[member], ticket_offers=[member], attending_count=2, maybe_count=1, ticket_seeker_count=1, ticket_offer_count=1, comments=[dict(member, body='Nachrichten <test>')], is_own_profile=True, can_view_details=True, can_edit_title=True, can_edit_details=True, can_manage_access=True, can_delete=True, can_edit=True, can_manage=True, user_results=[member], friend_requests=[member], event_invitations=[dict(event, invited_by='Nachrichten <test>')], conversations=[dict(member, unread_count=1, last_message='Nachrichten <test>')], messages=[dict(body='Nachrichten <test>')])
for language in ('de', 'en'):
current_language.set(language)
for path in Path(main.BASE_DIR, 'templates').glob('*.html'):
with self.subTest(language=language, template=path.name):
page = env.get_template(path.name).render(**context)
if not path.name.startswith('_'):
self.assertIn(f'<html lang="{language}">', page)
self.assertIn('class="language-switch"', page)
if os.environ.get('I18N_RENDER_DIR'):
target = Path(os.environ['I18N_RENDER_DIR'], language)
target.mkdir(parents=True, exist_ok=True)
(target / path.name).write_text(page)
def test_server_validation_is_localized(self):
current_language.set('en')
with self.assertRaisesRegex(ValueError, 'Instagram') as error:
main.normalize_instagram_url('https://evil.example/test')
self.assertIn('Please enter', str(error.exception))
response = main.change_language('invalid')
self.assertEqual(response.body, b'Invalid language.')
if __name__ == '__main__':
unittest.main()