mirror of
https://gitee.com/zoujingli/WeChatDeveloper.git
synced 2026-09-04 23:12:09 +08:00
test: 迁移测试目录并补充协议层用例
- 将 src/Tests 迁移到根目录 tests,并同步 composer autoload-dev 与 phpunit 配置。 - 补充 raw/download/upload、微信支付签名下载、XML 嵌套重复节点和消息加解密异常用例。 - 保持测试命名空间 We\Tests,统一通过 tests/bootstrap.php 引导 Composer 自动加载。
This commit is contained in:
parent
ca18f905d1
commit
27c6d03cbd
@ -35,7 +35,7 @@
|
|||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"We\\Tests\\": "src/Tests/"
|
"We\\Tests\\": "tests/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -8,15 +8,12 @@
|
|||||||
requireCoverageMetadata="false">
|
requireCoverageMetadata="false">
|
||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="WeChatDeveloper">
|
<testsuite name="WeChatDeveloper">
|
||||||
<directory suffix="Test.php">src/Tests</directory>
|
<directory suffix="Test.php">tests</directory>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<source>
|
<source>
|
||||||
<include>
|
<include>
|
||||||
<directory suffix=".php">src</directory>
|
<directory suffix=".php">src</directory>
|
||||||
</include>
|
</include>
|
||||||
<exclude>
|
|
||||||
<directory>src/Tests</directory>
|
|
||||||
</exclude>
|
|
||||||
</source>
|
</source>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace We\Tests;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use We\Exception\WechatException;
|
|
||||||
use We\Support\CacheKey;
|
|
||||||
use We\Support\TokenCacheKey;
|
|
||||||
|
|
||||||
#[CoversClass(CacheKey::class)]
|
|
||||||
final class CacheKeyTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testComposeThreeSegments(): void
|
|
||||||
{
|
|
||||||
$logical = TokenCacheKey::wechatOfficialAccessToken('wx_demo', '');
|
|
||||||
$full = CacheKey::compose('myapp', 'wechat.platform', $logical);
|
|
||||||
$this->assertSame('myapp:wechat.platform:' . $logical, $full);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testComposeTrimsColonNoiseOnSegments(): void
|
|
||||||
{
|
|
||||||
$logical = 'wechat:app:z:official:access_token';
|
|
||||||
$this->assertSame('ns:wechat.service:' . $logical, CacheKey::compose('::ns::', ':::wechat.service::', $logical));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testComposeThrowsWhenPrefixEmpty(): void
|
|
||||||
{
|
|
||||||
$this->expectException(WechatException::class);
|
|
||||||
$this->expectExceptionMessage('通用前缀');
|
|
||||||
CacheKey::compose('', 'wechat.wxapp', 'wechat:app:x:mini:access_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testComposeThrowsWhenChannelEmpty(): void
|
|
||||||
{
|
|
||||||
$this->expectException(WechatException::class);
|
|
||||||
$this->expectExceptionMessage('通道段');
|
|
||||||
CacheKey::compose('app', '', 'wechat:app:x:mini:access_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testComposeThrowsWhenLogicalEmpty(): void
|
|
||||||
{
|
|
||||||
$this->expectException(WechatException::class);
|
|
||||||
$this->expectExceptionMessage('逻辑段');
|
|
||||||
CacheKey::compose('app', 'wechat.platform', ' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace We\Tests;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use We\Support\MessageCrypto;
|
|
||||||
use We\Support\Signature;
|
|
||||||
|
|
||||||
#[CoversClass(MessageCrypto::class)]
|
|
||||||
final class MessageCryptoTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testEncryptAndDecryptMessage(): void
|
|
||||||
{
|
|
||||||
$key = substr(base64_encode(str_repeat('a', 32)), 0, 43);
|
|
||||||
$crypto = new MessageCrypto('token', $key, 'wx1234567890');
|
|
||||||
$xml = '<xml><ToUserName><![CDATA[to]]></ToUserName><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[hello]]></Content></xml>';
|
|
||||||
$timestamp = '1777600000';
|
|
||||||
$nonce = 'nonce';
|
|
||||||
|
|
||||||
$encrypted = $crypto->encryptMessage($xml, $timestamp, $nonce);
|
|
||||||
preg_match('/<Encrypt><!\[CDATA\[(.*?)\]\]><\/Encrypt>/', $encrypted, $matches);
|
|
||||||
$this->assertNotEmpty($matches[1] ?? '');
|
|
||||||
$signature = Signature::sha1(['token', $timestamp, $nonce, $matches[1]]);
|
|
||||||
|
|
||||||
$plain = $crypto->decryptMessage($encrypted, $signature, $timestamp, $nonce);
|
|
||||||
|
|
||||||
$this->assertSame('text', $plain['MsgType']);
|
|
||||||
$this->assertSame('hello', $plain['Content']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信公众平台 access_token 缓存行为测试。
|
||||||
|
*/
|
||||||
|
|
||||||
namespace We\Tests;
|
namespace We\Tests;
|
||||||
|
|
||||||
use GuzzleHttp\ClientInterface;
|
use GuzzleHttp\ClientInterface;
|
||||||
@ -15,20 +19,26 @@ use Psr\Http\Message\ResponseInterface;
|
|||||||
use We\Client;
|
use We\Client;
|
||||||
use We\Config\WechatPlatformConfig;
|
use We\Config\WechatPlatformConfig;
|
||||||
use We\Contract\StoreCacheInterface;
|
use We\Contract\StoreCacheInterface;
|
||||||
use We\Platform\Wechat\PlatformClient;
|
use We\Platform\Wechat\PlatformClient as WechatPlatformClient;
|
||||||
use We\Support\CacheKey;
|
use We\Support\CacheKey;
|
||||||
use We\Support\TokenCacheKey;
|
use We\Support\TokenCacheKey;
|
||||||
|
|
||||||
#[CoversClass(PlatformClient::class)]
|
/**
|
||||||
|
* 微信公众平台 access_token 缓存行为测试用例。
|
||||||
|
*/
|
||||||
|
#[CoversClass(WechatPlatformClient::class)]
|
||||||
final class AccessTokenCacheTest extends TestCase
|
final class AccessTokenCacheTest extends TestCase
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 测试命中 access_token 缓存时不会发起 HTTP 请求。
|
||||||
|
*/
|
||||||
public function testAccessTokenCacheHitDoesNotRequestHttp(): void
|
public function testAccessTokenCacheHitDoesNotRequestHttp(): void
|
||||||
{
|
{
|
||||||
$cache = new ArrayCacheStore();
|
$cache = new ArrayCacheStore();
|
||||||
$key = CacheKey::compose(
|
$key = CacheKey::compose(
|
||||||
Client::DEFAULT_CACHE_KEY_PREFIX,
|
Client::DEFAULT_CACHE_KEY_PREFIX,
|
||||||
'wechat.platform',
|
'wechat.platform',
|
||||||
TokenCacheKey::wechatOfficialAccessToken('wx_app'),
|
TokenCacheKey::wechatPlatformAccessToken('wx_app'),
|
||||||
);
|
);
|
||||||
$cache->set($key, 'cached-token', 3600);
|
$cache->set($key, 'cached-token', 3600);
|
||||||
$http = new FakeHttpClient(['access_token' => 'remote-token', 'expires_in' => 7200]);
|
$http = new FakeHttpClient(['access_token' => 'remote-token', 'expires_in' => 7200]);
|
||||||
@ -42,6 +52,9 @@ final class AccessTokenCacheTest extends TestCase
|
|||||||
$this->assertSame(0, $cache->lockCalls);
|
$this->assertSame(0, $cache->lockCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试刷新 access_token 时会使用锁并写入缓存。
|
||||||
|
*/
|
||||||
public function testAccessTokenRefreshUsesLockAndWritesCache(): void
|
public function testAccessTokenRefreshUsesLockAndWritesCache(): void
|
||||||
{
|
{
|
||||||
$cache = new ArrayCacheStore();
|
$cache = new ArrayCacheStore();
|
||||||
@ -57,6 +70,9 @@ final class AccessTokenCacheTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试用 StoreCacheInterface 内存实现。
|
||||||
|
*/
|
||||||
final class ArrayCacheStore implements StoreCacheInterface
|
final class ArrayCacheStore implements StoreCacheInterface
|
||||||
{
|
{
|
||||||
/** @var array<string,mixed> */
|
/** @var array<string,mixed> */
|
||||||
@ -64,21 +80,33 @@ final class ArrayCacheStore implements StoreCacheInterface
|
|||||||
|
|
||||||
public int $lockCalls = 0;
|
public int $lockCalls = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取测试缓存值。
|
||||||
|
*/
|
||||||
public function get(string $key, mixed $default = null): mixed
|
public function get(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
return $this->values[$key] ?? $default;
|
return $this->values[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入缓存值。
|
||||||
|
*/
|
||||||
public function set(string $key, mixed $value, int $ttl): void
|
public function set(string $key, mixed $value, int $ttl): void
|
||||||
{
|
{
|
||||||
$this->values[$key] = $value;
|
$this->values[$key] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除缓存值。
|
||||||
|
*/
|
||||||
public function del(string $key): void
|
public function del(string $key): void
|
||||||
{
|
{
|
||||||
unset($this->values[$key]);
|
unset($this->values[$key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在锁语义下执行回调。
|
||||||
|
*/
|
||||||
public function lock(string $key, int $ttl, callable $callback): mixed
|
public function lock(string $key, int $ttl, callable $callback): mixed
|
||||||
{
|
{
|
||||||
++$this->lockCalls;
|
++$this->lockCalls;
|
||||||
@ -87,25 +115,39 @@ final class ArrayCacheStore implements StoreCacheInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回固定 JSON 响应的测试 HTTP 客户端。
|
||||||
|
*/
|
||||||
final class FakeHttpClient implements ClientInterface
|
final class FakeHttpClient implements ClientInterface
|
||||||
{
|
{
|
||||||
public int $requests = 0;
|
public int $requests = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* 创建固定响应测试 HTTP 客户端。
|
||||||
|
*
|
||||||
* @param array<string,mixed> $payload
|
* @param array<string,mixed> $payload
|
||||||
*/
|
*/
|
||||||
public function __construct(private readonly array $payload) {}
|
public function __construct(private readonly array $payload) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实现测试 HTTP 客户端同步发送接口。
|
||||||
|
*/
|
||||||
public function send(RequestInterface $request, array $options = []): ResponseInterface
|
public function send(RequestInterface $request, array $options = []): ResponseInterface
|
||||||
{
|
{
|
||||||
return $this->response();
|
return $this->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实现测试 HTTP 客户端异步发送接口。
|
||||||
|
*/
|
||||||
public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface
|
public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface
|
||||||
{
|
{
|
||||||
return Create::rejectionFor(new \RuntimeException('sendAsync is not used in this test'));
|
return Create::rejectionFor(new \RuntimeException('sendAsync is not used in this test'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实现测试 HTTP 客户端请求接口或记录请求。
|
||||||
|
*/
|
||||||
public function request(string $method, $uri = '', array $options = []): ResponseInterface
|
public function request(string $method, $uri = '', array $options = []): ResponseInterface
|
||||||
{
|
{
|
||||||
++$this->requests;
|
++$this->requests;
|
||||||
@ -113,16 +155,25 @@ final class FakeHttpClient implements ClientInterface
|
|||||||
return $this->response();
|
return $this->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实现测试 HTTP 客户端异步请求接口。
|
||||||
|
*/
|
||||||
public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
|
public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
|
||||||
{
|
{
|
||||||
return Create::rejectionFor(new \RuntimeException('requestAsync is not used in this test'));
|
return Create::rejectionFor(new \RuntimeException('requestAsync is not used in this test'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回测试 HTTP 客户端配置。
|
||||||
|
*/
|
||||||
public function getConfig(?string $option = null): mixed
|
public function getConfig(?string $option = null): mixed
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造测试 HTTP 响应对象。
|
||||||
|
*/
|
||||||
private function response(): ResponseInterface
|
private function response(): ResponseInterface
|
||||||
{
|
{
|
||||||
return new Response(200, [], json_encode($this->payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
|
return new Response(200, [], json_encode($this->payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
|
||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付宝开放平台网关调用与验签测试。
|
||||||
|
*/
|
||||||
|
|
||||||
namespace We\Tests;
|
namespace We\Tests;
|
||||||
|
|
||||||
use GuzzleHttp\ClientInterface;
|
use GuzzleHttp\ClientInterface;
|
||||||
@ -13,17 +17,23 @@ use PHPUnit\Framework\TestCase;
|
|||||||
use Psr\Http\Message\RequestInterface;
|
use Psr\Http\Message\RequestInterface;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use We\Config\AlipayPlatformConfig;
|
use We\Config\AlipayPlatformConfig;
|
||||||
use We\Platform\Alipay\PlatformClient;
|
use We\Platform\Alipay\PlatformClient as AlipayPlatformClient;
|
||||||
|
|
||||||
#[CoversClass(PlatformClient::class)]
|
/**
|
||||||
|
* 支付宝开放平台网关调用与验签测试用例。
|
||||||
|
*/
|
||||||
|
#[CoversClass(AlipayPlatformClient::class)]
|
||||||
final class AlipayPlatformClientTest extends TestCase
|
final class AlipayPlatformClientTest extends TestCase
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 测试配置支付宝公钥时会校验同步响应签名。
|
||||||
|
*/
|
||||||
public function testRequestVerifiesSignedResponseWhenPublicKeyConfigured(): void
|
public function testRequestVerifiesSignedResponseWhenPublicKeyConfigured(): void
|
||||||
{
|
{
|
||||||
[$privateKey, $publicKey] = self::keyPair();
|
[$privateKey, $publicKey] = self::keyPair();
|
||||||
$responseNode = '{"code":"10000","msg":"Success","trade_no":"TRADE202605040001"}';
|
$responseNode = '{"code":"10000","msg":"Success","trade_no":"TRADE202605040001"}';
|
||||||
$body = '{"alipay_trade_query_response":' . $responseNode . ',"sign":"' . self::sign($responseNode, $privateKey) . '"}';
|
$body = '{"alipay_trade_query_response":' . $responseNode . ',"sign":"' . self::sign($responseNode, $privateKey) . '"}';
|
||||||
$client = new PlatformClient(
|
$client = new AlipayPlatformClient(
|
||||||
new AlipayPlatformConfig('ali_app', $privateKey, $publicKey),
|
new AlipayPlatformConfig('ali_app', $privateKey, $publicKey),
|
||||||
new AlipayFakeHttpClient($body),
|
new AlipayFakeHttpClient($body),
|
||||||
);
|
);
|
||||||
@ -33,6 +43,9 @@ final class AlipayPlatformClientTest extends TestCase
|
|||||||
self::assertSame('TRADE202605040001', $data['trade_no']);
|
self::assertSame('TRADE202605040001', $data['trade_no']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试支付宝异步通知验签。
|
||||||
|
*/
|
||||||
public function testVerifyNotify(): void
|
public function testVerifyNotify(): void
|
||||||
{
|
{
|
||||||
[$privateKey, $publicKey] = self::keyPair();
|
[$privateKey, $publicKey] = self::keyPair();
|
||||||
@ -44,12 +57,14 @@ final class AlipayPlatformClientTest extends TestCase
|
|||||||
'sign_type' => 'RSA2',
|
'sign_type' => 'RSA2',
|
||||||
];
|
];
|
||||||
$params['sign'] = self::sign('app_id=ali_app¬ify_time=2026-05-04 12:00:00&out_trade_no=P202605040001&trade_status=TRADE_SUCCESS', $privateKey);
|
$params['sign'] = self::sign('app_id=ali_app¬ify_time=2026-05-04 12:00:00&out_trade_no=P202605040001&trade_status=TRADE_SUCCESS', $privateKey);
|
||||||
$client = new PlatformClient(new AlipayPlatformConfig('ali_app', $privateKey, $publicKey));
|
$client = new AlipayPlatformClient(new AlipayPlatformConfig('ali_app', $privateKey, $publicKey));
|
||||||
|
|
||||||
self::assertTrue($client->verifyNotify($params));
|
self::assertTrue($client->verifyNotify($params));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* 生成测试使用的 RSA 密钥对。
|
||||||
|
*
|
||||||
* @return array{0:string,1:string}
|
* @return array{0:string,1:string}
|
||||||
*/
|
*/
|
||||||
private static function keyPair(): array
|
private static function keyPair(): array
|
||||||
@ -63,6 +78,9 @@ final class AlipayPlatformClientTest extends TestCase
|
|||||||
return [$privateKey, (string)$details['key']];
|
return [$privateKey, (string)$details['key']];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用测试私钥生成签名。
|
||||||
|
*/
|
||||||
private static function sign(string $source, string $privateKey): string
|
private static function sign(string $source, string $privateKey): string
|
||||||
{
|
{
|
||||||
$ok = openssl_sign($source, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
$ok = openssl_sign($source, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||||
@ -72,35 +90,59 @@ final class AlipayPlatformClientTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回固定网关响应的支付宝测试 HTTP 客户端。
|
||||||
|
*/
|
||||||
final class AlipayFakeHttpClient implements ClientInterface
|
final class AlipayFakeHttpClient implements ClientInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 创建固定响应测试 HTTP 客户端。
|
||||||
|
*/
|
||||||
public function __construct(private readonly string $body) {}
|
public function __construct(private readonly string $body) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实现测试 HTTP 客户端同步发送接口。
|
||||||
|
*/
|
||||||
public function send(RequestInterface $request, array $options = []): ResponseInterface
|
public function send(RequestInterface $request, array $options = []): ResponseInterface
|
||||||
{
|
{
|
||||||
return $this->response();
|
return $this->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实现测试 HTTP 客户端异步发送接口。
|
||||||
|
*/
|
||||||
public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface
|
public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface
|
||||||
{
|
{
|
||||||
return Create::rejectionFor(new \RuntimeException('sendAsync is not used in this test'));
|
return Create::rejectionFor(new \RuntimeException('sendAsync is not used in this test'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实现测试 HTTP 客户端请求接口或记录请求。
|
||||||
|
*/
|
||||||
public function request(string $method, $uri = '', array $options = []): ResponseInterface
|
public function request(string $method, $uri = '', array $options = []): ResponseInterface
|
||||||
{
|
{
|
||||||
return $this->response();
|
return $this->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实现测试 HTTP 客户端异步请求接口。
|
||||||
|
*/
|
||||||
public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
|
public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
|
||||||
{
|
{
|
||||||
return Create::rejectionFor(new \RuntimeException('requestAsync is not used in this test'));
|
return Create::rejectionFor(new \RuntimeException('requestAsync is not used in this test'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回测试 HTTP 客户端配置。
|
||||||
|
*/
|
||||||
public function getConfig(?string $option = null): mixed
|
public function getConfig(?string $option = null): mixed
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造测试 HTTP 响应对象。
|
||||||
|
*/
|
||||||
private function response(): ResponseInterface
|
private function response(): ResponseInterface
|
||||||
{
|
{
|
||||||
return new Response(200, [], $this->body);
|
return new Response(200, [], $this->body);
|
||||||
94
tests/CacheKeyTest.php
Normal file
94
tests/CacheKeyTest.php
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 缓存键生成规则测试。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace We\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use We\Exception\WechatException;
|
||||||
|
use We\Support\CacheKey;
|
||||||
|
use We\Support\TokenCacheKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 缓存键生成规则测试用例。
|
||||||
|
*/
|
||||||
|
#[CoversClass(CacheKey::class)]
|
||||||
|
final class CacheKeyTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 测试缓存完整键由三段组成。
|
||||||
|
*/
|
||||||
|
public function testComposeThreeSegments(): void
|
||||||
|
{
|
||||||
|
$logical = TokenCacheKey::wechatPlatformAccessToken('wx_demo', '');
|
||||||
|
$full = CacheKey::compose('myapp', 'wechat.platform', $logical);
|
||||||
|
$this->assertSame('myapp:wechat.platform:' . $logical, $full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试缓存键段会去除多余冒号。
|
||||||
|
*/
|
||||||
|
public function testComposeTrimsColonNoiseOnSegments(): void
|
||||||
|
{
|
||||||
|
$logical = TokenCacheKey::wechatServiceComponentAccessToken('wx_service');
|
||||||
|
$this->assertSame('ns:wechat.service:' . $logical, CacheKey::compose('::ns::', ':::wechat.service::', $logical));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试微信 Token 逻辑键使用 platform、wxapp、service 统一命名。
|
||||||
|
*/
|
||||||
|
public function testWechatTokenLogicalKeysFollowChannelNames(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(
|
||||||
|
'wechat:app:wx_platform:platform:access_token',
|
||||||
|
TokenCacheKey::wechatPlatformAccessToken('wx_platform'),
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'wechat:app:wx_wxapp:wxapp:access_token',
|
||||||
|
TokenCacheKey::wechatWxappAccessToken('wx_wxapp'),
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'wechat:app:wx_service:service:component_access_token',
|
||||||
|
TokenCacheKey::wechatServiceComponentAccessToken('wx_service'),
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'wechat:app:wx_service:service:authorizer:wx_authorizer:access_token',
|
||||||
|
TokenCacheKey::wechatServiceAuthorizerAccessToken('wx_service', 'wx_authorizer'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试缓存键前缀为空时抛出异常。
|
||||||
|
*/
|
||||||
|
public function testComposeThrowsWhenPrefixEmpty(): void
|
||||||
|
{
|
||||||
|
$this->expectException(WechatException::class);
|
||||||
|
$this->expectExceptionMessage('通用前缀');
|
||||||
|
CacheKey::compose('', 'wechat.wxapp', 'wechat:app:x:wxapp:access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试缓存键通道段为空时抛出异常。
|
||||||
|
*/
|
||||||
|
public function testComposeThrowsWhenChannelEmpty(): void
|
||||||
|
{
|
||||||
|
$this->expectException(WechatException::class);
|
||||||
|
$this->expectExceptionMessage('通道段');
|
||||||
|
CacheKey::compose('app', '', 'wechat:app:x:wxapp:access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试缓存键逻辑段为空时抛出异常。
|
||||||
|
*/
|
||||||
|
public function testComposeThrowsWhenLogicalEmpty(): void
|
||||||
|
{
|
||||||
|
$this->expectException(WechatException::class);
|
||||||
|
$this->expectExceptionMessage('逻辑段');
|
||||||
|
CacheKey::compose('app', 'wechat.platform', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 缓存存储实现测试。
|
||||||
|
*/
|
||||||
|
|
||||||
namespace We\Tests;
|
namespace We\Tests;
|
||||||
|
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
@ -13,11 +17,17 @@ use We\Support\FileCacheStore;
|
|||||||
use We\Support\NullCacheStore;
|
use We\Support\NullCacheStore;
|
||||||
use We\Support\PsrSimpleCacheStore;
|
use We\Support\PsrSimpleCacheStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 缓存存储实现测试用例。
|
||||||
|
*/
|
||||||
#[CoversClass(FileCacheStore::class)]
|
#[CoversClass(FileCacheStore::class)]
|
||||||
#[CoversClass(NullCacheStore::class)]
|
#[CoversClass(NullCacheStore::class)]
|
||||||
#[CoversClass(PsrSimpleCacheStore::class)]
|
#[CoversClass(PsrSimpleCacheStore::class)]
|
||||||
final class CacheStoreTest extends TestCase
|
final class CacheStoreTest extends TestCase
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 测试文件缓存的写入、读取、删除与过期行为。
|
||||||
|
*/
|
||||||
public function testFileCacheStoreSetGetDelAndTtl(): void
|
public function testFileCacheStoreSetGetDelAndTtl(): void
|
||||||
{
|
{
|
||||||
$dir = $this->tempDir();
|
$dir = $this->tempDir();
|
||||||
@ -36,6 +46,9 @@ final class CacheStoreTest extends TestCase
|
|||||||
$this->removeDir($dir);
|
$this->removeDir($dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试文件缓存锁会执行回调。
|
||||||
|
*/
|
||||||
public function testFileCacheStoreLockExecutesCallback(): void
|
public function testFileCacheStoreLockExecutesCallback(): void
|
||||||
{
|
{
|
||||||
$dir = $this->tempDir();
|
$dir = $this->tempDir();
|
||||||
@ -47,6 +60,9 @@ final class CacheStoreTest extends TestCase
|
|||||||
$this->removeDir($dir);
|
$this->removeDir($dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试空缓存锁会直接执行回调。
|
||||||
|
*/
|
||||||
public function testNullCacheStoreLockExecutesCallback(): void
|
public function testNullCacheStoreLockExecutesCallback(): void
|
||||||
{
|
{
|
||||||
$store = new NullCacheStore();
|
$store = new NullCacheStore();
|
||||||
@ -54,6 +70,9 @@ final class CacheStoreTest extends TestCase
|
|||||||
$this->assertSame('ok', $store->lock('k', 10, static fn (): string => 'ok'));
|
$this->assertSame('ok', $store->lock('k', 10, static fn (): string => 'ok'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 PSR 缓存未配置锁能力时抛出异常。
|
||||||
|
*/
|
||||||
public function testPsrSimpleCacheStoreThrowsWhenLockerMissing(): void
|
public function testPsrSimpleCacheStoreThrowsWhenLockerMissing(): void
|
||||||
{
|
{
|
||||||
$store = new PsrSimpleCacheStore(new ArraySimpleCache());
|
$store = new PsrSimpleCacheStore(new ArraySimpleCache());
|
||||||
@ -64,6 +83,9 @@ final class CacheStoreTest extends TestCase
|
|||||||
$store->lock('k', 10, static fn (): string => 'never');
|
$store->lock('k', 10, static fn (): string => 'never');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 PSR 缓存会使用注入的锁回调。
|
||||||
|
*/
|
||||||
public function testPsrSimpleCacheStoreUsesInjectedLocker(): void
|
public function testPsrSimpleCacheStoreUsesInjectedLocker(): void
|
||||||
{
|
{
|
||||||
$calls = [];
|
$calls = [];
|
||||||
@ -77,11 +99,17 @@ final class CacheStoreTest extends TestCase
|
|||||||
$this->assertSame([['k', 10]], $calls);
|
$this->assertSame([['k', 10]], $calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建本次测试使用的临时目录路径。
|
||||||
|
*/
|
||||||
private function tempDir(): string
|
private function tempDir(): string
|
||||||
{
|
{
|
||||||
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'wechatdev_cache_' . bin2hex(random_bytes(8));
|
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'wechatdev_cache_' . bin2hex(random_bytes(8));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归删除测试临时目录。
|
||||||
|
*/
|
||||||
private function removeDir(string $dir): void
|
private function removeDir(string $dir): void
|
||||||
{
|
{
|
||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir)) {
|
||||||
@ -99,16 +127,25 @@ final class CacheStoreTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试用 PSR-16 内存缓存实现。
|
||||||
|
*/
|
||||||
final class ArraySimpleCache implements CacheInterface
|
final class ArraySimpleCache implements CacheInterface
|
||||||
{
|
{
|
||||||
/** @var array<string,mixed> */
|
/** @var array<string,mixed> */
|
||||||
private array $values = [];
|
private array $values = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取测试缓存值。
|
||||||
|
*/
|
||||||
public function get(string $key, mixed $default = null): mixed
|
public function get(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
return $this->values[$key] ?? $default;
|
return $this->values[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入缓存值。
|
||||||
|
*/
|
||||||
public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool
|
public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool
|
||||||
{
|
{
|
||||||
$this->values[$key] = $value;
|
$this->values[$key] = $value;
|
||||||
@ -116,6 +153,9 @@ final class ArraySimpleCache implements CacheInterface
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除测试缓存值。
|
||||||
|
*/
|
||||||
public function delete(string $key): bool
|
public function delete(string $key): bool
|
||||||
{
|
{
|
||||||
unset($this->values[$key]);
|
unset($this->values[$key]);
|
||||||
@ -123,6 +163,9 @@ final class ArraySimpleCache implements CacheInterface
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空测试缓存。
|
||||||
|
*/
|
||||||
public function clear(): bool
|
public function clear(): bool
|
||||||
{
|
{
|
||||||
$this->values = [];
|
$this->values = [];
|
||||||
@ -130,6 +173,9 @@ final class ArraySimpleCache implements CacheInterface
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量读取测试缓存值。
|
||||||
|
*/
|
||||||
public function getMultiple(iterable $keys, mixed $default = null): iterable
|
public function getMultiple(iterable $keys, mixed $default = null): iterable
|
||||||
{
|
{
|
||||||
foreach ($keys as $key) {
|
foreach ($keys as $key) {
|
||||||
@ -137,6 +183,9 @@ final class ArraySimpleCache implements CacheInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量写入测试缓存值。
|
||||||
|
*/
|
||||||
public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool
|
public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool
|
||||||
{
|
{
|
||||||
foreach ($values as $key => $value) {
|
foreach ($values as $key => $value) {
|
||||||
@ -146,6 +195,9 @@ final class ArraySimpleCache implements CacheInterface
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除测试缓存值。
|
||||||
|
*/
|
||||||
public function deleteMultiple(iterable $keys): bool
|
public function deleteMultiple(iterable $keys): bool
|
||||||
{
|
{
|
||||||
foreach ($keys as $key) {
|
foreach ($keys as $key) {
|
||||||
@ -155,6 +207,9 @@ final class ArraySimpleCache implements CacheInterface
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断测试缓存键是否存在。
|
||||||
|
*/
|
||||||
public function has(string $key): bool
|
public function has(string $key): bool
|
||||||
{
|
{
|
||||||
return array_key_exists($key, $this->values);
|
return array_key_exists($key, $this->values);
|
||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 根入口通道工厂测试。
|
||||||
|
*/
|
||||||
|
|
||||||
namespace We\Tests;
|
namespace We\Tests;
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
@ -15,9 +19,15 @@ use We\Platform\Alipay\PlatformClient as AlipayPlatformClient;
|
|||||||
use We\Platform\Wechat\PlatformClient as WechatPlatformClient;
|
use We\Platform\Wechat\PlatformClient as WechatPlatformClient;
|
||||||
use We\Platform\Wechat\ServiceClient as WechatServiceClient;
|
use We\Platform\Wechat\ServiceClient as WechatServiceClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 根入口通道工厂测试用例。
|
||||||
|
*/
|
||||||
#[CoversClass(Client::class)]
|
#[CoversClass(Client::class)]
|
||||||
final class ClientTest extends TestCase
|
final class ClientTest extends TestCase
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 测试根客户端缓存前缀为空时抛出异常。
|
||||||
|
*/
|
||||||
public function testConstructorThrowsWhenCacheKeyPrefixEmpty(): void
|
public function testConstructorThrowsWhenCacheKeyPrefixEmpty(): void
|
||||||
{
|
{
|
||||||
$this->expectException(WechatException::class);
|
$this->expectException(WechatException::class);
|
||||||
@ -25,6 +35,9 @@ final class ClientTest extends TestCase
|
|||||||
new Client(cacheKeyPrefix: ' ');
|
new Client(cacheKeyPrefix: ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试默认缓存目录位于系统临时目录下。
|
||||||
|
*/
|
||||||
public function testDefaultCacheStoreDirectoryUnderSysTemp(): void
|
public function testDefaultCacheStoreDirectoryUnderSysTemp(): void
|
||||||
{
|
{
|
||||||
$dir = Client::defaultCacheStoreDirectory();
|
$dir = Client::defaultCacheStoreDirectory();
|
||||||
@ -32,6 +45,9 @@ final class ClientTest extends TestCase
|
|||||||
$this->assertStringEndsWith(Client::DEFAULT_CACHE_STORE_DIR_NAME, $dir);
|
$this->assertStringEndsWith(Client::DEFAULT_CACHE_STORE_DIR_NAME, $dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试不支持的通道标识会抛出异常。
|
||||||
|
*/
|
||||||
public function testGetThrowsWhenChannelUnsupported(): void
|
public function testGetThrowsWhenChannelUnsupported(): void
|
||||||
{
|
{
|
||||||
$client = new Client();
|
$client = new Client();
|
||||||
@ -41,6 +57,9 @@ final class ClientTest extends TestCase
|
|||||||
$client->get('unknown.channel', new WechatPlatformConfig('wx_x', 'sec'));
|
$client->get('unknown.channel', new WechatPlatformConfig('wx_x', 'sec'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试通道配置类型不匹配会抛出异常。
|
||||||
|
*/
|
||||||
public function testGetThrowsWhenConfigMismatch(): void
|
public function testGetThrowsWhenConfigMismatch(): void
|
||||||
{
|
{
|
||||||
$client = new Client();
|
$client = new Client();
|
||||||
@ -50,6 +69,9 @@ final class ClientTest extends TestCase
|
|||||||
$client->get('wechat.platform', new WechatServiceConfig('app', 'sec', 'token', 'encoding'));
|
$client->get('wechat.platform', new WechatServiceConfig('app', 'sec', 'token', 'encoding'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试微信公众平台工厂返回正确客户端。
|
||||||
|
*/
|
||||||
public function testWechatPlatformFactoryReturnsTypedClient(): void
|
public function testWechatPlatformFactoryReturnsTypedClient(): void
|
||||||
{
|
{
|
||||||
$client = new Client();
|
$client = new Client();
|
||||||
@ -58,7 +80,30 @@ final class ClientTest extends TestCase
|
|||||||
$this->assertInstanceOf(WechatPlatformClient::class, $wechat);
|
$this->assertInstanceOf(WechatPlatformClient::class, $wechat);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testServiceClientAuthorizationUrlStillAvailable(): void
|
/**
|
||||||
|
* 测试微信公众平台可生成 open.weixin.qq.com 网页授权地址。
|
||||||
|
*/
|
||||||
|
public function testWechatPlatformBuildsOpenAuthorizeUrl(): void
|
||||||
|
{
|
||||||
|
$client = new Client();
|
||||||
|
$wechat = $client->wechatPlatform(new WechatPlatformConfig('wx_appid', 'app_secret'));
|
||||||
|
$result = $wechat->get('connect/oauth2/authorize', [
|
||||||
|
'redirect_uri' => 'https://example.com/wechat/callback',
|
||||||
|
'scope' => 'snsapi_userinfo',
|
||||||
|
'state' => 'S1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('url', $result);
|
||||||
|
$this->assertStringStartsWith('https://open.weixin.qq.com/connect/oauth2/authorize?', (string)$result['url']);
|
||||||
|
$this->assertStringContainsString('appid=wx_appid', (string)$result['url']);
|
||||||
|
$this->assertStringContainsString('scope=snsapi_userinfo', (string)$result['url']);
|
||||||
|
$this->assertStringEndsWith('#wechat_redirect', (string)$result['url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试微信服务平台工厂返回正确客户端,并保留授权地址生成能力。
|
||||||
|
*/
|
||||||
|
public function testWechatServiceFactoryReturnsTypedClientAndBuildsAuthorizationUrl(): void
|
||||||
{
|
{
|
||||||
$client = new Client();
|
$client = new Client();
|
||||||
$service = $client->wechatService(new WechatServiceConfig(
|
$service = $client->wechatService(new WechatServiceConfig(
|
||||||
@ -67,12 +112,17 @@ final class ClientTest extends TestCase
|
|||||||
'component_token',
|
'component_token',
|
||||||
'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'
|
'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'
|
||||||
));
|
));
|
||||||
$result = $service->authorizationUrl('preauthcode', 'https://example.com/callback', 3, 'STATE_TEST');
|
$url = $service->authorizationUrl('preauthcode', 'https://example.com/callback', 3, 'STATE_TEST');
|
||||||
|
|
||||||
$this->assertStringContainsString('componentloginpage', $result);
|
$this->assertInstanceOf(WechatServiceClient::class, $service);
|
||||||
$this->assertStringContainsString('pre_auth_code=preauthcode', $result);
|
$this->assertStringContainsString('componentloginpage', $url);
|
||||||
|
$this->assertStringContainsString('pre_auth_code=preauthcode', $url);
|
||||||
|
$this->assertStringContainsString('state=STATE_TEST', $url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试支付宝授权调用返回跳转地址。
|
||||||
|
*/
|
||||||
public function testAlipayPlatformCallReturnsAuthorizationUrl(): void
|
public function testAlipayPlatformCallReturnsAuthorizationUrl(): void
|
||||||
{
|
{
|
||||||
$client = new Client();
|
$client = new Client();
|
||||||
@ -87,6 +137,9 @@ final class ClientTest extends TestCase
|
|||||||
$this->assertStringContainsString('state=S2', (string)$result['url']);
|
$this->assertStringContainsString('state=S2', (string)$result['url']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试按通道字符串创建指定客户端。
|
||||||
|
*/
|
||||||
public function testGetCanReturnSpecificChannelClient(): void
|
public function testGetCanReturnSpecificChannelClient(): void
|
||||||
{
|
{
|
||||||
$client = new Client();
|
$client = new Client();
|
||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台配置契约与配置对象测试。
|
||||||
|
*/
|
||||||
|
|
||||||
namespace We\Tests;
|
namespace We\Tests;
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
@ -15,14 +19,20 @@ use We\Config\WechatWxappConfig;
|
|||||||
use We\Contract\ConfigInterface;
|
use We\Contract\ConfigInterface;
|
||||||
use We\Exception\WechatException;
|
use We\Exception\WechatException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台配置契约与配置对象测试用例。
|
||||||
|
*/
|
||||||
#[CoversClass(ConfigInterface::class)]
|
#[CoversClass(ConfigInterface::class)]
|
||||||
final class ConfigInterfaceTest extends TestCase
|
final class ConfigInterfaceTest extends TestCase
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 测试所有配置对象都实现配置契约。
|
||||||
|
*/
|
||||||
public function testAllConfigClassesImplementContract(): void
|
public function testAllConfigClassesImplementContract(): void
|
||||||
{
|
{
|
||||||
foreach ([
|
foreach ([
|
||||||
new WechatPlatformConfig('wx_app', 'secret'),
|
new WechatPlatformConfig('wx_app', 'secret'),
|
||||||
new WechatWxappConfig('wx_mini', 'secret'),
|
new WechatWxappConfig('wx_wxapp', 'secret'),
|
||||||
new WechatServiceConfig('wx_component', 'secret', 'token', 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'),
|
new WechatServiceConfig('wx_component', 'secret', 'token', 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'),
|
||||||
new WechatPaymentConfig('wx_app', 'mch', str_repeat('k', 32), 'serial', 'private-key'),
|
new WechatPaymentConfig('wx_app', 'mch', str_repeat('k', 32), 'serial', 'private-key'),
|
||||||
new AlipayPlatformConfig('ali_app', 'private-key'),
|
new AlipayPlatformConfig('ali_app', 'private-key'),
|
||||||
@ -32,6 +42,9 @@ final class ConfigInterfaceTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试构造配置对象时会校验必填字段。
|
||||||
|
*/
|
||||||
public function testConfigValidateRequiredFieldsOnConstruct(): void
|
public function testConfigValidateRequiredFieldsOnConstruct(): void
|
||||||
{
|
{
|
||||||
$this->expectException(WechatException::class);
|
$this->expectException(WechatException::class);
|
||||||
@ -40,6 +53,9 @@ final class ConfigInterfaceTest extends TestCase
|
|||||||
new WechatPlatformConfig('', 'secret');
|
new WechatPlatformConfig('', 'secret');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试通过数组构造配置对象时会校验必填字段。
|
||||||
|
*/
|
||||||
public function testConfigValidateRequiredFieldsFromArray(): void
|
public function testConfigValidateRequiredFieldsFromArray(): void
|
||||||
{
|
{
|
||||||
$this->expectException(WechatException::class);
|
$this->expectException(WechatException::class);
|
||||||
@ -48,6 +64,9 @@ final class ConfigInterfaceTest extends TestCase
|
|||||||
WechatPaymentConfig::fromArray(['appid' => 'wx_app']);
|
WechatPaymentConfig::fromArray(['appid' => 'wx_app']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试支付宝支付配置 fromArray 返回子类实例。
|
||||||
|
*/
|
||||||
public function testAlipayPaymentFromArrayReturnsChildClass(): void
|
public function testAlipayPaymentFromArrayReturnsChildClass(): void
|
||||||
{
|
{
|
||||||
$config = AlipayPaymentConfig::fromArray([
|
$config = AlipayPaymentConfig::fromArray([
|
||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON HTTP 客户端安全约束测试。
|
||||||
|
*/
|
||||||
|
|
||||||
namespace We\Tests;
|
namespace We\Tests;
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
@ -9,9 +13,15 @@ use PHPUnit\Framework\TestCase;
|
|||||||
use We\Exception\ApiException;
|
use We\Exception\ApiException;
|
||||||
use We\Support\JsonClient;
|
use We\Support\JsonClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON HTTP 客户端安全约束测试用例。
|
||||||
|
*/
|
||||||
#[CoversClass(JsonClient::class)]
|
#[CoversClass(JsonClient::class)]
|
||||||
final class JsonClientTest extends TestCase
|
final class JsonClientTest extends TestCase
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 测试 JSON 客户端拒绝绝对 URL。
|
||||||
|
*/
|
||||||
public function testSendRejectsAbsoluteUri(): void
|
public function testSendRejectsAbsoluteUri(): void
|
||||||
{
|
{
|
||||||
$client = new JsonClient();
|
$client = new JsonClient();
|
||||||
@ -22,6 +32,9 @@ final class JsonClientTest extends TestCase
|
|||||||
$client->send('GET', 'https://example.com/evil');
|
$client->send('GET', 'https://example.com/evil');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 JSON 客户端拒绝网络路径 URL。
|
||||||
|
*/
|
||||||
public function testSendRejectsNetworkPathUri(): void
|
public function testSendRejectsNetworkPathUri(): void
|
||||||
{
|
{
|
||||||
$client = new JsonClient();
|
$client = new JsonClient();
|
||||||
126
tests/MessageCryptoTest.php
Normal file
126
tests/MessageCryptoTest.php
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信消息安全模式加解密测试。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace We\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use We\Exception\SignatureException;
|
||||||
|
use We\Exception\WechatException;
|
||||||
|
use We\Support\MessageCrypto;
|
||||||
|
use We\Support\Signature;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信消息安全模式加解密测试用例。
|
||||||
|
*/
|
||||||
|
#[CoversClass(MessageCrypto::class)]
|
||||||
|
final class MessageCryptoTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 测试微信消息加密后可以正确解密。
|
||||||
|
*/
|
||||||
|
public function testEncryptAndDecryptMessage(): void
|
||||||
|
{
|
||||||
|
$key = substr(base64_encode(str_repeat('a', 32)), 0, 43);
|
||||||
|
$crypto = new MessageCrypto('token', $key, 'wx1234567890');
|
||||||
|
$xml = '<xml><ToUserName><![CDATA[to]]></ToUserName><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[hello]]></Content></xml>';
|
||||||
|
$timestamp = '1777600000';
|
||||||
|
$nonce = 'nonce';
|
||||||
|
|
||||||
|
$encrypted = $crypto->encryptMessage($xml, $timestamp, $nonce);
|
||||||
|
preg_match('/<Encrypt><!\[CDATA\[(.*?)\]\]><\/Encrypt>/', $encrypted, $matches);
|
||||||
|
$this->assertNotEmpty($matches[1] ?? '');
|
||||||
|
$signature = Signature::sha1(['token', $timestamp, $nonce, $matches[1]]);
|
||||||
|
|
||||||
|
$plain = $crypto->decryptMessage($encrypted, $signature, $timestamp, $nonce);
|
||||||
|
|
||||||
|
$this->assertSame('text', $plain['MsgType']);
|
||||||
|
$this->assertSame('hello', $plain['Content']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试微信消息密文 Base64 无效时抛出异常。
|
||||||
|
*/
|
||||||
|
public function testDecryptRejectsInvalidBase64Cipher(): void
|
||||||
|
{
|
||||||
|
$crypto = new MessageCrypto('token', self::encodingAesKey(), 'wx1234567890');
|
||||||
|
$timestamp = '1777600000';
|
||||||
|
$nonce = 'nonce';
|
||||||
|
$encrypt = 'not@@base64';
|
||||||
|
$xml = '<xml><Encrypt><![CDATA[' . $encrypt . ']]></Encrypt></xml>';
|
||||||
|
$signature = Signature::sha1(['token', $timestamp, $nonce, $encrypt]);
|
||||||
|
|
||||||
|
$this->expectException(WechatException::class);
|
||||||
|
$this->expectExceptionMessage('Base64');
|
||||||
|
|
||||||
|
$crypto->decryptMessage($xml, $signature, $timestamp, $nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试微信消息填充无效时抛出异常。
|
||||||
|
*/
|
||||||
|
public function testDecryptRejectsInvalidPadding(): void
|
||||||
|
{
|
||||||
|
$key = self::encodingAesKey();
|
||||||
|
$crypto = new MessageCrypto('token', $key, 'wx1234567890');
|
||||||
|
$xml = '<xml><MsgType><![CDATA[text]]></MsgType></xml>';
|
||||||
|
$timestamp = '1777600000';
|
||||||
|
$nonce = 'nonce';
|
||||||
|
$encrypt = self::encryptRawWithInvalidPadding($key, $xml, 'wx1234567890');
|
||||||
|
$signature = Signature::sha1(['token', $timestamp, $nonce, $encrypt]);
|
||||||
|
|
||||||
|
$this->expectException(WechatException::class);
|
||||||
|
$this->expectExceptionMessage('填充');
|
||||||
|
|
||||||
|
$crypto->decryptMessage('<xml><Encrypt><![CDATA[' . $encrypt . ']]></Encrypt></xml>', $signature, $timestamp, $nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试微信消息 AppID 不匹配时抛出签名异常。
|
||||||
|
*/
|
||||||
|
public function testDecryptRejectsAppidMismatch(): void
|
||||||
|
{
|
||||||
|
$key = self::encodingAesKey();
|
||||||
|
$encryptor = new MessageCrypto('token', $key, 'wx_source');
|
||||||
|
$decryptor = new MessageCrypto('token', $key, 'wx_other');
|
||||||
|
$timestamp = '1777600000';
|
||||||
|
$nonce = 'nonce';
|
||||||
|
$encrypted = $encryptor->encryptMessage('<xml><MsgType><![CDATA[text]]></MsgType></xml>', $timestamp, $nonce);
|
||||||
|
preg_match('/<Encrypt><!\[CDATA\[(.*?)\]\]><\/Encrypt>/', $encrypted, $matches);
|
||||||
|
$signature = Signature::sha1(['token', $timestamp, $nonce, $matches[1] ?? '']);
|
||||||
|
|
||||||
|
$this->expectException(SignatureException::class);
|
||||||
|
$this->expectExceptionMessage('AppID');
|
||||||
|
|
||||||
|
$decryptor->decryptMessage($encrypted, $signature, $timestamp, $nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成测试使用的 EncodingAESKey。
|
||||||
|
*/
|
||||||
|
private static function encodingAesKey(): string
|
||||||
|
{
|
||||||
|
return substr(base64_encode(str_repeat('a', 32)), 0, 43);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造填充无效的加密消息密文。
|
||||||
|
*/
|
||||||
|
private static function encryptRawWithInvalidPadding(string $encodingAesKey, string $xml, string $appid): string
|
||||||
|
{
|
||||||
|
$aesKey = base64_decode($encodingAesKey . '=', true);
|
||||||
|
self::assertIsString($aesKey);
|
||||||
|
$payload = str_repeat('r', 16) . pack('N', strlen($xml)) . $xml . $appid;
|
||||||
|
$pad = 32 - strlen($payload) % 32;
|
||||||
|
$payload .= str_repeat("\0", $pad);
|
||||||
|
$cipher = openssl_encrypt($payload, 'AES-256-CBC', $aesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, substr($aesKey, 0, 16));
|
||||||
|
self::assertIsString($cipher);
|
||||||
|
|
||||||
|
return base64_encode($cipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,18 +2,28 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付 APIv3 通知验签与解密测试。
|
||||||
|
*/
|
||||||
|
|
||||||
namespace We\Tests;
|
namespace We\Tests;
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use We\Config\WechatPaymentConfig;
|
use We\Config\WechatPaymentConfig;
|
||||||
use We\Exception\SignatureException;
|
use We\Exception\SignatureException;
|
||||||
use We\Platform\Wechat\PaymentClient;
|
use We\Platform\Wechat\PaymentClient as WechatPaymentClient;
|
||||||
use We\Support\Signature;
|
use We\Support\Signature;
|
||||||
|
|
||||||
#[CoversClass(PaymentClient::class)]
|
/**
|
||||||
|
* 微信支付 APIv3 通知验签与解密测试用例。
|
||||||
|
*/
|
||||||
|
#[CoversClass(WechatPaymentClient::class)]
|
||||||
final class PaymentClientTest extends TestCase
|
final class PaymentClientTest extends TestCase
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 测试微信支付回调验签使用原始请求体。
|
||||||
|
*/
|
||||||
public function testDecryptNotificationUsesRawBodyForSignature(): void
|
public function testDecryptNotificationUsesRawBodyForSignature(): void
|
||||||
{
|
{
|
||||||
[$platformPrivateKey, $platformPublicKey] = self::keyPair();
|
[$platformPrivateKey, $platformPublicKey] = self::keyPair();
|
||||||
@ -33,7 +43,7 @@ final class PaymentClientTest extends TestCase
|
|||||||
'Wechatpay-Serial' => 'platform-serial',
|
'Wechatpay-Serial' => 'platform-serial',
|
||||||
'Wechatpay-Signature' => Signature::paymentV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"),
|
'Wechatpay-Signature' => Signature::paymentV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"),
|
||||||
];
|
];
|
||||||
$client = new PaymentClient(new WechatPaymentConfig(
|
$client = new WechatPaymentClient(new WechatPaymentConfig(
|
||||||
'wx_app',
|
'wx_app',
|
||||||
'mch_id',
|
'mch_id',
|
||||||
$apiV3Key,
|
$apiV3Key,
|
||||||
@ -44,12 +54,18 @@ final class PaymentClientTest extends TestCase
|
|||||||
'platform-serial',
|
'platform-serial',
|
||||||
));
|
));
|
||||||
|
|
||||||
$data = $client->decryptNotification($headers, $rawBody);
|
$data = $client->post('decrypt_notification', [], [
|
||||||
|
'headers' => $headers,
|
||||||
|
'raw_body' => $rawBody,
|
||||||
|
]);
|
||||||
|
|
||||||
self::assertSame('T202605040001', $data['out_trade_no']);
|
self::assertSame('T202605040001', $data['out_trade_no']);
|
||||||
self::assertSame('SUCCESS', $data['trade_state']);
|
self::assertSame('SUCCESS', $data['trade_state']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试微信支付回调平台序列号不匹配时拒绝处理。
|
||||||
|
*/
|
||||||
public function testDecryptNotificationRejectsPlatformSerialMismatch(): void
|
public function testDecryptNotificationRejectsPlatformSerialMismatch(): void
|
||||||
{
|
{
|
||||||
[$platformPrivateKey, $platformPublicKey] = self::keyPair();
|
[$platformPrivateKey, $platformPublicKey] = self::keyPair();
|
||||||
@ -62,7 +78,7 @@ final class PaymentClientTest extends TestCase
|
|||||||
'Wechatpay-Serial' => 'other-serial',
|
'Wechatpay-Serial' => 'other-serial',
|
||||||
'Wechatpay-Signature' => Signature::paymentV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"),
|
'Wechatpay-Signature' => Signature::paymentV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"),
|
||||||
];
|
];
|
||||||
$client = new PaymentClient(new WechatPaymentConfig(
|
$client = new WechatPaymentClient(new WechatPaymentConfig(
|
||||||
'wx_app',
|
'wx_app',
|
||||||
'mch_id',
|
'mch_id',
|
||||||
str_repeat('k', 32),
|
str_repeat('k', 32),
|
||||||
@ -76,10 +92,15 @@ final class PaymentClientTest extends TestCase
|
|||||||
$this->expectException(SignatureException::class);
|
$this->expectException(SignatureException::class);
|
||||||
$this->expectExceptionMessage('序列号');
|
$this->expectExceptionMessage('序列号');
|
||||||
|
|
||||||
$client->decryptNotification($headers, $rawBody);
|
$client->post('decrypt_notification', [], [
|
||||||
|
'headers' => $headers,
|
||||||
|
'raw_body' => $rawBody,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* 生成测试使用的 RSA 密钥对。
|
||||||
|
*
|
||||||
* @return array{0:string,1:string}
|
* @return array{0:string,1:string}
|
||||||
*/
|
*/
|
||||||
private static function keyPair(): array
|
private static function keyPair(): array
|
||||||
@ -2,15 +2,25 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付 APIv3 通知 resource 解密测试。
|
||||||
|
*/
|
||||||
|
|
||||||
namespace We\Tests;
|
namespace We\Tests;
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use We\Support\PaymentCrypto;
|
use We\Support\PaymentCrypto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付 APIv3 通知 resource 解密测试用例。
|
||||||
|
*/
|
||||||
#[CoversClass(PaymentCrypto::class)]
|
#[CoversClass(PaymentCrypto::class)]
|
||||||
final class PaymentCryptoTest extends TestCase
|
final class PaymentCryptoTest extends TestCase
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 测试微信支付回调资源解密。
|
||||||
|
*/
|
||||||
public function testDecryptResource(): void
|
public function testDecryptResource(): void
|
||||||
{
|
{
|
||||||
$key = str_repeat('k', 32);
|
$key = str_repeat('k', 32);
|
||||||
293
tests/ProtocolClientTest.php
Normal file
293
tests/ProtocolClientTest.php
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协议层原始响应、下载和上传能力测试。
|
||||||
|
*/
|
||||||
|
|
||||||
|
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\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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协议层原始响应、下载和上传能力测试用例。
|
||||||
|
*/
|
||||||
|
#[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->assertSame('merchant-serial', $headers['Wechatpay-Serial']);
|
||||||
|
$this->assertSame('2026-05-08', $http->requests[0]['options']['query']['bill_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试微信服务平台代授权方 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', 'component_token', 'encoding_key'));
|
||||||
|
|
||||||
|
$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 客户端请求接口并记录请求。
|
||||||
|
*/
|
||||||
|
public function request(string $method, $uri = '', array $options = []): ResponseInterface
|
||||||
|
{
|
||||||
|
$this->requests[] = ['method' => $method, 'uri' => $uri, 'options' => $options];
|
||||||
|
|
||||||
|
return $this->nextResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实现测试 HTTP 客户端异步请求接口。
|
||||||
|
*/
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
55
tests/XmlTest.php
Normal file
55
tests/XmlTest.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信 XML 编解码工具测试。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace We\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use We\Support\Xml;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信 XML 编解码工具测试用例。
|
||||||
|
*/
|
||||||
|
#[CoversClass(Xml::class)]
|
||||||
|
final class XmlTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 测试重复节点和嵌套节点可正确解码。
|
||||||
|
*/
|
||||||
|
public function testDecodeKeepsRepeatedAndNestedNodes(): void
|
||||||
|
{
|
||||||
|
$xml = '<xml><Articles><item><Title><![CDATA[A]]></Title></item><item><Title><![CDATA[B]]></Title></item></Articles><Tag><![CDATA[x]]></Tag><Tag><![CDATA[y]]></Tag></xml>';
|
||||||
|
|
||||||
|
$data = Xml::decode($xml);
|
||||||
|
|
||||||
|
$this->assertSame('A', $data['Articles']['item'][0]['Title']);
|
||||||
|
$this->assertSame('B', $data['Articles']['item'][1]['Title']);
|
||||||
|
$this->assertSame(['x', 'y'], $data['Tag']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试嵌套数组、列表节点和包含 CDATA 结束标记的文本可正确编码。
|
||||||
|
*/
|
||||||
|
public function testEncodeSupportsNestedListsAndCdataEndMarker(): void
|
||||||
|
{
|
||||||
|
$xml = Xml::encode([
|
||||||
|
'Articles' => [
|
||||||
|
'item' => [
|
||||||
|
['Title' => 'A', 'Description' => 'hello ]]> world'],
|
||||||
|
['Title' => 'B', 'Description' => 'second'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = Xml::decode($xml);
|
||||||
|
|
||||||
|
$this->assertSame('A', $data['Articles']['item'][0]['Title']);
|
||||||
|
$this->assertSame('hello ]]> world', $data['Articles']['item'][0]['Description']);
|
||||||
|
$this->assertSame('B', $data['Articles']['item'][1]['Title']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHPUnit 测试启动文件。
|
||||||
|
*/
|
||||||
|
|
||||||
$autoloadCandidates = [
|
$autoloadCandidates = [
|
||||||
dirname(__DIR__) . '/vendor/autoload.php',
|
dirname(__DIR__) . '/vendor/autoload.php',
|
||||||
dirname(__DIR__, 2) . '/vendor/autoload.php',
|
dirname(__DIR__, 2) . '/vendor/autoload.php',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user