bootstrap.php 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. <?php
  2. /**
  3. * 测试引导:加载生成的 Guzzle 客户端 + 注册/断言 helper。
  4. * 纯 PHP,无测试框架,不依赖 Laravel。
  5. */
  6. require_once __DIR__ . '/../client/vendor/autoload.php';
  7. use GuzzleHttp\Client;
  8. use \WikipaliApi\ApiException;
  9. use \WikipaliApi\Configuration;
  10. final class Fail extends RuntimeException
  11. {
  12. }
  13. final class Skip extends RuntimeException
  14. {
  15. }
  16. $GLOBALS['__tests'] = [];
  17. function test(string $name, callable $fn): void
  18. {
  19. $GLOBALS['__tests'][] = [$name, $fn];
  20. }
  21. function skip(string $reason): void
  22. {
  23. throw new Skip($reason);
  24. }
  25. function fail(string $message): void
  26. {
  27. throw new Fail($message);
  28. }
  29. function config(): array
  30. {
  31. return $GLOBALS['config'];
  32. }
  33. function fixtures(): array
  34. {
  35. return $GLOBALS['fixtures'];
  36. }
  37. /** 造一个已指向目标服务器的 Configuration;传 $token 时带 bearer。 */
  38. function makeConfig(?string $token = null): Configuration
  39. {
  40. $c = new Configuration();
  41. $c->setHost(config()['base_url']);
  42. if ($token !== null) {
  43. $c->setAccessToken($token);
  44. }
  45. return $c;
  46. }
  47. /** 实例化生成的 Api 类。$class 传完整 FQCN,如 \WikipaliApi\Api\HeartbeatApi::class */
  48. function api(string $class, ?string $token = null): object
  49. {
  50. // http_errors 保持默认 true:生成的客户端靠 RequestException→ApiException 这条链
  51. // 把非 2xx 的 body 塞进 ApiException(尤其 204-only 的 DELETE,它没有显式状态检查,
  52. // 关掉 http_errors 会让错误 body 丢失)。
  53. $http = new Client(['timeout' => config()['timeout']]);
  54. return new $class($http, makeConfig($token));
  55. }
  56. /**
  57. * 统一执行一次请求,返回 ['status'=>int, 'body'=>?array, 'headers'=>array](头名已转小写)。
  58. * $fn 必须返回 WithHttpInfo 的三元组 [$content, $status, $headers]。
  59. * 非 2xx 时生成的客户端抛 ApiException,这里捕获并解出状态码与 body。
  60. */
  61. function request(callable $fn): array
  62. {
  63. try {
  64. [$content, $status, $headers] = $fn();
  65. return ['status' => $status, 'body' => normalize($content), 'headers' => array_change_key_case($headers, CASE_LOWER)];
  66. } catch (ApiException $e) {
  67. $status = $e->getCode();
  68. // 网络层错误(DNS/连接/超时)时,生成客户端把异常包成 ApiException 但 code 不是
  69. // 合法 HTTP 状态(可能是 0,真实 curl 错误在 message 里);转成可读的 Fail。
  70. if ($status < 100 || $status > 599) {
  71. throw new Fail(sprintf('网络错误(服务器不可达):%s', $e->getMessage()));
  72. }
  73. $body = json_decode((string) $e->getResponseBody(), true);
  74. return ['status' => $status, 'body' => is_array($body) ? $body : null, 'headers' => array_change_key_case($e->getResponseHeaders() ?? [], CASE_LOWER)];
  75. }
  76. }
  77. /**
  78. * 原始 HTTP 请求:绕过生成客户端的必填参数校验,用于「缺参/非法组合」这类负向用例。
  79. * $path 以 / 开头(如 /v3/reactions),base URL 已含 /api 前缀。
  80. */
  81. function raw(string $method, string $path, ?array $query = null, ?array $json = null, ?string $token = null, array $headers = []): array
  82. {
  83. $http = new Client(['timeout' => config()['timeout'], 'http_errors' => false]);
  84. $opts = ['headers' => $headers];
  85. if ($query !== null) {
  86. $opts['query'] = $query;
  87. }
  88. if ($json !== null) {
  89. $opts['json'] = $json;
  90. }
  91. if ($token !== null) {
  92. $opts['headers']['Authorization'] = 'Bearer ' . $token;
  93. }
  94. try {
  95. $resp = $http->request($method, config()['base_url'] . $path, $opts);
  96. } catch (\GuzzleHttp\Exception\GuzzleException $e) {
  97. // 网络层错误(DNS 解析失败、连接拒绝、超时等)→ 转成 Fail,别让 Guzzle 异常
  98. // 一路冒成 fatal error。消息带上目标,方便定位是哪个服务器连不上。
  99. throw new Fail(sprintf('网络错误(%s %s%s):%s', strtoupper($method), config()['base_url'], $path, $e->getMessage()));
  100. }
  101. $body = json_decode((string) $resp->getBody(), true);
  102. // 头名一律转小写:HTTP 头名不区分大小写,staging 经 envoy 转发后全是小写,
  103. // 本地 artisan serve 则是 Content-Language 这种写法,按原样取会两边不一致。
  104. $headers = array_change_key_case($resp->getHeaders(), CASE_LOWER);
  105. return ['status' => $resp->getStatusCode(), 'body' => is_array($body) ? $body : null, 'headers' => $headers];
  106. }
  107. function normalize(mixed $content): mixed
  108. {
  109. if ($content === null || is_array($content)) {
  110. return $content;
  111. }
  112. if (is_object($content)) {
  113. return json_decode((string) json_encode($content), true);
  114. }
  115. return $content;
  116. }
  117. /**
  118. * 调 v3 登录接口换 bearer token。
  119. * `POST /v3/sessions {login, password}`(login 可以是用户名或邮箱),成功 201
  120. * `{data:{token, user}}`;账号或密码不对 422,尝试过频 429(同账号 + IP 每分钟 5 次)。
  121. */
  122. function login(string $username, string $password): string
  123. {
  124. $res = raw('POST', '/v3/sessions', null, ['login' => $username, 'password' => $password]);
  125. $token = $res['body']['data']['token'] ?? null;
  126. if ($res['status'] === 201 && is_string($token) && $token !== '') {
  127. return $token;
  128. }
  129. fail(sprintf('登录失败(HTTP %d):%s', $res['status'], $res['body']['detail'] ?? $res['body']['title'] ?? '未知错误'));
  130. }
  131. /** 取当前 token;没有就 FAIL(需登录用例不跳过,缺 token 就是失败)。 */
  132. function required_token(): string
  133. {
  134. $token = config()['token'];
  135. if (! $token) {
  136. fail('缺少 token:请用 --token= 或 --username/--password(自动调 /v3/sessions)');
  137. }
  138. return $token;
  139. }
  140. /* ---- 断言 ---- */
  141. function assert_status(int $expected, array $res, string $what): void
  142. {
  143. if ($res['status'] !== $expected) {
  144. fail(sprintf(
  145. '%s:期望 HTTP %d,实际 %d,body=%s',
  146. $what,
  147. $expected,
  148. $res['status'],
  149. substr((string) json_encode($res['body'], JSON_UNESCAPED_UNICODE), 0, 400)
  150. ));
  151. }
  152. }
  153. function assert_has_key(string $key, mixed $value, string $what): void
  154. {
  155. if (! is_array($value) || ! array_key_exists($key, $value)) {
  156. fail(sprintf('%s:响应缺少字段 `%s`,body=%s', $what, $key, substr((string) json_encode($value, JSON_UNESCAPED_UNICODE), 0, 400)));
  157. }
  158. }
  159. function assert_eq(mixed $expected, mixed $actual, string $what): void
  160. {
  161. if ($expected !== $actual) {
  162. fail(sprintf('%s:期望 %s,实际 %s', $what, var_export($expected, true), var_export($actual, true)));
  163. }
  164. }
  165. /** 断言失败响应是 RFC 9457 Problem Details,且 status 与 HTTP 码一致。 */
  166. function assert_problem(array $res, string $what): void
  167. {
  168. $b = $res['body'];
  169. if (! is_array($b) || ! isset($b['type'], $b['title'], $b['status'])) {
  170. fail(sprintf('%s:不是 Problem Details(缺 type/title/status),body=%s', $what, substr((string) json_encode($b, JSON_UNESCAPED_UNICODE), 0, 400)));
  171. }
  172. assert_eq($res['status'], (int) $b['status'], $what . ':Problem.status 与 HTTP 状态码不一致');
  173. }
  174. /** 断言 422 Problem Details 的 `errors` 里有指定字段(FormRequest / ValidationException 的字段级错误)。 */
  175. function assert_field_error(string $field, array $res, string $what): void
  176. {
  177. assert_status(422, $res, $what);
  178. assert_problem($res, $what);
  179. if (! isset($res['body']['errors'][$field])) {
  180. fail(sprintf('%s:errors 里缺少字段 `%s`,body=%s', $what, $field, substr((string) json_encode($res['body'], JSON_UNESCAPED_UNICODE), 0, 400)));
  181. }
  182. }