diff --git a/.idea/problem-details-symfony-bundle.iml b/.idea/problem-details-symfony-bundle.iml index 2446eea..43a3eb2 100644 --- a/.idea/problem-details-symfony-bundle.iml +++ b/.idea/problem-details-symfony-bundle.iml @@ -3,6 +3,7 @@ + diff --git a/phpstan.neon b/phpstan.neon index 3898cf4..7dbf278 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,3 +4,4 @@ parameters: - src parallel: maximumNumberOfProcesses: 4 + treatPhpDocTypesAsCertain: false diff --git a/src/ProblemDetailsResponse.php b/src/ProblemDetailsResponse.php index 9f2869b..8a203d6 100644 --- a/src/ProblemDetailsResponse.php +++ b/src/ProblemDetailsResponse.php @@ -53,7 +53,7 @@ public static function create( ]; if (!empty($detail)) { - $data['detail'] = $instance; + $data['detail'] = $detail; } if (!empty($instance)) { @@ -87,12 +87,8 @@ public static function assertReservedResponseFields(array $extensions): void /** * Validates if the given status code is a valid client-side (4xx) or server-side (5xx) error. */ - protected static function assertValidStatusCode(?int $statusCode): void + protected static function assertValidStatusCode(int $statusCode): void { - if (!$statusCode) { - return; - } - if (!($statusCode >= 400 && $statusCode < 500) && !($statusCode >= 500 && $statusCode < 600)) { throw new LogicException(sprintf( 'Invalid status code %s provided for a Problem Details response. ' diff --git a/src/ThrowableToProblemDetailsKernelListener.php b/src/ThrowableToProblemDetailsKernelListener.php index 12cc7a2..64b8bb9 100644 --- a/src/ThrowableToProblemDetailsKernelListener.php +++ b/src/ThrowableToProblemDetailsKernelListener.php @@ -32,7 +32,7 @@ public function __construct( protected array $exceptionConverters = [] ) { if (empty($this->exceptionConverters)) { - throw new InvalidArgumentException('No exception converter passed!'); + throw new InvalidArgumentException('At least one converter must be provided'); } } @@ -49,6 +49,10 @@ private function processConverters(ExceptionEvent $event): void { $throwable = $event->getThrowable(); foreach ($this->exceptionConverters as $exceptionConverter) { + if (!$exceptionConverter instanceof ExceptionConverterInterface) { + throw new InvalidArgumentException('All converters must implement ' . ExceptionConverterInterface::class); + } + if (!$exceptionConverter->canHandle($throwable)) { continue; } diff --git a/tests/Unit/ExceptionConversion/ValidationFailedExceptionConverterTest.php b/tests/Unit/ExceptionConversion/ValidationFailedExceptionConverterTest.php index 7219ad4..70ac422 100644 --- a/tests/Unit/ExceptionConversion/ValidationFailedExceptionConverterTest.php +++ b/tests/Unit/ExceptionConversion/ValidationFailedExceptionConverterTest.php @@ -11,8 +11,11 @@ use Phauthentic\Symfony\ProblemDetails\Validation\ValidationErrorsBuilder; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use ReflectionMethod; +use RuntimeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationList; @@ -102,4 +105,22 @@ private function getConstraintViolationList(): ConstraintViolationList return new ConstraintViolationList([$violation1, $violation2]); } + + #[Test] + public function testExtractValidationFailedExceptionThrowsRuntimeException(): void + { + // Arrange + $exception = new UnprocessableEntityHttpException('Validation failed', new Exception(), 0, []); + $kernel = $this->createMock(HttpKernelInterface::class); + $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/profile/1']); + $request->headers->add(['Accept' => 'application/json']); + $event = new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception); + + // Assert + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('ValidationFailedException not found'); + + // Act + $this->converter->convertExceptionToErrorDetails($exception, $event); + } } diff --git a/tests/Unit/ProblemDetailsResponseTest.php b/tests/Unit/ProblemDetailsResponseTest.php new file mode 100644 index 0000000..1e11b20 --- /dev/null +++ b/tests/Unit/ProblemDetailsResponseTest.php @@ -0,0 +1,117 @@ + 30, 'accounts' => ['/account/12345', '/account/67890']]; + + // Act + $response = ProblemDetailsResponse::create( + status: $status, + type: $type, + title: $title, + detail: $detail, + instance: $instance, + extensions: $extensions + ); + + // Assert + $this->assertEquals($status, $response->getStatusCode()); + $this->assertEquals('application/problem+json', $response->headers->get('Content-Type')); + $this->assertJsonStringEqualsJsonString( + json_encode([ + 'status' => $status, + 'type' => $type, + 'title' => $title, + 'detail' => $detail, + 'instance' => $instance, + 'balance' => 30, + 'accounts' => ['/account/12345', '/account/67890'] + ], JSON_THROW_ON_ERROR), + $response->getContent() + ); + } + + public function testCreateResponseWithReservedFieldInExtensions(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The key "status" is a reserved key and cannot be used as an extension.'); + + // Arrange + $status = 422; + $type = 'https://example.com/probs/out-of-credit'; + $title = 'You do not have enough credit.'; + $detail = 'Your current balance is 30, but that costs 50.'; + $instance = '/account/12345/msgs/abc'; + $extensions = ['status' => 'reserved']; + + // Act + ProblemDetailsResponse::create( + status: $status, + type: $type, + title: $title, + detail: $detail, + instance: $instance, + extensions: $extensions + ); + } + + public function testCreateResponseWithInvalidStatusCode(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Invalid status code 200 provided for a Problem Details response.'); + + // Arrange + $status = 200; + $type = 'https://example.com/probs/out-of-credit'; + $title = 'You do not have enough credit.'; + $detail = 'Your current balance is 30, but that costs 50.'; + $instance = '/account/12345/msgs/abc'; + + // Act + ProblemDetailsResponse::create( + status: $status, + type: $type, + title: $title, + detail: $detail, + instance: $instance + ); + } + + public function testCreateResponseWithMinimalParameters(): void + { + // Arrange + $status = 500; + + // Act + $response = ProblemDetailsResponse::create($status); + + // Assert + $this->assertEquals($status, $response->getStatusCode()); + $this->assertEquals('application/problem+json', $response->headers->get('Content-Type')); + $this->assertJsonStringEqualsJsonString( + json_encode([ + 'status' => $status, + 'type' => 'about:blank', + 'title' => null, + ], JSON_THROW_ON_ERROR), + $response->getContent() + ); + } +} diff --git a/tests/Unit/ThrowableToProblemDetailsKernelListenerTest.php b/tests/Unit/ThrowableToProblemDetailsKernelListenerTest.php index 6a0f490..2b978c0 100644 --- a/tests/Unit/ThrowableToProblemDetailsKernelListenerTest.php +++ b/tests/Unit/ThrowableToProblemDetailsKernelListenerTest.php @@ -5,6 +5,7 @@ namespace Phauthentic\Symfony\ProblemDetails\Tests\Unit; use Exception; +use InvalidArgumentException; use Phauthentic\Symfony\ProblemDetails\ExceptionConversion\GenericThrowableConverter; use Phauthentic\Symfony\ProblemDetails\ProblemDetailsFactory; use PHPUnit\Framework\Attributes\DataProvider; @@ -65,4 +66,33 @@ public function testOnKernelException(string $environment, bool $shouldHaveTrace $this->assertArrayNotHasKey('trace', $data); } } + + #[Test] + public function testInstantiationWithoutConverters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one converter must be provided'); + + new ThrowableToProblemDetailsKernelListener([]); + } + + #[Test] + public function testInstantiationWithoutValidConverter(): void + { + // Arrange + $throwable = new Exception('Unmapped exception'); + $kernel = $this->createMock(HttpKernelInterface::class); + $request = new Request( + server: ['HTTP_ACCEPT' => 'application/json'] + ); + $event = new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $throwable); + + // Expect + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All converters must implement Phauthentic\Symfony\ProblemDetails\ExceptionConversion\ExceptionConverterInterface'); + + // Act + $listener = new ThrowableToProblemDetailsKernelListener([new \stdClass()]); + $listener->onKernelException($event); + } }