instance('log', new Psr\Log\NullLogger()); Config::set(['trtc' => ['sdkAppId' => 123, 'secretKey' => 'im-pager-test-key']], 'project'); class RoamHttpFixture extends TencentImService { public array $responses = []; public array $requests = []; protected function httpPost(string $url, string $data, int $timeout = 10) { $this->requests[] = ['data' => json_decode($data, true), 'timeout' => $timeout]; if ($this->responses === []) { throw new RuntimeException('Unexpected extra HTTP request'); } $response = array_shift($this->responses); if ($response instanceof Throwable) { throw $response; } return is_array($response) ? json_encode($response, JSON_THROW_ON_ERROR) : $response; } } function roamExpect(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } } function roamFails(callable $action, string $message, int $code = 0): void { try { $action(); } catch (RuntimeException $exception) { roamExpect(str_contains($exception->getMessage(), $message), 'Expected error: ' . $message . '; got: ' . $exception->getMessage()); roamExpect($exception->getCode() === $code, 'Cloud error code must survive pager failure'); return; } throw new RuntimeException('Expected page failure: ' . $message); } $message = static fn (string $key, int $time, bool $reverse = false): array => [ 'From_Account' => $reverse ? 'patient_2' : 'doctor_1', 'To_Account' => $reverse ? 'doctor_1' : 'patient_2', 'MsgKey' => $key, 'MsgTimeStamp' => $time, 'MsgBody' => [['MsgType' => 'TIMTextElem', 'MsgContent' => ['Text' => $key]]], ]; $page = static fn (int $complete, ?int $time, ?string $key, array $messages): array => [ 'ActionStatus' => 'OK', 'ErrorCode' => 0, 'Complete' => $complete, 'LastMsgTime' => $time, 'LastMsgKey' => $key, 'MsgCnt' => count($messages), 'MsgList' => $messages, ]; $service = new RoamHttpFixture(); $service->responses = [ $page(0, 200, 'a', [$message('a', 200)]), $page(0, 200, 'b', [$message('b', 200, true)]), $page(1, 199, 'c', [$message('c', 199)]), ]; $first = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'); roamExpect(!$first['completed'] && count($service->requests) === 1, 'One step pulls exactly one page'); $second = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $first['cursor']); roamExpect(!$second['completed'] && $second['cursor']['seen_keys'] === ['a', 'b'], 'Different keys allow multiple pages in the same second'); $third = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $second['cursor']); roamExpect($third['completed'] && $third['cursor']['seen_keys'] === ['c'], 'History of keys resets only when time moves into an earlier second'); roamExpect(array_column(array_merge($first['msgList'], $second['msgList'], $third['msgList']), 'MsgKey') === ['a', 'b', 'c'], 'All pages preserve raw messages in both directions'); foreach ($service->requests as $request) { roamExpect($request['timeout'] === 15, 'Each page uses a bounded 15 second timeout'); roamExpect($request['data']['MinTime'] === 0, 'Initial full scan does not use the latest archived message as its lower bound'); roamExpect(!array_key_exists('LastMsgTime', $request['data']), 'LastMsgTime is never a request field'); } roamExpect($service->requests[0]['data']['MaxTime'] === 4294967295 && !isset($service->requests[0]['data']['LastMsgKey']), 'Initial request has the full range and no last key'); roamExpect($service->requests[1]['data']['MaxTime'] === 200 && $service->requests[1]['data']['LastMsgKey'] === 'a', 'Second request uses response LastMsgTime as MaxTime'); roamExpect($service->requests[2]['data']['MaxTime'] === 200 && $service->requests[2]['data']['LastMsgKey'] === 'b', 'Same-second continuation advances by key'); $service->responses = [$page(1, null, null, [])]; $legacy = $service->adminGetRoamMsg('doctor_1', 'patient_2', 100, 0, 4294967295, 'old-key', 123); $legacyRequest = $service->requests[count($service->requests) - 1]['data']; roamExpect($legacy['success'] && $legacyRequest['MaxTime'] === 123 && !isset($legacyRequest['LastMsgTime']), 'Legacy seven argument calls also use the correct continuation field'); foreach ([ ['ActionStatus' => 'FAIL', 'ErrorCode' => 91000, 'ErrorInfo' => 'Cloud retry later'], ['ActionStatus' => 'OK', 'ErrorCode' => 90009, 'ErrorInfo' => 'Cloud permission denied'], ] as $error) { $service->responses = [$error]; $before = count($service->requests); roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), $error['ErrorInfo'], $error['ErrorCode']); roamExpect(count($service->requests) === $before + 1, 'Cloud errors are reported without an inline retry'); } $service->responses = [['ActionStatus' => 'FAIL', 'ErrorCode' => 91000, 'ErrorInfo' => 'Cloud retry later']]; $failedPage = $service->adminGetRoamMsg('doctor_1', 'patient_2'); roamExpect(!$failedPage['success'] && $failedPage['complete'] === 0 && $failedPage['error'] === 'Cloud retry later' && $failedPage['rawErrorCode'] === 91000, 'Legacy service errors retain the error and code without marking the conversation complete'); foreach ([ ['not-json', 'IM响应解析失败'], [false, 'IM接口无响应'], [new RuntimeException('transport timeout'), 'transport timeout'], [['ActionStatus' => 'OK', 'ErrorCode' => 0, 'MsgList' => []], 'Complete'], [array_replace($page(1, null, null, []), ['MsgList' => 'invalid']), 'MsgList'], [array_replace($page(1, null, null, []), ['MsgCnt' => 1]), 'MsgCnt'], [array_replace($page(1, null, null, []), ['LastMsgTime' => '200']), 'LastMsgTime'], [array_replace($page(1, null, null, []), ['LastMsgKey' => ['bad']]), 'LastMsgKey'], [$page(0, null, null, []), 'LastMsgTime/LastMsgKey'], [$page(0, 200, null, []), 'LastMsgTime/LastMsgKey'], [$page(0, null, 'a', []), 'LastMsgTime/LastMsgKey'], ] as [$invalid, $error]) { $service->responses = [$invalid]; roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), $error); } foreach ([0, 1] as $complete) { $service->responses = [$page($complete, 200, 'a', [$message('a', 200)])]; roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $first['cursor']), '游标重复'); $service->responses = [$page($complete, 200, 'a', [$message('a', 200)])]; roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $second['cursor']), '游标重复'); } $service->responses = [$page(0, 201, 'future', [])]; roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $first['cursor']), '时间超出'); $otherPatientMessage = $message('other-patient', 200); $otherPatientMessage['To_Account'] = 'patient_999'; $service->responses = [$page(1, 200, 'other-patient', [$otherPatientMessage])]; roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), '不属于当前会话'); foreach ([ 'invalid-message', array_replace($message('bad', 200), ['MsgKey' => '']), array_replace($message('bad', 200), ['MsgTimeStamp' => '200']), array_replace($message('bad', 200), ['MsgBody' => null]), ] as $invalidMessage) { $service->responses = [$page(1, 200, 'bad', [$invalidMessage])]; roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), '非法'); } $before = count($service->requests); foreach ([['max_time' => '200'], ['max_time' => -1], ['min_time' => 201, 'max_time' => 200], ['last_key' => []], ['seen_keys' => [null]]] as $invalidCursor) { roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $invalidCursor), '游标无效'); } roamFails(static fn () => ImRoamMessagePager::nextPage($service, '', 'patient_2'), '账号无效'); roamExpect(count($service->requests) === $before, 'Invalid requests are rejected before HTTP'); $service->responses = [$page(1, null, null, [])]; $empty = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'); roamExpect($empty['completed'] && $empty['msgList'] === [], 'Explicit successful empty response can complete a conversation'); echo "IM_ROAM_MESSAGE_PAGER_TEST_OK\n";