Files
pingu-concerts/app/tests/test_i18n.py
T

173 lines
11 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="Switch language: EN"', page.text)
self.assertIn('lang="en" hreflang="en" aria-current="true"', page.text)
self.assertIn('>Deutsch</span>', 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_patch_has_a_long_description_with_an_english_translation(self):
codes = {definition[0] for definition in main.BADGE_DEFINITIONS}
self.assertEqual(set(main.BADGE_LONG_DESCRIPTIONS), codes)
current_language.set('en')
for description in main.BADGE_LONG_DESCRIPTIONS.values():
with self.subTest(description=description):
self.assertNotEqual(main._(description), description)
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', long_description=main.BADGE_LONG_DESCRIPTIONS['founder'], earned=True, icon='⚔️', image_path='/patch.jpg')
profile_badges = [badge]
for code in ('alpha_tester', 'beta_tester', 'early_bird'):
name, icon, _threshold, reason, _category = main.BADGE_BY_CODE[code]
profile_badges.append(dict(code=code, name=name, description=reason,
long_description=main.BADGE_LONG_DESCRIPTIONS[code],
earned=True, icon=icon, image_path='/patch.jpg',
awarded_at='14.09.2026', trigger_concert=None))
event['badges'] = [badge]
event['linked_events'] = []
profile = dict(member, attended_concerts=1, earned_codes={badge['code'] for badge in profile_badges})
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=profile_badges, 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):
from bug_reporter import CATEGORIES, SEVERITIES
page = env.get_template(path.name).render(**context, categories=CATEGORIES, severities=SEVERITIES, form={})
if not path.name.startswith('_'):
self.assertIn(f'<html lang="{language}">', page)
self.assertIn('class="language-switch"', page)
if path.name == 'profile.html':
self.assertIn('grid-template-columns: repeat(2, minmax(0, 1fr))', page)
self.assertIn('This patch recognizes you as part of the first crew' if language == 'en' else 'Dieser Patch würdigt dich als Teil der ersten Crew', page)
self.assertIn('Reason:' if language == 'en' else 'Grund:', page)
self.assertEqual(page.count('class="patch-button"'), len(profile_badges))
self.assertIn('<details class="profile-edit"', page)
self.assertIn('<summary class="profile-edit-summary">' + ('Edit profile' if language == 'en' else 'Profil bearbeiten') + '</summary>', page)
self.assertLess(page.index('<details class="profile-edit"'), page.index('id="profile-form"'))
expanded_profile = env.get_template('profile.html').render(**context, form_error='validation error', form_success=None)
self.assertIn('<details class="profile-edit" open>', expanded_profile)
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))
from starlette.requests import Request
response = main.change_language(Request({'type': 'http', 'headers': []}), 'invalid')
self.assertEqual(response.body, b'Invalid language.')
if __name__ == '__main__':
unittest.main()