new Response(200, [], json_encode($data)); $group = static fn (array $tags = [], string $id = 'promotion_group', string $name = '推广渠道'): array => [ 'group_id' => $id, 'group_name' => $name, 'tag' => $tags, ]; $listing = static fn (array $groups): Response => $json(['errcode' => 0, 'tag_group' => $groups]); $created = static fn (string $id, string $name, string $groupId = 'promotion_group'): Response => $json([ 'errcode' => 0, 'tag_group' => $group([['id' => $id, 'name' => $name]], $groupId), ]); $fixture = static function (array $responses, array &$history): QywxPromotionContactApiService { $history = []; $stack = HandlerStack::create(new MockHandler($responses)); $stack->push(Middleware::history($history)); return new QywxPromotionContactApiService(new Client([ 'base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => $stack, 'http_errors' => false, ]), static fn (): string => 'mock_token'); }; $addCount = static fn (array $history): int => count(array_filter($history, static fn (array $entry): bool => $entry['request']->getUri()->getPath() === '/cgi-bin/externalcontact/add_corp_tag')); $history = []; $name30 = str_repeat('渠', 30); $api = $fixture([$listing([]), $created('new_tag', $name30)], $history); $result = $api->createTag(' ' . $name30 . ' '); createTagCheck($result === ['tag' => ['id' => 'new_tag', 'name' => $name30], 'group_id' => 'promotion_group', 'group_name' => '推广渠道', 'reused' => false], 'new tag response and 30 Unicode characters'); $body = json_decode((string) $history[1]['request']->getBody(), true); createTagCheck($body === ['tag' => [['name' => $name30]], 'group_name' => '推广渠道'], 'new group uses group_name plus nonempty tag array'); createTagCheck($history[1]['request']->getMethod() === 'POST' && $addCount($history) === 1, 'official add endpoint called once'); $api = $fixture([$listing([$group([['id' => 'old', 'name' => '旧标签']])]), $created('tag2', '直播')], $history); $result = $api->createTag('直播'); $body = json_decode((string) $history[1]['request']->getBody(), true); createTagCheck($body === ['tag' => [['name' => '直播']], 'group_id' => 'promotion_group'], 'existing group uses ID and does not rename/reorder it'); createTagCheck($result['reused'] === false && $result['tag']['id'] === 'tag2', 'existing-group creation returns actual ID'); $api = $fixture([$listing([ $group([['id' => 'other_group_tag', 'name' => '直播']], 'other_group', '其他分组'), $group([['id' => 'existing_tag', 'name' => '直播']]), ])], $history); $result = $api->createTag('直播'); createTagCheck($result['reused'] && $result['tag']['id'] === 'existing_tag' && count($history) === 1 && $addCount($history) === 0, 'reuse only exact name in fixed group without write'); $api = $fixture([$listing([$group([['id' => 'other', 'name' => '直播']], 'other_group', '其他分组')]), $created('fixed_group_tag', '直播')], $history); createTagCheck($api->createTag('直播')['tag']['id'] === 'fixed_group_tag' && $addCount($history) === 1, 'same name in different group is not reused'); $api = $fixture([ $listing([$group()]), $json(['errcode' => 40058, 'errmsg' => 'mock concurrent creation conflict']), $listing([$group([['id' => 'concurrent_tag', 'name' => '直播']])]), ], $history); $result = $api->createTag('直播'); createTagCheck($result['reused'] && $result['tag']['id'] === 'concurrent_tag' && $addCount($history) === 1 && count($history) === 3, 'concurrent conflict resolved by one read-back'); $networkError = static fn (): ConnectException => new ConnectException('secret=do_not_expose', new Request('POST', 'https://example.invalid/?access_token=secret')); $api = $fixture([$listing([]), $networkError(), $listing([$group([['id' => 'committed_tag', 'name' => '直播']])])], $history); $result = $api->createTag('直播'); createTagCheck($result['reused'] && $result['tag']['id'] === 'committed_tag' && $addCount($history) === 1, 'uncertain transport uses read-back, never duplicate creation'); $api = $fixture([$listing([]), $networkError(), $listing([])], $history); $error = null; try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; } createTagCheck($error !== null && str_contains($error->getMessage(), '无法确认') && str_contains($error->getMessage(), '勿重复提交'), 'unknown result must not invent an ID or claim success'); createTagCheck($addCount($history) === 1 && count($history) === 3 && !str_contains($error->getMessage(), 'secret') && $error->getPrevious() === null, 'uncertain result no resend/no secret chain'); $api = $fixture([$listing([]), $json(['errcode' => 48002, 'errmsg' => 'secret']), $listing([])], $history); $error = null; try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; } createTagCheck($error !== null && $error->getCode() === 48002 && $addCount($history) === 1, 'explicit failure retained after read-back absent'); $api = $fixture([$listing([]), $json(['errcode' => 0, 'tag_group' => $group([['name' => '直播']])]), $listing([$group([['id' => 'verified_tag', 'name' => '直播']])])], $history); createTagCheck($api->createTag('直播')['tag']['id'] === 'verified_tag' && $addCount($history) === 1, 'success without ID still requires authoritative read-back'); $api = $fixture([$listing([$group([['id' => 'deleted_tag', 'name' => '直播', 'deleted' => true]])]), $created('recreated', '直播')], $history); createTagCheck($api->createTag('直播')['tag']['id'] === 'recreated' && $addCount($history) === 1, 'deleted tag is not reused'); $api = $fixture([$listing([]), $networkError(), new Response(503, [], '')], $history); $error = null; try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; } createTagCheck($error !== null && str_contains($error->getMessage(), '无法确认') && $addCount($history) === 1, 'failed read-back retains uncertainty without write retry'); $api = $fixture([$json(['errcode' => 48002])], $history); $error = null; try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; } createTagCheck($error !== null && count($history) === 1 && $addCount($history) === 0, 'initial read failure cannot proceed to creation'); foreach (['', ' ', "\u{3000}", str_repeat('字', 31), str_repeat('😀', 31), "标签\n", "标\t签", "\0标签", "\u{200B}标签", "\xFF"] as $invalid) { $api = $fixture([], $history); $failed = false; try { $api->createTag($invalid); } catch (RuntimeException) { $failed = true; } createTagCheck($failed && $history === [], 'invalid name rejected before HTTP'); } echo "QYWX_PROMOTION_CREATE_TAG_OK\n";