Files
creator-hub/browser_gateway/test_private_messages.py
T
rogee 360bf96bab
douyin-release-gate / verify (push) Failing after 18m0s
feat: push Douyin events through shared WebSocket and SSE
2026-10-07 23:45:32 +08:00

329 lines
25 KiB
Python

import unittest
import threading
import json
import subprocess
from contextlib import nullcontext, contextmanager
from unittest.mock import Mock, patch
from .platform.douyin import DouyinBrowser, DouyinError, inbox_expression, im_expression, private_identity_expression
from .server.http import Gateway, GatewayHandler, RequestError
class PrivateMessageTests(unittest.TestCase):
def test_private_login_uses_sdk_uid_and_never_queries_profile(self):
script='''
const vm=require('node:vm'),assert=require('node:assert/strict');
const source=SOURCE;
const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>{throw Error('login must not read messages')}};
const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:{imSdkManager:{getImSdkInstance:()=>sdk}}}}}}}};
const chunks=[];chunks.push=function(args){args[2](req)};
const context={location:{origin:'https://www.douyin.com'},fetch:()=>{throw Error('profile is not chat authentication')},crypto:{randomUUID:()=> 'id'},window:{'@pc-im/im:version':chunks}};
(async()=>{
let result=await vm.runInNewContext(source,context);assert.equal(result.status,'logged_in');assert.equal(result.uid,'123');
sdk.ctx.option.userId='999';result=await vm.runInNewContext(source,context);assert.equal(result.code,'ACCOUNT_IDENTITY_MISMATCH');
context.window={};result=await vm.runInNewContext(source,context);assert.equal(result.status,'manual_login');assert.equal(result.reason,'awaiting_login');
})().catch(e=>{console.error(e);process.exitCode=1});
'''.replace('SOURCE',json.dumps(private_identity_expression('123')))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_private_message_entry_reuses_logged_in_profile_without_qr(self):
from .platform.douyin import CHAT_PAGE_URL, CHAT_ENTRY_SCRIPT
self.assertEqual(CHAT_PAGE_URL,'https://www.douyin.com/user/self')
self.assertIn('im-entry',CHAT_ENTRY_SCRIPT)
self.assertIn('something-button',CHAT_ENTRY_SCRIPT)
self.assertIn('imSaasContainerId',CHAT_ENTRY_SCRIPT)
self.assertNotIn('get_qrcode',CHAT_ENTRY_SCRIPT)
script='''
const vm=require('node:vm'),assert=require('node:assert/strict');let opened=false,clicks=0;
const container={getBoundingClientRect:()=>({width:opened?700:0,height:opened?400:0})};
const entry={click:()=>{clicks++;opened=true}};
const document={querySelector:selector=>selector==='#imSaasContainerId'?container:entry};
(async()=>{const context={document,location:{origin:'https://www.douyin.com'},Date,setTimeout};
assert.equal((await vm.runInNewContext(SOURCE,context)).ready,true);
assert.equal((await vm.runInNewContext(SOURCE,context)).ready,true);assert.equal(clicks,1);
})().catch(e=>{console.error(e);process.exitCode=1});
'''.replace('SOURCE',json.dumps(CHAT_ENTRY_SCRIPT))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_gateway_requests_queue_before_the_native_exclusive_lock(self):
active=threading.Lock();entered=threading.Event();release=threading.Event();second=threading.Event();errors=[]
@contextmanager
def native_lock(alias):
if not active.acquire(blocking=False):raise AssertionError('native lock overlapped')
try:yield
finally:active.release()
runtimes=Mock();runtimes.alias_lock.side_effect=native_lock
gateway=Gateway(runtimes,'token','node',Mock())
def first():
try:
with gateway._alias_lock('account'):entered.set();release.wait(3)
except BaseException as e:errors.append(e)
def following():
try:
with gateway._alias_lock('account'):second.set()
except BaseException as e:errors.append(e)
t1=threading.Thread(target=first);t2=threading.Thread(target=following);t1.start();self.assertTrue(entered.wait(1));t2.start()
try:self.assertFalse(second.wait(.1));self.assertEqual(errors,[])
finally:release.set();t1.join(3);t2.join(3)
self.assertTrue(second.is_set());self.assertEqual(errors,[])
def test_extra_private_login_routes_are_removed(self):
handler=object.__new__(GatewayHandler);gateway=Mock();handler.server_as_gateway=Mock(return_value=Mock(gateway=gateway))
for action in ['private-login-qr','private-login-verify']:
with self.assertRaises(RequestError):handler._route('POST','/v1/browsers/account/douyin/'+action,{}, {'expected_uid':'123'})
def test_listener_restart_validates_checkpoint_before_touching_browser(self):
gateway = object.__new__(Gateway)
gateway.subscriptions = Mock()
with self.assertRaises(RequestError):
gateway.start_douyin_events('account', {'expected_uid':'123','notice_checkpoints':{'960':'1'}})
gateway.subscriptions.start.assert_not_called()
def test_inbox_route_is_dispatched(self):
handler = object.__new__(GatewayHandler)
gateway = Mock()
server = Mock(gateway=gateway)
handler.server_as_gateway = Mock(return_value=server)
body = {'expected_uid':'123'}
handler._route('POST', '/v1/browsers/account/douyin/inbox', {}, body)
gateway.douyin_inbox.assert_called_once_with('account',body)
def test_inbox_script_uses_identity_and_server_messages(self):
script = inbox_expression('123')
for text in ['getConversationList', 'getMessageList', 's:server_message_create_time', 's:sender_uid', 'serverId', 'ACCOUNT_IDENTITY_MISMATCH', 'IM_INBOX_LIMIT_EXCEEDED']:
self.assertIn(text, script)
self.assertNotIn('getMessagesByConversation', script)
self.assertNotIn('sendMessage(', script)
self.assertNotIn('created_at:Date.now()', script)
self.assertIn('123', script)
def test_sdk_network_error_does_not_claim_definitive_rejection(self):
script='''
const vm=require('node:vm'),assert=require('node:assert/strict');
const source=SOURCE;
let result={success:false,statusCode:1008,status:1};
const conversation={type:1,id:'conversation',shortId:'short',toParticipantUserId:'456'};
const message={serverId:'100',clientId:'client',sender:'123',type:7,content:'{}',sendFunc(){}};
const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>[conversation],createMessage:async()=>message,sendMessage:async()=>result};
const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:{imSdkManager:{getImSdkInstance:()=>sdk}}}}}}}};
const chunks=[];chunks.push=function(args){args[2](req)};
const context={location:{origin:'https://www.douyin.com'},fetch:async()=>({ok:true,json:async()=>({status_code:0,user:{uid:'123'}})}),AbortSignal:{timeout:()=>null},Date,setTimeout:()=>0,window:{'@pc-im/im:version':chunks}};
(async()=>{
let response=await vm.runInNewContext(source,context);assert.equal(response.status,'unknown');assert.equal(response.code,'SDK_NETWORK_ERROR');assert.equal(response.status_code,1008);
result={success:false,statusCode:0,checkCode:5,checkMessage:'unavailable'};response=await vm.runInNewContext(source,context);assert.equal(response.status,'failed');assert.equal(response.check_code,'5');
result={success:false,statusCode:0};response=await vm.runInNewContext(source,context);assert.equal(response.status,'unknown');
})().catch(e=>{console.error(e);process.exitCode=1});
'''.replace('SOURCE',json.dumps(im_expression({'uid':'456','action':'send','text':'你好','confirm':True},'123')))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_inbox_reads_live_sdk_cache_and_honors_committed_checkpoints(self):
script = '''
const vm=require('node:vm'),assert=require('node:assert/strict');
const source=SOURCE;
let seen=0;
const message={type:7,serverId:'100',sender:'456',content:'{"aweType":700,"text":"你好"}',ext:{'s:server_message_create_time':'1700000000000'}};
const conversation={type:1,toParticipantUserId:'456',firstPageParticipant:{participants:[{user_id:'456',sec_uid:'peer-sec'}]},lastMessage:message,coreInfo:{name:'456'},getMessageList(){seen++;return [message]}};
const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>[conversation]};
let profileCalls=0;
const service={imSdkManager:{getImSdkInstance:()=>sdk},context:{requestManager:{request:async config=>{profileCalls++;assert.equal(config.method,'POST');assert.equal(config.url,'/aweme/v1/web/im/user/info/?version_code=170400');assert.equal(config.headers['Content-Type'],'application/x-www-form-urlencoded');assert.deepEqual(JSON.parse(config.data.sec_user_ids),['peer-sec']);return {status_code:0,data:[{uid:'456',sec_uid:'peer-sec',nickname:'真实昵称'}]}}}}};
const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:service}}}}}};
const chunks=[];chunks.push=function(args){args[2](req)};
const context={location:{origin:'https://www.douyin.com'},fetch:async()=>{throw Error('must not use public profile endpoint')},crypto:{randomUUID:()=> 'id'},window:{'@pc-im/im:version':chunks}};
(async()=>{
let result=await vm.runInNewContext(source,context);
assert.equal(result.status,'succeeded');assert.equal(result.messages[0].server_id,'100');assert.equal(result.checkpoints['456'],'100');assert.equal(seen,1);
assert.equal(result.messages[0].peer_name,'真实昵称');assert.equal(result.peers[0].peer_name,'真实昵称');assert.equal(profileCalls,1);
result=await vm.runInNewContext(source.replace('const expected="123", checkpoints={}', 'const expected="123", checkpoints={"456":"100"}'),context);
assert.equal(result.messages.length,0);assert.equal(seen,1);assert.equal(profileCalls,1);assert.equal(result.peers[0].peer_name,'真实昵称');
sdk.ctx.option.userId='999';result=await vm.runInNewContext(source,context);assert.equal(result.code,'ACCOUNT_IDENTITY_MISMATCH');sdk.ctx.option.userId='123';
message.sender='777';result=await vm.runInNewContext(source,context);assert.equal(result.code,'MESSAGE_IDENTITY_INVALID');
conversation.toParticipantUserId='bad';result=await vm.runInNewContext(source,context);assert.equal(result.code,'CONVERSATION_IDENTITY_INVALID');
context.window={};result=await vm.runInNewContext(source,context);assert.equal(result.code,'IM_SDK_NOT_READY');
})().catch(e=>{console.error(e);process.exitCode=1});
'''.replace('SOURCE',json.dumps(inbox_expression('123')))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_inbox_profiles_refresh_without_messages_and_report_failures(self):
script = '''
const vm=require('node:vm'),assert=require('node:assert/strict');
const source=SOURCE;let now=1000,calls=0,profile={uid:'456',sec_uid:'peer-sec',nickname:'初始昵称'},status=0,network=false,data;
const conversation={type:1,toParticipantUserId:'456',firstPageParticipant:{participants:[{user_id:'456',sec_uid:'peer-sec'}]},lastMessage:{serverId:'100'},coreInfo:{name:'不是用户昵称'},getMessageList(){throw Error('unchanged checkpoint must not read messages')}};
const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>[conversation]};
const service={imSdkManager:{getImSdkInstance:()=>sdk},context:{requestManager:{request:async()=>{calls++;if(network)throw Error('offline');return {status_code:status,data:data===undefined?[profile]:data}}}}};
const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:service}}}}}};
const chunks=[];chunks.push=function(args){args[2](req)};
const context={location:{origin:'https://www.douyin.com'},fetch:async()=>{throw Error('must not use public profile endpoint')},crypto:{randomUUID:()=> 'id'},Date:{now:()=>now},window:{'@pc-im/im:version':chunks}};
const read=()=>vm.runInNewContext(source,context);
(async()=>{
let result=await read();assert.equal(result.messages.length,0);assert.equal(result.peers[0].peer_name,'初始昵称');assert.equal(calls,1);
profile.nickname='改名后的昵称';now+=300001;result=await read();assert.equal(result.peers[0].peer_name,'改名后的昵称');assert.equal(calls,2);
for(const failure of ['mismatch','sec-mismatch','missing','missing-record','invalid-data','duplicate','business','network']){
now+=300001;profile={uid:failure==='mismatch'?'789':'456',sec_uid:failure==='sec-mismatch'?'wrong-sec':'peer-sec',nickname:failure==='missing'?'':'错误昵称'};data=({'missing-record':[],'invalid-data':{},duplicate:[profile,profile]})[failure];status=failure==='business'?8:0;network=failure==='network';
result=await read();assert.equal(result.status,'succeeded');assert.equal(result.messages.length,0);assert.equal(result.peers[0].peer_name,'');assert.match(result.peers[0].error,/昵称/);
const count=calls;await read();assert.equal(calls,count);
}
profile={uid:'456',sec_uid:'peer-sec',nickname:'恢复昵称'};network=false;status=0;data=undefined;now+=30001;result=await read();assert.equal(result.peers[0].peer_name,'恢复昵称');assert.equal(result.peers[0].error,'');
service.context.requestManager.request=async()=>{sdk.ctx.option.userId='999';return {status_code:0,data:[{uid:'456',sec_uid:'peer-sec',nickname:'不可保存'}]}};
now+=300001;result=await read();assert.equal(result.code,'ACCOUNT_IDENTITY_MISMATCH');
})().catch(e=>{console.error(e);process.exitCode=1});
'''.replace('SOURCE',json.dumps(inbox_expression('123', {'456':'100'})))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_inbox_batches_chat_profiles_and_keeps_valid_peers_when_one_profile_is_missing(self):
script = '''
const vm=require('node:vm'),assert=require('node:assert/strict');const source=SOURCE;
let calls=0;
const rows=Array.from({length:80},(_,i)=>({uid:String(1000+i),sec_uid:'sec-'+i,nickname:i===0?'用户3290971208926':i===1?'正式AI分身':'联系人'+i}));
const conversations=rows.map(row=>({type:1,toParticipantUserId:row.uid,firstPageParticipant:{participants:[{user_id:row.uid,sec_uid:row.sec_uid}]},lastMessage:{serverId:'100'},coreInfo:{name:''},getMessageList:()=>[]}));
const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>conversations};
const service={imSdkManager:{getImSdkInstance:()=>sdk},context:{requestManager:{request:async config=>{calls++;const ids=JSON.parse(config.data.sec_user_ids);assert.ok(ids.length<=30);return {status_code:0,data:rows.filter(r=>ids.includes(r.sec_uid)&&r.uid!=='1002')}}}}};
const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:service}}}}}};
const chunks=[];chunks.push=function(args){args[2](req)};
const context={location:{origin:'https://www.douyin.com'},crypto:{randomUUID:()=> 'id'},Date,window:{'@pc-im/im:version':chunks}};
(async()=>{
let r=await vm.runInNewContext(source,context);assert.equal(r.status,'succeeded');assert.equal(calls,3);assert.equal(r.peers.length,80);assert.equal(r.peers[0].peer_name,'用户3290971208926');assert.equal(r.peers[1].peer_name,'正式AI分身');assert.match(r.peers[2].error,/昵称/);assert.equal(r.peers[79].peer_name,'联系人79');
await vm.runInNewContext(source,context);assert.equal(calls,3);
context.window.__creatorhubPrivatePeerProfiles.source='obsolete';await vm.runInNewContext(source,context);assert.equal(calls,6);
conversations[0].firstPageParticipant={participants:[]};context.window.__creatorhubPrivatePeerProfiles=undefined;r=await vm.runInNewContext(source,context);assert.match(r.peers[0].error,/secUID/);assert.equal(r.peers[1].peer_name,'正式AI分身');
service.context.requestManager=undefined;context.window.__creatorhubPrivatePeerProfiles=undefined;r=await vm.runInNewContext(source,context);assert.equal(r.status,'succeeded');assert.ok(r.peers.every(p=>p.error));
})().catch(e=>{console.error(e);process.exitCode=1});
'''.replace('SOURCE',json.dumps(inbox_expression('123')))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_action_ownership_write_returns_a_cdp_value(self):
browser=object.__new__(DouyinBrowser)
browser._evaluate=Mock()
browser.set_action_ownership('account',{'operation_id':'one'})
self.assertIn('return true',browser._evaluate.call_args.args[1])
def test_manual_dm_uses_the_chat_page_not_the_primary_page(self):
browser=DouyinBrowser()
private=Mock();private._evaluate.return_value={'status':'logged_in','uid':'123'};private._confirmed_evaluate.return_value={'status':'succeeded'}
browser._chat_browser=Mock(return_value=private)
browser.identity=Mock(side_effect=AssertionError('primary page used for DM'))
result=browser.action('account','123','dm','456',text='你好',confirm=True)
self.assertEqual(result['status'],'succeeded')
private.identity.assert_not_called()
private._evaluate.assert_called_once_with('account',private_identity_expression('123'))
private._confirmed_evaluate.assert_called_once()
browser.identity.assert_not_called()
def test_chat_page_is_created_once_and_never_navigates_the_primary_page(self):
browser=DouyinBrowser(endpoint=lambda alias:'http://127.0.0.1:19000')
from urllib.parse import urlsplit
primary={'id':'primary','type':'page','url':'https://www.douyin.com/user/self'}
chat={'id':'chat','type':'page','url':'https://www.douyin.com/user/self#__creatorhub_private_messages'}
browser._target_list=Mock(side_effect=[(urlsplit('http://127.0.0.1:19000'),19000,[primary]),(urlsplit('http://127.0.0.1:19000'),19000,[primary,chat])])
browser._create_chat_target=Mock(return_value='chat')
dedicated=Mock();cdp=Mock();cdp.evaluate.side_effect=lambda expression:'/user/self' if expression=='location.pathname' else {'ready':True}
dedicated.connection=lambda alias:nullcontext(cdp);browser._browser_for_target=Mock(return_value=dedicated)
self.assertIs(browser._chat_browser('account'),dedicated)
self.assertIs(browser._chat_browser('account'),dedicated)
browser._create_chat_target.assert_called_once()
browser._browser_for_target.assert_called_with('chat')
def test_chat_page_is_rediscovered_after_gateway_restart(self):
browser=DouyinBrowser(endpoint=lambda alias:'http://127.0.0.1:19000')
from urllib.parse import urlsplit
browser._target_list=Mock(return_value=(urlsplit('http://127.0.0.1:19000'),19000,[{'id':'primary','type':'page','url':'https://www.douyin.com/user/self'},{'id':'chat','type':'page','url':'https://www.douyin.com/user/self#__creatorhub_private_messages'}]))
browser._is_chat_target=Mock(side_effect=lambda alias,target:target['id']=='chat')
browser._create_chat_target=Mock()
dedicated=Mock();cdp=Mock();cdp.evaluate.side_effect=lambda expression:'/user/self' if expression=='location.pathname' else {'ready':True}
dedicated.connection=lambda alias:nullcontext(cdp);browser._browser_for_target=Mock(return_value=dedicated)
self.assertIs(browser._chat_browser('account'),dedicated)
browser._create_chat_target.assert_not_called()
self.assertEqual(browser._chat_targets['account'],'chat')
def test_chat_entry_does_not_wait_on_background_page_timers(self):
from .platform.douyin import CHAT_ENTRY_SCRIPT
script = '''
const vm=require('node:vm'),assert=require('node:assert/strict');let clicks=0;
const context={document:{querySelector:s=>s==='#imSaasContainerId'?null:{click:()=>clicks++}},location:{origin:'https://www.douyin.com'},Date,setTimeout:()=>{throw Error('background timers cannot drive readiness')}};
const result=vm.runInNewContext(SOURCE,context);
assert.equal(typeof result.then,'undefined');assert.equal(result.ready,false);assert.equal(result.clicked,true);assert.equal(clicks,1);
'''.replace('SOURCE', json.dumps(CHAT_ENTRY_SCRIPT))
result = subprocess.run(['node', '-e', script], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def chat_setup(self, cdp):
from urllib.parse import urlsplit
browser = DouyinBrowser(endpoint=lambda alias: 'http://127.0.0.1:19000')
browser._target_list = Mock(return_value=(urlsplit('http://127.0.0.1:19000'), 19000, [{'id': 'chat', 'type': 'page', 'url': 'https://www.douyin.com/user/self#__creatorhub_private_messages'}]))
browser._chat_targets['account'] = 'chat'
dedicated = Mock()
dedicated.connection = lambda alias: nullcontext(cdp)
browser._browser_for_target = Mock(return_value=dedicated)
return browser, dedicated
def test_stale_chat_page_is_refreshed_once_without_touching_the_primary_page(self):
from .platform.douyin import CHAT_ENTRY_SCRIPT
cdp = Mock(); refreshed = False
def evaluate(script):
if script == 'location.pathname': return '/user/self'
return {'ready': refreshed, 'clicked': not refreshed}
def call(method, *args, **kwargs):
nonlocal refreshed
if method == 'Page.reload': refreshed = True
cdp.evaluate.side_effect = evaluate; cdp.command.side_effect = call
browser, dedicated = self.chat_setup(cdp)
with patch('browser_gateway.platform.douyin.time.monotonic', side_effect=[0, 0, 13, 13, 13]), patch('browser_gateway.platform.douyin.time.sleep'):
self.assertIs(browser._chat_browser('account'), dedicated)
self.assertEqual([c.args[0] for c in cdp.command.call_args_list], ['Page.enable', 'Page.reload'])
cdp.wait_event.assert_called_once()
self.assertEqual(cdp.wait_event.call_args.args[0], 'Page.domContentEventFired')
self.assertEqual(sum(c.args[0] == CHAT_ENTRY_SCRIPT for c in cdp.evaluate.call_args_list), 2)
browser._browser_for_target.assert_called_once_with('chat')
def test_chat_polling_clicks_entry_once_and_still_reports_failure_after_refresh(self):
from .platform.douyin import CHAT_ENTRY_SCRIPT
cdp = Mock()
cdp.evaluate.side_effect = lambda script: '/user/self' if script == 'location.pathname' else {'ready': False, 'clicked': True}
browser, _ = self.chat_setup(cdp)
with patch('browser_gateway.platform.douyin.time.monotonic', side_effect=[0, 0, 1, 13, 13, 13, 14, 26]), patch('browser_gateway.platform.douyin.time.sleep'):
with self.assertRaisesRegex(DouyinError, 'private-message panel.*refresh'):
browser._chat_browser('account')
self.assertEqual(sum(c.args[0] == CHAT_ENTRY_SCRIPT for c in cdp.evaluate.call_args_list), 2)
self.assertEqual([c.args[0] for c in cdp.command.call_args_list], ['Page.enable', 'Page.reload'])
def test_chat_invalid_response_is_not_hidden_by_refresh(self):
cdp = Mock()
cdp.evaluate.side_effect = ['/user/self', {}]
browser, _ = self.chat_setup(cdp)
with self.assertRaisesRegex(DouyinError, 'response is invalid'):
browser._chat_browser('account')
cdp.command.assert_not_called()
def test_browser_inbox_validation_and_response(self):
browser = object.__new__(DouyinBrowser)
browser.identity = Mock()
browser._evaluate = Mock(return_value={'status': 'succeeded', 'account_uid': '123', 'messages': []})
browser._chat_browser = Mock(return_value=browser)
self.assertEqual(browser.inbox('account', '123')['status'], 'succeeded')
browser.identity.assert_not_called()
with self.assertRaises(DouyinError): browser.inbox('account', 'invalid')
browser._evaluate.return_value = []
with self.assertRaises(DouyinError): browser.inbox('account', '123')
def test_gateway_generation_and_response(self):
gateway = object.__new__(Gateway)
gateway.browser = Mock()
gateway.browser.inbox.return_value = {'status':'succeeded','account_uid':'123','messages':[]}
gateway._alias_lock = Mock(return_value=nullcontext())
gateway._require_douyin_generation = Mock()
payload = {'runtime_id':'a'*64,'network_id':'native-'+'b'*32,'binding_version':1,'expected_uid':'123'}
self.assertEqual(gateway.douyin_inbox('alias', payload)['status'],'succeeded')
self.assertEqual(gateway._require_douyin_generation.call_count,2)
gateway.browser.inbox.assert_called_once_with('alias','123',{})
for bad in [{},dict(payload,expected_uid='bad'),dict(payload,binding_version=0)]:
with self.assertRaises(RequestError):gateway.douyin_inbox('alias',bad)
gateway.browser.inbox.return_value = {'status':'failed','code':'IM_SDK_NOT_READY'}
self.assertEqual(gateway.douyin_inbox('alias',payload)['code'],'IM_SDK_NOT_READY')
gateway.browser.inbox.side_effect = DouyinError('broken')
with self.assertRaises(RequestError):gateway.douyin_inbox('alias',payload)