'ww_fake', 'secret' => 'acquisition_application_secret'], 'qywx_customer_acquisition'); Config::set(['wechat_work' => ['external_pay_secret' => 'never_use_payment_secret']], 'pay'); function apiCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } } $json = static fn (array $value): Response => new Response(200, [], json_encode($value)); $history = []; $stack = HandlerStack::create(new MockHandler([ $json(['errcode' => 0, 'tag_group' => [ ['group_id' => 'group', 'group_name' => '来源', 'tag' => [['id' => 'tag', 'name' => '推广'], ['id' => 'deleted', 'deleted' => true]]], ['group_id' => 'removed_group', 'deleted' => true], ]]), $json(['errcode' => 0, 'external_contact' => ['name' => '小李'], 'next_cursor' => 'next']), $json(['errcode' => 0, 'name' => '张医助']), $json(['errcode' => 0]), $json(['errcode' => 0]), $json(['errcode' => 0]), $json(['media_id' => 'media_1', 'created_at' => time(), 'type' => 'image']), $json(['errcode' => 40014, 'errmsg' => 'sensitive mock token']), $json(['errcode' => 0]), ])); $stack->push(Middleware::history($history)); $calls = 0; $api = new QywxPromotionContactApiService(new Client(['base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => $stack]), static function () use (&$calls): string { return 'mock_token_' . ++$calls; }); apiCheck($api->credentialFingerprint() === hash('sha256', 'ww_fake|acquisition_application_secret'), 'must use acquisition app, not payment secret'); apiCheck($api->tagOptions() === ['tag_groups' => [['group_id' => 'group', 'group_name' => '来源', 'tag' => [['id' => 'tag', 'name' => '推广']]]]], 'tag option mapping/deleted filter'); apiCheck($api->getExternalContact('external', 'cursor')['next_cursor'] === 'next', 'customer detail'); apiCheck($api->getUser('staff')['name'] === '张医助', 'staff name'); $api->markTags('staff', 'external', ['tag', 'tag']); $api->remark('staff', 'external', ['remark' => '备注', 'remark_mobiles' => ['do-not-send']]); $api->sendWelcome('one_time_code', '您好', [['msgtype' => 'image', 'image' => ['media_id' => 'media']]]); $stream = fopen('php://temp', 'w+'); fwrite($stream, 'mock_image_bytes'); rewind($stream); apiCheck($api->uploadMedia($stream, 'image', 'cover.png')['media_id'] === 'media_1', 'upload media response'); $api->sendWelcome('other_code', 'hello', []); $paths = array_map(static fn (array $h): string => $h['request']->getUri()->getPath(), $history); apiCheck($paths === [ '/cgi-bin/externalcontact/get_corp_tag_list', '/cgi-bin/externalcontact/get', '/cgi-bin/user/get', '/cgi-bin/externalcontact/mark_tag', '/cgi-bin/externalcontact/remark', '/cgi-bin/externalcontact/send_welcome_msg', '/cgi-bin/media/upload', '/cgi-bin/externalcontact/send_welcome_msg', '/cgi-bin/externalcontact/send_welcome_msg', ], 'official endpoints'); apiCheck((string) $history[0]['request']->getBody() === '{}', 'empty tag request must be JSON object'); parse_str($history[1]['request']->getUri()->getQuery(), $query); apiCheck($query['external_userid'] === 'external' && $query['cursor'] === 'cursor', 'GET query fields'); $body = json_decode((string) $history[3]['request']->getBody(), true); apiCheck($body['add_tag'] === ['tag'] && !isset($body['remove_tag']), 'only add configured tags'); $body = json_decode((string) $history[4]['request']->getBody(), true); apiCheck($body === ['userid' => 'staff', 'external_userid' => 'external', 'remark' => '备注'], 'only enabled remark fields'); $body = (string) $history[6]['request']->getBody(); apiCheck(str_contains($body, 'name="media"') && str_contains($body, 'filename="cover.png"'), 'multipart file field'); fclose($stream); apiCheck($calls === 9, 'explicit token invalid response refreshes once'); $uploadRetry = new QywxPromotionContactApiService(new Client([ 'base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => HandlerStack::create(new MockHandler([ $json(['errcode' => 40014]), $json(['media_id' => 'retry_media', 'created_at' => time()]), ])), ]), static fn (): string => 'mock'); $retryStream = fopen('php://temp', 'w+'); fwrite($retryStream, 'mock_image_bytes'); rewind($retryStream); apiCheck($uploadRetry->uploadMedia($retryStream, 'image', 'retry.png')['media_id'] === 'retry_media', 'multipart stream survives explicit token refresh'); if (is_resource($retryStream)) { fclose($retryStream); } foreach ([ [new ConnectException('secret=should_never_escape', new Request('POST', 'https://mock/?access_token=secret')), true, 0], [new Response(502, [], 'secret'), true, 0], [$json(['errcode' => 41051, 'errmsg' => 'welcome_code=secret']), false, 41051], [$json(['errcode' => 41096, 'errmsg' => 'secret']), false, 41096], ] as [$response, $uncertain, $code]) { $mock = new MockHandler([$response]); $service = new QywxPromotionContactApiService(new Client(['base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => HandlerStack::create($mock), 'http_errors' => false]), static fn (): string => 'mock'); try { $service->sendWelcome('code', 'hello', []); throw new RuntimeException('expected failure'); } catch (QywxPromotionContactApiException $e) { apiCheck($e->uncertain === $uncertain && $e->getCode() === $code, 'uncertain result classification'); apiCheck(!str_contains($e->getMessage(), 'secret') && $e->getPrevious() === null, 'do not leak secrets in exception chain'); apiCheck(count($mock) === 0, 'network error is not retried'); } } echo "QYWX_PROMOTION_CONTACT_API_OK\n";