From cd28cb447eee1756dd69e10bbece45574f32ab97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Thu, 6 Aug 2026 11:31:58 +0200 Subject: [PATCH] Cover ResponseAwareException fully getResponse(), the lazy stream-read parse path, the integer error_code cast and the non-map errors branch were uncovered (50% of methods, 89.8% of lines). New tests cover them all, plus two that pin behavior the class exists for: - the pre-read $body wins over an already-drained response stream (the non-seekable-stream robustness described in the docblock) - the default message embeds a SANITIZED request context - query string and fragment stripped so consumer secrets never leak into messages ResponseAwareException: 8/8 methods, 100% lines. Suite MSI 80% -> 85%, covered MSI 82% -> 85%. The four remaining escaped mutants in the class are behaviorally equivalent (idempotent re-decode, trims of never-padded strings, sprintf's implicit string cast). --- tests/Exception/ExceptionHierarchyTest.php | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/Exception/ExceptionHierarchyTest.php b/tests/Exception/ExceptionHierarchyTest.php index 07f7e6f..2eb764b 100644 --- a/tests/Exception/ExceptionHierarchyTest.php +++ b/tests/Exception/ExceptionHierarchyTest.php @@ -4,6 +4,7 @@ namespace Setono\Quickpay\Exception; +use Nyholm\Psr7\Request; use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -69,4 +70,70 @@ public function the_error_getters_degrade_gracefully_on_an_empty_body(): void self::assertNull($e->getErrorCode()); self::assertSame([], $e->getValidationErrors()); } + + #[Test] + public function it_exposes_the_response(): void + { + $response = new Response(500); + + self::assertSame($response, (new InternalServerErrorException($response))->getResponse()); + } + + #[Test] + public function it_parses_the_body_lazily_from_the_response_stream(): void + { + // No $body at construction (and a custom $message, so the constructor never touches the + // stream) — the getters must read and decode the response body on first use. + $e = new InternalServerErrorException( + new Response(500, [], '{"message":"Oops","errors":{"a":["x"],"b":["y"]}}'), + message: 'custom', + ); + + self::assertSame('custom', $e->getMessage()); + self::assertSame('Oops', $e->getMessageText()); + self::assertSame(['a' => ['x'], 'b' => ['y']], $e->getValidationErrors()); + } + + #[Test] + public function it_prefers_the_pre_read_body_over_the_response_stream(): void + { + // Simulates a non-seekable stream that was already drained: the response stream yields + // nothing, and only the pre-read $body keeps the getters working. + $e = new ValidationException(new Response(422), body: '{"message":"Validation error"}'); + + self::assertSame('Validation error', $e->getMessageText()); + } + + #[Test] + public function it_casts_an_integer_error_code_to_string(): void + { + $body = '{"error_code":40000}'; + $e = new ValidationException(new Response(400, [], $body), body: $body); + + self::assertSame('40000', $e->getErrorCode()); + } + + #[Test] + public function it_returns_no_validation_errors_when_the_errors_field_is_not_a_map(): void + { + $body = '{"errors":"boom"}'; + $e = new ValidationException(new Response(400, [], $body), body: $body); + + self::assertSame([], $e->getValidationErrors()); + } + + #[Test] + public function it_builds_a_default_message_with_a_sanitized_request_context_and_the_body(): void + { + $request = new Request('GET', 'https://api.quickpay.net/payments?apikey=secret#frag'); + $e = new ValidationException(new Response(422), body: ' {"message":"boom"} ', request: $request); + + // The query string and fragment are stripped so consumer-supplied secrets never leak into + // exception messages or logs; the body text is trimmed. + self::assertSame( + 'The status code was: 422. [GET https://api.quickpay.net/payments] The body was: {"message":"boom"}.', + $e->getMessage(), + ); + self::assertSame(0, $e->getCode()); + } }