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()); } }