diff --git a/composer.json b/composer.json index 40d6a39..b69ef0d 100644 --- a/composer.json +++ b/composer.json @@ -32,16 +32,16 @@ "psr/http-factory": "^1.1", "psr/http-message": "^2.0", "tiny-blocks/mapper": "^3.2", - "tiny-blocks/time": "^2.6" + "tiny-blocks/time": "^2.7" }, "require-dev": { - "ergebnis/composer-normalize": "^2.52", - "guzzlehttp/guzzle": "^8.0", - "infection/infection": "^0.34", - "laminas/laminas-httphandlerrunner": "^2.13", + "ergebnis/composer-normalize": "^2.54", + "guzzlehttp/guzzle": "^8.3", + "infection/infection": "^0.35", + "laminas/laminas-httphandlerrunner": "^2.14", "nyholm/psr7": "^1.8", - "phpstan/phpstan": "^2.2", - "phpunit/phpunit": "^13.2", + "phpstan/phpstan": "^2.3", + "phpunit/phpunit": "^13.4", "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", "slevomat/coding-standard": "^8.31", @@ -86,7 +86,7 @@ "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", "tests": [ "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", - "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html" ] } } diff --git a/src/Client/Resilience/RetryingClientBuilder.php b/src/Client/Resilience/RetryingClientBuilder.php index 68a3194..730ab48 100644 --- a/src/Client/Resilience/RetryingClientBuilder.php +++ b/src/Client/Resilience/RetryingClientBuilder.php @@ -55,14 +55,14 @@ public function build(): RetryingClient } /** - * Sets the delay policy applied between attempts and returns the builder. + * Sets the monotonic clock measuring each attempt and returns the builder. * - * @param Backoff $backoff The delay policy applied between attempts. + * @param MonotonicClock $clock The monotonic clock measuring each attempt. * @return RetryingClientBuilder The builder instance for fluent configuration. */ - public function withBackoff(Backoff $backoff): RetryingClientBuilder + public function withClock(MonotonicClock $clock): RetryingClientBuilder { - $this->backoff = $backoff; + $this->clock = $clock; return $this; } @@ -79,14 +79,26 @@ public function withClient(ClientInterface $client): RetryingClientBuilder } /** - * Sets the monotonic clock measuring each attempt and returns the builder. + * Sets the delay policy applied between attempts and returns the builder. * - * @param MonotonicClock $clock The monotonic clock measuring each attempt. + * @param Backoff $backoff The delay policy applied between attempts. * @return RetryingClientBuilder The builder instance for fluent configuration. */ - public function withClock(MonotonicClock $clock): RetryingClientBuilder + public function withBackoff(Backoff $backoff): RetryingClientBuilder { - $this->clock = $clock; + $this->backoff = $backoff; + return $this; + } + + /** + * Sets the suspension applied between attempts and returns the builder. + * + * @param Sleeper $sleeper The suspension applied between attempts. + * @return RetryingClientBuilder The builder instance for fluent configuration. + */ + public function withSleeper(Sleeper $sleeper): RetryingClientBuilder + { + $this->sleeper = $sleeper; return $this; } @@ -116,16 +128,4 @@ public function withMaxAttempts(int $maxAttempts): RetryingClientBuilder $this->maxAttempts = $maxAttempts; return $this; } - - /** - * Sets the suspension applied between attempts and returns the builder. - * - * @param Sleeper $sleeper The suspension applied between attempts. - * @return RetryingClientBuilder The builder instance for fluent configuration. - */ - public function withSleeper(Sleeper $sleeper): RetryingClientBuilder - { - $this->sleeper = $sleeper; - return $this; - } } diff --git a/src/Code.php b/src/Code.php index 99c8d4a..f011c81 100644 --- a/src/Code.php +++ b/src/Code.php @@ -8,11 +8,11 @@ * HTTP response status codes indicate whether a specific HTTP request has been successfully completed. * Responses are grouped in five classes: * - * Informational (100 – 199) - * Successful (200 – 299) - * Redirection (300 – 399) - * Client error (400 – 499) - * Server error (500 – 599) + * Informational (100 to 199) + * Successful (200 to 299) + * Redirection (300 to 399) + * Client error (400 to 499) + * Server error (500 to 599) * * The is*() instance helpers validate by enum case, so they cover only the status codes * represented here. Status codes outside this list (for example 250) return false for every helper. diff --git a/src/Internal/Server/Response/ResponseHeaders.php b/src/Internal/Server/Response/ResponseHeaders.php index 63a31c9..162366c 100644 --- a/src/Internal/Server/Response/ResponseHeaders.php +++ b/src/Internal/Server/Response/ResponseHeaders.php @@ -26,6 +26,21 @@ private static function mergeInto(Headerable $header, array $merged): array return $merged; } + public static function fromWithDefaultContentType(Headerable ...$headers): ResponseHeaders + { + $provided = ResponseHeaders::fromWithoutDefaultContentType(...$headers); + + if ($provided->hasHeader(name: ResponseHeaders::CONTENT_TYPE)) { + return $provided; + } + + $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + + return new ResponseHeaders( + headers: ResponseHeaders::mergeInto(header: $contentType, merged: $provided->toArray()) + ); + } + /** * Merges the supplied headers exactly as given, with no media type of the library's own. A response built * without a body has no payload to describe, and announcing one over zero bytes misleads a strict client @@ -42,21 +57,6 @@ public static function fromWithoutDefaultContentType(Headerable ...$headers): Re return new ResponseHeaders(headers: $merged); } - public static function fromWithDefaultContentType(Headerable ...$headers): ResponseHeaders - { - $provided = ResponseHeaders::fromWithoutDefaultContentType(...$headers); - - if ($provided->hasHeader(name: ResponseHeaders::CONTENT_TYPE)) { - return $provided; - } - - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - - return new ResponseHeaders( - headers: ResponseHeaders::mergeInto(header: $contentType, merged: $provided->toArray()) - ); - } - private function findKey(string $name): ?string { $lowered = strtolower($name); diff --git a/src/Internal/Server/Stream/Stream.php b/src/Internal/Server/Stream/Stream.php index b3e3dac..6f1b02f 100644 --- a/src/Internal/Server/Stream/Stream.php +++ b/src/Internal/Server/Stream/Stream.php @@ -76,7 +76,7 @@ final class Stream implements StreamInterface private mixed $resource; - private function __construct(private readonly bool $seekable, mixed $resource) + private function __construct(mixed $resource, private readonly bool $seekable) { $this->resource = $resource; } diff --git a/src/Server/Response.php b/src/Server/Response.php index b224295..2fb9072 100644 --- a/src/Server/Response.php +++ b/src/Server/Response.php @@ -20,14 +20,14 @@ private function __construct() { } - public static function from(mixed $body, Code $code, Headerable ...$headers): ResponseInterface + public static function ok(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, $code, ...$headers); + return InternalResponse::createWithBody($body, Code::OK, ...$headers); } - public static function ok(mixed $body, Headerable ...$headers): ResponseInterface + public static function from(mixed $body, Code $code, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::OK, ...$headers); + return InternalResponse::createWithBody($body, $code, ...$headers); } public static function created(mixed $body, Headerable ...$headers): ResponseInterface @@ -40,53 +40,53 @@ public static function accepted(mixed $body, Headerable ...$headers): ResponseIn return InternalResponse::createWithBody($body, Code::ACCEPTED, ...$headers); } - public static function noContent(Headerable ...$headers): ResponseInterface + public static function conflict(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithoutBody(Code::NO_CONTENT, ...$headers); + return InternalResponse::createWithBody($body, Code::CONFLICT, ...$headers); } - public static function badRequest(mixed $body, Headerable ...$headers): ResponseInterface + public static function notFound(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::BAD_REQUEST, ...$headers); + return InternalResponse::createWithBody($body, Code::NOT_FOUND, ...$headers); } - public static function unauthorized(mixed $body, Headerable ...$headers): ResponseInterface + public static function forbidden(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::UNAUTHORIZED, ...$headers); + return InternalResponse::createWithBody($body, Code::FORBIDDEN, ...$headers); } - public static function forbidden(mixed $body, Headerable ...$headers): ResponseInterface + public static function noContent(Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::FORBIDDEN, ...$headers); + return InternalResponse::createWithoutBody(Code::NO_CONTENT, ...$headers); } - public static function notFound(mixed $body, Headerable ...$headers): ResponseInterface + public static function badGateway(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::NOT_FOUND, ...$headers); + return InternalResponse::createWithBody($body, Code::BAD_GATEWAY, ...$headers); } - public static function conflict(mixed $body, Headerable ...$headers): ResponseInterface + public static function badRequest(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::CONFLICT, ...$headers); + return InternalResponse::createWithBody($body, Code::BAD_REQUEST, ...$headers); } - public static function unprocessableEntity(mixed $body, Headerable ...$headers): ResponseInterface + public static function unauthorized(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::UNPROCESSABLE_ENTITY, ...$headers); + return InternalResponse::createWithBody($body, Code::UNAUTHORIZED, ...$headers); } - public static function internalServerError(mixed $body, Headerable ...$headers): ResponseInterface + public static function serviceUnavailable(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::INTERNAL_SERVER_ERROR, ...$headers); + return InternalResponse::createWithBody($body, Code::SERVICE_UNAVAILABLE, ...$headers); } - public static function badGateway(mixed $body, Headerable ...$headers): ResponseInterface + public static function internalServerError(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::BAD_GATEWAY, ...$headers); + return InternalResponse::createWithBody($body, Code::INTERNAL_SERVER_ERROR, ...$headers); } - public static function serviceUnavailable(mixed $body, Headerable ...$headers): ResponseInterface + public static function unprocessableEntity(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::SERVICE_UNAVAILABLE, ...$headers); + return InternalResponse::createWithBody($body, Code::UNPROCESSABLE_ENTITY, ...$headers); } } diff --git a/src/Server/Responses.php b/src/Server/Responses.php index b68e6ae..04e2e02 100644 --- a/src/Server/Responses.php +++ b/src/Server/Responses.php @@ -18,23 +18,23 @@ interface Responses { /** - * Creates a response with the specified status code, body, and headers. + * Creates a response with a 200 OK status. * * @param mixed $body The body of the response. - * @param Code $code The HTTP status code for the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated response with the specified status code, body, and headers. + * @return ResponseInterface The generated 200 OK response. */ - public static function from(mixed $body, Code $code, Headerable ...$headers): ResponseInterface; + public static function ok(mixed $body, Headerable ...$headers): ResponseInterface; /** - * Creates a response with a 200 OK status. + * Creates a response with the specified status code, body, and headers. * * @param mixed $body The body of the response. + * @param Code $code The HTTP status code for the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated 200 OK response. + * @return ResponseInterface The generated response with the specified status code, body, and headers. */ - public static function ok(mixed $body, Headerable ...$headers): ResponseInterface; + public static function from(mixed $body, Code $code, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 201 Created status. @@ -55,95 +55,95 @@ public static function created(mixed $body, Headerable ...$headers): ResponseInt public static function accepted(mixed $body, Headerable ...$headers): ResponseInterface; /** - * Creates a response with a 204 No Content status. - * - * Unlike the body-carrying helpers, this one adds no default media type: there is no payload to describe, and - * a Content-Type over zero bytes makes a strict client parse a body that was never sent. A ContentType passed - * by the caller is still honored. + * Creates a response with a 409 Conflict status. * + * @param mixed $body The body of the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated 204 No Content response. + * @return ResponseInterface The generated 409 Conflict response. */ - public static function noContent(Headerable ...$headers): ResponseInterface; + public static function conflict(mixed $body, Headerable ...$headers): ResponseInterface; /** - * Creates a response with a 400 Bad Request status. + * Creates a response with a 404 Not Found status. * * @param mixed $body The body of the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated 400 Bad Request response. + * @return ResponseInterface The generated 404 Not Found response. */ - public static function badRequest(mixed $body, Headerable ...$headers): ResponseInterface; + public static function notFound(mixed $body, Headerable ...$headers): ResponseInterface; /** - * Creates a response with a 401 Unauthorized status. + * Creates a response with a 403 Forbidden status. * * @param mixed $body The body of the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated 401 Unauthorized response. + * @return ResponseInterface The generated 403 Forbidden response. */ - public static function unauthorized(mixed $body, Headerable ...$headers): ResponseInterface; + public static function forbidden(mixed $body, Headerable ...$headers): ResponseInterface; /** - * Creates a response with a 403 Forbidden status. + * Creates a response with a 204 No Content status. + * + * Unlike the body-carrying helpers, this one adds no default media type: there is no payload to describe, and + * a Content-Type over zero bytes makes a strict client parse a body that was never sent. A ContentType passed + * by the caller is still honored. * - * @param mixed $body The body of the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated 403 Forbidden response. + * @return ResponseInterface The generated 204 No Content response. */ - public static function forbidden(mixed $body, Headerable ...$headers): ResponseInterface; + public static function noContent(Headerable ...$headers): ResponseInterface; /** - * Creates a response with a 404 Not Found status. + * Creates a response with a 502 Bad Gateway status. * * @param mixed $body The body of the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated 404 Not Found response. + * @return ResponseInterface The generated 502 Bad Gateway response. */ - public static function notFound(mixed $body, Headerable ...$headers): ResponseInterface; + public static function badGateway(mixed $body, Headerable ...$headers): ResponseInterface; /** - * Creates a response with a 409 Conflict status. + * Creates a response with a 400 Bad Request status. * * @param mixed $body The body of the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated 409 Conflict response. + * @return ResponseInterface The generated 400 Bad Request response. */ - public static function conflict(mixed $body, Headerable ...$headers): ResponseInterface; + public static function badRequest(mixed $body, Headerable ...$headers): ResponseInterface; /** - * Creates a response with a 422 Unprocessable Entity status. + * Creates a response with a 401 Unauthorized status. * * @param mixed $body The body of the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated 422 Unprocessable Entity response. + * @return ResponseInterface The generated 401 Unauthorized response. */ - public static function unprocessableEntity(mixed $body, Headerable ...$headers): ResponseInterface; + public static function unauthorized(mixed $body, Headerable ...$headers): ResponseInterface; /** - * Creates a response with a 500 Internal Server Error status. + * Creates a response with a 503 Service Unavailable status. * * @param mixed $body The body of the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated 500 Internal Server Error response. + * @return ResponseInterface The generated 503 Service Unavailable response. */ - public static function internalServerError(mixed $body, Headerable ...$headers): ResponseInterface; + public static function serviceUnavailable(mixed $body, Headerable ...$headers): ResponseInterface; /** - * Creates a response with a 502 Bad Gateway status. + * Creates a response with a 500 Internal Server Error status. * * @param mixed $body The body of the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated 502 Bad Gateway response. + * @return ResponseInterface The generated 500 Internal Server Error response. */ - public static function badGateway(mixed $body, Headerable ...$headers): ResponseInterface; + public static function internalServerError(mixed $body, Headerable ...$headers): ResponseInterface; /** - * Creates a response with a 503 Service Unavailable status. + * Creates a response with a 422 Unprocessable Entity status. * * @param mixed $body The body of the response. * @param Headerable ...$headers Optional additional headers for the response. - * @return ResponseInterface The generated 503 Service Unavailable response. + * @return ResponseInterface The generated 422 Unprocessable Entity response. */ - public static function serviceUnavailable(mixed $body, Headerable ...$headers): ResponseInterface; + public static function unprocessableEntity(mixed $body, Headerable ...$headers): ResponseInterface; } diff --git a/tests/Unit/Client/HeaderSettingClientTest.php b/tests/Unit/Client/HeaderSettingClientTest.php index 0910c88..5e9156c 100644 --- a/tests/Unit/Client/HeaderSettingClientTest.php +++ b/tests/Unit/Client/HeaderSettingClientTest.php @@ -12,24 +12,6 @@ final class HeaderSettingClientTest extends TestCase { - public function testSetsTheResolvedValueAsAHeader(): void - { - /** @Given a client setting a header resolved at send time */ - $network = new ScriptedClient(); - $network->answers(new Response()); - $client = HeaderSettingClient::with( - client: $network, - headerValues: ['Correlation-Id' => static fn(): string => 'corr-123'] - ); - - /** @When a request is sent */ - $client->sendRequest(new Request('GET', 'https://service.test/ping')); - - /** @Then the outbound request carries the resolved header */ - $sentRequest = $network->requestAt(index: 0); - self::assertSame('corr-123', $sentRequest->getHeaderLine('Correlation-Id')); - } - public function testResolvesTheValueOnEverySend(): void { /** @Given a value that changes between sends */ @@ -52,6 +34,24 @@ public function testResolvesTheValueOnEverySend(): void self::assertSame('second-id', $network->requestAt(index: 1)->getHeaderLine('Correlation-Id')); } + public function testSetsTheResolvedValueAsAHeader(): void + { + /** @Given a client setting a header resolved at send time */ + $network = new ScriptedClient(); + $network->answers(new Response()); + $client = HeaderSettingClient::with( + client: $network, + headerValues: ['Correlation-Id' => static fn(): string => 'corr-123'] + ); + + /** @When a request is sent */ + $client->sendRequest(new Request('GET', 'https://service.test/ping')); + + /** @Then the outbound request carries the resolved header */ + $sentRequest = $network->requestAt(index: 0); + self::assertSame('corr-123', $sentRequest->getHeaderLine('Correlation-Id')); + } + public function testOmitsTheHeaderWhenTheValueResolvesEmpty(): void { /** @Given a header value that resolves to an empty string */ @@ -90,50 +90,50 @@ public function testReplacesAHeaderAlreadyPresentOnTheRequest(): void self::assertSame('resolved-id', $network->requestAt(index: 0)->getHeaderLine('Correlation-Id')); } - public function testKeepsSettingTheRemainingHeadersAfterAnEmptyValue(): void + public function testSetsSeveralHeadersPreservingUnrelatedOnes(): void { - /** @Given a first header resolving empty followed by a second one resolving a value */ + /** @Given a client setting two headers on a request carrying an unrelated one */ $network = new ScriptedClient(); $network->answers(new Response()); $client = HeaderSettingClient::with( client: $network, headerValues: [ - 'Correlation-Id' => static fn(): string => '', - 'X-Service-Token' => static fn(): string => 'token-xyz' + 'X-Service-Token' => static fn(): string => 'token-abc', + 'Correlation-Id' => static fn(): string => 'corr-999' ] ); - /** @When a request is sent */ - $client->sendRequest(new Request('GET', 'https://service.test/ping')); + /** @When a request with an unrelated header is sent */ + $client->sendRequest( + new Request('GET', 'https://service.test/ping', ['Accept' => 'application/json']) + ); - /** @Then the empty header is omitted and the following one is still set */ + /** @Then both resolved headers are set and the unrelated one is preserved */ $sentRequest = $network->requestAt(index: 0); - self::assertFalse($sentRequest->hasHeader('Correlation-Id')); - self::assertSame('token-xyz', $sentRequest->getHeaderLine('X-Service-Token')); + self::assertSame('token-abc', $sentRequest->getHeaderLine('X-Service-Token')); + self::assertSame('corr-999', $sentRequest->getHeaderLine('Correlation-Id')); + self::assertSame('application/json', $sentRequest->getHeaderLine('Accept')); } - public function testSetsSeveralHeadersPreservingUnrelatedOnes(): void + public function testKeepsSettingTheRemainingHeadersAfterAnEmptyValue(): void { - /** @Given a client setting two headers on a request carrying an unrelated one */ + /** @Given a first header resolving empty followed by a second one resolving a value */ $network = new ScriptedClient(); $network->answers(new Response()); $client = HeaderSettingClient::with( client: $network, headerValues: [ - 'X-Service-Token' => static fn(): string => 'token-abc', - 'Correlation-Id' => static fn(): string => 'corr-999' + 'Correlation-Id' => static fn(): string => '', + 'X-Service-Token' => static fn(): string => 'token-xyz' ] ); - /** @When a request with an unrelated header is sent */ - $client->sendRequest( - new Request('GET', 'https://service.test/ping', ['Accept' => 'application/json']) - ); + /** @When a request is sent */ + $client->sendRequest(new Request('GET', 'https://service.test/ping')); - /** @Then both resolved headers are set and the unrelated one is preserved */ + /** @Then the empty header is omitted and the following one is still set */ $sentRequest = $network->requestAt(index: 0); - self::assertSame('token-abc', $sentRequest->getHeaderLine('X-Service-Token')); - self::assertSame('corr-999', $sentRequest->getHeaderLine('Correlation-Id')); - self::assertSame('application/json', $sentRequest->getHeaderLine('Accept')); + self::assertFalse($sentRequest->hasHeader('Correlation-Id')); + self::assertSame('token-xyz', $sentRequest->getHeaderLine('X-Service-Token')); } } diff --git a/tests/Unit/Client/ResponseTest.php b/tests/Unit/Client/ResponseTest.php index f4b9639..e10f7cb 100644 --- a/tests/Unit/Client/ResponseTest.php +++ b/tests/Unit/Client/ResponseTest.php @@ -178,6 +178,18 @@ public function testRawWhenPsrResponseWrappedThenReturnsUnderlyingInstance(): vo self::assertSame($psrResponse, $response->raw()); } + public function testOrFailWhenSuccessfulResponseGivenThenReturnsSameInstance(): void + { + /** @Given a successful synthesized response */ + $response = Response::with(code: Code::OK); + + /** @When requiring a successful status via orFail */ + $actual = $response->orFail(); + + /** @Then the same response instance is returned */ + self::assertSame($response, $actual); + } + public function testWithWhenCodeAndBodyGivenThenSynthesizesAccessibleResponse(): void { /** @Given a status code and a body payload */ @@ -230,29 +242,27 @@ public function testHeadersWhenPsrResponseGivenThenAccessibleViaHeadersValueObje self::assertSame('123', $response->headers()->get('X-Request-ID')); } - public function testRawWhenSynthesizedResponseGivenThenThrowsSynthesizedResponseHasNoRaw(): void + public function testOrFailWhenUnsuccessfulResponseGivenThenExceptionCarriesCodeAndBody(): void { - /** @Given a synthesized response */ - $response = Response::with(code: Code::OK); - - /** @Then SynthesizedResponseHasNoRaw is thrown with the documented message */ - $this->expectException(SynthesizedResponseHasNoRaw::class); - $this->expectExceptionMessage('Response was synthesized via Response::with(...)'); + /** @Given an unsuccessful status code */ + $code = Code::UNPROCESSABLE_ENTITY; - /** @When calling raw() */ - $response->raw(); - } + /** @And a decoded error body */ + $body = ['error' => 'Invalid payload.']; - public function testOrFailWhenSuccessfulResponseGivenThenReturnsSameInstance(): void - { - /** @Given a successful synthesized response */ - $response = Response::with(code: Code::OK); + /** @And a synthesized response carrying that code and body */ + $response = Response::with(code: $code, body: $body); - /** @When requiring a successful status via orFail */ - $actual = $response->orFail(); + try { + /** @When requiring a successful status via orFail */ + $response->orFail(); + } catch (HttpResponseUnsuccessful $exception) { + /** @Then the exception carries the response status code */ + self::assertSame($code, $exception->code()); - /** @Then the same response instance is returned */ - self::assertSame($response, $actual); + /** @And the exception carries the decoded response body */ + self::assertSame($body['error'], $exception->body()->get(key: 'error')->toString()); + } } public function testOrFailWhenClientErrorResponseGivenThenThrowsHttpResponseUnsuccessful(): void @@ -280,26 +290,16 @@ public function testOrFailWhenServerErrorResponseGivenThenThrowsHttpResponseUnsu $response->orFail(); } - public function testOrFailWhenUnsuccessfulResponseGivenThenExceptionCarriesCodeAndBody(): void + public function testRawWhenSynthesizedResponseGivenThenThrowsSynthesizedResponseHasNoRaw(): void { - /** @Given an unsuccessful status code */ - $code = Code::UNPROCESSABLE_ENTITY; - - /** @And a decoded error body */ - $body = ['error' => 'Invalid payload.']; - - /** @And a synthesized response carrying that code and body */ - $response = Response::with(code: $code, body: $body); + /** @Given a synthesized response */ + $response = Response::with(code: Code::OK); - try { - /** @When requiring a successful status via orFail */ - $response->orFail(); - } catch (HttpResponseUnsuccessful $exception) { - /** @Then the exception carries the response status code */ - self::assertSame($code, $exception->code()); + /** @Then SynthesizedResponseHasNoRaw is thrown with the documented message */ + $this->expectException(SynthesizedResponseHasNoRaw::class); + $this->expectExceptionMessage('Response was synthesized via Response::with(...)'); - /** @And the exception carries the decoded response body */ - self::assertSame($body['error'], $exception->body()->get(key: 'error')->toString()); - } + /** @When calling raw() */ + $response->raw(); } } diff --git a/tests/Unit/CodeTest.php b/tests/Unit/CodeTest.php index 6f1a2cd..fb64023 100644 --- a/tests/Unit/CodeTest.php +++ b/tests/Unit/CodeTest.php @@ -34,6 +34,17 @@ public function testIsSuccessWhenCodeOkGivenThenReturnsTrue(): void self::assertTrue($actual); } + #[DataProvider('timeoutCodesDataProvider')] + public function testIsTimeoutWhenCodeGivenThenReturnsExpected(Code $code, bool $expected): void + { + /** @Given an HTTP status code */ + /** @When checking whether it represents a request or gateway timeout */ + $actual = $code->isTimeout(); + + /** @Then the result matches the expected boolean */ + self::assertSame($expected, $actual); + } + public function testIsRedirectionWhenCodeOkGivenThenReturnsFalse(): void { /** @Given Code::OK */ @@ -91,17 +102,6 @@ public function testIsSuccessCodeWhenIntegerGivenThenReturnsExpected(int $code, self::assertSame($expected, $actual); } - #[DataProvider('nullableCodesDataProvider')] - public function testTryFromNullableWhenIntegerOrNullGivenThenResolvesExpected(?int $code, ?Code $expected): void - { - /** @Given a nullable HTTP status code */ - /** @When resolving it through tryFromNullable */ - $actual = Code::tryFromNullable(code: $code); - - /** @Then the resolved Code matches the expected value */ - self::assertSame($expected, $actual); - } - #[DataProvider('messagesDataProvider')] public function testMessageWhenKnownCodeGivenThenReturnsRfcDescription(Code $code, string $expected): void { @@ -113,17 +113,6 @@ public function testMessageWhenKnownCodeGivenThenReturnsRfcDescription(Code $cod self::assertSame($expected, $actual); } - #[DataProvider('timeoutCodesDataProvider')] - public function testIsTimeoutWhenCodeGivenThenReturnsExpected(Code $code, bool $expected): void - { - /** @Given an HTTP status code */ - /** @When checking whether it represents a request or gateway timeout */ - $actual = $code->isTimeout(); - - /** @Then the result matches the expected boolean */ - self::assertSame($expected, $actual); - } - public function testIsClientErrorWhenCodeBadRequestGivenThenReturnsTrue(): void { /** @Given Code::BAD_REQUEST */ @@ -220,6 +209,17 @@ public function testIsSuccessWhenCodeInternalServerErrorGivenThenReturnsFalse(): self::assertFalse($actual); } + #[DataProvider('nullableCodesDataProvider')] + public function testTryFromNullableWhenIntegerOrNullGivenThenResolvesExpected(?int $code, ?Code $expected): void + { + /** @Given a nullable HTTP status code */ + /** @When resolving it through tryFromNullable */ + $actual = Code::tryFromNullable(code: $code); + + /** @Then the resolved Code matches the expected value */ + self::assertSame($expected, $actual); + } + public function testIsRedirectionWhenCodePermanentRedirectGivenThenReturnsTrue(): void { /** @Given Code::PERMANENT_REDIRECT */ diff --git a/tests/Unit/ContentTypeTest.php b/tests/Unit/ContentTypeTest.php index c02e628..9ef1720 100644 --- a/tests/Unit/ContentTypeTest.php +++ b/tests/Unit/ContentTypeTest.php @@ -59,27 +59,27 @@ public function testToArrayWhenCharsetGivenThenReturnsHeaderMapWithCharset(): vo self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual); } - public function testMimeTypeWhenJsonContentTypeGivenThenReturnsApplicationJsonMimeType(): void + public function testMimeTypeWhenCharsetGivenThenReturnsMimeTypeWithoutCharset(): void { - /** @Given a ContentType for application/json */ - $contentType = ContentType::applicationJson(); + /** @Given a ContentType for text/plain carrying a charset */ + $contentType = ContentType::textPlain(charset: Charset::UTF_8); /** @When asking for its MIME type */ $actual = $contentType->mimeType(); - /** @Then the application/json MIME type is returned */ - self::assertSame(MimeType::APPLICATION_JSON, $actual); + /** @Then the bare text/plain MIME type is returned regardless of the charset */ + self::assertSame(MimeType::TEXT_PLAIN, $actual); } - public function testMimeTypeWhenCharsetGivenThenReturnsMimeTypeWithoutCharset(): void + public function testMimeTypeWhenJsonContentTypeGivenThenReturnsApplicationJsonMimeType(): void { - /** @Given a ContentType for text/plain carrying a charset */ - $contentType = ContentType::textPlain(charset: Charset::UTF_8); + /** @Given a ContentType for application/json */ + $contentType = ContentType::applicationJson(); /** @When asking for its MIME type */ $actual = $contentType->mimeType(); - /** @Then the bare text/plain MIME type is returned regardless of the charset */ - self::assertSame(MimeType::TEXT_PLAIN, $actual); + /** @Then the application/json MIME type is returned */ + self::assertSame(MimeType::APPLICATION_JSON, $actual); } } diff --git a/tests/Unit/FailingTransport.php b/tests/Unit/FailingTransport.php index 6c0507d..dfb7298 100644 --- a/tests/Unit/FailingTransport.php +++ b/tests/Unit/FailingTransport.php @@ -20,7 +20,7 @@ private function __construct(private Closure $factory) { } - public static function raisingNetworkFailure(string $reason, RuntimeException $cause): FailingTransport + public static function raisingNetworkFailure(RuntimeException $cause, string $reason): FailingTransport { $factory = static fn(Request $request): HttpException => HttpNetworkFailed::from( url: $request->url(), @@ -32,7 +32,7 @@ public static function raisingNetworkFailure(string $reason, RuntimeException $c return new FailingTransport(factory: $factory); } - public static function raisingRequestFailure(string $reason, RuntimeException $cause): FailingTransport + public static function raisingRequestFailure(RuntimeException $cause, string $reason): FailingTransport { $factory = static fn(Request $request): HttpException => HttpRequestFailed::from( url: $request->url(), @@ -44,7 +44,7 @@ public static function raisingRequestFailure(string $reason, RuntimeException $c return new FailingTransport(factory: $factory); } - public static function raisingRequestInvalid(string $reason, RuntimeException $cause): FailingTransport + public static function raisingRequestInvalid(RuntimeException $cause, string $reason): FailingTransport { $factory = static fn(Request $request): HttpException => HttpRequestInvalid::from( url: $request->url(), diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php index d2ecc9e..0af0c5c 100644 --- a/tests/Unit/HeadersTest.php +++ b/tests/Unit/HeadersTest.php @@ -51,6 +51,16 @@ public function testHasWhenMissingKeyGivenThenReturnsFalse(): void self::assertFalse($headers->has('Content-Type')); } + public function testAttributeWhenHeaderAbsentThenReturnsNull(): void + { + /** @Given headers carrying a single entry */ + $headers = Headers::fromArray(entries: ['X-Trace-Id' => 'abc-123']); + + /** @When asking for a header that is not present */ + /** @Then null is returned */ + self::assertNull($headers->attribute(name: 'X-Missing')); + } + #[DataProvider('invalidHeaderNameProvider')] public function testWithWhenInvalidHeaderNameGivenThenThrows(string $name): void { @@ -384,16 +394,6 @@ public function testAttributeWhenHeaderPresentThenReturnsAttributeWrappingValue( self::assertSame('abc-123', $actual?->toString()); } - public function testAttributeWhenHeaderAbsentThenReturnsNull(): void - { - /** @Given headers carrying a single entry */ - $headers = Headers::fromArray(entries: ['X-Trace-Id' => 'abc-123']); - - /** @When asking for a header that is not present */ - /** @Then null is returned */ - self::assertNull($headers->attribute(name: 'X-Missing')); - } - public function testAttributeWhenHeaderPresentButEmptyThenReturnsAttributeWrappingEmptyString(): void { /** @Given headers carrying an entry with an empty value */ diff --git a/tests/Unit/HttpBuilderTest.php b/tests/Unit/HttpBuilderTest.php index bf1b36a..6474d08 100644 --- a/tests/Unit/HttpBuilderTest.php +++ b/tests/Unit/HttpBuilderTest.php @@ -139,6 +139,40 @@ public function testBuildWhenFullyConfiguredThenProducesWorkingHttp(): void self::assertSame(Code::OK, $response->code()); } + public function testBuildWhenDefaultHeadersProvidedThenReachTransport(): void + { + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + + /** @And a builder configured with a default header */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with(client: $client, factory: new Psr17Factory())) + ->withDefaultHeaders(headers: Headers::fromArray(entries: ['Authorization' => 'Bearer token'])) + ->build(); + + /** @When sending a request */ + $http->send(request: Request::get(url: '/dragons')); + + /** @Then the default header reaches the transport */ + self::assertNotNull($client->captured); + self::assertSame('Bearer token', $client->captured->getHeaderLine('Authorization')); + } + + public function testWithDefaultHeadersWhenInvokedThenReturnsNewBuilder(): void + { + /** @Given an empty builder */ + $original = Http::create(); + + /** @When calling withDefaultHeaders */ + $updated = $original->withDefaultHeaders( + headers: Headers::fromArray(entries: ['Authorization' => 'Bearer token']) + ); + + /** @Then a new builder instance is returned */ + self::assertNotSame($original, $updated); + } + public function testWithBaseUrlWhenInvokedThenOriginalBuilderStillThrows(): void { /** @Given an empty builder */ @@ -266,38 +300,4 @@ public function testWithBaseUrlWhenSchemeEmbeddedInPathGivenThenThrowsBaseUrlIsI /** @When setting a base URL with the scheme embedded mid-string */ $builder->withBaseUrl(url: 'example.com?redirect=https://api.example.com'); } - - public function testWithDefaultHeadersWhenInvokedThenReturnsNewBuilder(): void - { - /** @Given an empty builder */ - $original = Http::create(); - - /** @When calling withDefaultHeaders */ - $updated = $original->withDefaultHeaders( - headers: Headers::fromArray(entries: ['Authorization' => 'Bearer token']) - ); - - /** @Then a new builder instance is returned */ - self::assertNotSame($original, $updated); - } - - public function testBuildWhenDefaultHeadersProvidedThenReachTransport(): void - { - /** @Given a capturing client */ - $client = CapturingClient::returningStatus(statusCode: 200); - - /** @And a builder configured with a default header */ - $http = Http::create() - ->withBaseUrl(url: 'https://api.example.com') - ->withTransport(transport: NetworkTransport::with(client: $client, factory: new Psr17Factory())) - ->withDefaultHeaders(headers: Headers::fromArray(entries: ['Authorization' => 'Bearer token'])) - ->build(); - - /** @When sending a request */ - $http->send(request: Request::get(url: '/dragons')); - - /** @Then the default header reaches the transport */ - self::assertNotNull($client->captured); - self::assertSame('Bearer token', $client->captured->getHeaderLine('Authorization')); - } } diff --git a/tests/Unit/HttpTest.php b/tests/Unit/HttpTest.php index c53f111..a956b0b 100644 --- a/tests/Unit/HttpTest.php +++ b/tests/Unit/HttpTest.php @@ -151,6 +151,26 @@ public function testWithWhenFtpSchemeGivenThenThrowsBaseUrlIsInvalid(): void Http::with(baseUrl: 'ftp://example.com', transport: $transport); } + public function testSendWhenDefaultHeaderProvidedThenReachesTransport(): void + { + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + + /** @And an Http instance carrying a default Authorization header */ + $http = Http::with( + baseUrl: 'https://api.example.com', + transport: NetworkTransport::with(client: $client, factory: $this->factory), + defaultHeaders: Headers::fromArray(entries: ['Authorization' => 'Bearer token']) + ); + + /** @When sending a request that does not set that header */ + $http->send(request: Request::get(url: '/dragons')); + + /** @Then the default header reaches the transport */ + self::assertNotNull($client->captured); + self::assertSame('Bearer token', $client->captured->getHeaderLine('Authorization')); + } + public function testWithWhenControlCharGivenThenThrowsBaseUrlIsInvalid(): void { /** @Given a transport seeded with a response */ @@ -199,6 +219,27 @@ public function testSendWhenControlCharsInPathGivenThenThrowsMalformedPath(): vo $http->send(request: Request::get(url: "/dragons\x00/evil")); } + public function testSendWhenDefaultHeaderMatchesJsonDefaultThenDefaultWins(): void + { + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + + /** @And an Http instance whose default Content-Type differs from the JSON default */ + $http = Http::with( + baseUrl: 'https://api.example.com', + transport: NetworkTransport::with(client: $client, factory: $this->factory), + defaultHeaders: Headers::fromArray(entries: ['Content-Type' => 'application/xml']) + ); + + /** @When sending a request that does not set Content-Type */ + $http->send(request: Request::get(url: '/dragons')); + + /** @Then the default Content-Type wins while the JSON Accept default still applies */ + self::assertNotNull($client->captured); + self::assertSame('application/xml', $client->captured->getHeaderLine('Content-Type')); + self::assertSame('application/json', $client->captured->getHeaderLine('Accept')); + } + public function testSendWhenEmptyQueryArrayGivenThenNoTrailingQuestionMark(): void { /** @Given an Http instance and an empty query array */ @@ -219,6 +260,26 @@ public function testSendWhenEmptyQueryArrayGivenThenNoTrailingQuestionMark(): vo self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); } + public function testSendWhenNoDefaultHeadersGivenThenJsonDefaultsStillApply(): void + { + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + + /** @And an Http instance without default headers */ + $http = Http::with( + baseUrl: 'https://api.example.com', + transport: NetworkTransport::with(client: $client, factory: $this->factory) + ); + + /** @When sending a plain request */ + $http->send(request: Request::get(url: '/dragons')); + + /** @Then the JSON defaults are applied */ + self::assertNotNull($client->captured); + self::assertSame('application/json', $client->captured->getHeaderLine('Accept')); + self::assertSame('application/json', $client->captured->getHeaderLine('Content-Type')); + } + public function testWithWhenJavascriptSchemeGivenThenThrowsBaseUrlIsInvalid(): void { /** @Given a transport seeded with a response */ @@ -273,6 +334,32 @@ public function testSendWhenNetworkExceptionRaisedThenPreservesPreviousChain(): } } + public function testSendWhenPerRequestHeaderMatchesDefaultThenPerRequestWins(): void + { + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + + /** @And an Http instance carrying a default Authorization header */ + $http = Http::with( + baseUrl: 'https://api.example.com', + transport: NetworkTransport::with(client: $client, factory: $this->factory), + defaultHeaders: Headers::fromArray(entries: ['Authorization' => 'Bearer default']) + ); + + /** @And a request setting its own Authorization header */ + $request = Request::get( + url: '/dragons', + headers: Headers::fromArray(entries: ['Authorization' => 'Bearer per-request']) + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the per-request header wins over the default */ + self::assertNotNull($client->captured); + self::assertSame('Bearer per-request', $client->captured->getHeaderLine('Authorization')); + } + public function testSendWhenProtocolRelativePathGivenThenThrowsMalformedPath(): void { /** @Given an Http instance with a base URL */ @@ -291,6 +378,32 @@ public function testSendWhenProtocolRelativePathGivenThenThrowsMalformedPath(): $http->send(request: Request::get(url: '//evil.example.com/attack')); } + public function testSendWhenPerRequestContentTypeGivenThenOverridesJsonDefault(): void + { + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + + /** @And an Http instance without default headers */ + $http = Http::with( + baseUrl: 'https://api.example.com', + transport: NetworkTransport::with(client: $client, factory: $this->factory) + ); + + /** @And a request setting its own Content-Type */ + $request = Request::post( + url: '/dragons', + body: ['name' => 'Hydra'], + headers: Headers::fromArray(entries: ['Content-Type' => 'application/xml']) + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the per-request Content-Type overrides the JSON default */ + self::assertNotNull($client->captured); + self::assertSame('application/xml', $client->captured->getHeaderLine('Content-Type')); + } + public function testSendWhenBaseUrlEmptyAndRelativePathGivenThenUsesPathDirectly(): void { /** @Given an Http instance with an empty base URL */ @@ -586,117 +699,4 @@ public function testSendWhenBaseUrlWithoutTrailingSlashAndPathWithoutLeadingSlas self::assertNotNull($client->captured); self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); } - - public function testSendWhenDefaultHeaderProvidedThenReachesTransport(): void - { - /** @Given a capturing client */ - $client = CapturingClient::returningStatus(statusCode: 200); - - /** @And an Http instance carrying a default Authorization header */ - $http = Http::with( - baseUrl: 'https://api.example.com', - transport: NetworkTransport::with(client: $client, factory: $this->factory), - defaultHeaders: Headers::fromArray(entries: ['Authorization' => 'Bearer token']) - ); - - /** @When sending a request that does not set that header */ - $http->send(request: Request::get(url: '/dragons')); - - /** @Then the default header reaches the transport */ - self::assertNotNull($client->captured); - self::assertSame('Bearer token', $client->captured->getHeaderLine('Authorization')); - } - - public function testSendWhenNoDefaultHeadersGivenThenJsonDefaultsStillApply(): void - { - /** @Given a capturing client */ - $client = CapturingClient::returningStatus(statusCode: 200); - - /** @And an Http instance without default headers */ - $http = Http::with( - baseUrl: 'https://api.example.com', - transport: NetworkTransport::with(client: $client, factory: $this->factory) - ); - - /** @When sending a plain request */ - $http->send(request: Request::get(url: '/dragons')); - - /** @Then the JSON defaults are applied */ - self::assertNotNull($client->captured); - self::assertSame('application/json', $client->captured->getHeaderLine('Accept')); - self::assertSame('application/json', $client->captured->getHeaderLine('Content-Type')); - } - - public function testSendWhenPerRequestHeaderMatchesDefaultThenPerRequestWins(): void - { - /** @Given a capturing client */ - $client = CapturingClient::returningStatus(statusCode: 200); - - /** @And an Http instance carrying a default Authorization header */ - $http = Http::with( - baseUrl: 'https://api.example.com', - transport: NetworkTransport::with(client: $client, factory: $this->factory), - defaultHeaders: Headers::fromArray(entries: ['Authorization' => 'Bearer default']) - ); - - /** @And a request setting its own Authorization header */ - $request = Request::get( - url: '/dragons', - headers: Headers::fromArray(entries: ['Authorization' => 'Bearer per-request']) - ); - - /** @When sending the request */ - $http->send(request: $request); - - /** @Then the per-request header wins over the default */ - self::assertNotNull($client->captured); - self::assertSame('Bearer per-request', $client->captured->getHeaderLine('Authorization')); - } - - public function testSendWhenDefaultHeaderMatchesJsonDefaultThenDefaultWins(): void - { - /** @Given a capturing client */ - $client = CapturingClient::returningStatus(statusCode: 200); - - /** @And an Http instance whose default Content-Type differs from the JSON default */ - $http = Http::with( - baseUrl: 'https://api.example.com', - transport: NetworkTransport::with(client: $client, factory: $this->factory), - defaultHeaders: Headers::fromArray(entries: ['Content-Type' => 'application/xml']) - ); - - /** @When sending a request that does not set Content-Type */ - $http->send(request: Request::get(url: '/dragons')); - - /** @Then the default Content-Type wins while the JSON Accept default still applies */ - self::assertNotNull($client->captured); - self::assertSame('application/xml', $client->captured->getHeaderLine('Content-Type')); - self::assertSame('application/json', $client->captured->getHeaderLine('Accept')); - } - - public function testSendWhenPerRequestContentTypeGivenThenOverridesJsonDefault(): void - { - /** @Given a capturing client */ - $client = CapturingClient::returningStatus(statusCode: 200); - - /** @And an Http instance without default headers */ - $http = Http::with( - baseUrl: 'https://api.example.com', - transport: NetworkTransport::with(client: $client, factory: $this->factory) - ); - - /** @And a request setting its own Content-Type */ - $request = Request::post( - url: '/dragons', - body: ['name' => 'Hydra'], - headers: Headers::fromArray(entries: ['Content-Type' => 'application/xml']) - ); - - /** @When sending the request */ - $http->send(request: $request); - - /** @Then the per-request Content-Type overrides the JSON default */ - self::assertNotNull($client->captured); - self::assertSame('application/xml', $client->captured->getHeaderLine('Content-Type')); - } } diff --git a/tests/Unit/Server/HeadersTest.php b/tests/Unit/Server/HeadersTest.php index 285e180..f67b2a2 100644 --- a/tests/Unit/Server/HeadersTest.php +++ b/tests/Unit/Server/HeadersTest.php @@ -38,6 +38,16 @@ public function testWithHeaderWhenHeaderAbsentThenCreatesIt(): void self::assertSame(['value'], $actual->getHeader('X-Trace')); } + public function testNoContentWhenInvokedThenCarriesNoContentType(): void + { + /** @When a no-content response is created */ + $response = Response::noContent(); + + /** @Then no Content-Type is announced, because there is no payload for it to describe */ + self::assertFalse($response->hasHeader('Content-Type')); + self::assertSame([], $response->getHeaders()); + } + public function testGetHeaderWhenHeaderMissingThenReturnsEmptyArray(): void { /** @Given an HTTP response with no custom headers */ @@ -63,37 +73,6 @@ public function testNoContentWhenContentTypeIsPdfThenHeaderReflectsIt(): void self::assertSame('application/pdf', $actual->getHeaderLine('Content-Type')); } - public function testNoContentWhenInvokedThenCarriesNoContentType(): void - { - /** @When a no-content response is created */ - $response = Response::noContent(); - - /** @Then no Content-Type is announced, because there is no payload for it to describe */ - self::assertFalse($response->hasHeader('Content-Type')); - self::assertSame([], $response->getHeaders()); - } - - public function testNoContentWhenContentTypeGivenThenTheExplicitValueSurvives(): void - { - /** @Given a media type the caller chose for a bodiless response */ - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - - /** @When the response is created with it */ - $actual = Response::noContent($contentType); - - /** @Then the caller's value is carried, since dropping the default never drops an explicit header */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testOkWhenNoContentTypeGivenThenCarriesDefaultContentType(): void - { - /** @When a response with a body is created without a media type */ - $actual = Response::ok(body: ['name' => 'Hydra']); - - /** @Then the default Content-Type still applies, because there is a payload to describe */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - public function testWithHeaderWhenSameHeaderSetTwiceThenLastValueWins(): void { /** @Given an HTTP response carrying no Content-Type */ @@ -135,6 +114,15 @@ public function testNoContentWhenContentTypeIsJsonThenHeaderReflectsIt(): void self::assertSame('application/json', $actual->getHeaderLine('Content-Type')); } + public function testOkWhenNoContentTypeGivenThenCarriesDefaultContentType(): void + { + /** @When a response with a body is created without a media type */ + $actual = Response::ok(body: ['name' => 'Hydra']); + + /** @Then the default Content-Type still applies, because there is a payload to describe */ + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + } + public function testWithoutHeaderWhenCaseMismatchedThenStillRemovesHeader(): void { /** @Given an HTTP response with a custom header */ @@ -173,6 +161,20 @@ public function testNoContentWhenContentTypeIsPlainTextThenHeaderReflectsIt(): v self::assertSame('text/plain', $actual->getHeaderLine('Content-Type')); } + public function testWithHeaderWhenChainedWithDistinctKeysThenBothArePresent(): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @When two distinct custom headers are added in a chain */ + $actual = $response + ->withHeader('X-ID', '100') + ->withHeader('X-NAME', 'Xpto'); + + /** @Then both custom headers are present */ + self::assertSame(['X-ID' => ['100'], 'X-NAME' => ['Xpto']], $actual->getHeaders()); + } + public function testNoContentWhenHeaderableEmitsStringValueThenWrapsItInList(): void { /** @Given a Headerable whose toArray() emits a string value (not a list) */ @@ -185,6 +187,18 @@ public function testNoContentWhenHeaderableEmitsStringValueThenWrapsItInList(): self::assertSame(['MyApp/1.2.3'], $actual->getHeader('User-Agent')); } + public function testNoContentWhenContentTypeGivenThenTheExplicitValueSurvives(): void + { + /** @Given a media type the caller chose for a bodiless response */ + $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + + /** @When the response is created with it */ + $actual = Response::noContent($contentType); + + /** @Then the caller's value is carried, since dropping the default never drops an explicit header */ + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + } + public function testNoContentWhenContentTypeIsOctetStreamThenHeaderReflectsIt(): void { /** @Given the Content-Type header set to application/octet-stream */ @@ -250,19 +264,6 @@ public function testNoContentWhenMultipleHeaderablesGivenThenCacheControlIsPrese self::assertSame(['no-store'], $actual->getHeader('Cache-Control')); } - public function testWithAddedHeaderWhenDistinctValueGivenThenAppendsToExistingHeader(): void - { - /** @Given an HTTP response with a custom header */ - $response = Response::noContent()->withHeader('X-Trace', 'first'); - - /** @When a distinct value is added to the same header */ - $actual = $response->withAddedHeader('X-Trace', 'second'); - - /** @Then both values are preserved in the original order */ - self::assertSame('first, second', $actual->getHeaderLine('X-Trace')); - self::assertSame(['first', 'second'], $actual->getHeader('X-Trace')); - } - public function testNoContentWhenMultipleHeaderablesGivenThenContentTypeIsPreserved(): void { /** @Given a Cache-Control header */ @@ -278,6 +279,19 @@ public function testNoContentWhenMultipleHeaderablesGivenThenContentTypeIsPreser self::assertSame(['text/plain'], $actual->getHeader('Content-Type')); } + public function testWithAddedHeaderWhenDistinctValueGivenThenAppendsToExistingHeader(): void + { + /** @Given an HTTP response with a custom header */ + $response = Response::noContent()->withHeader('X-Trace', 'first'); + + /** @When a distinct value is added to the same header */ + $actual = $response->withAddedHeader('X-Trace', 'second'); + + /** @Then both values are preserved in the original order */ + self::assertSame('first, second', $actual->getHeaderLine('X-Trace')); + self::assertSame(['first', 'second'], $actual->getHeader('X-Trace')); + } + public function testNoContentWhenCacheControlWithEveryDirectiveGivenThenHeaderRendersAll(): void { /** @Given a Cache-Control header with multiple directives */ @@ -307,18 +321,4 @@ public function testNoContentWhenCacheControlWithEveryDirectiveGivenThenHeaderRe /** @And nothing sits beside it, because a bodiless response invents no media type of its own */ self::assertSame($cacheControl->toArray(), $actual->getHeaders()); } - - public function testWithHeaderWhenChainedWithDistinctKeysThenBothArePresent(): void - { - /** @Given an HTTP response */ - $response = Response::noContent(); - - /** @When two distinct custom headers are added in a chain */ - $actual = $response - ->withHeader('X-ID', '100') - ->withHeader('X-NAME', 'Xpto'); - - /** @Then both custom headers are present */ - self::assertSame(['X-ID' => ['100'], 'X-NAME' => ['Xpto']], $actual->getHeaders()); - } } diff --git a/tests/Unit/Server/RequestTest.php b/tests/Unit/Server/RequestTest.php index 289c54a..a99ea4e 100644 --- a/tests/Unit/Server/RequestTest.php +++ b/tests/Unit/Server/RequestTest.php @@ -33,6 +33,56 @@ public function testDecodeWhenUriGivenThenExposesAsString(): void self::assertSame($expectedUri, $actual); } + public function testHeaderWhenHeaderAbsentThenReturnsNull(): void + { + /** @Given a server request without the looked-up header */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com'); + + /** @When asking for a header that is not present */ + /** @Then null is returned */ + self::assertNull(Request::from(request: $serverRequest)->header(name: 'X-Missing')); + } + + public function testOnlyWhenNoKeysGivenThenReturnsEmptyMap(): void + { + /** @Given a server request carrying route attributes */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', 'id' => 'dragon-id']); + + /** @When extracting with no requested keys */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->only(keys: []); + + /** @Then the resulting map is empty */ + self::assertSame([], $actual); + } + + public function testQueryWhenUriHasParametersThenReturnsThem(): void + { + /** @Given a server request carrying query string parameters */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withQueryParams(['sort' => 'name', 'order' => 'asc']); + + /** @When asking for the query parameters */ + $actual = Request::from(request: $serverRequest)->query(); + + /** @Then the query parameters are exposed */ + self::assertSame(['sort' => 'name', 'order' => 'asc'], $actual->toArray()); + self::assertSame('name', $actual->get(key: 'sort')->toString()); + } + + public function testRawBodyWhenBodyEmptyThenReturnsEmptyString(): void + { + /** @Given a server request with an empty body */ + $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com') + ->withBody($this->factory->createStream('')); + + /** @When reading the raw body */ + $actual = Request::from(request: $serverRequest)->rawBody(); + + /** @Then an empty string is returned */ + self::assertSame('', $actual); + } + public function testDecodeWhenBodyGivenThenExposesTypedAccessors(): void { /** @Given a payload to send */ @@ -140,6 +190,20 @@ public function testMethodWhenAnyHttpVerbGivenThenReturnsMatchingEnum( self::assertSame($methodString, $actual->value); } + public function testHeadersWhenMultiValueHeaderGivenThenFoldsWithComma(): void + { + /** @Given a server request carrying a multi-value Accept header */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withHeader('Accept', 'application/json') + ->withAddedHeader('Accept', 'text/html'); + + /** @When asking for the typed headers */ + $actual = Request::from(request: $serverRequest)->headers(); + + /** @Then the multi-value header line is folded with a comma separator */ + self::assertSame('application/json, text/html', $actual->get('Accept')); + } + public function testDecodeWhenInvalidJsonBodyGivenThenReturnsEmptyArray(): void { /** @Given a non-JSON body */ @@ -201,6 +265,25 @@ public function testDecodeWhenRouteHasMultipleAttributesThenExposesEach(): void self::assertSame($attributes['weight'], $route->get(key: 'weight')->toFloat()); } + public function testOnlyWhenAbsentKeyRequestedThenMapWrapsNullForThatKey(): void + { + /** @Given a server request carrying a single route attribute */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', 'id' => 'dragon-id']); + + /** @When extracting a present key alongside an absent one */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->only(keys: ['id', 'missing']); + + /** @Then both requested keys are present in the map */ + self::assertSame(['id', 'missing'], array_keys($actual)); + + /** @And the present key resolves to its value */ + self::assertSame('dragon-id', $actual['id']->toString()); + + /** @And the absent key resolves to an Attribute wrapping null */ + self::assertSame('', $actual['missing']->toString()); + } + public function testDecodeWhenDirectAttributesPresentThenFallbackResolves(): void { /** @Given a request that stores route params as direct attributes */ @@ -235,6 +318,28 @@ public function testDecodeWhenQueryParamsPresentThenExposesTypedAccessors(): voi self::assertTrue($actual->get(key: 'active')->toBoolean()); } + public function testRawBodyWhenSeekableBodyReadThenDecodeStillSeesFullBody(): void + { + /** @Given a server request with a seekable JSON body */ + $serverRequest = new ServerRequest( + method: 'POST', + uri: 'https://api.example.com', + body: $this->factory->createStream('{"name":"Hydra"}') + ); + + /** @And a typed wrapper over that request */ + $request = Request::from(request: $serverRequest); + + /** @When reading the raw body */ + $actual = $request->rawBody(); + + /** @Then the raw body is returned */ + self::assertSame('{"name":"Hydra"}', $actual); + + /** @And a subsequent decode still reads the full body */ + self::assertSame('Hydra', $request->decode()->body()->get(key: 'name')->toString()); + } + public function testDecodeWhenRouteObjectExposesPublicPropertyThenResolvesIt(): void { /** @Given a route object exposing public properties */ @@ -275,29 +380,6 @@ public function getArguments(): array self::assertSame('dragon@fire.com', $route->get(key: 'email')->toString()); } - public function testDecodeWhenRouteObjectExposesMethodAndPropertyThenMethodWins(): void - { - /** @Given a route object exposing both getArguments() and a public $arguments property */ - $routeObject = new class { - public array $arguments = ['id' => '99', 'source' => 'property']; - - public function getArguments(): array - { - return ['id' => '42', 'source' => 'method']; - } - }; - - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withAttribute('__route__', $routeObject); - - /** @When decoding the route */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - - /** @Then the method wins over the property */ - self::assertSame('42', $route->get(key: 'id')->toString()); - self::assertSame('method', $route->get(key: 'source')->toString()); - } - public function testDecodeWhenSymfonyAttributePresentThenFallbackScanFindsIt(): void { /** @Given Symfony stores params under _route_params and default __route__ is absent */ @@ -311,6 +393,69 @@ public function testDecodeWhenSymfonyAttributePresentThenFallbackScanFindsIt(): self::assertSame('55', $route->get(key: 'id')->toString()); } + public function testHeaderWhenHeaderPresentThenReturnsAttributeWrappingValue(): void + { + /** @Given a server request carrying a Content-Type header */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withHeader('Content-Type', 'application/json'); + + /** @When asking for the single header as a typed attribute */ + $actual = Request::from(request: $serverRequest)->header(name: 'content-type'); + + /** @Then the attribute wraps the folded header value */ + self::assertSame('application/json', $actual?->toString()); + } + + public function testRawBodyWhenBodyGivenThenReturnsExactBytesWithoutDecoding(): void + { + /** @Given a server request with a raw body carrying insignificant whitespace */ + $serverRequest = new ServerRequest( + method: 'POST', + uri: 'https://api.example.com', + body: $this->factory->createStream('{ "name" : "Hydra" }') + ); + + /** @When reading the raw body */ + $actual = Request::from(request: $serverRequest)->rawBody(); + + /** @Then the exact bytes are returned without JSON decoding */ + self::assertSame('{ "name" : "Hydra" }', $actual); + } + + public function testHeadersWhenMessageHasHeaderThenExposesItCaseInsensitively(): void + { + /** @Given a server request carrying a Content-Type header */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withHeader('Content-Type', 'application/json'); + + /** @When asking for the typed headers */ + $actual = Request::from(request: $serverRequest)->headers(); + + /** @Then the header is exposed and looked up case-insensitively */ + self::assertSame('application/json', $actual->get('content-type')); + self::assertTrue($actual->has('Content-Type')); + } + + public function testOnlyWhenSeveralPresentKeysGivenThenReturnsMapOfAttributes(): void + { + /** @Given a set of route attributes */ + $attributes = ['id' => 'dragon-id', 'skill' => 'fire-breath']; + + /** @And a server request carrying those attributes under the canonical route key */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withAttribute('__route__', ['name' => '/v1/dragons/{id}/skills/{skill}', ...$attributes]); + + /** @When extracting only the named route attributes */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->only(keys: ['id', 'skill']); + + /** @Then the resulting map is keyed by exactly the requested attributes */ + self::assertSame(['id', 'skill'], array_keys($actual)); + + /** @And each requested key resolves to its typed Attribute value */ + self::assertSame($attributes['id'], $actual['id']->toString()); + self::assertSame($attributes['skill'], $actual['skill']->toString()); + } + public function testDecodeWhenEmptyStreamAndNonArrayParsedBodyThenReturnsEmpty(): void { /** @Given an empty stream and a non-array parsed body */ @@ -367,6 +512,29 @@ public function testDecodeWhenParsedBodyPresentAndStreamEmptyThenUsesParsedBody( self::assertSame($payload['is_legendary'], $actual->get(key: 'is_legendary')->toBoolean()); } + public function testDecodeWhenRouteObjectExposesMethodAndPropertyThenMethodWins(): void + { + /** @Given a route object exposing both getArguments() and a public $arguments property */ + $routeObject = new class { + public array $arguments = ['id' => '99', 'source' => 'property']; + + public function getArguments(): array + { + return ['id' => '42', 'source' => 'method']; + } + }; + + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withAttribute('__route__', $routeObject); + + /** @When decoding the route */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then the method wins over the property */ + self::assertSame('42', $route->get(key: 'id')->toString()); + self::assertSame('method', $route->get(key: 'source')->toString()); + } + public function testDecodeWhenMezzioStyleRouteResultGivenThenResolvesMatchedParams(): void { /** @Given a Mezzio-style route result object that uses getMatchedParams() */ @@ -443,174 +611,6 @@ public function getArguments(): string self::assertSame('', $route->get(key: 'id')->toString()); } - public function testQueryWhenUriHasParametersThenReturnsThem(): void - { - /** @Given a server request carrying query string parameters */ - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withQueryParams(['sort' => 'name', 'order' => 'asc']); - - /** @When asking for the query parameters */ - $actual = Request::from(request: $serverRequest)->query(); - - /** @Then the query parameters are exposed */ - self::assertSame(['sort' => 'name', 'order' => 'asc'], $actual->toArray()); - self::assertSame('name', $actual->get(key: 'sort')->toString()); - } - - public function testRawBodyWhenBodyEmptyThenReturnsEmptyString(): void - { - /** @Given a server request with an empty body */ - $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com') - ->withBody($this->factory->createStream('')); - - /** @When reading the raw body */ - $actual = Request::from(request: $serverRequest)->rawBody(); - - /** @Then an empty string is returned */ - self::assertSame('', $actual); - } - - public function testRawBodyWhenBodyGivenThenReturnsExactBytesWithoutDecoding(): void - { - /** @Given a server request with a raw body carrying insignificant whitespace */ - $serverRequest = new ServerRequest( - method: 'POST', - uri: 'https://api.example.com', - body: $this->factory->createStream('{ "name" : "Hydra" }') - ); - - /** @When reading the raw body */ - $actual = Request::from(request: $serverRequest)->rawBody(); - - /** @Then the exact bytes are returned without JSON decoding */ - self::assertSame('{ "name" : "Hydra" }', $actual); - } - - public function testHeadersWhenMessageHasHeaderThenExposesItCaseInsensitively(): void - { - /** @Given a server request carrying a Content-Type header */ - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withHeader('Content-Type', 'application/json'); - - /** @When asking for the typed headers */ - $actual = Request::from(request: $serverRequest)->headers(); - - /** @Then the header is exposed and looked up case-insensitively */ - self::assertSame('application/json', $actual->get('content-type')); - self::assertTrue($actual->has('Content-Type')); - } - - public function testHeadersWhenMultiValueHeaderGivenThenFoldsWithComma(): void - { - /** @Given a server request carrying a multi-value Accept header */ - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withHeader('Accept', 'application/json') - ->withAddedHeader('Accept', 'text/html'); - - /** @When asking for the typed headers */ - $actual = Request::from(request: $serverRequest)->headers(); - - /** @Then the multi-value header line is folded with a comma separator */ - self::assertSame('application/json, text/html', $actual->get('Accept')); - } - - public function testRawBodyWhenSeekableBodyReadThenDecodeStillSeesFullBody(): void - { - /** @Given a server request with a seekable JSON body */ - $serverRequest = new ServerRequest( - method: 'POST', - uri: 'https://api.example.com', - body: $this->factory->createStream('{"name":"Hydra"}') - ); - - /** @And a typed wrapper over that request */ - $request = Request::from(request: $serverRequest); - - /** @When reading the raw body */ - $actual = $request->rawBody(); - - /** @Then the raw body is returned */ - self::assertSame('{"name":"Hydra"}', $actual); - - /** @And a subsequent decode still reads the full body */ - self::assertSame('Hydra', $request->decode()->body()->get(key: 'name')->toString()); - } - - public function testHeaderWhenHeaderPresentThenReturnsAttributeWrappingValue(): void - { - /** @Given a server request carrying a Content-Type header */ - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withHeader('Content-Type', 'application/json'); - - /** @When asking for the single header as a typed attribute */ - $actual = Request::from(request: $serverRequest)->header(name: 'content-type'); - - /** @Then the attribute wraps the folded header value */ - self::assertSame('application/json', $actual?->toString()); - } - - public function testHeaderWhenHeaderAbsentThenReturnsNull(): void - { - /** @Given a server request without the looked-up header */ - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com'); - - /** @When asking for a header that is not present */ - /** @Then null is returned */ - self::assertNull(Request::from(request: $serverRequest)->header(name: 'X-Missing')); - } - - public function testOnlyWhenSeveralPresentKeysGivenThenReturnsMapOfAttributes(): void - { - /** @Given a set of route attributes */ - $attributes = ['id' => 'dragon-id', 'skill' => 'fire-breath']; - - /** @And a server request carrying those attributes under the canonical route key */ - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withAttribute('__route__', ['name' => '/v1/dragons/{id}/skills/{skill}', ...$attributes]); - - /** @When extracting only the named route attributes */ - $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->only(keys: ['id', 'skill']); - - /** @Then the resulting map is keyed by exactly the requested attributes */ - self::assertSame(['id', 'skill'], array_keys($actual)); - - /** @And each requested key resolves to its typed Attribute value */ - self::assertSame($attributes['id'], $actual['id']->toString()); - self::assertSame($attributes['skill'], $actual['skill']->toString()); - } - - public function testOnlyWhenAbsentKeyRequestedThenMapWrapsNullForThatKey(): void - { - /** @Given a server request carrying a single route attribute */ - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', 'id' => 'dragon-id']); - - /** @When extracting a present key alongside an absent one */ - $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->only(keys: ['id', 'missing']); - - /** @Then both requested keys are present in the map */ - self::assertSame(['id', 'missing'], array_keys($actual)); - - /** @And the present key resolves to its value */ - self::assertSame('dragon-id', $actual['id']->toString()); - - /** @And the absent key resolves to an Attribute wrapping null */ - self::assertSame('', $actual['missing']->toString()); - } - - public function testOnlyWhenNoKeysGivenThenReturnsEmptyMap(): void - { - /** @Given a server request carrying route attributes */ - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', 'id' => 'dragon-id']); - - /** @When extracting with no requested keys */ - $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->only(keys: []); - - /** @Then the resulting map is empty */ - self::assertSame([], $actual); - } - public static function httpMethodsProvider(): array { return [ diff --git a/tests/Unit/Server/ResponseTest.php b/tests/Unit/Server/ResponseTest.php index 0148bf1..cddf361 100644 --- a/tests/Unit/Server/ResponseTest.php +++ b/tests/Unit/Server/ResponseTest.php @@ -501,6 +501,19 @@ public function testGetBodyWhenMetadataRequestedWithoutKeyThenReturnsArray(): vo self::assertIsArray($metadata); } + public function testNoContentWhenUnrelatedHeaderGivenThenAddsNoContentType(): void + { + /** @Given a header that says nothing about the media type */ + $link = Link::to(uri: '/dragons?page=2', relation: LinkRelation::NEXT); + + /** @When a bodiless response is created with that header */ + $actual = Response::noContent($link); + + /** @Then the header is carried and no media type is invented for the absent body */ + self::assertSame(['; rel="next"'], $actual->getHeader('Link')); + self::assertSame([], $actual->getHeader('Content-Type')); + } + public function testBadGatewayWhenBodyGivenThenReturnsResponseWithStatus502(): void { /** @Given a body with upstream failure details */ @@ -631,19 +644,6 @@ public function testGetBodyWhenMetadataRequestedAfterCloseThenReturnsEmptyArray( self::assertSame([], $metadata); } - public function testNoContentWhenUnrelatedHeaderGivenThenAddsNoContentType(): void - { - /** @Given a header that says nothing about the media type */ - $link = Link::to(uri: '/dragons?page=2', relation: LinkRelation::NEXT); - - /** @When a bodiless response is created with that header */ - $actual = Response::noContent($link); - - /** @Then the header is carried and no media type is invented for the absent body */ - self::assertSame(['; rel="next"'], $actual->getHeader('Link')); - self::assertSame([], $actual->getHeader('Content-Type')); - } - public function testResponseFacadeForbidsInstantiationThroughAPrivateConstructor(): void { /** @Given the reflection of the public Response façade */