diff --git a/composer.json b/composer.json index 2623dec..d4028e5 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ }, "autoload-dev": { "psr-4": { - "We\\Tests\\": "src/Tests/" + "We\\Tests\\": "tests/" } }, "scripts": { diff --git a/phpunit.xml b/phpunit.xml index 27534ec..0a91e83 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,15 +8,12 @@ requireCoverageMetadata="false"> - src/Tests + tests src - - src/Tests - diff --git a/src/Tests/CacheKeyTest.php b/src/Tests/CacheKeyTest.php deleted file mode 100644 index 19ce736..0000000 --- a/src/Tests/CacheKeyTest.php +++ /dev/null @@ -1,49 +0,0 @@ -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', ' '); - } -} diff --git a/src/Tests/MessageCryptoTest.php b/src/Tests/MessageCryptoTest.php deleted file mode 100644 index 271ef2f..0000000 --- a/src/Tests/MessageCryptoTest.php +++ /dev/null @@ -1,33 +0,0 @@ -'; - $timestamp = '1777600000'; - $nonce = 'nonce'; - - $encrypted = $crypto->encryptMessage($xml, $timestamp, $nonce); - preg_match('/<\/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']); - } -} diff --git a/src/Tests/AccessTokenCacheTest.php b/tests/AccessTokenCacheTest.php similarity index 74% rename from src/Tests/AccessTokenCacheTest.php rename to tests/AccessTokenCacheTest.php index 9122192..054ca72 100644 --- a/src/Tests/AccessTokenCacheTest.php +++ b/tests/AccessTokenCacheTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +/** + * 微信公众平台 access_token 缓存行为测试。 + */ + namespace We\Tests; use GuzzleHttp\ClientInterface; @@ -15,20 +19,26 @@ use Psr\Http\Message\ResponseInterface; use We\Client; use We\Config\WechatPlatformConfig; use We\Contract\StoreCacheInterface; -use We\Platform\Wechat\PlatformClient; +use We\Platform\Wechat\PlatformClient as WechatPlatformClient; use We\Support\CacheKey; use We\Support\TokenCacheKey; -#[CoversClass(PlatformClient::class)] +/** + * 微信公众平台 access_token 缓存行为测试用例。 + */ +#[CoversClass(WechatPlatformClient::class)] final class AccessTokenCacheTest extends TestCase { + /** + * 测试命中 access_token 缓存时不会发起 HTTP 请求。 + */ public function testAccessTokenCacheHitDoesNotRequestHttp(): void { $cache = new ArrayCacheStore(); $key = CacheKey::compose( Client::DEFAULT_CACHE_KEY_PREFIX, 'wechat.platform', - TokenCacheKey::wechatOfficialAccessToken('wx_app'), + TokenCacheKey::wechatPlatformAccessToken('wx_app'), ); $cache->set($key, 'cached-token', 3600); $http = new FakeHttpClient(['access_token' => 'remote-token', 'expires_in' => 7200]); @@ -42,6 +52,9 @@ final class AccessTokenCacheTest extends TestCase $this->assertSame(0, $cache->lockCalls); } + /** + * 测试刷新 access_token 时会使用锁并写入缓存。 + */ public function testAccessTokenRefreshUsesLockAndWritesCache(): void { $cache = new ArrayCacheStore(); @@ -57,6 +70,9 @@ final class AccessTokenCacheTest extends TestCase } } +/** + * 测试用 StoreCacheInterface 内存实现。 + */ final class ArrayCacheStore implements StoreCacheInterface { /** @var array */ @@ -64,21 +80,33 @@ final class ArrayCacheStore implements StoreCacheInterface public int $lockCalls = 0; + /** + * 读取测试缓存值。 + */ 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 { ++$this->lockCalls; @@ -87,25 +115,39 @@ final class ArrayCacheStore implements StoreCacheInterface } } +/** + * 返回固定 JSON 响应的测试 HTTP 客户端。 + */ final class FakeHttpClient implements ClientInterface { public int $requests = 0; /** + * 创建固定响应测试 HTTP 客户端。 + * * @param array $payload */ public function __construct(private readonly array $payload) {} + /** + * 实现测试 HTTP 客户端同步发送接口。 + */ public function send(RequestInterface $request, array $options = []): ResponseInterface { return $this->response(); } + /** + * 实现测试 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; @@ -113,16 +155,25 @@ final class FakeHttpClient implements ClientInterface return $this->response(); } + /** + * 实现测试 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; } + /** + * 构造测试 HTTP 响应对象。 + */ private function response(): ResponseInterface { return new Response(200, [], json_encode($this->payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); diff --git a/src/Tests/AlipayPlatformClientTest.php b/tests/AlipayPlatformClientTest.php similarity index 74% rename from src/Tests/AlipayPlatformClientTest.php rename to tests/AlipayPlatformClientTest.php index 5d6418c..165ef3b 100644 --- a/src/Tests/AlipayPlatformClientTest.php +++ b/tests/AlipayPlatformClientTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +/** + * 支付宝开放平台网关调用与验签测试。 + */ + namespace We\Tests; use GuzzleHttp\ClientInterface; @@ -13,17 +17,23 @@ use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; 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 { + /** + * 测试配置支付宝公钥时会校验同步响应签名。 + */ public function testRequestVerifiesSignedResponseWhenPublicKeyConfigured(): void { [$privateKey, $publicKey] = self::keyPair(); $responseNode = '{"code":"10000","msg":"Success","trade_no":"TRADE202605040001"}'; $body = '{"alipay_trade_query_response":' . $responseNode . ',"sign":"' . self::sign($responseNode, $privateKey) . '"}'; - $client = new PlatformClient( + $client = new AlipayPlatformClient( new AlipayPlatformConfig('ali_app', $privateKey, $publicKey), new AlipayFakeHttpClient($body), ); @@ -33,6 +43,9 @@ final class AlipayPlatformClientTest extends TestCase self::assertSame('TRADE202605040001', $data['trade_no']); } + /** + * 测试支付宝异步通知验签。 + */ public function testVerifyNotify(): void { [$privateKey, $publicKey] = self::keyPair(); @@ -44,12 +57,14 @@ final class AlipayPlatformClientTest extends TestCase '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); - $client = new PlatformClient(new AlipayPlatformConfig('ali_app', $privateKey, $publicKey)); + $client = new AlipayPlatformClient(new AlipayPlatformConfig('ali_app', $privateKey, $publicKey)); self::assertTrue($client->verifyNotify($params)); } /** + * 生成测试使用的 RSA 密钥对。 + * * @return array{0:string,1:string} */ private static function keyPair(): array @@ -63,6 +78,9 @@ final class AlipayPlatformClientTest extends TestCase return [$privateKey, (string)$details['key']]; } + /** + * 使用测试私钥生成签名。 + */ private static function sign(string $source, string $privateKey): string { $ok = openssl_sign($source, $signature, $privateKey, OPENSSL_ALGO_SHA256); @@ -72,35 +90,59 @@ final class AlipayPlatformClientTest extends TestCase } } +/** + * 返回固定网关响应的支付宝测试 HTTP 客户端。 + */ final class AlipayFakeHttpClient implements ClientInterface { + /** + * 创建固定响应测试 HTTP 客户端。 + */ public function __construct(private readonly string $body) {} + /** + * 实现测试 HTTP 客户端同步发送接口。 + */ public function send(RequestInterface $request, array $options = []): ResponseInterface { return $this->response(); } + /** + * 实现测试 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 { return $this->response(); } + /** + * 实现测试 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; } + /** + * 构造测试 HTTP 响应对象。 + */ private function response(): ResponseInterface { return new Response(200, [], $this->body); diff --git a/tests/CacheKeyTest.php b/tests/CacheKeyTest.php new file mode 100644 index 0000000..9ccc74a --- /dev/null +++ b/tests/CacheKeyTest.php @@ -0,0 +1,94 @@ +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', ' '); + } +} diff --git a/src/Tests/CacheStoreTest.php b/tests/CacheStoreTest.php similarity index 80% rename from src/Tests/CacheStoreTest.php rename to tests/CacheStoreTest.php index b54973b..cf31a0e 100644 --- a/src/Tests/CacheStoreTest.php +++ b/tests/CacheStoreTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +/** + * SDK 缓存存储实现测试。 + */ + namespace We\Tests; use DateInterval; @@ -13,11 +17,17 @@ use We\Support\FileCacheStore; use We\Support\NullCacheStore; use We\Support\PsrSimpleCacheStore; +/** + * SDK 缓存存储实现测试用例。 + */ #[CoversClass(FileCacheStore::class)] #[CoversClass(NullCacheStore::class)] #[CoversClass(PsrSimpleCacheStore::class)] final class CacheStoreTest extends TestCase { + /** + * 测试文件缓存的写入、读取、删除与过期行为。 + */ public function testFileCacheStoreSetGetDelAndTtl(): void { $dir = $this->tempDir(); @@ -36,6 +46,9 @@ final class CacheStoreTest extends TestCase $this->removeDir($dir); } + /** + * 测试文件缓存锁会执行回调。 + */ public function testFileCacheStoreLockExecutesCallback(): void { $dir = $this->tempDir(); @@ -47,6 +60,9 @@ final class CacheStoreTest extends TestCase $this->removeDir($dir); } + /** + * 测试空缓存锁会直接执行回调。 + */ public function testNullCacheStoreLockExecutesCallback(): void { $store = new NullCacheStore(); @@ -54,6 +70,9 @@ final class CacheStoreTest extends TestCase $this->assertSame('ok', $store->lock('k', 10, static fn (): string => 'ok')); } + /** + * 测试 PSR 缓存未配置锁能力时抛出异常。 + */ public function testPsrSimpleCacheStoreThrowsWhenLockerMissing(): void { $store = new PsrSimpleCacheStore(new ArraySimpleCache()); @@ -64,6 +83,9 @@ final class CacheStoreTest extends TestCase $store->lock('k', 10, static fn (): string => 'never'); } + /** + * 测试 PSR 缓存会使用注入的锁回调。 + */ public function testPsrSimpleCacheStoreUsesInjectedLocker(): void { $calls = []; @@ -77,11 +99,17 @@ final class CacheStoreTest extends TestCase $this->assertSame([['k', 10]], $calls); } + /** + * 创建本次测试使用的临时目录路径。 + */ private function tempDir(): string { return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'wechatdev_cache_' . bin2hex(random_bytes(8)); } + /** + * 递归删除测试临时目录。 + */ private function removeDir(string $dir): void { if (!is_dir($dir)) { @@ -99,16 +127,25 @@ final class CacheStoreTest extends TestCase } } +/** + * 测试用 PSR-16 内存缓存实现。 + */ final class ArraySimpleCache implements CacheInterface { /** @var array */ private array $values = []; + /** + * 读取测试缓存值。 + */ public function get(string $key, mixed $default = null): mixed { return $this->values[$key] ?? $default; } + /** + * 写入缓存值。 + */ public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool { $this->values[$key] = $value; @@ -116,6 +153,9 @@ final class ArraySimpleCache implements CacheInterface return true; } + /** + * 删除测试缓存值。 + */ public function delete(string $key): bool { unset($this->values[$key]); @@ -123,6 +163,9 @@ final class ArraySimpleCache implements CacheInterface return true; } + /** + * 清空测试缓存。 + */ public function clear(): bool { $this->values = []; @@ -130,6 +173,9 @@ final class ArraySimpleCache implements CacheInterface return true; } + /** + * 批量读取测试缓存值。 + */ public function getMultiple(iterable $keys, mixed $default = null): iterable { foreach ($keys as $key) { @@ -137,6 +183,9 @@ final class ArraySimpleCache implements CacheInterface } } + /** + * 批量写入测试缓存值。 + */ public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool { foreach ($values as $key => $value) { @@ -146,6 +195,9 @@ final class ArraySimpleCache implements CacheInterface return true; } + /** + * 批量删除测试缓存值。 + */ public function deleteMultiple(iterable $keys): bool { foreach ($keys as $key) { @@ -155,6 +207,9 @@ final class ArraySimpleCache implements CacheInterface return true; } + /** + * 判断测试缓存键是否存在。 + */ public function has(string $key): bool { return array_key_exists($key, $this->values); diff --git a/src/Tests/ClientTest.php b/tests/ClientTest.php similarity index 62% rename from src/Tests/ClientTest.php rename to tests/ClientTest.php index f693db7..87bc140 100644 --- a/src/Tests/ClientTest.php +++ b/tests/ClientTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +/** + * SDK 根入口通道工厂测试。 + */ + namespace We\Tests; 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\ServiceClient as WechatServiceClient; +/** + * SDK 根入口通道工厂测试用例。 + */ #[CoversClass(Client::class)] final class ClientTest extends TestCase { + /** + * 测试根客户端缓存前缀为空时抛出异常。 + */ public function testConstructorThrowsWhenCacheKeyPrefixEmpty(): void { $this->expectException(WechatException::class); @@ -25,6 +35,9 @@ final class ClientTest extends TestCase new Client(cacheKeyPrefix: ' '); } + /** + * 测试默认缓存目录位于系统临时目录下。 + */ public function testDefaultCacheStoreDirectoryUnderSysTemp(): void { $dir = Client::defaultCacheStoreDirectory(); @@ -32,6 +45,9 @@ final class ClientTest extends TestCase $this->assertStringEndsWith(Client::DEFAULT_CACHE_STORE_DIR_NAME, $dir); } + /** + * 测试不支持的通道标识会抛出异常。 + */ public function testGetThrowsWhenChannelUnsupported(): void { $client = new Client(); @@ -41,6 +57,9 @@ final class ClientTest extends TestCase $client->get('unknown.channel', new WechatPlatformConfig('wx_x', 'sec')); } + /** + * 测试通道配置类型不匹配会抛出异常。 + */ public function testGetThrowsWhenConfigMismatch(): void { $client = new Client(); @@ -50,6 +69,9 @@ final class ClientTest extends TestCase $client->get('wechat.platform', new WechatServiceConfig('app', 'sec', 'token', 'encoding')); } + /** + * 测试微信公众平台工厂返回正确客户端。 + */ public function testWechatPlatformFactoryReturnsTypedClient(): void { $client = new Client(); @@ -58,7 +80,30 @@ final class ClientTest extends TestCase $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(); $service = $client->wechatService(new WechatServiceConfig( @@ -67,12 +112,17 @@ final class ClientTest extends TestCase 'component_token', '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->assertStringContainsString('pre_auth_code=preauthcode', $result); + $this->assertInstanceOf(WechatServiceClient::class, $service); + $this->assertStringContainsString('componentloginpage', $url); + $this->assertStringContainsString('pre_auth_code=preauthcode', $url); + $this->assertStringContainsString('state=STATE_TEST', $url); } + /** + * 测试支付宝授权调用返回跳转地址。 + */ public function testAlipayPlatformCallReturnsAuthorizationUrl(): void { $client = new Client(); @@ -87,6 +137,9 @@ final class ClientTest extends TestCase $this->assertStringContainsString('state=S2', (string)$result['url']); } + /** + * 测试按通道字符串创建指定客户端。 + */ public function testGetCanReturnSpecificChannelClient(): void { $client = new Client(); diff --git a/src/Tests/ConfigInterfaceTest.php b/tests/ConfigInterfaceTest.php similarity index 79% rename from src/Tests/ConfigInterfaceTest.php rename to tests/ConfigInterfaceTest.php index e3d4abc..d25d4a7 100644 --- a/src/Tests/ConfigInterfaceTest.php +++ b/tests/ConfigInterfaceTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +/** + * 平台配置契约与配置对象测试。 + */ + namespace We\Tests; use PHPUnit\Framework\Attributes\CoversClass; @@ -15,14 +19,20 @@ use We\Config\WechatWxappConfig; use We\Contract\ConfigInterface; use We\Exception\WechatException; +/** + * 平台配置契约与配置对象测试用例。 + */ #[CoversClass(ConfigInterface::class)] final class ConfigInterfaceTest extends TestCase { + /** + * 测试所有配置对象都实现配置契约。 + */ public function testAllConfigClassesImplementContract(): void { foreach ([ new WechatPlatformConfig('wx_app', 'secret'), - new WechatWxappConfig('wx_mini', 'secret'), + new WechatWxappConfig('wx_wxapp', 'secret'), new WechatServiceConfig('wx_component', 'secret', 'token', 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'), new WechatPaymentConfig('wx_app', 'mch', str_repeat('k', 32), 'serial', 'private-key'), new AlipayPlatformConfig('ali_app', 'private-key'), @@ -32,6 +42,9 @@ final class ConfigInterfaceTest extends TestCase } } + /** + * 测试构造配置对象时会校验必填字段。 + */ public function testConfigValidateRequiredFieldsOnConstruct(): void { $this->expectException(WechatException::class); @@ -40,6 +53,9 @@ final class ConfigInterfaceTest extends TestCase new WechatPlatformConfig('', 'secret'); } + /** + * 测试通过数组构造配置对象时会校验必填字段。 + */ public function testConfigValidateRequiredFieldsFromArray(): void { $this->expectException(WechatException::class); @@ -48,6 +64,9 @@ final class ConfigInterfaceTest extends TestCase WechatPaymentConfig::fromArray(['appid' => 'wx_app']); } + /** + * 测试支付宝支付配置 fromArray 返回子类实例。 + */ public function testAlipayPaymentFromArrayReturnsChildClass(): void { $config = AlipayPaymentConfig::fromArray([ diff --git a/src/Tests/JsonClientTest.php b/tests/JsonClientTest.php similarity index 76% rename from src/Tests/JsonClientTest.php rename to tests/JsonClientTest.php index 223f141..f1f20e7 100644 --- a/src/Tests/JsonClientTest.php +++ b/tests/JsonClientTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +/** + * JSON HTTP 客户端安全约束测试。 + */ + namespace We\Tests; use PHPUnit\Framework\Attributes\CoversClass; @@ -9,9 +13,15 @@ use PHPUnit\Framework\TestCase; use We\Exception\ApiException; use We\Support\JsonClient; +/** + * JSON HTTP 客户端安全约束测试用例。 + */ #[CoversClass(JsonClient::class)] final class JsonClientTest extends TestCase { + /** + * 测试 JSON 客户端拒绝绝对 URL。 + */ public function testSendRejectsAbsoluteUri(): void { $client = new JsonClient(); @@ -22,6 +32,9 @@ final class JsonClientTest extends TestCase $client->send('GET', 'https://example.com/evil'); } + /** + * 测试 JSON 客户端拒绝网络路径 URL。 + */ public function testSendRejectsNetworkPathUri(): void { $client = new JsonClient(); diff --git a/tests/MessageCryptoTest.php b/tests/MessageCryptoTest.php new file mode 100644 index 0000000..aec0a9a --- /dev/null +++ b/tests/MessageCryptoTest.php @@ -0,0 +1,126 @@ +'; + $timestamp = '1777600000'; + $nonce = 'nonce'; + + $encrypted = $crypto->encryptMessage($xml, $timestamp, $nonce); + preg_match('/<\/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 = ''; + $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 = ''; + $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('', $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('', $timestamp, $nonce); + preg_match('/<\/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); + } +} diff --git a/src/Tests/PaymentClientTest.php b/tests/PaymentClientTest.php similarity index 78% rename from src/Tests/PaymentClientTest.php rename to tests/PaymentClientTest.php index 7a3971c..8cf91c8 100644 --- a/src/Tests/PaymentClientTest.php +++ b/tests/PaymentClientTest.php @@ -2,18 +2,28 @@ declare(strict_types=1); +/** + * 微信支付 APIv3 通知验签与解密测试。 + */ + namespace We\Tests; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use We\Config\WechatPaymentConfig; use We\Exception\SignatureException; -use We\Platform\Wechat\PaymentClient; +use We\Platform\Wechat\PaymentClient as WechatPaymentClient; use We\Support\Signature; -#[CoversClass(PaymentClient::class)] +/** + * 微信支付 APIv3 通知验签与解密测试用例。 + */ +#[CoversClass(WechatPaymentClient::class)] final class PaymentClientTest extends TestCase { + /** + * 测试微信支付回调验签使用原始请求体。 + */ public function testDecryptNotificationUsesRawBodyForSignature(): void { [$platformPrivateKey, $platformPublicKey] = self::keyPair(); @@ -33,7 +43,7 @@ final class PaymentClientTest extends TestCase 'Wechatpay-Serial' => 'platform-serial', 'Wechatpay-Signature' => Signature::paymentV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"), ]; - $client = new PaymentClient(new WechatPaymentConfig( + $client = new WechatPaymentClient(new WechatPaymentConfig( 'wx_app', 'mch_id', $apiV3Key, @@ -44,12 +54,18 @@ final class PaymentClientTest extends TestCase '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('SUCCESS', $data['trade_state']); } + /** + * 测试微信支付回调平台序列号不匹配时拒绝处理。 + */ public function testDecryptNotificationRejectsPlatformSerialMismatch(): void { [$platformPrivateKey, $platformPublicKey] = self::keyPair(); @@ -62,7 +78,7 @@ final class PaymentClientTest extends TestCase 'Wechatpay-Serial' => 'other-serial', 'Wechatpay-Signature' => Signature::paymentV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"), ]; - $client = new PaymentClient(new WechatPaymentConfig( + $client = new WechatPaymentClient(new WechatPaymentConfig( 'wx_app', 'mch_id', str_repeat('k', 32), @@ -76,10 +92,15 @@ final class PaymentClientTest extends TestCase $this->expectException(SignatureException::class); $this->expectExceptionMessage('序列号'); - $client->decryptNotification($headers, $rawBody); + $client->post('decrypt_notification', [], [ + 'headers' => $headers, + 'raw_body' => $rawBody, + ]); } /** + * 生成测试使用的 RSA 密钥对。 + * * @return array{0:string,1:string} */ private static function keyPair(): array diff --git a/src/Tests/PaymentCryptoTest.php b/tests/PaymentCryptoTest.php similarity index 82% rename from src/Tests/PaymentCryptoTest.php rename to tests/PaymentCryptoTest.php index a65fba6..3f6e810 100644 --- a/src/Tests/PaymentCryptoTest.php +++ b/tests/PaymentCryptoTest.php @@ -2,15 +2,25 @@ declare(strict_types=1); +/** + * 微信支付 APIv3 通知 resource 解密测试。 + */ + namespace We\Tests; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use We\Support\PaymentCrypto; +/** + * 微信支付 APIv3 通知 resource 解密测试用例。 + */ #[CoversClass(PaymentCrypto::class)] final class PaymentCryptoTest extends TestCase { + /** + * 测试微信支付回调资源解密。 + */ public function testDecryptResource(): void { $key = str_repeat('k', 32); diff --git a/tests/ProtocolClientTest.php b/tests/ProtocolClientTest.php new file mode 100644 index 0000000..37f961d --- /dev/null +++ b/tests/ProtocolClientTest.php @@ -0,0 +1,293 @@ + '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}> */ + public array $requests = []; + + /** + * 创建协议层测试 HTTP 客户端。 + * + * @param array $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 */ + 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 $payload + */ + public function saveAuthorizerToken(string $authorizerAppid, array $payload): void {} +} diff --git a/tests/XmlTest.php b/tests/XmlTest.php new file mode 100644 index 0000000..4974c56 --- /dev/null +++ b/tests/XmlTest.php @@ -0,0 +1,55 @@ +<![CDATA[A]]><![CDATA[B]]>'; + + $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']); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 40963a2..861a38e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,6 +2,10 @@ declare(strict_types=1); +/** + * PHPUnit 测试启动文件。 + */ + $autoloadCandidates = [ dirname(__DIR__) . '/vendor/autoload.php', dirname(__DIR__, 2) . '/vendor/autoload.php',