| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Unit\ApiClient;
- use App\ApiClient\ApiAuthException;
- use App\ApiClient\ApiClient;
- use App\ApiClient\ApiHealth;
- use App\ApiClient\ApiNotFoundException;
- use App\ApiClient\ApiServerException;
- use App\ApiClient\ApiUnreachableException;
- use App\ApiClient\ApiValidationException;
- use GuzzleHttp\Client;
- use GuzzleHttp\Exception\ConnectException;
- use GuzzleHttp\Handler\MockHandler;
- use GuzzleHttp\HandlerStack;
- use GuzzleHttp\Psr7\Request;
- use GuzzleHttp\Psr7\Response;
- use PHPUnit\Framework\TestCase;
- /**
- * Verifies the ApiClient's status-code → exception mapping and the
- * single-retry-on-5xx-or-connect-error policy.
- */
- final class ApiClientTest extends TestCase
- {
- public function testSuccessfulResponseReturnsDecodedJson(): void
- {
- $client = $this->build([new Response(200, [], (string) json_encode(['user_id' => 42, 'role' => 'admin']))]);
- $body = $client->request('GET', '/api/v1/admin/me', [], 42);
- self::assertSame(42, $body['user_id']);
- self::assertSame('admin', $body['role']);
- }
- public function test401MapsToApiAuthException(): void
- {
- $client = $this->build([new Response(401, [], (string) json_encode(['error' => 'unauthorized']))]);
- $this->expectException(ApiAuthException::class);
- $client->request('GET', '/api/v1/admin/me');
- }
- public function test403MapsToApiAuthException(): void
- {
- $client = $this->build([new Response(403, [], (string) json_encode(['error' => 'forbidden']))]);
- $this->expectException(ApiAuthException::class);
- $client->request('GET', '/api/v1/admin/policies');
- }
- public function test404MapsToApiNotFoundException(): void
- {
- $client = $this->build([new Response(404, [], (string) json_encode(['error' => 'not_found']))]);
- $this->expectException(ApiNotFoundException::class);
- $client->request('GET', '/api/v1/admin/ips/missing');
- }
- public function test400ValidationCarriesDetails(): void
- {
- $client = $this->build([new Response(400, [], (string) json_encode([
- 'error' => 'validation_failed',
- 'details' => ['name' => 'required'],
- ]))]);
- try {
- $client->request('POST', '/api/v1/admin/policies');
- self::fail('expected ApiValidationException');
- } catch (ApiValidationException $e) {
- self::assertSame(400, $e->statusCode);
- self::assertSame(['name' => 'required'], $e->details);
- }
- }
- public function test5xxRetriesOnceThenThrowsServerException(): void
- {
- $client = $this->build([
- new Response(500, [], (string) json_encode(['error' => 'boom'])),
- new Response(500, [], (string) json_encode(['error' => 'boom'])),
- ]);
- $this->expectException(ApiServerException::class);
- $client->request('GET', '/api/v1/admin/me');
- }
- public function test5xxRetrySucceedsOnSecondAttempt(): void
- {
- $client = $this->build([
- new Response(503, [], ''),
- new Response(200, [], (string) json_encode(['ok' => true])),
- ]);
- $body = $client->request('GET', '/api/v1/admin/me');
- self::assertSame(true, $body['ok']);
- }
- public function testConnectionErrorThrowsApiUnreachable(): void
- {
- $client = $this->build([
- new ConnectException('connection refused', new Request('GET', '/')),
- new ConnectException('connection refused', new Request('GET', '/')),
- ]);
- $this->expectException(ApiUnreachableException::class);
- $client->request('GET', '/api/v1/admin/me');
- }
- public function testHealthMarkedReachableAfterSuccess(): void
- {
- $health = new ApiHealth();
- $client = $this->build([new Response(200, [], '{}')], $health);
- $client->request('GET', '/healthz');
- self::assertTrue($health->isReachable());
- self::assertNotNull($health->lastSuccessAt());
- }
- public function testHealthMarkedUnreachableAfterConnectionError(): void
- {
- $health = new ApiHealth();
- $client = $this->build([
- new ConnectException('refused', new Request('GET', '/')),
- new ConnectException('refused', new Request('GET', '/')),
- ], $health);
- try {
- $client->request('GET', '/healthz');
- } catch (ApiUnreachableException) {
- // expected
- }
- self::assertFalse($health->isReachable());
- }
- public function testServiceTokenAttachedAsBearer(): void
- {
- $captured = [];
- $mock = new MockHandler([new Response(200, [], '{}')]);
- $stack = HandlerStack::create($mock);
- $stack->push(\GuzzleHttp\Middleware::tap(static function ($req) use (&$captured): void {
- $captured[] = $req->getHeaderLine('Authorization');
- }));
- $http = new Client(['handler' => $stack]);
- $client = new ApiClient($http, 'irdb_svc_TESTTOKEN', new ApiHealth());
- $client->request('GET', '/api/v1/admin/me', [], 42);
- self::assertSame('Bearer irdb_svc_TESTTOKEN', $captured[0]);
- }
- public function testActingUserIdAttachedWhenSet(): void
- {
- $captured = [];
- $mock = new MockHandler([new Response(200, [], '{}')]);
- $stack = HandlerStack::create($mock);
- $stack->push(\GuzzleHttp\Middleware::tap(static function ($req) use (&$captured): void {
- $captured[] = $req->getHeaderLine('X-Acting-User-Id');
- }));
- $http = new Client(['handler' => $stack]);
- $client = new ApiClient($http, 'tok', new ApiHealth());
- $client->request('GET', '/api/v1/admin/me', [], 42);
- self::assertSame('42', $captured[0]);
- }
- public function testActingUserIdOmittedWhenNull(): void
- {
- $captured = [];
- $mock = new MockHandler([new Response(200, [], '{}')]);
- $stack = HandlerStack::create($mock);
- $stack->push(\GuzzleHttp\Middleware::tap(static function ($req) use (&$captured): void {
- $captured[] = $req->hasHeader('X-Acting-User-Id');
- }));
- $http = new Client(['handler' => $stack]);
- $client = new ApiClient($http, 'tok', new ApiHealth());
- $client->request('POST', '/api/v1/auth/users/upsert-local');
- self::assertFalse($captured[0]);
- }
- /**
- * @param list<\Psr\Http\Message\ResponseInterface|\Throwable> $responses
- */
- private function build(array $responses, ?ApiHealth $health = null): ApiClient
- {
- $mock = new MockHandler($responses);
- $stack = HandlerStack::create($mock);
- $http = new Client(['handler' => $stack]);
- return new ApiClient($http, 'tok', $health ?? new ApiHealth());
- }
- }
|