ApiClientTest.php 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\ApiClient;
  4. use App\ApiClient\ApiAuthException;
  5. use App\ApiClient\ApiClient;
  6. use App\ApiClient\ApiHealth;
  7. use App\ApiClient\ApiNotFoundException;
  8. use App\ApiClient\ApiServerException;
  9. use App\ApiClient\ApiUnreachableException;
  10. use App\ApiClient\ApiValidationException;
  11. use GuzzleHttp\Client;
  12. use GuzzleHttp\Exception\ConnectException;
  13. use GuzzleHttp\Handler\MockHandler;
  14. use GuzzleHttp\HandlerStack;
  15. use GuzzleHttp\Psr7\Request;
  16. use GuzzleHttp\Psr7\Response;
  17. use PHPUnit\Framework\TestCase;
  18. /**
  19. * Verifies the ApiClient's status-code → exception mapping and the
  20. * single-retry-on-5xx-or-connect-error policy.
  21. */
  22. final class ApiClientTest extends TestCase
  23. {
  24. public function testSuccessfulResponseReturnsDecodedJson(): void
  25. {
  26. $client = $this->build([new Response(200, [], (string) json_encode(['user_id' => 42, 'role' => 'admin']))]);
  27. $body = $client->request('GET', '/api/v1/admin/me', [], 42);
  28. self::assertSame(42, $body['user_id']);
  29. self::assertSame('admin', $body['role']);
  30. }
  31. public function test401MapsToApiAuthException(): void
  32. {
  33. $client = $this->build([new Response(401, [], (string) json_encode(['error' => 'unauthorized']))]);
  34. $this->expectException(ApiAuthException::class);
  35. $client->request('GET', '/api/v1/admin/me');
  36. }
  37. public function test403MapsToApiAuthException(): void
  38. {
  39. $client = $this->build([new Response(403, [], (string) json_encode(['error' => 'forbidden']))]);
  40. $this->expectException(ApiAuthException::class);
  41. $client->request('GET', '/api/v1/admin/policies');
  42. }
  43. public function test404MapsToApiNotFoundException(): void
  44. {
  45. $client = $this->build([new Response(404, [], (string) json_encode(['error' => 'not_found']))]);
  46. $this->expectException(ApiNotFoundException::class);
  47. $client->request('GET', '/api/v1/admin/ips/missing');
  48. }
  49. public function test400ValidationCarriesDetails(): void
  50. {
  51. $client = $this->build([new Response(400, [], (string) json_encode([
  52. 'error' => 'validation_failed',
  53. 'details' => ['name' => 'required'],
  54. ]))]);
  55. try {
  56. $client->request('POST', '/api/v1/admin/policies');
  57. self::fail('expected ApiValidationException');
  58. } catch (ApiValidationException $e) {
  59. self::assertSame(400, $e->statusCode);
  60. self::assertSame(['name' => 'required'], $e->details);
  61. }
  62. }
  63. public function test5xxRetriesOnceThenThrowsServerException(): void
  64. {
  65. $client = $this->build([
  66. new Response(500, [], (string) json_encode(['error' => 'boom'])),
  67. new Response(500, [], (string) json_encode(['error' => 'boom'])),
  68. ]);
  69. $this->expectException(ApiServerException::class);
  70. $client->request('GET', '/api/v1/admin/me');
  71. }
  72. public function test5xxRetrySucceedsOnSecondAttempt(): void
  73. {
  74. $client = $this->build([
  75. new Response(503, [], ''),
  76. new Response(200, [], (string) json_encode(['ok' => true])),
  77. ]);
  78. $body = $client->request('GET', '/api/v1/admin/me');
  79. self::assertSame(true, $body['ok']);
  80. }
  81. public function testConnectionErrorThrowsApiUnreachable(): void
  82. {
  83. $client = $this->build([
  84. new ConnectException('connection refused', new Request('GET', '/')),
  85. new ConnectException('connection refused', new Request('GET', '/')),
  86. ]);
  87. $this->expectException(ApiUnreachableException::class);
  88. $client->request('GET', '/api/v1/admin/me');
  89. }
  90. public function testHealthMarkedReachableAfterSuccess(): void
  91. {
  92. $health = new ApiHealth();
  93. $client = $this->build([new Response(200, [], '{}')], $health);
  94. $client->request('GET', '/healthz');
  95. self::assertTrue($health->isReachable());
  96. self::assertNotNull($health->lastSuccessAt());
  97. }
  98. public function testHealthMarkedUnreachableAfterConnectionError(): void
  99. {
  100. $health = new ApiHealth();
  101. $client = $this->build([
  102. new ConnectException('refused', new Request('GET', '/')),
  103. new ConnectException('refused', new Request('GET', '/')),
  104. ], $health);
  105. try {
  106. $client->request('GET', '/healthz');
  107. } catch (ApiUnreachableException) {
  108. // expected
  109. }
  110. self::assertFalse($health->isReachable());
  111. }
  112. public function testServiceTokenAttachedAsBearer(): void
  113. {
  114. $captured = [];
  115. $mock = new MockHandler([new Response(200, [], '{}')]);
  116. $stack = HandlerStack::create($mock);
  117. $stack->push(\GuzzleHttp\Middleware::tap(static function ($req) use (&$captured): void {
  118. $captured[] = $req->getHeaderLine('Authorization');
  119. }));
  120. $http = new Client(['handler' => $stack]);
  121. $client = new ApiClient($http, 'irdb_svc_TESTTOKEN', new ApiHealth());
  122. $client->request('GET', '/api/v1/admin/me', [], 42);
  123. self::assertSame('Bearer irdb_svc_TESTTOKEN', $captured[0]);
  124. }
  125. public function testActingUserIdAttachedWhenSet(): void
  126. {
  127. $captured = [];
  128. $mock = new MockHandler([new Response(200, [], '{}')]);
  129. $stack = HandlerStack::create($mock);
  130. $stack->push(\GuzzleHttp\Middleware::tap(static function ($req) use (&$captured): void {
  131. $captured[] = $req->getHeaderLine('X-Acting-User-Id');
  132. }));
  133. $http = new Client(['handler' => $stack]);
  134. $client = new ApiClient($http, 'tok', new ApiHealth());
  135. $client->request('GET', '/api/v1/admin/me', [], 42);
  136. self::assertSame('42', $captured[0]);
  137. }
  138. public function testActingUserIdOmittedWhenNull(): void
  139. {
  140. $captured = [];
  141. $mock = new MockHandler([new Response(200, [], '{}')]);
  142. $stack = HandlerStack::create($mock);
  143. $stack->push(\GuzzleHttp\Middleware::tap(static function ($req) use (&$captured): void {
  144. $captured[] = $req->hasHeader('X-Acting-User-Id');
  145. }));
  146. $http = new Client(['handler' => $stack]);
  147. $client = new ApiClient($http, 'tok', new ApiHealth());
  148. $client->request('POST', '/api/v1/auth/users/upsert-local');
  149. self::assertFalse($captured[0]);
  150. }
  151. /**
  152. * @param list<\Psr\Http\Message\ResponseInterface|\Throwable> $responses
  153. */
  154. private function build(array $responses, ?ApiHealth $health = null): ApiClient
  155. {
  156. $mock = new MockHandler($responses);
  157. $stack = HandlerStack::create($mock);
  158. $http = new Client(['handler' => $stack]);
  159. return new ApiClient($http, 'tok', $health ?? new ApiHealth());
  160. }
  161. }