mirror of
https://gitee.com/zoujingli/WeChatDeveloper.git
synced 2026-09-04 23:12:09 +08:00
- 微信支付 APIv3 call/raw 统一按实际 query/body 生成签名,并继续透传 Guzzle options。 - 微信支付默认关闭 http_errors,解析非 2xx JSON 错误并统一抛出 ApiException。 - 移除默认写入错误的 Wechatpay-Serial,请求敏感信息加密时由业务传平台序列号。 - 支付宝网关请求捕获 Guzzle 异常并转换为 SDK 异常,保留验签和支付快捷调用。 - 补充支付下载、通知解密、错误响应、支付宝验签和 options 透传测试。
357 lines
12 KiB
PHP
357 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
/**
|
||
* This file is part of HyperfAdmin.
|
||
*
|
||
* @Link https://thinkadmin.top
|
||
* @Author Anyon<zoujingli@qq.com>
|
||
*/
|
||
|
||
namespace We\Tests;
|
||
|
||
use GuzzleHttp\ClientInterface;
|
||
use GuzzleHttp\Promise\Create;
|
||
use GuzzleHttp\Promise\PromiseInterface;
|
||
use GuzzleHttp\Psr7\Response;
|
||
use PHPUnit\Framework\Attributes\CoversClass;
|
||
use PHPUnit\Framework\TestCase;
|
||
use Psr\Http\Message\RequestInterface;
|
||
use Psr\Http\Message\ResponseInterface;
|
||
use We\Client;
|
||
use We\Config\WechatPaymentConfig;
|
||
use We\Config\WechatPlatformConfig;
|
||
use We\Config\WechatServiceConfig;
|
||
use We\Contract\StoreCacheInterface;
|
||
use We\Contract\StoreTokenInterface;
|
||
use We\Exception\ApiException;
|
||
use We\Platform\Wechat\PaymentClient as WechatPaymentClient;
|
||
use We\Platform\Wechat\PlatformClient as WechatPlatformClient;
|
||
use We\Platform\Wechat\ServiceClient as WechatServiceClient;
|
||
use We\Support\CacheKey;
|
||
use We\Support\JsonClient;
|
||
use We\Support\TokenCacheKey;
|
||
|
||
/**
|
||
* 协议层原始响应、下载和上传能力测试用例。
|
||
* @internal
|
||
*/
|
||
#[CoversClass(JsonClient::class)]
|
||
#[CoversClass(WechatPlatformClient::class)]
|
||
#[CoversClass(WechatPaymentClient::class)]
|
||
#[CoversClass(WechatServiceClient::class)]
|
||
final class ProtocolClientTest extends TestCase
|
||
{
|
||
/**
|
||
* 测试 raw 返回原始响应,不执行 JSON 解析。
|
||
*/
|
||
public function testJsonClientRawReturnsResponseWithoutJsonParsing(): void
|
||
{
|
||
$http = new ProtocolHttpClient([new Response(200, ['Content-Type' => 'image/png'], 'PNG-DATA')]);
|
||
$client = new JsonClient($http);
|
||
|
||
$response = $client->raw('GET', 'cgi-bin/media/get', ['media_id' => 'm1']);
|
||
|
||
$this->assertSame('PNG-DATA', (string)$response->getBody());
|
||
$this->assertSame(['media_id' => 'm1'], $http->requests[0]['options']['query']);
|
||
}
|
||
|
||
/**
|
||
* 测试微信公众平台下载接口返回二进制内容且自动附加 access_token。
|
||
*/
|
||
public function testWechatPlatformDownloadReturnsBinaryWithAccessToken(): void
|
||
{
|
||
$cache = new ProtocolCacheStore();
|
||
$cache->set(CacheKey::compose(
|
||
Client::DEFAULT_CACHE_KEY_PREFIX,
|
||
'wechat.platform',
|
||
TokenCacheKey::wechatPlatformAccessToken('wx_app'),
|
||
), 'cached-token', 3600);
|
||
$http = new ProtocolHttpClient([new Response(200, ['Content-Type' => 'image/jpeg'], 'JPEG-DATA')]);
|
||
$platform = (new Client(cache: $cache, http: $http))->wechatPlatform(new WechatPlatformConfig('wx_app', 'secret'));
|
||
|
||
$response = $platform->download('cgi-bin/media/get', ['media_id' => 'm1']);
|
||
|
||
$this->assertSame('JPEG-DATA', (string)$response->getBody());
|
||
$this->assertSame('cached-token', $http->requests[0]['options']['query']['access_token']);
|
||
$this->assertSame('m1', $http->requests[0]['options']['query']['media_id']);
|
||
}
|
||
|
||
/**
|
||
* 测试微信公众平台上传接口透传 multipart 并解析 JSON 响应。
|
||
*/
|
||
public function testWechatPlatformUploadSendsMultipartAndParsesJson(): void
|
||
{
|
||
$cache = new ProtocolCacheStore();
|
||
$cache->set(CacheKey::compose(
|
||
Client::DEFAULT_CACHE_KEY_PREFIX,
|
||
'wechat.platform',
|
||
TokenCacheKey::wechatPlatformAccessToken('wx_app'),
|
||
), 'cached-token', 3600);
|
||
$http = new ProtocolHttpClient([new Response(200, [], '{"media_id":"MEDIA_ID"}')]);
|
||
$platform = (new Client(cache: $cache, http: $http))->wechatPlatform(new WechatPlatformConfig('wx_app', 'secret'));
|
||
$multipart = [
|
||
['name' => 'media', 'contents' => 'file-content', 'filename' => 'demo.jpg'],
|
||
];
|
||
|
||
$data = $platform->upload('cgi-bin/media/upload', $multipart, ['type' => 'image']);
|
||
|
||
$this->assertSame('MEDIA_ID', $data['media_id']);
|
||
$this->assertSame($multipart, $http->requests[0]['options']['multipart']);
|
||
$this->assertSame('cached-token', $http->requests[0]['options']['query']['access_token']);
|
||
$this->assertSame('image', $http->requests[0]['options']['query']['type']);
|
||
}
|
||
|
||
/**
|
||
* 测试微信支付下载接口会生成 APIv3 Authorization 签名并返回原始响应。
|
||
*/
|
||
public function testWechatPaymentDownloadSignsRequestAndReturnsRawResponse(): void
|
||
{
|
||
[$merchantPrivateKey] = self::keyPair();
|
||
$http = new ProtocolHttpClient([new Response(200, ['Content-Type' => 'text/plain'], 'BILL-DATA')]);
|
||
$payment = new WechatPaymentClient(new WechatPaymentConfig(
|
||
'wx_app',
|
||
'mch_id',
|
||
str_repeat('k', 32),
|
||
'merchant-serial',
|
||
$merchantPrivateKey,
|
||
), $http);
|
||
|
||
$response = $payment->download('v3/bill/tradebill', ['bill_date' => '2026-05-08']);
|
||
|
||
$this->assertSame('BILL-DATA', (string)$response->getBody());
|
||
$headers = $http->requests[0]['options']['headers'];
|
||
$this->assertStringStartsWith('WECHATPAY2-SHA256-RSA2048 ', (string)$headers['Authorization']);
|
||
$this->assertArrayNotHasKey('Wechatpay-Serial', $headers);
|
||
$this->assertSame('2026-05-08', $http->requests[0]['options']['query']['bill_date']);
|
||
}
|
||
|
||
/**
|
||
* 测试微信支付通用调用会透传 Guzzle options,并用 json option 生成参与签名的请求体。
|
||
*/
|
||
public function testWechatPaymentCallPassesGuzzleOptions(): void
|
||
{
|
||
[$merchantPrivateKey] = self::keyPair();
|
||
$http = new ProtocolHttpClient([new Response(200, [], '{"ok":true}')]);
|
||
$payment = new WechatPaymentClient(new WechatPaymentConfig(
|
||
'wx_app',
|
||
'mch_id',
|
||
str_repeat('k', 32),
|
||
'merchant-serial',
|
||
$merchantPrivateKey,
|
||
), $http);
|
||
|
||
$data = $payment->post('v3/custom/request', ['ignored' => 'payload'], [
|
||
'query' => ['debug' => '1'],
|
||
'headers' => ['X-Request-Id' => 'RID-20260508', 'Wechatpay-Serial' => 'platform-serial'],
|
||
'timeout' => 5.0,
|
||
'json' => ['custom' => 'body'],
|
||
]);
|
||
|
||
$this->assertTrue($data['ok']);
|
||
$this->assertSame('1', $http->requests[0]['options']['query']['debug']);
|
||
$this->assertSame('RID-20260508', $http->requests[0]['options']['headers']['X-Request-Id']);
|
||
$this->assertSame('platform-serial', $http->requests[0]['options']['headers']['Wechatpay-Serial']);
|
||
$this->assertSame(5.0, $http->requests[0]['options']['timeout']);
|
||
$this->assertSame('{"custom":"body"}', $http->requests[0]['options']['body']);
|
||
$this->assertArrayNotHasKey('json', $http->requests[0]['options']);
|
||
}
|
||
|
||
/**
|
||
* 测试微信支付非 2xx JSON 错误响应会被解析并转换为 ApiException。
|
||
*/
|
||
public function testWechatPaymentRequestThrowsOnHttpErrorPayload(): void
|
||
{
|
||
[$merchantPrivateKey] = self::keyPair();
|
||
$http = new ProtocolHttpClient([new Response(400, [], '{"code":"PARAM_ERROR","message":"参数错误"}')]);
|
||
$payment = new WechatPaymentClient(new WechatPaymentConfig(
|
||
'wx_app',
|
||
'mch_id',
|
||
str_repeat('k', 32),
|
||
'merchant-serial',
|
||
$merchantPrivateKey,
|
||
), $http);
|
||
|
||
try {
|
||
$payment->post('v3/pay/transactions/jsapi', ['appid' => 'wx_app']);
|
||
self::fail('Expected ApiException was not thrown.');
|
||
} catch (ApiException $exception) {
|
||
self::assertSame(400, $exception->getCode());
|
||
self::assertSame('参数错误', $exception->getMessage());
|
||
self::assertSame('PARAM_ERROR', $exception->context()['code']);
|
||
self::assertSame(false, $http->requests[0]['options']['http_errors']);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 测试微信服务平台代授权方 GET 调用会把 params 作为 query 并附加授权方 access_token。
|
||
*/
|
||
public function testWechatServiceAuthorizerGetUsesParamsAsQuery(): void
|
||
{
|
||
$cache = new ProtocolCacheStore();
|
||
$cache->set(CacheKey::compose(
|
||
Client::DEFAULT_CACHE_KEY_PREFIX,
|
||
'wechat.service',
|
||
TokenCacheKey::wechatServiceAuthorizerAccessToken('component_app', 'authorizer_app'),
|
||
), 'authorizer-token', 3600);
|
||
$http = new ProtocolHttpClient([new Response(200, [], '{"ok":true}')]);
|
||
$service = (new Client(cache: $cache, authorizers: new ProtocolAuthorizerTokenStore(), http: $http))
|
||
->wechatService(new WechatServiceConfig('component_app', 'component_secret', 'componentToken123', TestKeys::encodingAesKey()));
|
||
|
||
$data = $service->get('cgi-bin/user/get', ['next_openid' => 'NEXT'], [
|
||
'authorizer_appid' => 'authorizer_app',
|
||
'component_access_token' => 'component-token',
|
||
]);
|
||
|
||
$this->assertTrue($data['ok']);
|
||
$this->assertSame('NEXT', $http->requests[0]['options']['query']['next_openid']);
|
||
$this->assertSame('authorizer-token', $http->requests[0]['options']['query']['access_token']);
|
||
}
|
||
|
||
/**
|
||
* 生成测试使用的 RSA 密钥对。
|
||
*
|
||
* @return array{0:string,1:string}
|
||
*/
|
||
private static function keyPair(): array
|
||
{
|
||
$resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
||
self::assertNotFalse($resource);
|
||
openssl_pkey_export($resource, $privateKey);
|
||
$details = openssl_pkey_get_details($resource);
|
||
self::assertIsArray($details);
|
||
|
||
return [$privateKey, (string)$details['key']];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 协议层测试用 HTTP 客户端。
|
||
*/
|
||
final class ProtocolHttpClient implements ClientInterface
|
||
{
|
||
/** @var array<int,array{method:string,uri:mixed,options:array<string,mixed>}> */
|
||
public array $requests = [];
|
||
|
||
/**
|
||
* 创建协议层测试 HTTP 客户端。
|
||
*
|
||
* @param array<int,ResponseInterface> $responses
|
||
*/
|
||
public function __construct(private array $responses) {}
|
||
|
||
/**
|
||
* 实现测试 HTTP 客户端同步发送接口。
|
||
*/
|
||
public function send(RequestInterface $request, array $options = []): ResponseInterface
|
||
{
|
||
return $this->nextResponse();
|
||
}
|
||
|
||
/**
|
||
* 实现测试 HTTP 客户端异步发送接口。
|
||
*/
|
||
public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface
|
||
{
|
||
return Create::rejectionFor(new \RuntimeException('sendAsync is not used in this test'));
|
||
}
|
||
|
||
/**
|
||
* 实现测试 HTTP 客户端请求接口并记录请求。
|
||
* @param mixed $uri
|
||
*/
|
||
public function request(string $method, $uri = '', array $options = []): ResponseInterface
|
||
{
|
||
$this->requests[] = ['method' => $method, 'uri' => $uri, 'options' => $options];
|
||
|
||
return $this->nextResponse();
|
||
}
|
||
|
||
/**
|
||
* 实现测试 HTTP 客户端异步请求接口。
|
||
* @param mixed $uri
|
||
*/
|
||
public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
|
||
{
|
||
return Create::rejectionFor(new \RuntimeException('requestAsync is not used in this test'));
|
||
}
|
||
|
||
/**
|
||
* 返回测试 HTTP 客户端配置。
|
||
*/
|
||
public function getConfig(?string $option = null): mixed
|
||
{
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 返回下一个测试响应。
|
||
*/
|
||
private function nextResponse(): ResponseInterface
|
||
{
|
||
return array_shift($this->responses) ?? new Response(200, [], '{}');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 协议层测试用缓存实现。
|
||
*/
|
||
final class ProtocolCacheStore implements StoreCacheInterface
|
||
{
|
||
/** @var array<string,mixed> */
|
||
private array $values = [];
|
||
|
||
/**
|
||
* 读取测试缓存值。
|
||
*/
|
||
public function get(string $key, mixed $default = null): mixed
|
||
{
|
||
return $this->values[$key] ?? $default;
|
||
}
|
||
|
||
/**
|
||
* 写入测试缓存值。
|
||
*/
|
||
public function set(string $key, mixed $value, int $ttl): void
|
||
{
|
||
$this->values[$key] = $value;
|
||
}
|
||
|
||
/**
|
||
* 删除测试缓存值。
|
||
*/
|
||
public function del(string $key): void
|
||
{
|
||
unset($this->values[$key]);
|
||
}
|
||
|
||
/**
|
||
* 在锁语义下执行回调。
|
||
*/
|
||
public function lock(string $key, int $ttl, callable $callback): mixed
|
||
{
|
||
return $callback();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 协议层测试用授权方 Token 仓库。
|
||
*/
|
||
final class ProtocolAuthorizerTokenStore implements StoreTokenInterface
|
||
{
|
||
/**
|
||
* 返回测试授权方刷新凭据。
|
||
*/
|
||
public function refreshToken(string $authorizerAppid): string
|
||
{
|
||
return 'refresh-token';
|
||
}
|
||
|
||
/**
|
||
* 忽略授权方 Token 回写。
|
||
*
|
||
* @param array<string,mixed> $payload
|
||
*/
|
||
public function saveAuthorizerToken(string $authorizerAppid, array $payload): void {}
|
||
}
|