"""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('', response.text) self.assertIn('Anmelden', response.text) response = client.get('/language/en?next=/login') self.assertEqual(response.status_code, 200) self.assertIn('', 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 to German"', page.text) response = client.get('/language/de?next=/login') self.assertIn('Anmelden', response.text) self.assertIn('', 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'', 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': '', 'is_admin': True, 'notification_count': 2, }) self.assertIn('<script>Nachrichten</script>', 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 ', email='sample@example.invalid', is_admin=True, notification_count=2, created_at='14.09.2026') event = dict(id=1, concert_id=1, artist='Nachrichten ', title='Nachrichten ', 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 ', notes='Nachrichten ', 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 ', 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 ')], 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 ')], conversations=[dict(member, unread_count=1, last_message='Nachrichten ')], messages=[dict(body='Nachrichten ')]) 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'', 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)) 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()