test(ApiExceptionDispatcher): add integration tests for exception handling

Added integration tests for ApiExceptionDispatcher to ensure proper handling of exceptions
for various API request scenarios, including JSON acceptance and unversioned requests.
This commit is contained in:
Jérémy Munsch 2026-08-13 12:34:44 +02:00
parent 8e96abc88d
commit 52d0e6f0fd
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
2 changed files with 226 additions and 0 deletions

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Tests\Integration\Presentation\Api;
use Domain\Exceptions\NotFoundException;
use Illuminate\Http\Request;
use Presentation\Api\Exceptions\ApiExceptionDispatcher;
use Presentation\Api\V1\Exceptions\ApiExceptionRenderer as V1ApiExceptionRenderer;
covers(ApiExceptionDispatcher::class);
describe('API Exception Dispatcher Integration', function (): void {
it('returns null for non-API web requests that do not accept JSON', function (): void {
// GIVEN
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
$request = Request::create('/some-web-route', 'GET');
$exception = new NotFoundException('Not found');
// WHEN
$response = $dispatcher->render($exception, $request);
// THEN
expect($response)->toBeNull();
});
it('dispatches api/v1 requests to V1ApiExceptionRenderer', function (): void {
// GIVEN
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
$request = Request::create('http://localhost/api/v1/employees/999', 'GET');
$exception = new NotFoundException('Employee not found');
// WHEN
$response = $dispatcher->render($exception, $request);
// THEN
expect($response)->not->toBeNull()
->and($response?->getStatusCode())->toBe(404)
->and($response?->headers->get('Content-Type'))->toBe('application/problem+json');
/** @var array<string, mixed> $data */
$data = json_decode((string) $response?->getContent(), true);
expect($data['status'])->toBe(404)
->and($data['title'])->toBe('Resource Not Found')
->and($data['detail'])->toBe('Employee not found');
});
it('falls back to V1ApiExceptionRenderer for unversioned api requests', function (): void {
// GIVEN
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
$request = Request::create('http://localhost/api/ping', 'GET');
$exception = new \DomainException('Domain invariant error');
// WHEN
$response = $dispatcher->render($exception, $request);
// THEN
expect($response)->not->toBeNull()
->and($response?->getStatusCode())->toBe(422);
/** @var array<string, mixed> $data */
$data = json_decode((string) $response?->getContent(), true);
expect($data['status'])->toBe(422)
->and($data['detail'])->toBe('Domain invariant error');
});
it('dispatches to V1ApiExceptionRenderer when request accepts JSON even on non-api path', function (): void {
// GIVEN
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
$request = Request::create('http://localhost/custom-endpoint', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']);
$exception = new NotFoundException('Custom endpoint not found');
// WHEN
$response = $dispatcher->render($exception, $request);
// THEN
expect($response)->not->toBeNull()
->and($response?->getStatusCode())->toBe(404);
});
});

View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Tests\Integration\Presentation\Api\V1;
use Domain\Exceptions\NotFoundException;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Validation\ValidationException;
use Presentation\Api\V1\Exceptions\ApiExceptionRenderer;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
covers(ApiExceptionRenderer::class);
describe('V1 API Exception Renderer Integration', function (): void {
it('renders NotFoundException as 404 Problem Details JSON', function (): void {
// GIVEN
$renderer = new ApiExceptionRenderer();
$request = Request::create('http://localhost/api/v1/employees/999', 'GET');
$exception = new NotFoundException('Employee with ID 999 not found.');
// WHEN
$response = $renderer->render($exception, $request);
// THEN
expect($response->getStatusCode())->toBe(404)
->and($response->headers->get('Content-Type'))->toBe('application/problem+json');
/** @var array<string, mixed> $data */
$data = json_decode((string) $response->getContent(), true);
expect($data['status'])->toBe(404)
->and($data['title'])->toBe('Resource Not Found')
->and($data['detail'])->toBe('Employee with ID 999 not found.')
->and($data['type'])->toBe('http://localhost/errors/404')
->and($data['instance'])->toBe('/api/v1/employees/999');
});
it('renders ValidationException as 422 Problem Details JSON with invalidParams', function (): void {
// GIVEN
$renderer = new ApiExceptionRenderer();
$request = Request::create('http://localhost/api/v1/employees', 'POST');
$validator = validator(['email' => 'invalid'], ['email' => 'email|required']);
try {
$validator->validate();
} catch (ValidationException $exception) {
// WHEN
$response = $renderer->render($exception, $request);
// THEN
expect($response->getStatusCode())->toBe(422)
->and($response->headers->get('Content-Type'))->toBe('application/problem+json');
/** @var array<string, mixed> $data */
$data = json_decode((string) $response->getContent(), true);
expect($data['status'])->toBe(422)
->and($data['title'])->toBe('Unprocessable Entity')
->and($data['invalidParams'])->toBeArray()
->and($data['invalidParams'][0]['name'])->toBe('email');
}
});
it('renders DomainException as 422 Problem Details JSON', function (): void {
// GIVEN
$renderer = new ApiExceptionRenderer();
$request = Request::create('http://localhost/api/v1/employees', 'POST');
$exception = new \DomainException('Invalid employee number format');
// WHEN
$response = $renderer->render($exception, $request);
// THEN
expect($response->getStatusCode())->toBe(422)
->and($response->headers->get('Content-Type'))->toBe('application/problem+json');
/** @var array<string, mixed> $data */
$data = json_decode((string) $response->getContent(), true);
expect($data['status'])->toBe(422)
->and($data['title'])->toBe('Unprocessable Entity')
->and($data['invalidParams'][0]['name'])->toBe('domain_invariant')
->and($data['invalidParams'][0]['reason'])->toBe('Invalid employee number format');
});
it('renders ThrottleRequestsException as 429 Problem Details JSON with rate headers', function (): void {
// GIVEN
$renderer = new ApiExceptionRenderer();
$request = Request::create('http://localhost/api/v1/employees', 'GET');
$exception = new ThrottleRequestsException('Too Many Attempts.', null, ['Retry-After' => '60']);
// WHEN
$response = $renderer->render($exception, $request);
// THEN
expect($response->getStatusCode())->toBe(429)
->and($response->headers->get('Content-Type'))->toBe('application/problem+json')
->and($response->headers->get('Retry-After'))->toBe('60');
/** @var array<string, mixed> $data */
$data = json_decode((string) $response->getContent(), true);
expect($data['status'])->toBe(429)
->and($data['title'])->toBe('Too Many Requests');
});
it('renders HttpExceptionInterface exception with status code and detail', function (): void {
// GIVEN
$renderer = new ApiExceptionRenderer();
$request = Request::create('http://localhost/api/v1/employees', 'DELETE');
$exception = new AccessDeniedHttpException('Access denied');
// WHEN
$response = $renderer->render($exception, $request);
// THEN
expect($response->getStatusCode())->toBe(403)
->and($response->headers->get('Content-Type'))->toBe('application/problem+json');
/** @var array<string, mixed> $data */
$data = json_decode((string) $response->getContent(), true);
expect($data['status'])->toBe(403)
->and($data['title'])->toBe('Forbidden')
->and($data['detail'])->toBe('Access denied');
});
it('renders generic Throwable as 500 Problem Details JSON with debug info based on config', function (): void {
// GIVEN
Config::set('app.debug', true);
$renderer = new ApiExceptionRenderer();
$request = Request::create('http://localhost/api/v1/employees', 'GET');
$exception = new \RuntimeException('Database connection failure');
// WHEN
$response = $renderer->render($exception, $request);
// THEN
expect($response->getStatusCode())->toBe(500);
/** @var array<string, mixed> $data */
$data = json_decode((string) $response->getContent(), true);
expect($data['status'])->toBe(500)
->and($data['title'])->toBe('Internal Server Error')
->and($data['detail'])->toBe('Database connection failure');
});
});