"""Local PostgreSQL and simulated Firebase tests. Never contacts Firebase.""" from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta import hashlib import os from pathlib import Path import re import unittest from unittest.mock import Mock, patch from uuid import uuid4 from zoneinfo import ZoneInfo import bcrypt import psycopg from psycopg import sql from psycopg.conninfo import make_conninfo from fastapi.testclient import TestClient import main from community_badges import cohort, reconcile from i18n import current_language from notifications import DeliveryError, FirebaseSender, PushWorker, enqueue, save_language class BadgeAndLanguageTests(unittest.TestCase): def test_registration_boundaries_and_timezone(self): with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2026-10-31', 'BETA_TESTER_UNTIL':'2026-12-31'}): for value, expected in ( (datetime(2026, 10, 31, 23, 59, 59), 'alpha_tester'), (datetime(2026, 11, 1), 'beta_tester'), (datetime(2026, 12, 31, 23, 59, 59), 'beta_tester'), (datetime(2027, 1, 1), 'early_bird'), (datetime(2026, 10, 31, 23, tzinfo=ZoneInfo('UTC')), 'beta_tester')): self.assertEqual(cohort(value), expected) def test_configurable_dates_and_invalid_order(self): with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2026-11-30', 'BETA_TESTER_UNTIL':'2027-01-31'}): self.assertEqual(cohort(datetime(2026, 11, 15)), 'alpha_tester') self.assertEqual(cohort(datetime(2027, 1, 1)), 'beta_tester') with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2027-02-01', 'BETA_TESTER_UNTIL':'2027-01-31'}): with self.assertRaises(ValueError): cohort(datetime(2026, 1, 1)) def test_language_dropdown_offers_both_languages_and_marks_current(self): for language in ('de', 'en'): token = current_language.set(language) try: html = main.templates.get_template('_language_switch.html').render() finally: current_language.reset(token) self.assertEqual(len(re.findall(r']*lang="{language}"[^>]*aria-current="true"') self.assertEqual(html.count('aria-current="true"'), 1) def test_all_push_kinds_share_generic_android_payload_and_unique_tags(self): from firebase_admin import messaging from notifications import TEXT sender = FirebaseSender() sender.app = object() tags = set() for kind, (title, body) in TEXT.items(): with self.subTest(kind=kind), patch.object(messaging, 'send') as send: identifier = str(uuid4()) sender.send('synthetic-token', title, body, {'notification_id': identifier, 'session_tag': 'a' * 64}, identifier) payload = send.call_args.args[0] self.assertEqual(payload.notification.title, title) self.assertEqual(payload.notification.body, body) self.assertEqual(set(payload.data), {'notification_id', 'session_tag'}) self.assertEqual(payload.android.notification.tag, identifier) self.assertIsNone(payload.android.notification.channel_id) self.assertIsNone(payload.android.notification.click_action) self.assertEqual(payload.android.priority, 'high') self.assertEqual(payload.android.notification.sound, 'default') self.assertEqual(payload.android.notification.icon, 'ic_notification') tags.add(identifier) self.assertEqual(len(tags), 3) def test_sender_missing_credentials_is_safe(self): with patch.dict(os.environ, {'GOOGLE_APPLICATION_CREDENTIALS':'', 'FIREBASE_PROJECT_ID':''}): with self.assertRaisesRegex(DeliveryError, '^configuration$'): FirebaseSender().send('secret-token', 'title', 'body', {}, 'tag') def test_sdk_payload_errors_and_no_credentials_in_payload(self): from firebase_admin import messaging, exceptions sender = FirebaseSender() sender.app = object() with patch.object(messaging, 'send') as send: sender.send('synthetic-token', 'New message', 'You have a new message.', {'notification_id':str(uuid4()), 'session_tag':'a'*64}, 'tag') payload = send.call_args.args[0] self.assertEqual(payload.android.notification.visibility, 'private') self.assertEqual(payload.android.ttl.total_seconds(), 300) self.assertEqual(payload.notification.body, 'You have a new message.') for failure, expected in ((messaging.UnregisteredError('sensitive'), 'unregistered'), (exceptions.UnavailableError('sensitive'), 'transient'), (exceptions.PermissionDeniedError('sensitive'), 'configuration')): with patch.object(messaging, 'send', side_effect=failure): with self.assertRaisesRegex(DeliveryError, '^' + expected + '$'): sender.send('secret-token', 'title', 'body', {}, 'tag') @unittest.skipUnless(os.environ.get('METALCIRCLE_TEST_DATABASE') == '1', 'explicit local DB opt-in required') class NotificationDatabaseTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.original_dsn = main.DATABASE_URL cls.schema = 'metalcircle_push_test_' + uuid4().hex with psycopg.connect(cls.original_dsn) as db: db.execute(sql.SQL('CREATE SCHEMA {}').format(sql.Identifier(cls.schema))) main.DATABASE_URL = make_conninfo(cls.original_dsn, options='-csearch_path='+cls.schema) with main.get_db_connection() as db: db.execute(Path('/test-init/01_initial.sql').read_text()) with patch.object(main, 'INITIAL_ADMIN_USERNAME', None): main.ensure_schema() cls.password_hash = bcrypt.hashpw(b'Push-local-test-123', bcrypt.gensalt()).decode() @classmethod def tearDownClass(cls): main.DATABASE_URL = cls.original_dsn with psycopg.connect(cls.original_dsn) as db: db.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(cls.schema))) def setUp(self): self.flags = patch.dict(os.environ, {'PUSH_ENABLED':'true', 'ALPHA_TESTER_UNTIL':'2026-10-31', 'BETA_TESTER_UNTIL':'2026-12-31'}) self.flags.start() self.secure = patch.object(main, 'COOKIE_SECURE', False) self.secure.start() main.rate_limit_buckets.clear() with main.get_db_connection() as db: db.execute('TRUNCATE users,concerts RESTART IDENTITY CASCADE') for name in ('sender', 'recipient', 'outsider'): db.execute('INSERT INTO users(username,email,password_hash,created_at) VALUES (%s,%s,%s,%s)', (name, name+'@example.invalid', self.password_hash, datetime(2026, 9, 1))) self.clients = [] for name in ('sender', 'recipient', 'outsider'): client = TestClient(main.app) client.headers['Origin'] = 'http://testserver' self.assertEqual(client.post('/login', data={'username':name, 'password':'Push-local-test-123'}, follow_redirects=False).status_code, 303) self.clients.append(client) self.device = dict(device_id=str(uuid4()), token='synthetic-fcm-token-'+'x'*120, platform='android', session_tag=self.clients[1].get('/api/push/session').json()['session_tag']) self.assertEqual(self.clients[1].post('/api/push/devices', json=self.device).status_code, 200) self.sender = Mock() self.worker = PushWorker(main.get_db_connection, self.sender) def tearDown(self): for client in self.clients: client.close() self.secure.stop() self.flags.stop() def scalar(self, query, args=()): with main.get_db_connection() as db: return db.execute(query, args).fetchone()[0] def friendship(self): result = self.clients[0].post('/users/recipient/friend-request', follow_redirects=False) self.assertEqual(result.status_code, 303) def message(self): with main.get_db_connection() as db: db.execute("INSERT INTO friendships(requester_id,addressee_id,status) VALUES (1,2,'accepted') ON CONFLICT DO NOTHING") result = self.clients[0].post('/messages/recipient', data={'body':'PRIVATE message content'}, follow_redirects=False) self.assertEqual(result.status_code, 303) def invitation(self): with main.get_db_connection() as db: db.execute("INSERT INTO friendships(requester_id,addressee_id,status) VALUES (1,2,'accepted') ON CONFLICT DO NOTHING") result = self.clients[0].post('/concerts', data={'artist':'Private test event', 'start_datetime':'2027-04-01T20:00', 'event_type':'other','visibility':'private','invited_user_ids':'2'}, follow_redirects=False) self.assertEqual(result.status_code, 303) return int(result.headers['location'].rsplit('/', 1)[1]) def test_friend_event_deduplicated_and_english_recipient(self): self.clients[1].get('/language/en', follow_redirects=False) self.friendship() self.friendship() self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 1) self.assertTrue(self.worker.deliver_one()) args = self.sender.send.call_args.args self.assertEqual(args[1], 'New friend request') self.assertNotIn('PRIVATE', str(args)) target = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False) self.assertEqual(target.headers['location'], '/users/sender') denied = self.clients[2].get('/notifications/'+args[3]['notification_id'], follow_redirects=False) self.assertEqual(denied.headers['location'], '/') def test_message_route_and_private_content(self): self.message() self.worker.deliver_one() args = self.sender.send.call_args.args self.assertEqual(args[1:3], ('Neue Nachricht', 'Du hast eine neue Nachricht.')) self.assertNotIn('PRIVATE message content', str(args)) response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False) self.assertEqual(response.headers['location'], '/messages/sender#latest') def test_opening_chat_before_delivery_drops_push_and_read_tap_returns_home(self): self.message() self.assertEqual(self.clients[1].get('/messages/sender').status_code, 200) self.worker.deliver_one() self.sender.send.assert_not_called() self.assertEqual(self.scalar('SELECT state FROM push_notifications'), 'dropped') self.message() self.worker.deliver_one() self.sender.send.assert_called_once() identifier = self.sender.send.call_args.args[3]['notification_id'] self.clients[1].get('/messages/sender') response = self.clients[1].get('/notifications/' + identifier, follow_redirects=False) self.assertEqual(response.headers['location'], '/') def test_invitation_route_and_removed_invitation(self): concert = self.invitation() self.worker.deliver_one() args = self.sender.send.call_args.args response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False) self.assertEqual(response.headers['location'], '/concerts/'+str(concert)) with main.get_db_connection() as db: db.execute('DELETE FROM event_invitations') response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False) self.assertEqual(response.headers['location'], '/') def test_disabled_sender_has_no_backlog(self): with patch.dict(os.environ, {'PUSH_ENABLED':'false'}): self.friendship() self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0) def test_invitation_edit_only_notifies_new_invitees(self): concert = self.invitation() form = {'artist':'Private test event','start_datetime':'2027-04-01T20:00', 'event_type':'other','visibility':'private','invited_user_ids':'2'} result = self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False) self.assertEqual(result.status_code, 303) self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE state='pending'"), 1) form.pop('invited_user_ids') self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False) form['invited_user_ids'] = '2' self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False) self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE state='pending'"), 1) def event(self, owner=1, past=False, kind='other', visibility='public'): start = datetime.now() + timedelta(days=-10 if past else 10) with main.get_db_connection() as db: return db.execute("""INSERT INTO concerts (artist,start_datetime,end_datetime,event_type,visibility,created_by) VALUES (%s,%s,%s,%s,%s,%s) RETURNING id""", ('Event access test', start, start + timedelta(days=1) if kind == 'festival' else None, kind, visibility, owner)).fetchone()[0] def test_creator_can_delete_own_events_including_past_in_both_languages(self): for language in ('de', 'en'): self.clients[0].get('/language/' + language, follow_redirects=False) for past in (False, True): for kind in ('concert', 'festival', 'other'): with self.subTest(language=language, past=past, kind=kind): event = self.event(past=past, kind=kind) page = self.clients[0].get(f'/concerts/{event}') self.assertEqual(page.status_code, 200) self.assertIn(f'action="/concerts/{event}/delete"', page.text) self.assertIn('Delete event' if language == 'en' else 'Veranstaltung löschen', page.text) if past: self.assertNotIn(f'href="/concerts/{event}/edit"', page.text) result = self.clients[0].post(f'/concerts/{event}/delete', follow_redirects=False) self.assertEqual(result.status_code, 303) self.assertEqual(self.scalar('SELECT count(*) FROM concerts WHERE id=%s', (event,)), 0) def test_delete_rejects_foreign_and_anonymous_but_preserves_admin_access(self): for past in (False, True): event = self.event(owner=2, past=past) page = self.clients[0].get(f'/concerts/{event}') self.assertNotIn(f'action="/concerts/{event}/delete"', page.text) self.assertEqual(self.clients[0].post(f'/concerts/{event}/delete', follow_redirects=False).status_code, 403) self.assertEqual(self.scalar('SELECT count(*) FROM concerts WHERE id=%s', (event,)), 1) with TestClient(main.app) as anonymous: self.assertEqual(anonymous.post(f'/concerts/{event}/delete', follow_redirects=False).status_code, 303) self.assertEqual(self.clients[1].post(f'/concerts/{event}/delete', headers={'Origin':'https://evil.invalid'}, follow_redirects=False).status_code, 403) with main.get_db_connection() as db: db.execute('UPDATE users SET is_admin=TRUE WHERE id=3') self.assertIn(f'action="/concerts/{event}/delete"', self.clients[2].get(f'/concerts/{event}').text) self.assertEqual(self.clients[2].post(f'/concerts/{event}/delete', follow_redirects=False).status_code, 303) # Deleting a parent does not delete another user's linked event. parent = self.event(kind='concert') child = self.event(owner=2) with main.get_db_connection() as db: db.execute('UPDATE concerts SET parent_event_id=%s WHERE id=%s', (parent, child)) self.assertEqual(self.clients[0].post(f'/concerts/{parent}/delete', follow_redirects=False).status_code, 303) self.assertIsNone(self.scalar('SELECT parent_event_id FROM concerts WHERE id=%s', (child,))) def test_private_picker_shows_only_confirmed_unblocked_owner_friends(self): event = self.event(visibility='private') with main.get_db_connection() as db: db.execute("INSERT INTO friendships(requester_id,addressee_id,status) VALUES (2,1,'accepted'),(1,3,'pending')") for language, heading in (('de','Freunde einladen'), ('en','Invite friends')): self.clients[0].get('/language/' + language, follow_redirects=False) for route in ('/concerts/new', f'/concerts/{event}/edit'): page = self.clients[0].get(route) self.assertEqual(page.status_code, 200) field = re.search(r'
', page.text, re.S).group() self.assertIn(heading, field) self.assertEqual(re.findall(r'name="invited_user_ids" value="(\d+)"', field), ['2']) with main.get_db_connection() as db: db.execute('UPDATE users SET is_admin=TRUE WHERE id=3') admin_page = self.clients[2].get(f'/concerts/{event}/edit') self.assertEqual(re.findall(r'name="invited_user_ids" value="(\d+)"', admin_page.text), ['2']) # Verify the other direction of the friendship and a block overriding it. self.assertEqual([u['id'] for u in main.get_invitable_users(2)], [1]) with main.get_db_connection() as db: db.execute('INSERT INTO user_blocks(blocker_id,blocked_id) VALUES (2,1)') self.assertEqual(main.get_invitable_users(1), []) for language, empty in (('de','Noch keine bestätigten Freunde vorhanden.'), ('en','No confirmed friends yet.')): self.clients[0].get('/language/' + language, follow_redirects=False) self.assertIn(empty, self.clients[0].get('/concerts/new').text) def test_private_invites_reject_forged_ids_and_preserve_existing_hidden_invites(self): form = {'artist':'Invitation access test', 'start_datetime':'2027-04-01T20:00', 'event_type':'other', 'visibility':'private'} for language, error in (('de','Es können nur bestätigte Freunde eingeladen werden.'), ('en','Only confirmed friends can be invited.')): self.clients[0].get('/language/' + language, follow_redirects=False) for selected in ('1','2','99999'): result = self.clients[0].post('/concerts', data=dict(form, invited_user_ids=selected), follow_redirects=False) self.assertEqual(result.status_code, 400) self.assertIn(error, result.text) self.assertEqual(self.scalar('SELECT count(*) FROM concerts'), 0) event = self.invitation() result = self.clients[0].post(f'/concerts/{event}/edit', data=dict(form, invited_user_ids=['2','3']), follow_redirects=False) self.assertEqual(result.status_code, 400) self.assertEqual(main.get_event_invitee_ids(event), {2}) self.assertEqual(self.scalar('SELECT artist FROM concerts WHERE id=%s', (event,)), 'Private test event') with main.get_db_connection() as db: db.execute('DELETE FROM friendships') # The now hidden legacy invitation survives an unrelated edit, without a new push. result = self.clients[0].post(f'/concerts/{event}/edit', data=form, follow_redirects=False) self.assertEqual(result.status_code, 303) self.assertEqual(main.get_event_invitee_ids(event), {2}) self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE kind='event_invitation'"), 1) # An invited non-owner cannot use the edit route to grant access to someone else. result = self.clients[1].post(f'/concerts/{event}/edit', data=dict(form, invited_user_ids='3'), follow_redirects=False) self.assertEqual(result.status_code, 303) self.assertEqual(main.get_event_invitee_ids(event), {2}) def test_linkable_events_are_chronological_in_create_and_edit_forms(self): with main.get_db_connection() as db: rows = db.execute(""" INSERT INTO concerts(artist, start_datetime, end_datetime, event_type, visibility, created_by) VALUES ('Active festival', date_trunc('day', CURRENT_TIMESTAMP) - INTERVAL '1 day' + INTERVAL '18 hours', date_trunc('day', CURRENT_TIMESTAMP) + INTERVAL '23 hours 59 minutes', 'festival', 'public', 1), ('Near concert', date_trunc('day', CURRENT_TIMESTAMP) + INTERVAL '2 days 20 hours', NULL, 'concert', 'public', 1), ('Same-time concert', date_trunc('day', CURRENT_TIMESTAMP) + INTERVAL '2 days 20 hours', NULL, 'concert', 'public', 1), ('Upcoming festival', date_trunc('day', CURRENT_TIMESTAMP) + INTERVAL '3 days 10 hours', date_trunc('day', CURRENT_TIMESTAMP) + INTERVAL '4 days 23 hours', 'festival', 'public', 1), ('Later concert', date_trunc('day', CURRENT_TIMESTAMP) + INTERVAL '6 days 20 hours', NULL, 'concert', 'public', 1), ('Past concert', date_trunc('day', CURRENT_TIMESTAMP) - INTERVAL '1 day', NULL, 'concert', 'public', 1), ('Finished festival', date_trunc('day', CURRENT_TIMESTAMP) - INTERVAL '3 days', date_trunc('day', CURRENT_TIMESTAMP) - INTERVAL '1 day', 'festival', 'public', 1) RETURNING id """).fetchall() active_festival, near_concert, same_time_concert, upcoming_festival, later_concert, _, _ = [row[0] for row in rows] edit_event_id = db.execute(""" INSERT INTO concerts(artist, start_datetime, event_type, visibility, created_by) VALUES ('Test pre-show', date_trunc('day', CURRENT_TIMESTAMP) + INTERVAL '5 days', 'other', 'public', 1) RETURNING id """).fetchone()[0] expected_ids = [active_festival, near_concert, same_time_concert, upcoming_festival, later_concert] for language in ('de', 'en'): self.clients[0].get('/language/' + language, follow_redirects=False) create_page = self.clients[0].get('/concerts/new') self.assertEqual(create_page.status_code, 200) create_select = re.search(r'