Files
creator-hub/browser_gateway/test_notice_polling.py
T
rogee e50bed9ad8
douyin-release-gate / verify (push) Failing after 18m54s
feat: add account events and private message management
Add Douyin notification polling, event details, and manual multi-account private messaging.

Refine environment memory settings, account operations, login collection recovery, and message UI; update tests and documentation.
2026-10-07 10:23:05 +08:00

216 lines
11 KiB
Python

import json
import unittest
from unittest.mock import Mock, patch
from urllib.parse import parse_qs, urlsplit
from .platform.douyin import BrowserResponse, DouyinBrowser, DouyinError
from .platform.notice_polling import NoticePollingSession, SubscriptionManager
NOW = '2026-10-06T10:00:00+00:00'
BOUNDARY = '2026-10-06T09:00:00+00:00'
def notice(nid='9007199254740993', timestamp=1791281100, uid='123', kind='follow'):
return {'nid': int(nid), 'user_id': int(uid), 'create_time': timestamp,
kind: {'from_user': [{'uid': 456}]}}
def page(items=(), more=0, cursor=0, status=0):
return BrowserResponse(200, json.dumps({'status_code': status, 'notice_list_v2': list(items),
'has_more': more, 'max_time': cursor}), False)
class NoticePollingTests(unittest.TestCase):
def browser(self, response=None):
browser = Mock()
browser.identity.return_value = {'uid': '123', 'platform_now': NOW}
browser.get.return_value = response or page()
browser._notification_browser.return_value = browser
return browser
def session(self, browser=None, checkpoints=None):
return NoticePollingSession(browser or self.browser(), 'safe', '123', BOUNDARY, checkpoints)
def test_initial_scan_backfills_history_and_reports_boundary_only_after_success(self):
browser = self.browser(page([notice(timestamp=1)]))
session = NoticePollingSession(browser, 'safe', '123')
self.assertEqual(session.pending(), [])
result = session.poll(100, 0)
self.assertEqual([d['kind'] for d in result], ['baseline', 'notice', 'checkpoint'])
self.assertTrue(result[1]['baseline'])
self.assertEqual(result[1]['notice']['platform_event_at'], '1970-01-01T00:00:01+00:00')
self.assertEqual(result[0]['boundary_at'], NOW)
for call in browser.get.call_args_list:
params = parse_qs(urlsplit(call.args[1]).query)
self.assertEqual(params['is_mark_read'], ['0'])
self.assertEqual(params['min_time'], ['0'])
self.assertEqual(params['count'], ['50'])
self.assertEqual(set(result[-1]['checkpoints']), {'700', '960', '961'})
def test_big_ids_and_duplicates_survive_replay_until_checkpoint_ack(self):
session = self.session(self.browser(page([notice(), notice()])))
first = session.poll(100, 0)
self.assertEqual(first, session.poll(100, 0))
events = [d for d in first if d['kind'] == 'notice']
self.assertEqual(len(events), 1)
self.assertEqual(events[0]['notice']['event_key'], '9007199254740993')
self.assertEqual(session.checkpoints, {})
session.ack([d['delivery_id'] for d in first[:-1]])
self.assertEqual(session.checkpoints, {})
session.ack([first[-1]['delivery_id']])
self.assertEqual(session.checkpoints['960'], '9007199254740993')
# Re-read the checkpoint page for out-of-order visibility; DB deduplicates it.
self.assertEqual(session.poll(100, 0)[-1]['kind'], 'checkpoint')
def test_incremental_scan_reads_entire_boundary_page_and_out_of_order_notices(self):
browser = self.browser()
def fetch(alias, url):
cursor = parse_qs(urlsplit(url).query)['max_time'][0]
if cursor == '0':
return page([notice('21', 1791281300)], 1, 1791281200)
return page([notice('20', 1791281200), notice('19', 1791281190)], 1, 1791281100)
browser.get.side_effect = fetch
session = self.session(browser, {'700': '20', '960': '20', '961': '20'})
session.last_full_scan_at = 1000
with patch('browser_gateway.platform.notice_polling.time.monotonic', return_value=1001):
result = session.poll(100, 0)
self.assertEqual({d['notice']['event_key'] for d in result if d['kind'] == 'notice'}, {'21', '20', '19'})
self.assertEqual(browser.get.call_count, 6)
def test_failure_never_advances_checkpoint_or_returns_partial_scan(self):
cases = [page(status=8), page([notice(uid='999')]), page([dict(notice(), nid=0)]),
page([dict(notice(), create_time=None)]), page([], 1, 0),
page([], 2), BrowserResponse(503, '{}', False),
BrowserResponse(200, 'not json', False), page([dict(notice(), follow='bad')])]
for bad in cases:
with self.subTest(body=bad.body):
session = self.session(self.browser(bad))
with self.assertRaises(DouyinError):
session.poll(100, 0)
self.assertEqual(session.pending(), [])
self.assertEqual(session.checkpoints, {})
def test_cursor_loops_fail_visibly(self):
session = self.session(self.browser(page([notice()], 1, 100)))
with self.assertRaisesRegex(DouyinError, 'cursor'):
session.poll(100, 0)
self.assertEqual(session.pending(), [])
def test_all_supported_interactions_have_distinct_types_and_accurate_actor(self):
raws = [notice(str(100 + i), kind=kind) for i, kind in enumerate(('follow','comment','digg','share'))]
result = self.session(self.browser(page(raws))).poll(100, 0)
events = [d['notice'] for d in result if d['kind'] == 'notice']
self.assertEqual({e['event_type'] for e in events}, {'follow','comment','like','repost'})
self.assertEqual({e['interactor_uid'] for e in events}, {'456'})
self.assertEqual(len(events), 4)
def test_restart_backfills_before_the_saved_head_and_before_enable_time(self):
browser = self.browser()
def fetch(alias, url):
cursor = parse_qs(urlsplit(url).query)['max_time'][0]
if cursor == '0':
return page([notice('21')], 1, 100)
return page([notice('20', 1)], 0)
browser.get.side_effect = fetch
session = self.session(browser, {'700':'21', '960':'21', '961':'21'})
deliveries = session.poll(100, 0)
events = [d['notice'] for d in deliveries if d['kind'] == 'notice']
self.assertEqual({e['event_key'] for e in events}, {'21','20'})
self.assertTrue(next(d for d in deliveries if d.get('notice',{}).get('event_key')=='20')['baseline'])
def test_periodic_full_reconciliation_recovers_old_holes_behind_the_head(self):
browser = self.browser()
missing_visible = False
def fetch(alias, url):
cursor = parse_qs(urlsplit(url).query)['max_time'][0]
if cursor == '0':
return page([notice('21')], 1, 100)
return page([notice('20', 1)] if missing_visible else [], 0)
browser.get.side_effect = fetch
session = self.session(browser)
with patch('browser_gateway.platform.notice_polling.time.monotonic', return_value=1000):
first = session.poll(100,0)
session.ack([d['delivery_id'] for d in first])
missing_visible = True
with patch('browser_gateway.platform.notice_polling.time.monotonic', return_value=1001):
incremental = session.poll(100,0)
self.assertNotIn('20', {d['notice']['event_key'] for d in incremental if d['kind']=='notice'})
session.ack([d['delivery_id'] for d in incremental])
with patch('browser_gateway.platform.notice_polling.time.monotonic', return_value=1301):
complete = session.poll(100,0)
self.assertIn('20', {d['notice']['event_key'] for d in complete if d['kind']=='notice'})
def test_history_scan_is_not_truncated_at_one_hundred_pages(self):
browser = self.browser()
def fetch(alias,url):
cursor = int(parse_qs(urlsplit(url).query)['max_time'][0])
current = cursor or 2000
return page([notice(str(current),1)], int(current>1899), current-1)
browser.get.side_effect = fetch
events = [d for d in self.session(browser).poll(1000,0) if d['kind']=='notice']
self.assertEqual(len(events), 102)
self.assertGreater(browser.get.call_count,300)
def test_private_messages_and_unrecognized_categories_are_not_aggregated(self):
session = self.session(self.browser(page([notice(kind='dm'), notice('2', kind='favorite')])))
result = session.poll(100, 0)
self.assertEqual([d['kind'] for d in result], ['checkpoint'])
self.assertEqual(result[-1]['ignored_types'], {'dm': 3, 'favorite': 3})
def test_checkpoint_missing_is_a_visible_gap_not_silent_continuity(self):
session = self.session(self.browser(page([notice()])), {'960': '404'})
result = session.poll(100, 0)
self.assertEqual(result[-1]['kind'], 'checkpoint')
self.assertIn('960', result[-1]['reason'])
def test_wrong_uid_and_invalid_restart_checkpoint_are_rejected(self):
browser = self.browser()
browser.identity.side_effect = DouyinError('identity mismatch')
with self.assertRaisesRegex(DouyinError, 'identity'):
self.session(browser)
for boundary, checkpoints in [('bad', {}), (BOUNDARY, {'bad': '1'}), (BOUNDARY, {'960': 'bad'}), (None, {'960': '1'})]:
with self.subTest(boundary=boundary, checkpoints=checkpoints), self.assertRaises(DouyinError):
NoticePollingSession(self.browser(), 'safe', '123', boundary, checkpoints)
def test_stop_prevents_further_reads_and_manager_restarts_from_persisted_state(self):
browser = self.browser()
manager = SubscriptionManager(browser)
self.assertTrue(manager.start('safe', '123', BOUNDARY, {'960': '9'})['connected'])
browser._notification_browser.assert_called_once_with('safe')
old = manager._items['safe']
manager.start('safe', '123', BOUNDARY, {'960': '10'})
with self.assertRaisesRegex(DouyinError, 'stopped'):
old.poll(100, 0)
self.assertEqual(manager._items['safe'].checkpoints, {'960': '10'})
manager.poll('safe', 100, 0)
manager.ack('safe', [d['delivery_id'] for d in manager._items['safe'].pending()])
manager.close()
manager.stop('safe')
with self.assertRaises(DouyinError):
manager.poll('safe', 100, 0)
with self.assertRaises(DouyinError):
manager.ack('safe', [])
def test_notifications_pin_one_page_when_multiple_tabs_exist(self):
browser = DouyinBrowser(lambda _: 'http://127.0.0.1:19000')
browser._target_list = Mock(return_value=(None, 19000, [
{'type':'page', 'id':'profile', 'url':'https://www.douyin.com/user/other'},
{'type':'page', 'id':'chat', 'url':'https://www.douyin.com/chat'},
{'type':'page', 'id':'self', 'url':'https://www.douyin.com/user/self'},
]))
self.assertEqual(browser._notification_browser('safe').target_id, 'self')
browser._target_list.return_value = (None, 19000, [{'type':'page','id':'external','url':'https://example.com'}])
with self.assertRaisesRegex(DouyinError, 'notification page is unavailable'):
browser._notification_browser('safe')
def test_changed_identity_after_start_is_reported(self):
browser = self.browser()
session = self.session(browser)
browser.identity.side_effect = DouyinError('identity changed')
with self.assertRaisesRegex(DouyinError, 'identity'):
session.poll(100, 0)
if __name__ == '__main__':
unittest.main()