import asyncio import re import tempfile import unittest from datetime import datetime, timezone from pathlib import Path from fastapi.testclient import TestClient from server.db import decode, encode from server.main import create_app try: from server.notifications import Config, Notifier except ImportError: Config = Notifier = None class RecordingTransport: """Only the external delivery boundary is replaced; queue and DB stay real.""" def __init__(self): self.messages = [] self.failures = {} async def send(self, channel, destination, text): if self.failures.get(channel, 0): self.failures[channel] -= 1 raise RuntimeError('secret-token-must-never-reach-browser') self.messages.append((channel, destination, text)) async def bot_username(self): return 'SimkeepTestBot' async def updates(self, offset): return [] class NotificationTests(unittest.TestCase): def setUp(self): self.assertIsNotNone(Notifier, 'Real notification service must be implemented') self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.path = Path(self.temp.name)/'test.db' self.config = Config(telegram_token='server-secret-token', smtp_host='smtp.example.com', smtp_from='bot@example.com', public_url='http://example.com') self.app = create_app(self.path, start_worker=False, config=self.config) self.a = TestClient(self.app) self.b = TestClient(self.app) self.addCleanup(self.a.close) self.addCleanup(self.b.close) for client, name in [(self.a, 'Alice'), (self.b, 'Bob')]: response = client.post('/api/auth/register', json={'name':name,'email':f'{name.lower()}@example.com','password':'long-password-123'}) self.assertEqual(response.status_code, 201, response.text) self.db = self.app.state.db self.notifier = self.app.state.notifier self.transport = RecordingTransport() self.notifier.transport = self.transport self.owner = self.a.get('/api/auth/me').json()['user']['id'] self.headers = {'X-CSRF-Token': self.a.get('/api/auth/me').json()['csrfToken']} self.now = datetime(2026, 10, 4, 1, 0, tzinfo=timezone.utc) self.card = self.a.post('/api/cards', headers=self.headers, json={'name':'Private SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':'2026-10-07','instructions':'发送一条短信'}]}).json()['workspace']['cards'][0] def enable(self, verified=True): with self.db.connect(write=True) as conn: settings = decode(conn.execute('SELECT settings FROM users WHERE id=?', (self.owner,)).fetchone()[0]) settings.update(telegram=True, email=True, chatId='123456', telegramVerified=verified, emailVerified=verified) conn.execute('UPDATE users SET settings=? WHERE id=?', (encode(settings), self.owner)) def tick(self, now=None): self.notifier.enqueue(now or self.now) asyncio.run(self.notifier.deliver(now or self.now)) def test_local_time_deduplication_and_sent_jobs_survive_restart(self): self.enable() self.tick(datetime(2026, 10, 4, 0, 59, tzinfo=timezone.utc)) self.assertEqual(self.transport.messages, []) self.tick() self.assertEqual(len(self.transport.messages), 2) self.assertIn('Private SIM', self.transport.messages[0][2]) self.assertIn('2026-10-07', self.transport.messages[0][2]) self.tick() restarted = Notifier(self.db, self.config, self.transport) restarted.recover() restarted.enqueue(self.now) asyncio.run(restarted.deliver(self.now)) self.assertEqual(len(self.transport.messages), 2) self.assertNotIn('server-secret-token', self.a.get('/api/workspace').text) def test_unverified_and_archived_cards_never_send(self): self.enable(verified=False) self.tick() self.assertEqual(self.transport.messages, []) self.enable() self.notifier.enqueue(self.now) self.a.post(f"/api/cards/{self.card['id']}/archive", headers=self.headers, json={'archived':True,'version':self.card['version']}) asyncio.run(self.notifier.deliver(self.now)) self.assertEqual(self.transport.messages, []) def test_old_rule_jobs_cancel_after_completion(self): self.enable() self.notifier.enqueue(self.now) rule = self.card['rules'][0] response = self.a.post(f"/api/rules/{rule['id']}/completions", headers=self.headers, json={'completedAt':'2026-10-04','note':'','requestId':'record-once-only','version':rule['version']}) self.assertEqual(response.status_code, 200, response.text) asyncio.run(self.notifier.deliver(self.now)) self.assertEqual(self.transport.messages, []) self.assertTrue(all(x['status']=='cancelled' for x in self.a.get('/api/workspace').json()['workspace']['notifications'])) def test_unconfigured_channel_does_not_starve_email_and_stale_jobs_cancel(self): self.enable() with self.db.connect(write=True) as conn: original=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0]) settings=decode(conn.execute('SELECT settings FROM users WHERE id=?',(self.owner,)).fetchone()[0]) settings['email']=False conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),self.owner)) # Arrange 50 real active rules so the previous batch was fully occupied. for index in range(49): card={**original,'id':f'card-{index}','rules':[{**original['rules'][0],'id':f'rule-{index}'}]} conn.execute('INSERT INTO cards VALUES(?,?,?)',(card['id'],self.owner,encode(card))) self.notifier.enqueue(self.now) self.notifier.config=Config(smtp_host='smtp.example.com',smtp_from='bot@example.com') with self.db.connect(write=True) as conn: settings['email']=True conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),self.owner)) self.tick() self.assertEqual(len(self.transport.messages),50) self.assertTrue(all(channel=='email' for channel,_,_ in self.transport.messages)) with self.db.connect(write=True) as conn: conn.execute("UPDATE notification_jobs SET ready_at=ready_at-86400 WHERE channel='telegram'") self.tick(datetime(2026,10,5,1,0,tzinfo=timezone.utc)) with self.db.connect() as conn: self.assertEqual(conn.execute("SELECT count(*) FROM notification_jobs WHERE channel='telegram' AND status='cancelled'").fetchone()[0],50) def test_rebind_check_waits_for_this_binding(self): self.enable() code=self.a.post('/api/notifications/telegram/binding',headers=self.headers).json()['code'] check=lambda:self.a.post('/api/notifications/telegram/binding/check',headers=self.headers,json={'code':code}) response=check() self.assertEqual(response.status_code,200,response.text) self.assertEqual(response.json()['bindingStatus'],'waiting') self.assertTrue(self.notifier.process_update({'message':{'chat':{'id':654321,'type':'private'},'text':f'/start {code}'}})) self.assertEqual(check().json()['bindingStatus'],'bound') bheaders={'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']} other=self.b.post('/api/notifications/telegram/binding/check',headers=bheaders,json={'code':code}) self.assertEqual(other.json()['bindingStatus'],'expired') def test_real_message_matches_suffix_preview(self): self.enable() with self.db.connect(write=True) as conn: card=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0]) card['phone']='+44 7700 900123' conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id'])) self.tick() for _,_,text in self.transport.messages: self.assertIn('尾号 0123',text) self.assertNotIn('+44 7700 900123',text) def test_reminder_includes_estimated_cost_and_matching_balance(self): self.enable() with self.db.connect(write=True) as conn: card=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0]) card['balances']=[{'currency':'USD','amount':'10.50'}] card['rules'][0]['cost']={'currency':'USD','amount':'2.50'} conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id'])) self.tick() for _,_,text in self.transport.messages: self.assertIn('续期金额:USD 2.50',text) self.assertIn('同币种余额:USD 10.50',text) def test_transport_failures_retry_without_blocking_other_channel(self): self.enable() self.transport.failures['telegram'] = 1 self.tick() self.assertEqual([x[0] for x in self.transport.messages], ['email']) self.assertNotIn('secret-token-must', self.a.get('/api/workspace').text) self.tick(datetime(2026, 10, 4, 1, 3, tzinfo=timezone.utc)) self.assertEqual(sorted(x[0] for x in self.transport.messages), ['email','telegram']) self.tick(datetime(2026, 10, 4, 1, 6, tzinfo=timezone.utc)) self.assertEqual(len(self.transport.messages), 2) def test_cancelled_pending_job_can_resume_without_resending_sent_job(self): self.enable() self.notifier.enqueue(self.now) settings = self.a.get('/api/workspace').json()['workspace']['settings'] fields = ['timezone','time','offsets','overdue','telegram','email','emailAddress'] payload = {k:settings[k] for k in fields} payload['telegram'] = payload['email'] = False self.a.put('/api/settings', headers=self.headers, json=payload) payload['telegram'] = payload['email'] = True self.a.put('/api/settings', headers=self.headers, json=payload) self.tick() self.assertEqual(len(self.transport.messages), 2) self.a.put('/api/settings', headers=self.headers, json=payload) self.tick() self.assertEqual(len(self.transport.messages), 2) def test_telegram_binding_is_private_expiring_and_single_use(self): response = self.a.post('/api/notifications/telegram/binding', headers=self.headers) self.assertEqual(response.status_code, 200, response.text) code = response.json()['code'] group = {'message':{'chat':{'id':-123,'type':'group'},'text':f'/start {code}'}} self.assertFalse(self.notifier.process_update(group)) private = {'message':{'chat':{'id':123456,'type':'private'},'text':f'/start {code}'}} self.assertTrue(self.notifier.process_update(private)) self.assertFalse(self.notifier.process_update(private)) settings = self.a.get('/api/workspace').json()['workspace']['settings'] self.assertTrue(settings['telegramVerified']) self.assertEqual(settings['chatId'], '123456') bheaders = {'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']} other = self.b.post('/api/notifications/telegram/binding', headers=bheaders).json()['code'] private['message']['text'] = f'/start {other}' self.assertFalse(self.notifier.process_update(private)) self.assertFalse(self.b.get('/api/workspace').json()['workspace']['settings']['telegramVerified']) expired = self.a.post('/api/notifications/telegram/binding', headers=self.headers).json()['code'] with self.db.connect(write=True) as conn: conn.execute("UPDATE bindings SET expires_at=0 WHERE kind='telegram'") private['message']['text'] = f'/start {expired}' self.assertFalse(self.notifier.process_update(private)) def test_email_confirmation_is_owner_scoped_and_destination_bound(self): response = self.a.post('/api/notifications/email/verify', headers=self.headers) self.assertEqual(response.status_code, 200, response.text) code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group() bheaders = {'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']} self.assertEqual(self.b.post('/api/notifications/email/confirm', headers=bheaders, json={'code':code}).status_code, 422) self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 200) self.assertTrue(self.a.get('/api/workspace').json()['workspace']['settings']['emailVerified']) self.assertEqual(self.a.post('/api/notifications/test/email', headers=self.headers).status_code, 200) self.assertEqual(self.b.post('/api/notifications/test/email', headers=bheaders).status_code, 422) self.a.post('/api/notifications/email/verify', headers=self.headers) code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group() self.a.put('/api/settings', headers=self.headers, json={'timezone':'Asia/Shanghai','time':'09:00','offsets':[3],'overdue':True,'telegram':False,'email':True,'emailAddress':'new@example.com'}) self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 422) def test_email_attempt_limit_and_unconfigured_service(self): self.a.post('/api/notifications/email/verify', headers=self.headers) code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group() wrong = '000000' if code != '000000' else '111111' for _ in range(5): self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':wrong}).status_code, 422) self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 422) app = create_app(Path(self.temp.name)/'no-config.db', start_worker=False, config=Config()) with TestClient(app) as client: client.post('/api/auth/register', json={'name':'Test','email':'test@example.com','password':'long-password-123'}) headers = {'X-CSRF-Token':client.get('/api/auth/me').json()['csrfToken']} self.assertEqual(client.post('/api/notifications/email/verify', headers=headers).status_code, 503) self.assertEqual(client.post('/api/notifications/telegram/binding', headers=headers).status_code, 503) def test_overdue_notification_is_once_per_local_day(self): self.enable() with self.db.connect(write=True) as conn: card = decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0]) card['rules'][0]['dueDate']='2026-10-03' conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id'])) self.tick() self.tick() self.assertEqual(len(self.transport.messages),2) self.tick(datetime(2026,10,5,1,0,tzinfo=timezone.utc)) self.assertEqual(len(self.transport.messages),4) if __name__ == '__main__': unittest.main()