diff --git a/tests/Integration/Presentation/Api/ApiExceptionDispatcherTest.php b/tests/Integration/Presentation/Api/ApiExceptionDispatcherTest.php new file mode 100644 index 0000000..e2731b3 --- /dev/null +++ b/tests/Integration/Presentation/Api/ApiExceptionDispatcherTest.php @@ -0,0 +1,81 @@ +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 $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 $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); + }); +}); diff --git a/tests/Integration/Presentation/Api/V1/ApiExceptionRendererTest.php b/tests/Integration/Presentation/Api/V1/ApiExceptionRendererTest.php new file mode 100644 index 0000000..86ed8c4 --- /dev/null +++ b/tests/Integration/Presentation/Api/V1/ApiExceptionRendererTest.php @@ -0,0 +1,145 @@ +render($exception, $request); + + // THEN + expect($response->getStatusCode())->toBe(404) + ->and($response->headers->get('Content-Type'))->toBe('application/problem+json'); + + /** @var array $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 $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 $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 $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 $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 $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'); + }); +});