"""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_one_language_link_targets_opposite_language(self):
import re
for language, target in [('de', 'en'), ('en', 'de')]:
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'' + target.upper() + '', html)
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):
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_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 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'