From 6f5b1c548d4edb547158705db29efb83090773de Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Wed, 9 Sep 2026 09:23:14 +0200 Subject: [PATCH 1/4] Fix: surface API error messages and harden URL/transport handling --- src/Core/Transport/RequestHandler.php | 60 +++++++++++++++++------- src/Exceptions/BadRequestException.php | 25 ++++++++++ src/Factory/GuzzleClientFactory.php | 10 ++-- test/Unit/Core/RequestHandlerTest.php | 63 ++++++++++++++++++++++++++ test/Unit/GuzzleClientFactoryTest.php | 19 ++++++++ 5 files changed, 157 insertions(+), 20 deletions(-) create mode 100644 src/Exceptions/BadRequestException.php diff --git a/src/Core/Transport/RequestHandler.php b/src/Core/Transport/RequestHandler.php index e84791b..3c9e589 100644 --- a/src/Core/Transport/RequestHandler.php +++ b/src/Core/Transport/RequestHandler.php @@ -14,6 +14,7 @@ use Psr\Log\NullLogger; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; use ZammadAPIClient\Exceptions\AuthenticationException; +use ZammadAPIClient\Exceptions\BadRequestException; use ZammadAPIClient\Exceptions\ForbiddenException; use ZammadAPIClient\Exceptions\NetworkException; use ZammadAPIClient\Exceptions\NotFoundException; @@ -135,7 +136,8 @@ public function getRaw(string $uri, array $query = [], array $headers = []): str $uri .= '?' . http_build_query($query); } - $options = !empty($headers) ? ['headers' => $headers] : []; + $headers += ['Accept' => '*/*']; + $options = ['headers' => $headers]; return (string) $this->dispatch('GET', $uri, $options)->getBody(); } @@ -250,33 +252,57 @@ private function dispatch(string $method, string $uri, array $options): Response private function mapError(int $status, string $uri, ResponseInterface $response): ZammadException { + $raw = (string) $response->getBody(); + return match (true) { - $status === 401 => new AuthenticationException('Invalid credentials'), - $status === 403 => new ForbiddenException("Access denied: {$uri}"), - $status === 404 => new NotFoundException("Resource not found: {$uri}"), - $status === 422 => $this->validationError($response), + $status === 400 => new BadRequestException($this->extractErrorMessage($raw) ?? 'Bad request'), + $status === 401 => new AuthenticationException($this->extractErrorMessage($raw) ?? 'Invalid credentials'), + $status === 403 => new ForbiddenException($this->extractErrorMessage($raw) ?? "Access denied: {$uri}"), + $status === 404 => new NotFoundException($this->extractErrorMessage($raw) ?? "Resource not found: {$uri}"), + $status === 422 => $this->validationError($raw), $status === 429 => new RateLimitException( 'Too many requests', (int) ($response->getHeaderLine('Retry-After') ?: 60), ), - $status >= 500 => new ServerErrorException("Server error: {$status}"), - default => new NetworkException("Unexpected status: {$status}"), + $status >= 500 => new ServerErrorException($this->extractErrorMessage($raw) ?? "Server error: {$status}"), + default => new NetworkException($this->extractErrorMessage($raw) ?? "Unexpected status: {$status}"), }; } - private function validationError(ResponseInterface $response): ValidationException + private function validationError(string $raw): ValidationException { - $raw = (string) $response->getBody(); + return new ValidationException( + $this->extractErrorMessage($raw) ?? $this->extractValidationMessage($raw), + $this->extractValidationErrors($this->decodeLenient($raw)), + ); + } + + /** + * Extracts a human-readable message from an error response body. + * + * Prefers the JSON `error`/`error_human` fields; falls back to the raw + * body for non-HTML text responses. Returns null when no usable message + * can be extracted (callers then supply a generic fallback). + */ + private function extractErrorMessage(string $raw): ?string + { + if ($raw === '') { + return null; + } + $body = $this->decodeLenient($raw); - $message = is_string($body['error'] ?? null) - ? $body['error'] - : $this->extractValidationMessage($raw); + $message = $body['error'] ?? $body['error_human'] ?? null; + if (is_string($message) && $message !== '') { + return $message; + } - return new ValidationException( - $message, - $this->extractValidationErrors($body), - ); + $trimmed = trim($raw); + if ($trimmed === '' || str_starts_with($trimmed, '<')) { + return null; + } + + return substr($trimmed, 0, 200); } /** @@ -285,7 +311,7 @@ private function validationError(ResponseInterface $response): ValidationExcepti */ private function extractValidationErrors(array $body): array { - $details = $body['details'] ?? $body['error_details'] ?? null; + $details = $body['details'] ?? $body['error_details'] ?? $body['errors'] ?? null; return is_array($details) ? $details : []; } diff --git a/src/Exceptions/BadRequestException.php b/src/Exceptions/BadRequestException.php new file mode 100644 index 0000000..c853d9a --- /dev/null +++ b/src/Exceptions/BadRequestException.php @@ -0,0 +1,25 @@ +handler->get('tickets'); } + public function testBadRequestMapsToBadRequestException(): void + { + $this->httpClient->response = new Response(400, [], (string) json_encode(['error' => 'invalid filter'])); + + try { + $this->handler->get('tickets'); + self::fail('Expected BadRequestException'); + } catch (BadRequestException $e) { + self::assertSame('invalid filter', $e->getMessage()); + } + } + + public function testServerErrorIncludesBodyMessage(): void + { + $this->httpClient->response = new Response(500, [], (string) json_encode(['error' => 'boom'])); + + try { + $this->handler->get('tickets'); + self::fail('Expected ServerErrorException'); + } catch (ServerErrorException $e) { + self::assertSame('boom', $e->getMessage()); + } + } + + public function testValidationExceptionReadsErrorHuman(): void + { + $this->httpClient->response = new Response(422, [], (string) json_encode(['error_human' => 'human readable'])); + + try { + $this->handler->post('tickets', ['x' => 1]); + self::fail('Expected ValidationException'); + } catch (ValidationException $e) { + self::assertSame('human readable', $e->getMessage()); + } + } + + public function testValidationExceptionExtractsErrorsKey(): void + { + $this->httpClient->response = new Response( + 422, + [], + (string) json_encode(['error' => 'bad', 'errors' => ['title' => 'required']]), + ); + + try { + $this->handler->post('tickets', ['x' => 1]); + self::fail('Expected ValidationException'); + } catch (ValidationException $e) { + self::assertSame(['title' => 'required'], $e->errors); + } + } + public function testGetRawReturnsUndecodedBody(): void { $binary = "PNG\x00\x01binary-not-json"; @@ -144,6 +197,16 @@ public function testGetRawReturnsUndecodedBody(): void self::assertSame($binary, $this->handler->getRaw('ticket_attachment/1/2/3')); } + public function testGetRawSendsWildcardAccept(): void + { + $this->httpClient->response = new Response(200, [], 'binary'); + + $this->handler->getRaw('ticket_attachment/1/2/3'); + + self::assertNotNull($this->httpClient->lastRequest); + self::assertSame('*/*', $this->httpClient->lastRequest->getHeaderLine('Accept')); + } + public function testNonJsonBodyOn200ThrowsNetworkException(): void { $this->httpClient->response = new Response(200, [], 'proxy error'); diff --git a/test/Unit/GuzzleClientFactoryTest.php b/test/Unit/GuzzleClientFactoryTest.php index fceaa26..b2986ca 100644 --- a/test/Unit/GuzzleClientFactoryTest.php +++ b/test/Unit/GuzzleClientFactoryTest.php @@ -42,4 +42,23 @@ public function testFactoryWrappedInZammadClient(): void self::assertInstanceOf(ClientInterface::class, $client); } + + public function testNormalizeUrlAppendsApiV1(): void + { + $method = new \ReflectionMethod(GuzzleClientFactory::class, 'normalizeUrl'); + + $cases = [ + 'https://zammad.example' => 'https://zammad.example/api/v1', + 'https://zammad.example/' => 'https://zammad.example/api/v1', + 'https://zammad.example/api' => 'https://zammad.example/api/v1', + 'https://zammad.example/api/' => 'https://zammad.example/api/v1', + 'https://zammad.example/api/v1' => 'https://zammad.example/api/v1', + 'https://zammad.example/api/v1/' => 'https://zammad.example/api/v1', + 'https://zammad.example/api/v2' => 'https://zammad.example/api/v2', + ]; + + foreach ($cases as $input => $expected) { + self::assertSame($expected, $method->invoke(null, $input), "URL: {$input}"); + } + } } From 923e97d1599927483250312c51f28cf23a617f6e Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Wed, 9 Sep 2026 10:20:24 +0200 Subject: [PATCH 2/4] Refactor: centralize API version in RequestHandler (client-agnostic) --- config/zammad.php | 6 ++++-- src/Bridge/LaravelServiceProvider.php | 4 ++-- src/Bridge/SymfonyBundle.php | 4 ++-- src/Core/Transport/RequestHandler.php | 17 +++++++++++++++-- src/Factory/GuzzleClientFactory.php | 19 +------------------ test/Unit/Core/RequestHandlerTest.php | 21 +++++++++++++++++++++ test/Unit/GuzzleClientFactoryTest.php | 19 ------------------- 7 files changed, 45 insertions(+), 45 deletions(-) diff --git a/config/zammad.php b/config/zammad.php index f70cd72..48f3c87 100644 --- a/config/zammad.php +++ b/config/zammad.php @@ -3,8 +3,10 @@ return [ /** Zammad API Base URL - * Full URL to your Zammad instance including the API prefix, e.g.: - * https://zammad.example.com/api/v1 + * Full URL to your Zammad instance, e.g.: + * https://zammad.example.com + * + * The API prefix (`/api/v1`) is appended automatically. */ 'url' => env('ZAMMAD_URL', 'http://127.0.0.1:8098'), diff --git a/src/Bridge/LaravelServiceProvider.php b/src/Bridge/LaravelServiceProvider.php index 4267a93..90f3c0e 100644 --- a/src/Bridge/LaravelServiceProvider.php +++ b/src/Bridge/LaravelServiceProvider.php @@ -40,7 +40,7 @@ * - Set your credentials in `.env`: * * ```env - * ZAMMAD_URL=https://zammad.example.com/api/v1 + * ZAMMAD_URL=https://zammad.example.com * ZAMMAD_TOKEN=your-api-token * ``` * @@ -69,7 +69,7 @@ * * 1. `config/zammad.php` values (after `vendor:publish`) * 2. `ZAMMAD_URL` / `ZAMMAD_TOKEN` environment variables (`.env`) - * 3. Built-in defaults (`http://127.0.0.1:8098/api/v1`, empty token) + * 3. Built-in defaults (`http://127.0.0.1:8098`, empty token) * * @see ZammadClient::withToken() */ diff --git a/src/Bridge/SymfonyBundle.php b/src/Bridge/SymfonyBundle.php index 2fb1e66..be7d0ec 100644 --- a/src/Bridge/SymfonyBundle.php +++ b/src/Bridge/SymfonyBundle.php @@ -42,7 +42,7 @@ * - Set the environment variables (`.env` or `.env.local`): * * ```env - * ZAMMAD_URL=https://zammad.example.com/api/v1 + * ZAMMAD_URL=https://zammad.example.com * ZAMMAD_TOKEN=your-api-token * ``` * @@ -80,7 +80,7 @@ public function load(array $configs, ContainerBuilder $container): void $resolved = array_merge($resolved, $config); } - $url = $resolved['url'] ?? (string) ($_ENV['ZAMMAD_URL'] ?? 'http://127.0.0.1:8098/api/v1'); + $url = $resolved['url'] ?? (string) ($_ENV['ZAMMAD_URL'] ?? 'http://127.0.0.1:8098'); $token = $resolved['token'] ?? (string) ($_ENV['ZAMMAD_TOKEN'] ?? ''); $client = new ZammadClient( diff --git a/src/Core/Transport/RequestHandler.php b/src/Core/Transport/RequestHandler.php index 3c9e589..6ae6da6 100644 --- a/src/Core/Transport/RequestHandler.php +++ b/src/Core/Transport/RequestHandler.php @@ -39,6 +39,8 @@ */ final class RequestHandler implements RequestHandlerInterface { + public const API_VERSION = 'v1'; + private ClientInterface $httpClient; private RequestFactoryInterface $requestFactory; private StreamFactoryInterface $streamFactory; @@ -49,7 +51,7 @@ final class RequestHandler implements RequestHandlerInterface /** * @param ClientInterface $httpClient PSR-18 client (any implementation). * @param RequestFactoryInterface $factory PSR-17 factory; must also implement {@see StreamFactoryInterface}. - * @param string $baseUrl Base URL incl. API prefix. + * @param string $baseUrl Base URL; the API prefix (`/api/v1`) is appended if missing. * @param LoggerInterface $logger PSR-3 logger; defaults to NullLogger. * @param int $maxRetries Max retries on HTTP 429 (0 = disable). */ @@ -70,10 +72,21 @@ public function __construct( : $httpClient; $this->requestFactory = $factory; $this->streamFactory = $factory; - $this->baseUrl = $baseUrl; + $this->baseUrl = self::normalizeBaseUrl($baseUrl); $this->logger = $logger; } + /** + * Ensures the base URL ends with the Zammad API prefix (`/api/v1`). + * + * A trailing `/api` or `/api/vN` is stripped first so the prefix is never + * duplicated and the version is always the one this client targets. + */ + private static function normalizeBaseUrl(string $url): string + { + return preg_replace('#/api(?:/v\d+)?$#', '', rtrim($url, '/')) . '/api/' . self::API_VERSION; + } + /** * Returns the raw PSR-7 response from the most recent request, or null if * no request has been made yet. diff --git a/src/Factory/GuzzleClientFactory.php b/src/Factory/GuzzleClientFactory.php index c5e0e61..da5400b 100644 --- a/src/Factory/GuzzleClientFactory.php +++ b/src/Factory/GuzzleClientFactory.php @@ -52,8 +52,6 @@ public function createHandler(): RequestHandlerInterface { $config = $this->config ?? new ConnectionConfig(); - $url = self::normalizeUrl($this->url); - $httpClient = new GuzzleClient([ 'headers' => [ 'User-Agent' => self::USER_AGENT, @@ -68,24 +66,9 @@ public function createHandler(): RequestHandlerInterface return new RequestHandler( $httpClient, new HttpFactory(), - $url, + $this->url, logger: $config->logger ?? new NullLogger(), maxRetries: $config->maxRetries, ); } - - private static function normalizeUrl(string $url): string - { - $url = rtrim($url, '/'); - - if (preg_match('#/api/v\d+$#', $url)) { - return $url; - } - - if (str_ends_with($url, '/api')) { - return $url . '/v1'; - } - - return $url . '/api/v1'; - } } diff --git a/test/Unit/Core/RequestHandlerTest.php b/test/Unit/Core/RequestHandlerTest.php index 754eed4..15e24fa 100644 --- a/test/Unit/Core/RequestHandlerTest.php +++ b/test/Unit/Core/RequestHandlerTest.php @@ -207,6 +207,27 @@ public function testGetRawSendsWildcardAccept(): void self::assertSame('*/*', $this->httpClient->lastRequest->getHeaderLine('Accept')); } + public function testNormalizesBaseUrlToApiV1(): void + { + $this->httpClient->response = new Response(200, [], '{}'); + + $cases = [ + 'https://zammad.example' => 'https://zammad.example/api/v1/tickets', + 'https://zammad.example/' => 'https://zammad.example/api/v1/tickets', + 'https://zammad.example/api' => 'https://zammad.example/api/v1/tickets', + 'https://zammad.example/api/' => 'https://zammad.example/api/v1/tickets', + 'https://zammad.example/api/v1' => 'https://zammad.example/api/v1/tickets', + 'https://zammad.example/api/v2' => 'https://zammad.example/api/v1/tickets', + ]; + + foreach ($cases as $baseUrl => $expected) { + $handler = new RequestHandler($this->httpClient, $this->httpFactory, $baseUrl, maxRetries: 0); + $handler->get('tickets'); + + self::assertSame($expected, (string) $this->httpClient->lastRequest->getUri(), "URL: {$baseUrl}"); + } + } + public function testNonJsonBodyOn200ThrowsNetworkException(): void { $this->httpClient->response = new Response(200, [], 'proxy error'); diff --git a/test/Unit/GuzzleClientFactoryTest.php b/test/Unit/GuzzleClientFactoryTest.php index b2986ca..fceaa26 100644 --- a/test/Unit/GuzzleClientFactoryTest.php +++ b/test/Unit/GuzzleClientFactoryTest.php @@ -42,23 +42,4 @@ public function testFactoryWrappedInZammadClient(): void self::assertInstanceOf(ClientInterface::class, $client); } - - public function testNormalizeUrlAppendsApiV1(): void - { - $method = new \ReflectionMethod(GuzzleClientFactory::class, 'normalizeUrl'); - - $cases = [ - 'https://zammad.example' => 'https://zammad.example/api/v1', - 'https://zammad.example/' => 'https://zammad.example/api/v1', - 'https://zammad.example/api' => 'https://zammad.example/api/v1', - 'https://zammad.example/api/' => 'https://zammad.example/api/v1', - 'https://zammad.example/api/v1' => 'https://zammad.example/api/v1', - 'https://zammad.example/api/v1/' => 'https://zammad.example/api/v1', - 'https://zammad.example/api/v2' => 'https://zammad.example/api/v2', - ]; - - foreach ($cases as $input => $expected) { - self::assertSame($expected, $method->invoke(null, $input), "URL: {$input}"); - } - } } From f621b87257a1fc14312139024b1e134e2da90a1f Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Wed, 9 Sep 2026 10:27:24 +0200 Subject: [PATCH 3/4] Style: put anonymous class closing brace on its own line --- test/Unit/Core/RequestHandlerTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/Unit/Core/RequestHandlerTest.php b/test/Unit/Core/RequestHandlerTest.php index 15e24fa..a925175 100644 --- a/test/Unit/Core/RequestHandlerTest.php +++ b/test/Unit/Core/RequestHandlerTest.php @@ -301,7 +301,8 @@ public function testDispatchCatchesClientException(): void $httpClient = new class implements ClientInterface { public function sendRequest(RequestInterface $request): ResponseInterface { - throw new class extends \RuntimeException implements ClientExceptionInterface {}; + throw new class extends \RuntimeException implements ClientExceptionInterface { + }; } }; From 420cf750b4c8d54e8eafefb351e8178a6643f6f7 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:18:00 +0000 Subject: [PATCH 4/4] Add PHPDoc annotations for request handling and tests --- src/Bridge/SymfonyBundle.php | 5 ++++ src/Core/Transport/RequestHandler.php | 6 +++++ src/Exceptions/BadRequestException.php | 3 +++ src/Factory/GuzzleClientFactory.php | 3 +++ test/Unit/Core/RequestHandlerTest.php | 33 ++++++++++++++++++++++++++ 5 files changed, 50 insertions(+) diff --git a/src/Bridge/SymfonyBundle.php b/src/Bridge/SymfonyBundle.php index be7d0ec..8f4a138 100644 --- a/src/Bridge/SymfonyBundle.php +++ b/src/Bridge/SymfonyBundle.php @@ -73,6 +73,11 @@ final class SymfonyBundle extends Bundle public function getContainerExtension(): ?ExtensionInterface { return new class implements ExtensionInterface { + /** + * Registers the configured Zammad client with the container. + * + * @param array> $configs + */ public function load(array $configs, ContainerBuilder $container): void { $resolved = []; diff --git a/src/Core/Transport/RequestHandler.php b/src/Core/Transport/RequestHandler.php index 6ae6da6..65a4b28 100644 --- a/src/Core/Transport/RequestHandler.php +++ b/src/Core/Transport/RequestHandler.php @@ -263,6 +263,9 @@ private function dispatch(string $method, string $uri, array $options): Response throw $this->mapError($status, $uri, $response); } + /** + * Maps an unsuccessful HTTP response to its corresponding domain exception. + */ private function mapError(int $status, string $uri, ResponseInterface $response): ZammadException { $raw = (string) $response->getBody(); @@ -282,6 +285,9 @@ private function mapError(int $status, string $uri, ResponseInterface $response) }; } + /** + * Builds a validation exception from a raw HTTP 422 response body. + */ private function validationError(string $raw): ValidationException { return new ValidationException( diff --git a/src/Exceptions/BadRequestException.php b/src/Exceptions/BadRequestException.php index c853d9a..3911034 100644 --- a/src/Exceptions/BadRequestException.php +++ b/src/Exceptions/BadRequestException.php @@ -18,6 +18,9 @@ */ final class BadRequestException extends \RuntimeException implements ZammadException { + /** + * Creates an HTTP 400 exception with the API-provided message. + */ public function __construct(string $message = 'Bad request') { parent::__construct($message, 400); diff --git a/src/Factory/GuzzleClientFactory.php b/src/Factory/GuzzleClientFactory.php index da5400b..4d6d923 100644 --- a/src/Factory/GuzzleClientFactory.php +++ b/src/Factory/GuzzleClientFactory.php @@ -48,6 +48,9 @@ public static function withBasicAuth( return new self($url, 'Basic ' . base64_encode("{$user}:{$pass}"), $config); } + /** + * Creates a request handler configured with this factory's credentials. + */ public function createHandler(): RequestHandlerInterface { $config = $this->config ?? new ConnectionConfig(); diff --git a/test/Unit/Core/RequestHandlerTest.php b/test/Unit/Core/RequestHandlerTest.php index a925175..98ad0a1 100644 --- a/test/Unit/Core/RequestHandlerTest.php +++ b/test/Unit/Core/RequestHandlerTest.php @@ -129,6 +129,9 @@ public function testUnauthorizedMapsToTypedException(): void $this->handler->get('tickets'); } + /** + * Verifies that HTTP 403 responses map to the forbidden exception. + */ public function testForbiddenMapsToTypedException(): void { $this->httpClient->response = new Response(403, [], ''); @@ -137,6 +140,9 @@ public function testForbiddenMapsToTypedException(): void $this->handler->get('tickets'); } + /** + * Verifies that HTTP 400 responses preserve the API error message. + */ public function testBadRequestMapsToBadRequestException(): void { $this->httpClient->response = new Response(400, [], (string) json_encode(['error' => 'invalid filter'])); @@ -149,6 +155,9 @@ public function testBadRequestMapsToBadRequestException(): void } } + /** + * Verifies that server exceptions preserve the API error message. + */ public function testServerErrorIncludesBodyMessage(): void { $this->httpClient->response = new Response(500, [], (string) json_encode(['error' => 'boom'])); @@ -161,6 +170,9 @@ public function testServerErrorIncludesBodyMessage(): void } } + /** + * Verifies that validation errors use the human-readable error field. + */ public function testValidationExceptionReadsErrorHuman(): void { $this->httpClient->response = new Response(422, [], (string) json_encode(['error_human' => 'human readable'])); @@ -173,6 +185,9 @@ public function testValidationExceptionReadsErrorHuman(): void } } + /** + * Verifies that validation details are extracted from the errors field. + */ public function testValidationExceptionExtractsErrorsKey(): void { $this->httpClient->response = new Response( @@ -189,6 +204,9 @@ public function testValidationExceptionExtractsErrorsKey(): void } } + /** + * Verifies that raw requests return the response body without decoding it. + */ public function testGetRawReturnsUndecodedBody(): void { $binary = "PNG\x00\x01binary-not-json"; @@ -197,6 +215,9 @@ public function testGetRawReturnsUndecodedBody(): void self::assertSame($binary, $this->handler->getRaw('ticket_attachment/1/2/3')); } + /** + * Verifies that raw requests accept responses of any content type. + */ public function testGetRawSendsWildcardAccept(): void { $this->httpClient->response = new Response(200, [], 'binary'); @@ -207,6 +228,9 @@ public function testGetRawSendsWildcardAccept(): void self::assertSame('*/*', $this->httpClient->lastRequest->getHeaderLine('Accept')); } + /** + * Verifies that supported base URL forms resolve to the v1 API path. + */ public function testNormalizesBaseUrlToApiV1(): void { $this->httpClient->response = new Response(200, [], '{}'); @@ -228,6 +252,9 @@ public function testNormalizesBaseUrlToApiV1(): void } } + /** + * Verifies that a successful response with invalid JSON is rejected. + */ public function testNonJsonBodyOn200ThrowsNetworkException(): void { $this->httpClient->response = new Response(200, [], 'proxy error'); @@ -296,9 +323,15 @@ public function testConstructorRejectsFactoryWithoutStreamFactory(): void ); } + /** + * Verifies that PSR client failures are wrapped as network exceptions. + */ public function testDispatchCatchesClientException(): void { $httpClient = new class implements ClientInterface { + /** + * Simulates a PSR client transport failure. + */ public function sendRequest(RequestInterface $request): ResponseInterface { throw new class extends \RuntimeException implements ClientExceptionInterface {