From 338581c2bd667e2c7c71bcf3d3cd9cebc750f19d Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 22 May 2026 11:49:02 -0300 Subject: [PATCH 1/2] docs: Update ordering rules for class members and chained calls. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/rules/php-library-code-style.md | 82 +++++++++++-------------- .claude/rules/php-library-testing.md | 5 +- 2 files changed, 39 insertions(+), 48 deletions(-) diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md index 8485df7..997b294 100644 --- a/.claude/rules/php-library-code-style.md +++ b/.claude/rules/php-library-code-style.md @@ -44,27 +44,45 @@ Verify every item before producing any PHP code. If any item fails, revise befor 5. Classes follow the rules in "Inheritance and constructors". `final readonly` is the default, with documented exceptions for extension points and for parents that are not `readonly`. 6. Members are ordered constants first, then constructor, then static methods, then instance - methods. Within each group, order by body size ascending (number of lines between `{` and `}`). - Constants and enum cases, which have no body, are ordered by name length ascending. This - ordering may be overridden only when the alternative carries explicit documentation value: - grouping by domain class with section markers (HTTP status codes by 1xx/2xx/3xx/etc), - mirroring the order of an implemented interface, or similar evident structure. The override - must be obvious at first reading. + methods. Within each group, order by **member name length ascending** (count the name only, + without parentheses, arguments, or return type). Constants, enum cases, and methods share + the same name-length-ascending rule, applied within their respective groups. This mirrors + the rule that governs constructor parameters and named arguments (rule 7). When two names + have equal length, order them alphabetically. This ordering may be overridden only when the + alternative carries explicit documentation value: grouping by domain class with section + markers (HTTP status codes by 1xx/2xx/3xx/etc), mirroring the order of an implemented + interface, or similar evident structure. The override must be obvious at first reading. **At call sites** (chained method calls in production code, tests, or documentation - examples), consecutive method invocations on the same receiver are ordered by the **visible - width** of each call expression ascending. The body is not visible at the call site, so the - visible width is the practical proxy for body size. Boolean toggles such as `->secure()` and - `->httpOnly()` come before parameterized `with*` builders for the same reason. When two - calls have equal width, order them alphabetically by method name. + examples), consecutive method invocations on the same receiver are ordered by **method name + length ascending**, the same rule that governs member declarations. Boolean toggles such as + `->secure()` and `->httpOnly()` come before parameterized `with*` builders because their + names are shorter, not because the expression is narrower. When two method names have equal + length, order them alphabetically. **Terminal methods that change the receiver type** stay at the end of the chain regardless - of width. A `build()` that returns the built value, a `commit()` that finalizes a unit of - work, a `send()` that flushes a request, are terminal: the chain ends with them. The + of name length. A `build()` that returns the built value, a `commit()` that finalizes a unit + of work, a `send()` that flushes a request, are terminal: the chain ends with them. The ordering rule applies only to consecutive calls on the same receiver type; calls that transition to a different type are not reorderable. The same applies in reverse to the factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) — it stays at its position. + + **PHPUnit test classes** follow a dedicated sub-grouping inside the instance-methods group + that overrides the name-length-ascending rule: + + 1. **Lifecycle hooks** first, in PHPUnit execution order: + `setUpBeforeClass` → `setUp` → `tearDown` → `tearDownAfterClass`. Only those actually + defined appear; never introduce an empty hook to satisfy the rule. + 2. **Test methods** (prefix `test`) next, ordered by name length ascending (alphabetical + tiebreak). + 3. **Data providers** last, ordered by name length ascending (alphabetical tiebreak). + + A method is a data provider if and only if its name appears as the string argument of a + `#[DataProvider('')]` attribute or a `@dataProvider ` docblock annotation on a + test method in the same class. The naming convention (`*DataProvider`) is informational + only; the reference is the authoritative signal. A method named `*DataProvider` that no + test references is dead code under rule 17, not a data provider. 7. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type), except when parameters have an implicit semantic order (for example, `$start/$end`, `$from/$to`, `$startAt/$endAt`), which takes precedence. Parameters with default @@ -225,10 +243,7 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali ### When required -- Every method of an interface, **including interfaces declared inside `src/Internal/`**. - Interfaces define contracts. The contract is documentation by definition, regardless of - namespace. The `Internal/` boundary applies to implementations, not to the contracts that - internal collaborators expose to each other. +- Every method of an interface. - Every public method of a concrete class outside `src/Internal/`. Public classes are at the public API boundary by definition. Consumers call every public method directly, and the PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt. @@ -244,10 +259,7 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali interface. The interface carries the docblock. - Anything inside `src/Internal/`. Internal types are implementation detail and must not carry PHPDoc. The namespace itself is the boundary. See `php-library-architecture.md` for the - architectural meaning of `Internal/`. **Exception**: interfaces and their methods. An - interface declared inside `src/Internal/` still defines a contract, and the contract is - documented per `### When required` regardless of namespace. The prohibition covers concrete - classes, traits, enums, and anonymous classes inside `Internal/`, never interfaces. + architectural meaning of `Internal/`. - Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyGivenThenZzz` naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in `php-library-testing.md` describe the steps. PHPDoc documentation (summary plus @@ -270,10 +282,7 @@ The PHPDoc prohibitions above take priority over the typed-array case. When PHPS - On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not add PHPDoc. -- On anything inside **`src/Internal/`** (concrete classes, traits, enums) → suppress via - `ignoreErrors`. Do not add PHPDoc. Interfaces inside `src/Internal/` are the exception: - they carry PHPDoc per `### When required`, and the PHPStan errors they raise are resolved - through the PHPDoc, never through `ignoreErrors`. +- On anything inside **`src/Internal/`** → suppress via `ignoreErrors`. Do not add PHPDoc. - On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc. - On a **public method of a public (non-Internal) class** → add full PHPDoc with summary, `@param` descriptions, and the typed-array information. The bare-tag form remains @@ -338,8 +347,7 @@ public function __construct(public array $entries) } ``` -**Prohibited.** PHPDoc on a **concrete class** inside `src/Internal/` (the prohibition does -not extend to interfaces; see "Correct" below for an Internal/ interface): +**Prohibited.** PHPDoc on anything inside `src/Internal/`: ```php namespace TinyBlocks\Http\Internal\Client; @@ -353,26 +361,6 @@ final readonly class Url } ``` -**Correct.** Interface declared **inside `src/Internal/`** still carries PHPDoc on every -method. The Internal/ prohibition covers concrete classes; interfaces are exempt because they -are the contract: - -```php -namespace TinyBlocks\Http\Internal\Client; - -interface RequestResolver -{ - /** - * Resolves the given URL against the configured base URL. - * - * @param string $url The path or absolute URL to resolve. - * @return string The absolute URL to dispatch. - * @throws MalformedPath If the URL violates RFC 3986. - */ - public function resolve(string $url): string; -} -``` - **Correct.** Generic array type with summary and `@param` description: ```php diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md index 86a0c10..81fdfb8 100644 --- a/.claude/rules/php-library-testing.md +++ b/.claude/rules/php-library-testing.md @@ -36,7 +36,8 @@ Verify every item before producing any test code. If any item fails, revise befo 4. No intermediate variables used only once. Chain method calls when the intermediate state is not referenced elsewhere (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...)` followed by `$money->add(...)`). -5. No private or helper methods in test classes. The only non-test methods allowed are data +5. No private or helper methods in test classes. The only non-test methods allowed are PHPUnit + lifecycle hooks (`setUp`, `setUpBeforeClass`, `tearDown`, `tearDownAfterClass`) and data providers. Setup logic complex enough to extract belongs in a dedicated fixture class. 6. Test only the public API. Never assert on private state or `Internal/` classes directly. 7. Test the behavior that **raises** an exception, never the exception itself. Exception classes @@ -69,6 +70,8 @@ Verify every item before producing any test code. If any item fails, revise befo 15. Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from coverage. Never suppress mutants via `infection.json.dist` or any other mechanism. See "Coverage and mutation discipline". +16. Member ordering in test classes follows `php-library-code-style.md` rule 6 (PHPUnit + test-class sub-grouping). ## Structure: Given/When/Then (BDD) From 8641c88aeb140c8d4e00e330d4dc7030f770eee9 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 22 May 2026 11:49:07 -0300 Subject: [PATCH 2/2] refactor: Reorder class members and chained calls by name length. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Attribute.php | 20 +- src/Body.php | 78 +- src/Charset.php | 2 +- src/Client/Request.php | 164 ++-- src/Client/Response.php | 40 +- src/Code.php | 70 +- src/ContentType.php | 16 +- src/Cookie.php | 132 +-- src/Headers.php | 104 +- src/Http.php | 26 +- src/HttpBuilder.php | 44 +- .../Server/Request/RouteParameterResolver.php | 52 +- .../Server/Response/InternalResponse.php | 68 +- .../Server/Response/ProtocolVersion.php | 8 +- .../Server/Response/ResponseHeaders.php | 54 +- src/Internal/Server/Stream/Stream.php | 96 +- src/Internal/Server/Stream/StreamFactory.php | 52 +- src/Method.php | 4 +- src/ResponseCacheDirectives.php | 38 +- src/Server/Decoded/QueryParameters.php | 20 +- src/Server/Decoded/Uri.php | 40 +- tests/Drivers/Laminas/LaminasTest.php | 42 +- tests/Drivers/Slim/SlimTest.php | 42 +- tests/Models/Products.php | 8 +- tests/Unit/Client/RequestTest.php | 436 ++++----- tests/Unit/Client/ResponseTest.php | 210 ++-- .../Transports/InMemoryTransportTest.php | 60 +- .../Transports/NetworkTransportTest.php | 90 +- tests/Unit/CodeTest.php | 224 ++--- tests/Unit/CookieTest.php | 330 +++---- tests/Unit/FailingTransport.php | 8 +- tests/Unit/HeadersTest.php | 426 ++++---- tests/Unit/HttpBuilderTest.php | 218 ++--- tests/Unit/HttpTest.php | 590 +++++------ tests/Unit/Server/HeadersTest.php | 310 +++--- tests/Unit/Server/RequestTest.php | 412 ++++---- tests/Unit/Server/ResponseTest.php | 924 +++++++++--------- tests/Unit/Server/ResponseWithCookiesTest.php | 88 +- tests/Unit/UserAgentTest.php | 92 +- 39 files changed, 2824 insertions(+), 2814 deletions(-) diff --git a/src/Attribute.php b/src/Attribute.php index b9b8a16..422191c 100644 --- a/src/Attribute.php +++ b/src/Attribute.php @@ -67,28 +67,28 @@ public function toString(): string } /** - * Returns the Attribute as an integer. + * Returns the Attribute as a boolean. * - * @return int The wrapped value coerced to an integer, or 0 when it is not scalar. + * @return bool The wrapped value coerced to a boolean, or false when it is not scalar. */ - public function toInteger(): int + public function toBoolean(): bool { return match (true) { - is_scalar($this->value) => (int)$this->value, - default => 0 + is_scalar($this->value) => (bool)$this->value, + default => false }; } /** - * Returns the Attribute as a boolean. + * Returns the Attribute as an integer. * - * @return bool The wrapped value coerced to a boolean, or false when it is not scalar. + * @return int The wrapped value coerced to an integer, or 0 when it is not scalar. */ - public function toBoolean(): bool + public function toInteger(): int { return match (true) { - is_scalar($this->value) => (bool)$this->value, - default => false + is_scalar($this->value) => (int)$this->value, + default => 0 }; } } diff --git a/src/Body.php b/src/Body.php index adf4b38..daeb66a 100644 --- a/src/Body.php +++ b/src/Body.php @@ -36,41 +36,6 @@ public static function fromArray(array $data): Body return new Body(data: $data); } - /** - * Creates a Body from a PSR-7 server request, decoding the JSON payload up to 64 levels deep. - * - * When the raw body is empty, falls back to the parsed body and degrades to an empty Body - * when the parsed body is not an array. JSON decoding uses JSON_THROW_ON_ERROR; - * any decoding failure degrades to an empty Body rather than propagating the exception. - * - * @param ServerRequestInterface $request The incoming PSR-7 server request. - * @return Body A Body carrying the decoded payload, or an empty Body when decoding fails or - * the payload is not an array. - */ - public static function fromServerRequest(ServerRequestInterface $request): Body - { - $streamFactory = StreamFactory::fromStream(stream: $request->getBody()); - - if (!$streamFactory->isEmptyContent()) { - try { - $decoded = json_decode( - $streamFactory->content(), - true, - Body::MAX_JSON_DEPTH, - JSON_THROW_ON_ERROR - ); - } catch (JsonException) { - return new Body(data: []); - } - - return new Body(data: is_array($decoded) ? $decoded : []); - } - - $parsedBody = $request->getParsedBody(); - - return new Body(data: is_array($parsedBody) ? $parsedBody : []); - } - /** * Creates a Body from a PSR-7 response, decoding the JSON payload and degrading to empty on failure. * @@ -106,13 +71,38 @@ public static function fromResponse(ResponseInterface $response): Body } /** - * Returns the Body as an associative array. + * Creates a Body from a PSR-7 server request, decoding the JSON payload up to 64 levels deep. * - * @return array The decoded body data. + * When the raw body is empty, falls back to the parsed body and degrades to an empty Body + * when the parsed body is not an array. JSON decoding uses JSON_THROW_ON_ERROR; + * any decoding failure degrades to an empty Body rather than propagating the exception. + * + * @param ServerRequestInterface $request The incoming PSR-7 server request. + * @return Body A Body carrying the decoded payload, or an empty Body when decoding fails or + * the payload is not an array. */ - public function toArray(): array + public static function fromServerRequest(ServerRequestInterface $request): Body { - return $this->data; + $streamFactory = StreamFactory::fromStream(stream: $request->getBody()); + + if (!$streamFactory->isEmptyContent()) { + try { + $decoded = json_decode( + $streamFactory->content(), + true, + Body::MAX_JSON_DEPTH, + JSON_THROW_ON_ERROR + ); + } catch (JsonException) { + return new Body(data: []); + } + + return new Body(data: is_array($decoded) ? $decoded : []); + } + + $parsedBody = $request->getParsedBody(); + + return new Body(data: is_array($parsedBody) ? $parsedBody : []); } /** @@ -127,4 +117,14 @@ public function get(string $key): Attribute return Attribute::from(value: $attributeValue); } + + /** + * Returns the Body as an associative array. + * + * @return array The decoded body data. + */ + public function toArray(): array + { + return $this->data; + } } diff --git a/src/Charset.php b/src/Charset.php index 49345e0..984c42d 100644 --- a/src/Charset.php +++ b/src/Charset.php @@ -14,10 +14,10 @@ enum Charset: string case BIG5 = 'big5'; case ASCII = 'ascii'; case UTF_8 = 'utf-8'; - case UTF_16 = 'utf-16'; case EUC_KR = 'euc-kr'; case GB2312 = 'gb2312'; case KOI8_R = 'koi8-r'; + case UTF_16 = 'utf-16'; case SHIFT_JIS = 'shift_jis'; case ISO_8859_1 = 'iso-8859-1'; case WINDOWS_1252 = 'windows-1252'; diff --git a/src/Client/Request.php b/src/Client/Request.php index a0392ee..08254f8 100644 --- a/src/Client/Request.php +++ b/src/Client/Request.php @@ -24,44 +24,49 @@ private function __construct( } /** - * Builds a GET request targeting the given URL. + * Builds a Request for any HTTP method, including those not covered by the six shortcut factories. + * + * Use this factory when the target method is OPTIONS, TRACE, + * CONNECT, or any other method not represented by a dedicated shortcut. * + * @param Method $method The HTTP method used by the request. * @param string $url The URL (relative or absolute) the request targets. - * @param array|null $queryParameters The query string parameters, or null when absent. + * @param array|null $body The request body as an associative array, or null when absent. * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. - * @return Request A new GET request instance. + * @param array|null $queryParameters The query string parameters, or null when absent. + * @return Request A new immutable request instance. */ - public static function get(string $url, ?array $queryParameters = null, ?Headers $headers = null): Request - { + public static function for( + Method $method, + string $url, + ?array $body = null, + ?Headers $headers = null, + ?array $queryParameters = null + ): Request { return new Request( url: $url, - body: null, - method: Method::GET, - headers: $headers ?? Headers::from(), + body: $body, + method: $method, + headers: $headers ?? Headers::empty(), queryParameters: $queryParameters ); } /** - * Builds a POST request targeting the given URL. + * Builds a GET request targeting the given URL. * * @param string $url The URL (relative or absolute) the request targets. - * @param array|null $body The request body as an associative array, or null when absent. - * @param array|null $queryParameters The query string parameters, or null when absent. * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. - * @return Request A new POST request instance. + * @param array|null $queryParameters The query string parameters, or null when absent. + * @return Request A new GET request instance. */ - public static function post( - string $url, - ?array $body = null, - ?array $queryParameters = null, - ?Headers $headers = null - ): Request { + public static function get(string $url, ?Headers $headers = null, ?array $queryParameters = null): Request + { return new Request( url: $url, - body: $body, - method: Method::POST, - headers: $headers ?? Headers::from(), + body: null, + method: Method::GET, + headers: $headers ?? Headers::empty(), queryParameters: $queryParameters ); } @@ -71,112 +76,107 @@ public static function post( * * @param string $url The URL (relative or absolute) the request targets. * @param array|null $body The request body as an associative array, or null when absent. - * @param array|null $queryParameters The query string parameters, or null when absent. * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. + * @param array|null $queryParameters The query string parameters, or null when absent. * @return Request A new PUT request instance. */ public static function put( string $url, ?array $body = null, - ?array $queryParameters = null, - ?Headers $headers = null + ?Headers $headers = null, + ?array $queryParameters = null ): Request { return new Request( url: $url, body: $body, method: Method::PUT, - headers: $headers ?? Headers::from(), + headers: $headers ?? Headers::empty(), queryParameters: $queryParameters ); } /** - * Builds a PATCH request targeting the given URL. + * Builds a HEAD request targeting the given URL. * * @param string $url The URL (relative or absolute) the request targets. - * @param array|null $body The request body as an associative array, or null when absent. - * @param array|null $queryParameters The query string parameters, or null when absent. * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. - * @return Request A new PATCH request instance. + * @param array|null $queryParameters The query string parameters, or null when absent. + * @return Request A new HEAD request instance. */ - public static function patch( - string $url, - ?array $body = null, - ?array $queryParameters = null, - ?Headers $headers = null - ): Request { + public static function head(string $url, ?Headers $headers = null, ?array $queryParameters = null): Request + { return new Request( url: $url, - body: $body, - method: Method::PATCH, - headers: $headers ?? Headers::from(), + body: null, + method: Method::HEAD, + headers: $headers ?? Headers::empty(), queryParameters: $queryParameters ); } /** - * Builds a DELETE request targeting the given URL. + * Builds a POST request targeting the given URL. * * @param string $url The URL (relative or absolute) the request targets. - * @param array|null $queryParameters The query string parameters, or null when absent. + * @param array|null $body The request body as an associative array, or null when absent. * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. - * @return Request A new DELETE request instance. + * @param array|null $queryParameters The query string parameters, or null when absent. + * @return Request A new POST request instance. */ - public static function delete(string $url, ?array $queryParameters = null, ?Headers $headers = null): Request - { + public static function post( + string $url, + ?array $body = null, + ?Headers $headers = null, + ?array $queryParameters = null + ): Request { return new Request( url: $url, - body: null, - method: Method::DELETE, - headers: $headers ?? Headers::from(), + body: $body, + method: Method::POST, + headers: $headers ?? Headers::empty(), queryParameters: $queryParameters ); } /** - * Builds a HEAD request targeting the given URL. + * Builds a PATCH request targeting the given URL. * * @param string $url The URL (relative or absolute) the request targets. - * @param array|null $queryParameters The query string parameters, or null when absent. + * @param array|null $body The request body as an associative array, or null when absent. * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. - * @return Request A new HEAD request instance. + * @param array|null $queryParameters The query string parameters, or null when absent. + * @return Request A new PATCH request instance. */ - public static function head(string $url, ?array $queryParameters = null, ?Headers $headers = null): Request - { + public static function patch( + string $url, + ?array $body = null, + ?Headers $headers = null, + ?array $queryParameters = null + ): Request { return new Request( url: $url, - body: null, - method: Method::HEAD, - headers: $headers ?? Headers::from(), + body: $body, + method: Method::PATCH, + headers: $headers ?? Headers::empty(), queryParameters: $queryParameters ); } /** - * Builds a Request for any HTTP method, including those not covered by the six shortcut factories. - * - * Use this factory when the target method is OPTIONS, TRACE, - * CONNECT, or any other method not represented by a dedicated shortcut. + * Builds a DELETE request targeting the given URL. * - * @param Method $method The HTTP method used by the request. * @param string $url The URL (relative or absolute) the request targets. - * @param array|null $body The request body as an associative array, or null when absent. - * @param array|null $queryParameters The query string parameters, or null when absent. * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. - * @return Request A new immutable request instance. + * @param array|null $queryParameters The query string parameters, or null when absent. + * @return Request A new DELETE request instance. */ - public static function for( - Method $method, - string $url, - ?array $body = null, - ?array $queryParameters = null, - ?Headers $headers = null - ): Request { + public static function delete(string $url, ?Headers $headers = null, ?array $queryParameters = null): Request + { return new Request( url: $url, - body: $body, - method: $method, - headers: $headers ?? Headers::from(), + body: null, + method: Method::DELETE, + headers: $headers ?? Headers::empty(), queryParameters: $queryParameters ); } @@ -221,16 +221,6 @@ public function headers(): Headers return $this->headers; } - /** - * Returns the query parameters. - * - * @return array|null The query string parameters, or null when absent. - */ - public function queryParameters(): ?array - { - return $this->queryParameters; - } - /** * Returns a copy of the Request with the URL replaced. * @@ -269,6 +259,16 @@ public function withHeader(string $name, string $value): Request ); } + /** + * Returns the query parameters. + * + * @return array|null The query string parameters, or null when absent. + */ + public function queryParameters(): ?array + { + return $this->queryParameters; + } + /** * Returns a copy of the Request with the given default headers merged in. * diff --git a/src/Client/Response.php b/src/Client/Response.php index 6f29fa2..a214174 100644 --- a/src/Client/Response.php +++ b/src/Client/Response.php @@ -63,13 +63,19 @@ public static function with(Code $code, ?array $body = null, ?Headers $headers = } /** - * Returns the status code. + * Returns the underlying PSR-7 response. * - * @return Code The status code carried by the response. + * @return ResponseInterface The original PSR-7 response wrapped by this instance. + * @throws SynthesizedResponseHasNoRaw If the response was synthesized via {@see Response::with()} and + * has no backing PSR-7 message. */ - public function code(): Code + public function raw(): ResponseInterface { - return $this->code; + if (is_null($this->psr)) { + throw SynthesizedResponseHasNoRaw::create(); + } + + return $this->psr; } /** @@ -82,6 +88,16 @@ public function body(): Body return $this->body; } + /** + * Returns the status code. + * + * @return Code The status code carried by the response. + */ + public function code(): Code + { + return $this->code; + } + /** * Returns the headers. * @@ -111,20 +127,4 @@ public function isSuccess(): bool { return $this->code->isSuccess(); } - - /** - * Returns the underlying PSR-7 response. - * - * @return ResponseInterface The original PSR-7 response wrapped by this instance. - * @throws SynthesizedResponseHasNoRaw If the response was synthesized via {@see Response::with()} and - * has no backing PSR-7 message. - */ - public function raw(): ResponseInterface - { - if (is_null($this->psr)) { - throw SynthesizedResponseHasNoRaw::create(); - } - - return $this->psr; - } } diff --git a/src/Code.php b/src/Code.php index 7eaacf3..38745f1 100644 --- a/src/Code.php +++ b/src/Code.php @@ -103,17 +103,6 @@ public static function isErrorCode(int $code): bool return $code >= Code::BAD_REQUEST->value && $code <= Code::NETWORK_AUTHENTICATION_REQUIRED->value; } - /** - * Tells whether the given code falls in the success range (2xx). - * - * @param int $code The HTTP status code to check. - * @return bool True when the code falls in the success range, otherwise false. - */ - public static function isSuccessCode(int $code): bool - { - return $code >= Code::OK->value && $code <= Code::IM_USED->value; - } - /** * Tells whether the given code is a valid HTTP status code represented by the enum. * @@ -126,13 +115,14 @@ public static function isValidCode(int $code): bool } /** - * Tells whether the status code falls in the 1xx range. + * Tells whether the given code falls in the success range (2xx). * - * @return bool True when the code represents an informational response. + * @param int $code The HTTP status code to check. + * @return bool True when the code falls in the success range, otherwise false. */ - public function isInformational(): bool + public static function isSuccessCode(int $code): bool { - return $this->value >= Code::CONTINUE->value && $this->value <= Code::EARLY_HINTS->value; + return $code >= Code::OK->value && $code <= Code::IM_USED->value; } /** @@ -148,23 +138,31 @@ public function isError(): bool } /** - * Tells whether the status code falls in the 2xx range. + * Returns the HTTP status message associated with the enum's code. * - * @return bool True when the code represents a successful response. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages + * @return string The formatted message with the status code and name. */ - public function isSuccess(): bool + public function message(): string { - return Code::isSuccessCode(code: $this->value); + $subject = match ($this) { + Code::OK => $this->name, + Code::IM_USED => 'IM Used', + Code::IM_A_TEAPOT => "I'm a teapot", + default => mb_convert_case($this->name, MB_CASE_TITLE) + }; + + return str_replace('_', ' ', $subject); } /** - * Tells whether the status code falls in the 3xx range. + * Tells whether the status code falls in the 2xx range. * - * @return bool True when the code represents a redirection response. + * @return bool True when the code represents a successful response. */ - public function isRedirection(): bool + public function isSuccess(): bool { - return $this->value >= Code::MULTIPLE_CHOICES->value && $this->value <= Code::PERMANENT_REDIRECT->value; + return Code::isSuccessCode(code: $this->value); } /** @@ -177,6 +175,16 @@ public function isClientError(): bool return $this->value >= Code::BAD_REQUEST->value && $this->value <= Code::UNAVAILABLE_FOR_LEGAL_REASONS->value; } + /** + * Tells whether the status code falls in the 3xx range. + * + * @return bool True when the code represents a redirection response. + */ + public function isRedirection(): bool + { + return $this->value >= Code::MULTIPLE_CHOICES->value && $this->value <= Code::PERMANENT_REDIRECT->value; + } + /** * Tells whether the status code falls in the 5xx range. * @@ -189,20 +197,12 @@ public function isServerError(): bool } /** - * Returns the HTTP status message associated with the enum's code. + * Tells whether the status code falls in the 1xx range. * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages - * @return string The formatted message with the status code and name. + * @return bool True when the code represents an informational response. */ - public function message(): string + public function isInformational(): bool { - $subject = match ($this) { - Code::OK => $this->name, - Code::IM_USED => 'IM Used', - Code::IM_A_TEAPOT => "I'm a teapot", - default => mb_convert_case($this->name, MB_CASE_TITLE) - }; - - return str_replace('_', ' ', $subject); + return $this->value >= Code::CONTINUE->value && $this->value <= Code::EARLY_HINTS->value; } } diff --git a/src/ContentType.php b/src/ContentType.php index 419b1b5..8743fa3 100644 --- a/src/ContentType.php +++ b/src/ContentType.php @@ -38,25 +38,25 @@ public static function textPlain(?Charset $charset = null): ContentType } /** - * Creates a ContentType for application/json with an optional charset. + * Creates a ContentType for application/pdf with an optional charset. * * @param Charset|null $charset The optional charset folded into the header value. - * @return ContentType A ContentType for application/json. + * @return ContentType A ContentType for application/pdf. */ - public static function applicationJson(?Charset $charset = null): ContentType + public static function applicationPdf(?Charset $charset = null): ContentType { - return new ContentType(mimeType: MimeType::APPLICATION_JSON, charset: $charset); + return new ContentType(mimeType: MimeType::APPLICATION_PDF, charset: $charset); } /** - * Creates a ContentType for application/pdf with an optional charset. + * Creates a ContentType for application/json with an optional charset. * * @param Charset|null $charset The optional charset folded into the header value. - * @return ContentType A ContentType for application/pdf. + * @return ContentType A ContentType for application/json. */ - public static function applicationPdf(?Charset $charset = null): ContentType + public static function applicationJson(?Charset $charset = null): ContentType { - return new ContentType(mimeType: MimeType::APPLICATION_PDF, charset: $charset); + return new ContentType(mimeType: MimeType::APPLICATION_JSON, charset: $charset); } /** diff --git a/src/Cookie.php b/src/Cookie.php index 1ea581b..e3c18e8 100644 --- a/src/Cookie.php +++ b/src/Cookie.php @@ -120,6 +120,51 @@ public function secure(): Cookie ); } + public function toArray(): array + { + $nameValueTemplate = '%s=%s'; + $parts = [sprintf($nameValueTemplate, $this->name->toString(), $this->value->toString())]; + + if (!is_null($this->maxAge)) { + $maxAgeTemplate = 'Max-Age=%d'; + $parts[] = sprintf($maxAgeTemplate, $this->maxAge); + } + + if (!is_null($this->expires)) { + $expiresTemplate = 'Expires=%s'; + $parts[] = sprintf($expiresTemplate, $this->expires->format(Cookie::EXPIRES_FORMAT)); + } + + if (!is_null($this->path)) { + $pathTemplate = 'Path=%s'; + $parts[] = sprintf($pathTemplate, $this->path); + } + + if (!is_null($this->domain)) { + $domainTemplate = 'Domain=%s'; + $parts[] = sprintf($domainTemplate, $this->domain); + } + + if ($this->secure) { + $parts[] = 'Secure'; + } + + if ($this->httpOnly) { + $parts[] = 'HttpOnly'; + } + + if (!is_null($this->sameSite)) { + $sameSiteTemplate = 'SameSite=%s'; + $parts[] = sprintf($sameSiteTemplate, $this->sameSite->value); + } + + if ($this->partitioned) { + $parts[] = 'Partitioned'; + } + + return ['Set-Cookie' => [implode('; ', $parts)]]; + } + /** * Returns a copy of the Cookie with the HttpOnly attribute enabled. * @@ -141,27 +186,6 @@ public function httpOnly(): Cookie ); } - /** - * Returns a copy of the Cookie with the Partitioned attribute enabled. - * - * @return Cookie A new instance carrying the Partitioned attribute. - */ - public function partitioned(): Cookie - { - return new Cookie( - name: $this->name, - path: $this->path, - value: $this->value, - domain: $this->domain, - maxAge: $this->maxAge, - secure: $this->secure, - expires: $this->expires, - httpOnly: $this->httpOnly, - sameSite: $this->sameSite, - partitioned: true - ); - } - /** * Returns a copy of the Cookie with the path replaced. * @@ -258,6 +282,27 @@ public function withMaxAge(int $seconds): Cookie ); } + /** + * Returns a copy of the Cookie with the Partitioned attribute enabled. + * + * @return Cookie A new instance carrying the Partitioned attribute. + */ + public function partitioned(): Cookie + { + return new Cookie( + name: $this->name, + path: $this->path, + value: $this->value, + domain: $this->domain, + maxAge: $this->maxAge, + secure: $this->secure, + expires: $this->expires, + httpOnly: $this->httpOnly, + sameSite: $this->sameSite, + partitioned: true + ); + } + /** * Returns a copy of the Cookie with Expires replaced (normalized to UTC) and Max-Age cleared. * @@ -312,49 +357,4 @@ public function withSameSite(SameSite $sameSite): Cookie partitioned: $this->partitioned ); } - - public function toArray(): array - { - $nameValueTemplate = '%s=%s'; - $parts = [sprintf($nameValueTemplate, $this->name->toString(), $this->value->toString())]; - - if (!is_null($this->maxAge)) { - $maxAgeTemplate = 'Max-Age=%d'; - $parts[] = sprintf($maxAgeTemplate, $this->maxAge); - } - - if (!is_null($this->expires)) { - $expiresTemplate = 'Expires=%s'; - $parts[] = sprintf($expiresTemplate, $this->expires->format(Cookie::EXPIRES_FORMAT)); - } - - if (!is_null($this->path)) { - $pathTemplate = 'Path=%s'; - $parts[] = sprintf($pathTemplate, $this->path); - } - - if (!is_null($this->domain)) { - $domainTemplate = 'Domain=%s'; - $parts[] = sprintf($domainTemplate, $this->domain); - } - - if ($this->secure) { - $parts[] = 'Secure'; - } - - if ($this->httpOnly) { - $parts[] = 'HttpOnly'; - } - - if (!is_null($this->sameSite)) { - $sameSiteTemplate = 'SameSite=%s'; - $parts[] = sprintf($sameSiteTemplate, $this->sameSite->value); - } - - if ($this->partitioned) { - $parts[] = 'Partitioned'; - } - - return ['Set-Cookie' => [implode('; ', $parts)]]; - } } diff --git a/src/Headers.php b/src/Headers.php index 9adc960..5b73bee 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -32,6 +32,35 @@ private function __construct(array $entries) $this->lowerIndex = $lowerIndex; } + /** + * Creates a Headers from a list of Headerable contributors, with the last one winning on collision. + * + * @param Headerable ...$headers The Headerable contributors merged into the result. + * @return Headers A Headers carrying every entry from the supplied contributors. + */ + public static function from(Headerable ...$headers): Headers + { + $entries = []; + + foreach ($headers as $header) { + foreach ($header->toArray() as $name => $value) { + $entries[$name] = is_array($value) ? implode(', ', $value) : $value; + } + } + + return Headers::fromArray(entries: $entries); + } + + /** + * Creates an empty Headers carrying no entries. + * + * @return Headers A Headers with no entries. + */ + public static function empty(): Headers + { + return new Headers(entries: []); + } + /** * Creates a Headers from a name-to-value map. * @@ -73,22 +102,20 @@ public static function fromMessage(MessageInterface $message): Headers } /** - * Creates a Headers from a list of Headerable contributors, with the last one winning on collision. + * Returns the value associated with the given header name, looking up case-insensitively. * - * @param Headerable ...$headers The Headerable contributors merged into the result. - * @return Headers A Headers carrying every entry from the supplied contributors. + * @param string $name The header name to look up. + * @return string|null The folded header value, or null when no entry matches. */ - public static function from(Headerable ...$headers): Headers + public function get(string $name): ?string { - $entries = []; + $key = strtolower($name); - foreach ($headers as $header) { - foreach ($header->toArray() as $name => $value) { - $entries[$name] = is_array($value) ? implode(', ', $value) : $value; - } + if (!isset($this->lowerIndex[$key])) { + return null; } - return Headers::fromArray(entries: $entries); + return $this->entries[$this->lowerIndex[$key]]; } /** @@ -103,30 +130,30 @@ public function has(string $name): bool } /** - * Returns the headers as a name to value map. + * Returns a copy of these Headers with the named entry replaced or appended. * - * @return array The header name to single folded value map. - */ - public function toArray(): array - { - return $this->entries; - } - - /** - * Returns the value associated with the given header name, looking up case-insensitively. + * The lookup is case-insensitive: with('content-type', 'text/plain') replaces + * an existing Content-Type entry regardless of how it was originally cased. + * When no entry matches, the new header is appended under the supplied name. * - * @param string $name The header name to look up. - * @return string|null The folded header value, or null when no entry matches. + * @param string $name The header name. + * @param string $value The replacement or new header value. + * @return Headers A new instance carrying the updated header. + * @throws HeaderNameIsInvalid If the name violates RFC 7230 token rules. + * @throws HeaderValueIsInvalid If the value contains a forbidden control character. */ - public function get(string $name): ?string + public function with(string $name, string $value): Headers { $key = strtolower($name); + $entries = $this->entries; - if (!isset($this->lowerIndex[$key])) { - return null; + if (isset($this->lowerIndex[$key])) { + $entries[$this->lowerIndex[$key]] = $value; + } else { + $entries[$name] = $value; } - return $this->entries[$this->lowerIndex[$key]]; + return Headers::fromArray(entries: $entries); } /** @@ -148,30 +175,13 @@ public function applyTo(MessageInterface $message): MessageInterface } /** - * Returns a copy of these Headers with the named entry replaced or appended. - * - * The lookup is case-insensitive: with('content-type', 'text/plain') replaces - * an existing Content-Type entry regardless of how it was originally cased. - * When no entry matches, the new header is appended under the supplied name. + * Returns the headers as a name to value map. * - * @param string $name The header name. - * @param string $value The replacement or new header value. - * @return Headers A new instance carrying the updated header. - * @throws HeaderNameIsInvalid If the name violates RFC 7230 token rules. - * @throws HeaderValueIsInvalid If the value contains a forbidden control character. + * @return array The header name to single folded value map. */ - public function with(string $name, string $value): Headers + public function toArray(): array { - $key = strtolower($name); - $entries = $this->entries; - - if (isset($this->lowerIndex[$key])) { - $entries[$this->lowerIndex[$key]] = $value; - } else { - $entries[$name] = $value; - } - - return Headers::fromArray(entries: $entries); + return $this->entries; } /** diff --git a/src/Http.php b/src/Http.php index 4492d6f..583edf0 100644 --- a/src/Http.php +++ b/src/Http.php @@ -32,19 +32,6 @@ private function __construct(string $baseUrl, private Transport $transport) $this->resolver = RequestResolver::withBaseUrl(baseUrl: $baseUrl->toString()); } - /** - * Returns a fluent builder used to assemble an Http instance. - * - * Both a transport and a base URL must be supplied through the builder - * before calling build(); otherwise HttpConfigurationInvalid is raised. - * - * @return HttpBuilder A new, empty builder. - */ - public static function create(): HttpBuilder - { - return new HttpBuilder(baseUrl: null, transport: null); - } - /** * Creates an Http instance directly from a base URL and transport. * @@ -61,6 +48,19 @@ public static function with(string $baseUrl, Transport $transport): Http return new Http(baseUrl: $baseUrl, transport: $transport); } + /** + * Returns a fluent builder used to assemble an Http instance. + * + * Both a transport and a base URL must be supplied through the builder + * before calling build(); otherwise HttpConfigurationInvalid is raised. + * + * @return HttpBuilder A new, empty builder. + */ + public static function create(): HttpBuilder + { + return new HttpBuilder(baseUrl: null, transport: null); + } + /** * Sends a request through the configured transport and returns the response. * diff --git a/src/HttpBuilder.php b/src/HttpBuilder.php index b175e69..4a34152 100644 --- a/src/HttpBuilder.php +++ b/src/HttpBuilder.php @@ -21,6 +21,28 @@ public function __construct(private ?string $baseUrl, private ?Transport $transp { } + /** + * Assembles the configured Http facade. + * + * Both a base URL and a transport must have been supplied via withBaseUrl() + * and withTransport() before this call. + * + * @return Http A configured Http facade. + * @throws HttpConfigurationInvalid When the base URL or the transport is missing. + */ + public function build(): Http + { + if (is_null($this->transport)) { + throw HttpConfigurationInvalid::missingTransport(); + } + + if (is_null($this->baseUrl)) { + throw HttpConfigurationInvalid::missingBaseUrl(); + } + + return Http::with(baseUrl: $this->baseUrl, transport: $this->transport); + } + /** * Returns a new builder carrying the given base URL. * @@ -45,26 +67,4 @@ public function withTransport(Transport $transport): HttpBuilder { return new HttpBuilder(baseUrl: $this->baseUrl, transport: $transport); } - - /** - * Assembles the configured Http facade. - * - * Both a base URL and a transport must have been supplied via withBaseUrl() - * and withTransport() before this call. - * - * @return Http A configured Http facade. - * @throws HttpConfigurationInvalid When the base URL or the transport is missing. - */ - public function build(): Http - { - if (is_null($this->transport)) { - throw HttpConfigurationInvalid::missingTransport(); - } - - if (is_null($this->baseUrl)) { - throw HttpConfigurationInvalid::missingBaseUrl(); - } - - return Http::with(baseUrl: $this->baseUrl, transport: $this->transport); - } } diff --git a/src/Internal/Server/Request/RouteParameterResolver.php b/src/Internal/Server/Request/RouteParameterResolver.php index 532ba06..a894fe5 100644 --- a/src/Internal/Server/Request/RouteParameterResolver.php +++ b/src/Internal/Server/Request/RouteParameterResolver.php @@ -40,21 +40,19 @@ public static function from(ServerRequestInterface $request): RouteParameterReso return new RouteParameterResolver(request: $request); } - public function resolveAttribute(string $key, string $attributeName, bool $scanKnownAttributes): mixed + private function resolve(string $attributeName): array { - $parameters = $this->resolve(attributeName: $attributeName); - - if (array_key_exists($key, $parameters)) { - return $parameters[$key]; - } - $attribute = $this->request->getAttribute($attributeName); - if (is_scalar($attribute)) { + if (is_array($attribute)) { return $attribute; } - return $this->resolveFallback(key: $key, scanKnownAttributes: $scanKnownAttributes); + if (is_object($attribute)) { + return $this->extractFromObject(object: $attribute); + } + + return []; } private function resolveFallback(string $key, bool $scanKnownAttributes): mixed @@ -70,32 +68,21 @@ private function resolveFallback(string $key, bool $scanKnownAttributes): mixed return $this->request->getAttribute($key); } - private function resolveFromKnownAttributes(): array + public function resolveAttribute(string $key, string $attributeName, bool $scanKnownAttributes): mixed { - foreach (RouteParameterResolver::KNOWN_ATTRIBUTE_KEYS as $key) { - $parameters = $this->resolve(attributeName: $key); + $parameters = $this->resolve(attributeName: $attributeName); - if (!empty($parameters)) { - return $parameters; - } + if (array_key_exists($key, $parameters)) { + return $parameters[$key]; } - return []; - } - - private function resolve(string $attributeName): array - { $attribute = $this->request->getAttribute($attributeName); - if (is_array($attribute)) { + if (is_scalar($attribute)) { return $attribute; } - if (is_object($attribute)) { - return $this->extractFromObject(object: $attribute); - } - - return []; + return $this->resolveFallback(key: $key, scanKnownAttributes: $scanKnownAttributes); } private function extractFromObject(object $object): array @@ -122,4 +109,17 @@ private function extractFromObject(object $object): array return []; } + + private function resolveFromKnownAttributes(): array + { + foreach (RouteParameterResolver::KNOWN_ATTRIBUTE_KEYS as $key) { + $parameters = $this->resolve(attributeName: $key); + + if (!empty($parameters)) { + return $parameters; + } + } + + return []; + } } diff --git a/src/Internal/Server/Response/InternalResponse.php b/src/Internal/Server/Response/InternalResponse.php index 9af22f1..6fb3627 100644 --- a/src/Internal/Server/Response/InternalResponse.php +++ b/src/Internal/Server/Response/InternalResponse.php @@ -44,52 +44,43 @@ public static function createWithoutBody(Code $code, Headerable ...$headers): Re ); } - public function hasHeader(string $name): bool - { - return $this->headers->hasHeader(name: $name); - } - public function getBody(): StreamInterface { return $this->body; } - public function getHeader(string $name): array - { - return $this->headers->getByName(name: $name); - } - - public function getHeaders(): array - { - return $this->headers->toArray(); - } - - public function getStatusCode(): int + public function withBody(StreamInterface $body): MessageInterface { - return $this->code->value; + return new InternalResponse( + body: $body, + code: $this->code, + headers: $this->headers, + protocolVersion: $this->protocolVersion, + customReasonPhrase: $this->customReasonPhrase + ); } - public function getHeaderLine(string $name): string + public function getHeader(string $name): array { - return implode(', ', $this->getHeader(name: $name)); + return $this->headers->getByName(name: $name); } - public function getReasonPhrase(): string + public function hasHeader(string $name): bool { - return $this->customReasonPhrase ?? $this->code->message(); + return $this->headers->hasHeader(name: $name); } - public function getProtocolVersion(): string + public function getHeaders(): array { - return $this->protocolVersion->version; + return $this->headers->toArray(); } - public function withBody(StreamInterface $body): MessageInterface + public function withHeader(string $name, mixed $value): MessageInterface { return new InternalResponse( - body: $body, + body: $this->body, code: $this->code, - headers: $this->headers, + headers: $this->headers->withReplaced(name: $name, value: $value), protocolVersion: $this->protocolVersion, customReasonPhrase: $this->customReasonPhrase ); @@ -106,15 +97,14 @@ public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterf ); } - public function withHeader(string $name, mixed $value): MessageInterface + public function getHeaderLine(string $name): string { - return new InternalResponse( - body: $this->body, - code: $this->code, - headers: $this->headers->withReplaced(name: $name, value: $value), - protocolVersion: $this->protocolVersion, - customReasonPhrase: $this->customReasonPhrase - ); + return implode(', ', $this->getHeader(name: $name)); + } + + public function getStatusCode(): int + { + return $this->code->value; } public function withoutHeader(string $name): MessageInterface @@ -128,6 +118,11 @@ public function withoutHeader(string $name): MessageInterface ); } + public function getReasonPhrase(): string + { + return $this->customReasonPhrase ?? $this->code->message(); + } + public function withAddedHeader(string $name, mixed $value): MessageInterface { return new InternalResponse( @@ -139,6 +134,11 @@ public function withAddedHeader(string $name, mixed $value): MessageInterface ); } + public function getProtocolVersion(): string + { + return $this->protocolVersion->version; + } + public function withProtocolVersion(string $version): MessageInterface { $protocolVersion = ProtocolVersion::from(version: $version); diff --git a/src/Internal/Server/Response/ProtocolVersion.php b/src/Internal/Server/Response/ProtocolVersion.php index 5c037be..aa3d186 100644 --- a/src/Internal/Server/Response/ProtocolVersion.php +++ b/src/Internal/Server/Response/ProtocolVersion.php @@ -12,13 +12,13 @@ private function __construct(public string $version) { } - public static function default(): ProtocolVersion + public static function from(string $version): ProtocolVersion { - return new ProtocolVersion(version: ProtocolVersion::DEFAULT_PROTOCOL_VERSION); + return $version === '' ? ProtocolVersion::default() : new ProtocolVersion(version: $version); } - public static function from(string $version): ProtocolVersion + public static function default(): ProtocolVersion { - return $version === '' ? ProtocolVersion::default() : new ProtocolVersion(version: $version); + return new ProtocolVersion(version: ProtocolVersion::DEFAULT_PROTOCOL_VERSION); } } diff --git a/src/Internal/Server/Response/ResponseHeaders.php b/src/Internal/Server/Response/ResponseHeaders.php index 632d436..ee073c1 100644 --- a/src/Internal/Server/Response/ResponseHeaders.php +++ b/src/Internal/Server/Response/ResponseHeaders.php @@ -32,9 +32,11 @@ public static function fromOrDefault(Headerable ...$headers): ResponseHeaders return new ResponseHeaders(headers: $merged); } - public function hasHeader(string $name): bool + private function findKey(string $name): ?string { - return !empty($this->getByName(name: $name)); + $lowered = strtolower($name); + + return array_find(array_keys($this->headers), static fn(string $key): bool => strtolower($key) === $lowered); } public function toArray(): array @@ -49,19 +51,32 @@ public function getByName(string $name): array return is_null($key) ? [] : $this->headers[$key]; } - private function findKey(string $name): ?string + public function hasHeader(string $name): bool { - $lowered = strtolower($name); - - return array_find(array_keys($this->headers), static fn(string $key): bool => strtolower($key) === $lowered); + return !empty($this->getByName(name: $name)); } - public function withReplaced(string $name, string|array $value): ResponseHeaders + public function withAdded(string $name, string|array $value): ResponseHeaders { $headers = $this->headers; $existingKey = $this->findKey(name: $name); - $targetKey = $existingKey ?? $name; - $headers[$targetKey] = is_array($value) ? $value : [$value]; + $appended = is_array($value) ? $value : [$value]; + + if (is_null($existingKey)) { + $headers[$name] = $appended; + + return new ResponseHeaders(headers: $headers); + } + + $existingValues = $headers[$existingKey]; + + foreach ($appended as $next) { + if (!in_array($next, $existingValues, true)) { + $existingValues[] = $next; + } + } + + $headers[$existingKey] = $existingValues; return new ResponseHeaders(headers: $headers); } @@ -78,27 +93,12 @@ public function removeByName(string $name): ResponseHeaders return new ResponseHeaders(headers: $headers); } - public function withAdded(string $name, string|array $value): ResponseHeaders + public function withReplaced(string $name, string|array $value): ResponseHeaders { $headers = $this->headers; $existingKey = $this->findKey(name: $name); - $appended = is_array($value) ? $value : [$value]; - - if (is_null($existingKey)) { - $headers[$name] = $appended; - - return new ResponseHeaders(headers: $headers); - } - - $existingValues = $headers[$existingKey]; - - foreach ($appended as $next) { - if (!in_array($next, $existingValues, true)) { - $existingValues[] = $next; - } - } - - $headers[$existingKey] = $existingValues; + $targetKey = $existingKey ?? $name; + $headers[$targetKey] = is_array($value) ? $value : [$value]; return new ResponseHeaders(headers: $headers); } diff --git a/src/Internal/Server/Stream/Stream.php b/src/Internal/Server/Stream/Stream.php index 0d5e0ef..19c703f 100644 --- a/src/Internal/Server/Stream/Stream.php +++ b/src/Internal/Server/Stream/Stream.php @@ -88,35 +88,33 @@ public static function from(mixed $resource): Stream return new Stream(seekable: $raw['seekable'], resource: $resource); } - public function close(): void + public function eof(): bool + { + return is_resource($this->resource) && feof($this->resource); + } + + public function read(int $length): string { if (!is_resource($this->resource)) { - return; + throw new NonReadableStream(); } - $resource = $this->resource; - $this->resource = null; - - fclose($resource); - } + if ($length < 1) { + throw new NonReadableStream(); + } - public function detach(): mixed - { - $resource = $this->resource; - $this->resource = null; + $chunk = fread($this->resource, $length); - return $resource; + return $chunk === false ? '' : $chunk; } - public function getSize(): ?int + public function seek(int $offset, int $whence = SEEK_SET): void { if (!is_resource($this->resource)) { - return null; + throw new NonSeekableStream(); } - $size = fstat($this->resource); - - return is_array($size) ? $size['size'] : null; + fseek($this->resource, $offset, $whence); } public function tell(): int @@ -128,18 +126,33 @@ public function tell(): int return ftell($this->resource); } - public function eof(): bool + public function close(): void { - return is_resource($this->resource) && feof($this->resource); + if (!is_resource($this->resource)) { + return; + } + + $resource = $this->resource; + $this->resource = null; + + fclose($resource); } - public function seek(int $offset, int $whence = SEEK_SET): void + public function write(string $string): int { if (!is_resource($this->resource)) { - throw new NonSeekableStream(); + throw new NonWritableStream(); } - fseek($this->resource, $offset, $whence); + return fwrite($this->resource, $string); + } + + public function detach(): mixed + { + $resource = $this->resource; + $this->resource = null; + + return $resource; } public function rewind(): void @@ -147,28 +160,24 @@ public function rewind(): void $this->seek(Stream::OFFSET_ZERO); } - public function read(int $length): string + public function getSize(): ?int { if (!is_resource($this->resource)) { - throw new NonReadableStream(); - } - - if ($length < 1) { - throw new NonReadableStream(); + return null; } - $chunk = fread($this->resource, $length); + $size = fstat($this->resource); - return $chunk === false ? '' : $chunk; + return is_array($size) ? $size['size'] : null; } - public function write(string $string): int + public function __toString(): string { - if (!is_resource($this->resource)) { - throw new NonWritableStream(); + if ($this->isSeekable()) { + $this->rewind(); } - return fwrite($this->resource, $string); + return $this->getContents(); } public function isReadable(): bool @@ -182,6 +191,11 @@ public function isReadable(): bool return in_array($mode, Stream::READABLE_MODES, true); } + public function isSeekable(): bool + { + return is_resource($this->resource) && $this->seekable; + } + public function isWritable(): bool { if (!is_resource($this->resource)) { @@ -193,11 +207,6 @@ public function isWritable(): bool return in_array($mode, Stream::WRITABLE_MODES, true); } - public function isSeekable(): bool - { - return is_resource($this->resource) && $this->seekable; - } - public function getContents(): string { if (!is_resource($this->resource)) { @@ -223,13 +232,4 @@ public function getMetadata(?string $key = null): mixed return $metaData[$key] ?? null; } - - public function __toString(): string - { - if ($this->isSeekable()) { - $this->rewind(); - } - - return $this->getContents(); - } } diff --git a/src/Internal/Server/Stream/StreamFactory.php b/src/Internal/Server/Stream/StreamFactory.php index d0457f5..f525ee9 100644 --- a/src/Internal/Server/Stream/StreamFactory.php +++ b/src/Internal/Server/Stream/StreamFactory.php @@ -20,16 +20,19 @@ private function __construct(private string $body) $this->stream = Stream::from(resource: $resource); } - public static function fromEmptyBody(): StreamFactory - { - return new StreamFactory(body: ''); - } - - private static function toJsonFrom(mixed $body): string + public static function fromBody(mixed $body): StreamFactory { - $encoded = json_encode($body, JSON_PRESERVE_ZERO_FRACTION); + $dataToWrite = match (true) { + $body instanceof Mapper => $body->toJson(), + $body instanceof BackedEnum => StreamFactory::toJsonFrom(body: $body->value), + $body instanceof UnitEnum => $body->name, + is_object($body) => throw BodyTypeIsUnsupported::for(class: $body::class), + is_string($body) => $body, + is_scalar($body) || is_array($body) => StreamFactory::toJsonFrom(body: $body), + default => '' + }; - return $encoded === false ? '' : $encoded; + return new StreamFactory(body: $dataToWrite); } public static function fromStream(StreamInterface $stream): StreamFactory @@ -47,29 +50,16 @@ public static function fromStream(StreamInterface $stream): StreamFactory return new StreamFactory(body: $body); } - public static function fromBody(mixed $body): StreamFactory + private static function toJsonFrom(mixed $body): string { - $dataToWrite = match (true) { - $body instanceof Mapper => $body->toJson(), - $body instanceof BackedEnum => StreamFactory::toJsonFrom(body: $body->value), - $body instanceof UnitEnum => $body->name, - is_object($body) => throw BodyTypeIsUnsupported::for(class: $body::class), - is_string($body) => $body, - is_scalar($body) || is_array($body) => StreamFactory::toJsonFrom(body: $body), - default => '' - }; - - return new StreamFactory(body: $dataToWrite); - } + $encoded = json_encode($body, JSON_PRESERVE_ZERO_FRACTION); - public function content(): string - { - return $this->body; + return $encoded === false ? '' : $encoded; } - public function isEmptyContent(): bool + public static function fromEmptyBody(): StreamFactory { - return $this->body === ''; + return new StreamFactory(body: ''); } public function write(): StreamInterface @@ -79,4 +69,14 @@ public function write(): StreamInterface return $this->stream; } + + public function content(): string + { + return $this->body; + } + + public function isEmptyContent(): bool + { + return $this->body === ''; + } } diff --git a/src/Method.php b/src/Method.php index 0ea35a7..3df7698 100644 --- a/src/Method.php +++ b/src/Method.php @@ -13,13 +13,13 @@ enum Method: string { case GET = 'GET'; case PUT = 'PUT'; - case POST = 'POST'; case HEAD = 'HEAD'; + case POST = 'POST'; case PATCH = 'PATCH'; case TRACE = 'TRACE'; case DELETE = 'DELETE'; - case OPTIONS = 'OPTIONS'; case CONNECT = 'CONNECT'; + case OPTIONS = 'OPTIONS'; /** * Tells whether the method is safe per RFC 9110 §9.2.1. diff --git a/src/ResponseCacheDirectives.php b/src/ResponseCacheDirectives.php index 50046df..458220c 100644 --- a/src/ResponseCacheDirectives.php +++ b/src/ResponseCacheDirectives.php @@ -29,16 +29,6 @@ public static function maxAge(int $maxAgeInWholeSeconds): ResponseCacheDirective return new ResponseCacheDirectives(value: Directives::MAX_AGE->toHeaderValue(value: $maxAgeInWholeSeconds)); } - /** - * Builds a ResponseCacheDirectives with the must-revalidate directive. - * - * @return ResponseCacheDirectives A directive that forbids using a stale response. - */ - public static function mustRevalidate(): ResponseCacheDirectives - { - return new ResponseCacheDirectives(value: Directives::MUST_REVALIDATE->toHeaderValue()); - } - /** * Builds a ResponseCacheDirectives with the no-cache directive. * @@ -71,24 +61,34 @@ public static function noTransform(): ResponseCacheDirectives } /** - * Builds a ResponseCacheDirectives with the proxy-revalidate directive. + * Builds a ResponseCacheDirectives with the stale-if-error directive. * - * @return ResponseCacheDirectives A directive that forbids shared caches from using a stale response. + * @return ResponseCacheDirectives A directive that allows caches to serve a stale response when an error + * is encountered. */ - public static function proxyRevalidate(): ResponseCacheDirectives + public static function staleIfError(): ResponseCacheDirectives { - return new ResponseCacheDirectives(value: Directives::PROXY_REVALIDATE->toHeaderValue()); + return new ResponseCacheDirectives(value: Directives::STALE_IF_ERROR->toHeaderValue()); } /** - * Builds a ResponseCacheDirectives with the stale-if-error directive. + * Builds a ResponseCacheDirectives with the must-revalidate directive. * - * @return ResponseCacheDirectives A directive that allows caches to serve a stale response when an error - * is encountered. + * @return ResponseCacheDirectives A directive that forbids using a stale response. */ - public static function staleIfError(): ResponseCacheDirectives + public static function mustRevalidate(): ResponseCacheDirectives { - return new ResponseCacheDirectives(value: Directives::STALE_IF_ERROR->toHeaderValue()); + return new ResponseCacheDirectives(value: Directives::MUST_REVALIDATE->toHeaderValue()); + } + + /** + * Builds a ResponseCacheDirectives with the proxy-revalidate directive. + * + * @return ResponseCacheDirectives A directive that forbids shared caches from using a stale response. + */ + public static function proxyRevalidate(): ResponseCacheDirectives + { + return new ResponseCacheDirectives(value: Directives::PROXY_REVALIDATE->toHeaderValue()); } /** diff --git a/src/Server/Decoded/QueryParameters.php b/src/Server/Decoded/QueryParameters.php index 045c767..5bfea6d 100644 --- a/src/Server/Decoded/QueryParameters.php +++ b/src/Server/Decoded/QueryParameters.php @@ -29,16 +29,6 @@ public static function from(ServerRequestInterface $request): QueryParameters return new QueryParameters(data: $request->getQueryParams()); } - /** - * Returns the QueryParameters as an associative array. - * - * @return array The raw query parameters keyed by name. - */ - public function toArray(): array - { - return $this->data; - } - /** * Returns the Attribute associated with the given query key. * @@ -51,4 +41,14 @@ public function get(string $key): Attribute return Attribute::from(value: $attributeValue); } + + /** + * Returns the QueryParameters as an associative array. + * + * @return array The raw query parameters keyed by name. + */ + public function toArray(): array + { + return $this->data; + } } diff --git a/src/Server/Decoded/Uri.php b/src/Server/Decoded/Uri.php index e16e4e3..aa7047e 100644 --- a/src/Server/Decoded/Uri.php +++ b/src/Server/Decoded/Uri.php @@ -39,26 +39,6 @@ public static function from(ServerRequestInterface $request): Uri ); } - /** - * Returns the Uri as a string. - * - * @return string The fully composed URI of the underlying request. - */ - public function toString(): string - { - return $this->request->getUri()->__toString(); - } - - /** - * Returns the query parameters carried by the request URI. - * - * @return QueryParameters The QueryParameters value object built from the request. - */ - public function queryParameters(): QueryParameters - { - return QueryParameters::from(request: $this->request); - } - /** * Returns the Attribute associated with the given route key. * @@ -93,4 +73,24 @@ public function route(string $name = Uri::ROUTE): Uri routeAttributeName: $name ); } + + /** + * Returns the Uri as a string. + * + * @return string The fully composed URI of the underlying request. + */ + public function toString(): string + { + return $this->request->getUri()->__toString(); + } + + /** + * Returns the query parameters carried by the request URI. + * + * @return QueryParameters The QueryParameters value object built from the request. + */ + public function queryParameters(): QueryParameters + { + return QueryParameters::from(request: $this->request); + } } diff --git a/tests/Drivers/Laminas/LaminasTest.php b/tests/Drivers/Laminas/LaminasTest.php index e6482d5..4332052 100644 --- a/tests/Drivers/Laminas/LaminasTest.php +++ b/tests/Drivers/Laminas/LaminasTest.php @@ -28,27 +28,6 @@ protected function setUp(): void $this->middleware = new Middleware(); } - public function testProcessWhenLaminasMiddlewareInvokedThenReturnsConfiguredResponse(): void - { - /** @Given a valid request */ - $request = new ServerRequest(method: 'GET', uri: 'https://api.example.com/'); - - /** @And an HTTP response is created with a 200 OK status, a JSON body, Content-Type, and Cache-Control */ - $response = Response::ok( - ['createdAt' => date(DateTimeInterface::ATOM)], - ContentType::applicationJson(charset: Charset::UTF_8), - CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) - ); - - /** @When the request is processed by the handler */ - $actual = $this->middleware->process($request, new Endpoint(response: $response)); - - /** @Then the response is returned through the middleware unchanged */ - self::assertSame(Code::OK->value, $actual->getStatusCode()); - self::assertSame($response->getBody()->__toString(), $actual->getBody()->__toString()); - self::assertSame($response->getHeaders(), $actual->getHeaders()); - } - public function testEmitWhenLaminasEmitterUsedThenWritesBodyToOutputBuffer(): void { /** @Given a response with Content-Type, Cache-Control, and a custom header */ @@ -69,4 +48,25 @@ public function testEmitWhenLaminasEmitterUsedThenWritesBodyToOutputBuffer(): vo self::assertSame('OK', $response->getReasonPhrase()); self::assertSame('123456', $response->getHeaderLine('X-Request-ID')); } + + public function testProcessWhenLaminasMiddlewareInvokedThenReturnsConfiguredResponse(): void + { + /** @Given a valid request */ + $request = new ServerRequest(method: 'GET', uri: 'https://api.example.com/'); + + /** @And an HTTP response is created with a 200 OK status, a JSON body, Content-Type, and Cache-Control */ + $response = Response::ok( + ['createdAt' => date(DateTimeInterface::ATOM)], + ContentType::applicationJson(charset: Charset::UTF_8), + CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) + ); + + /** @When the request is processed by the handler */ + $actual = $this->middleware->process($request, new Endpoint(response: $response)); + + /** @Then the response is returned through the middleware unchanged */ + self::assertSame(Code::OK->value, $actual->getStatusCode()); + self::assertSame($response->getBody()->__toString(), $actual->getBody()->__toString()); + self::assertSame($response->getHeaders(), $actual->getHeaders()); + } } diff --git a/tests/Drivers/Slim/SlimTest.php b/tests/Drivers/Slim/SlimTest.php index 3d8c1db..a64e9c9 100644 --- a/tests/Drivers/Slim/SlimTest.php +++ b/tests/Drivers/Slim/SlimTest.php @@ -28,27 +28,6 @@ protected function setUp(): void $this->middleware = new Middleware(); } - public function testProcessWhenSlimMiddlewareInvokedThenReturnsConfiguredResponse(): void - { - /** @Given a valid request */ - $request = new ServerRequest(method: 'GET', uri: 'https://api.example.com/'); - - /** @And an HTTP response is created with a 200 OK status, a JSON body, Content-Type, and Cache-Control */ - $response = Response::ok( - ['createdAt' => date(DateTimeInterface::ATOM)], - ContentType::applicationJson(charset: Charset::UTF_8), - CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) - ); - - /** @When the request is processed by the handler */ - $actual = $this->middleware->process($request, new Endpoint(response: $response)); - - /** @Then the response is returned through the middleware unchanged */ - self::assertSame(Code::OK->value, $actual->getStatusCode()); - self::assertSame($response->getBody()->__toString(), $actual->getBody()->__toString()); - self::assertSame($response->getHeaders(), $actual->getHeaders()); - } - public function testEmitWhenSlimEmitterUsedThenWritesBodyToOutputBuffer(): void { /** @Given a response with Content-Type, Cache-Control, and a custom header */ @@ -69,4 +48,25 @@ public function testEmitWhenSlimEmitterUsedThenWritesBodyToOutputBuffer(): void self::assertSame('OK', $response->getReasonPhrase()); self::assertSame('123456', $response->getHeaderLine('X-Request-ID')); } + + public function testProcessWhenSlimMiddlewareInvokedThenReturnsConfiguredResponse(): void + { + /** @Given a valid request */ + $request = new ServerRequest(method: 'GET', uri: 'https://api.example.com/'); + + /** @And an HTTP response is created with a 200 OK status, a JSON body, Content-Type, and Cache-Control */ + $response = Response::ok( + ['createdAt' => date(DateTimeInterface::ATOM)], + ContentType::applicationJson(charset: Charset::UTF_8), + CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) + ); + + /** @When the request is processed by the handler */ + $actual = $this->middleware->process($request, new Endpoint(response: $response)); + + /** @Then the response is returned through the middleware unchanged */ + self::assertSame(Code::OK->value, $actual->getStatusCode()); + self::assertSame($response->getBody()->__toString(), $actual->getBody()->__toString()); + self::assertSame($response->getHeaders(), $actual->getHeaders()); + } } diff --git a/tests/Models/Products.php b/tests/Models/Products.php index 23bd3b7..ea4af4b 100644 --- a/tests/Models/Products.php +++ b/tests/Models/Products.php @@ -21,13 +21,13 @@ public function __construct(iterable $elements = []) $this->elements = is_array($elements) ? array_values($elements) : iterator_to_array($elements, false); } - public function getIterator(): Traversable + public function getType(): string { - return new ArrayIterator($this->elements); + return Product::class; } - public function getType(): string + public function getIterator(): Traversable { - return Product::class; + return new ArrayIterator($this->elements); } } diff --git a/tests/Unit/Client/RequestTest.php b/tests/Unit/Client/RequestTest.php index bde36fd..ca324f7 100644 --- a/tests/Unit/Client/RequestTest.php +++ b/tests/Unit/Client/RequestTest.php @@ -17,28 +17,54 @@ final class RequestTest extends TestCase { - public function testForWhenMethodAndUrlGivenThenAccessorsReturnSuppliedValues(): void + public function testForWhenNullBodyGivenThenCarriesNoBody(): void { - /** @When creating a request for a specific method and URL */ - $request = Request::for(method: Method::GET, url: 'https://api.example.com/dragons'); + /** @When creating a request with an explicit null body */ + $request = Request::for(method: Method::GET, url: '/dragons'); - /** @Then accessors return the supplied values */ - self::assertSame('https://api.example.com/dragons', $request->url()); - self::assertSame(Method::GET, $request->method()); + /** @Then the body is null */ self::assertNull($request->body()); - self::assertNull($request->queryParameters()); - self::assertSame([], $request->headers()->toArray()); } - public function testForWhenNullBodyGivenThenCarriesNoBody(): void + #[DataProvider('bodyShortcutNullBodyCases')] + public function testBodyShortcutWhenBodyOmittedThenBodyIsNull(Closure $factory): void { - /** @When creating a request with an explicit null body */ - $request = Request::for(method: Method::GET, url: '/dragons'); + /** @Given a shortcut that accepts a body */ + + /** @When the factory is called without a body */ + $request = $factory(); /** @Then the body is null */ self::assertNull($request->body()); } + #[DataProvider('noBodyShortcutCases')] + public function testBodylessShortcutWhenInvokedThenBodyIsNull(Closure $factory): void + { + /** @Given a shortcut that does not accept a body */ + + /** @When the factory is called */ + $request = $factory(); + + /** @Then the body is null */ + self::assertNull($request->body()); + } + + public function testWithHeaderWhenNewNameGivenThenAppendsHeader(): void + { + /** @Given a request with no custom headers */ + $request = Request::get(url: '/dragons'); + + /** @When adding a new header */ + $updated = $request->withHeader(name: 'X-Trace-Id', value: 'abc-123'); + + /** @Then the new header is present on the updated instance */ + self::assertSame('abc-123', $updated->headers()->get('X-Trace-Id')); + + /** @And the original instance is unchanged */ + self::assertNull($request->headers()->get('X-Trace-Id')); + } + public function testForWhenMultipleHeadersGivenThenMergesEntries(): void { /** @Given a Content-Type header with charset */ @@ -58,78 +84,88 @@ public function testForWhenMultipleHeadersGivenThenMergesEntries(): void self::assertTrue($request->headers()->has('Content-Type')); } - public function testForWhenSameHeaderProvidedTwiceThenLastOneWins(): void + public function testHeadersGetWhenMissingKeyGivenThenReturnsNull(): void { - /** @Given a Content-Type header with charset */ - $first = ContentType::applicationJson(charset: Charset::UTF_8); + /** @Given a request with no headers */ + $request = Request::get(url: '/dragons'); - /** @And another Content-Type header without charset */ - $second = ContentType::applicationJson(); + /** @When looking up a non-existent header */ + /** @Then null is returned */ + self::assertNull($request->headers()->get('X-Missing')); + } - /** @When creating the request with both (last one wins) */ - $request = Request::for( - method: Method::POST, - url: '/dragons', - headers: Headers::from($first, $second) - ); + #[DataProvider('shortcutMethodCases')] + public function testShortcutWhenInvokedThenMethodMatchesExpected(Method $expected, Closure $factory): void + { + /** @Given a shortcut factory for a specific HTTP method */ - /** @Then the last one wins for the Content-Type key */ - self::assertSame('application/json', $request->headers()->get('Content-Type')); + /** @When the factory is called */ + $request = $factory(); + + /** @Then the method matches the expected enum case */ + self::assertSame($expected, $request->method()); } - public function testForWhenQueryParametersGivenThenPreservesArrayInProperty(): void + #[DataProvider('bodyShortcutWithBodyCases')] + public function testBodyShortcutWhenBodyGivenThenBodyIsPropagated(Closure $factory, array $body): void { - /** @Given query parameters */ - $queryParameters = ['sort' => 'name', 'order' => 'asc']; + /** @Given a shortcut that accepts a body */ - /** @When creating the request with query parameters */ - $request = Request::for(method: Method::GET, url: '/dragons', queryParameters: $queryParameters); + /** @When the factory is called with a body */ + $request = $factory(); - /** @Then the query parameters are preserved */ - self::assertSame($queryParameters, $request->queryParameters()); + /** @Then the body is propagated */ + self::assertSame($body, $request->body()); } - public function testWithUrlWhenInvokedThenReturnsNewInstanceWithReplacedUrl(): void + public function testForWhenDistinctKeyHeadersGivenThenBothPresent(): void { - /** @Given a request with an original URL */ - $request = Request::get(url: '/dragons'); + /** @Given a Content-Type header */ + $contentType = ContentType::applicationJson(); - /** @When calling withUrl */ - $updated = $request->withUrl(url: '/dragons/42'); + /** @And a Cache-Control header */ + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::mustRevalidate()); - /** @Then a new instance is returned with the URL replaced */ - self::assertNotSame($request, $updated); - self::assertSame('/dragons/42', $updated->url()); - self::assertSame('/dragons', $request->url()); + /** @When creating a request with both headers */ + $request = Request::for( + method: Method::GET, + url: '/dragons', + headers: Headers::from($contentType, $cacheControl) + ); + + /** @Then both header keys are present in the merged result */ + self::assertCount(2, $request->headers()->toArray()); } - public function testWithQueryParametersWhenInvokedThenReturnsNewInstanceWithReplacedQueryParameters(): void + public function testForWhenSameHeaderProvidedTwiceThenLastOneWins(): void { - /** @Given a request with original query parameters */ - $request = Request::get(url: '/dragons', queryParameters: ['sort' => 'name']); + /** @Given a Content-Type header with charset */ + $first = ContentType::applicationJson(charset: Charset::UTF_8); - /** @When calling withQueryParameters */ - $updated = $request->withQueryParameters(queryParameters: ['order' => 'asc']); + /** @And another Content-Type header without charset */ + $second = ContentType::applicationJson(); - /** @Then a new instance is returned with the query parameters replaced */ - self::assertNotSame($request, $updated); - self::assertSame(['order' => 'asc'], $updated->queryParameters()); - self::assertSame(['sort' => 'name'], $request->queryParameters()); + /** @When creating the request with both (last one wins) */ + $request = Request::for( + method: Method::POST, + url: '/dragons', + headers: Headers::from($first, $second) + ); + + /** @Then the last one wins for the Content-Type key */ + self::assertSame('application/json', $request->headers()->get('Content-Type')); } - public function testWithHeaderWhenNewNameGivenThenAppendsHeader(): void + #[DataProvider('shortcutWithHeadersCases')] + public function testShortcutWhenHeadersGivenThenHeadersIsPropagated(Closure $factory, Headers $headers): void { - /** @Given a request with no custom headers */ - $request = Request::get(url: '/dragons'); - - /** @When adding a new header */ - $updated = $request->withHeader(name: 'X-Trace-Id', value: 'abc-123'); + /** @Given a shortcut factory called with specific headers */ - /** @Then the new header is present on the updated instance */ - self::assertSame('abc-123', $updated->headers()->get('X-Trace-Id')); + /** @When the factory is called with headers */ + $request = $factory(); - /** @And the original instance is unchanged */ - self::assertNull($request->headers()->get('X-Trace-Id')); + /** @Then the headers are propagated unchanged */ + self::assertSame($headers, $request->headers()); } public function testWithHeaderWhenExistingNameGivenThenReplacesHeader(): void @@ -150,58 +186,23 @@ public function testWithHeaderWhenExistingNameGivenThenReplacesHeader(): void self::assertSame('application/json', $request->headers()->get('Content-Type')); } - public function testWithHeaderWhenCasingDiffersThenReplacesExistingEntry(): void - { - /** @Given a request with a Content-Type header stored under mixed case */ - $request = Request::post( - url: '/dragons', - headers: Headers::from(ContentType::applicationJson()) - ); - - /** @When replacing using a different casing */ - $updated = $request->withHeader(name: 'content-type', value: 'text/plain'); - - /** @Then only one Content-Type entry exists and it carries the new value */ - self::assertSame('text/plain', $updated->headers()->get('Content-Type')); - self::assertCount(1, $updated->headers()->toArray()); - } - - public function testForWhenDistinctKeyHeadersGivenThenBothPresent(): void + public function testForWhenNonStandardMethodGivenThenMethodIsPreserved(): void { - /** @Given a Content-Type header */ - $contentType = ContentType::applicationJson(); - - /** @And a Cache-Control header */ - $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::mustRevalidate()); - - /** @When creating a request with both headers */ - $request = Request::for( - method: Method::GET, - url: '/dragons', - headers: Headers::from($contentType, $cacheControl) - ); + /** @When creating a request for a non-standard method */ + $request = Request::for(method: Method::OPTIONS, url: '/dragons'); - /** @Then both header keys are present in the merged result */ - self::assertCount(2, $request->headers()->toArray()); + /** @Then the method is preserved */ + self::assertSame(Method::OPTIONS, $request->method()); } - public function testWithMergedHeadersWhenCustomConflictsWithDefaultThenCustomWins(): void + public function testHeadersWhenRequestCreatedThenExposesHeadersInstance(): void { - /** @Given a request with a custom Content-Type header */ - $request = Request::post( - url: '/dragons', - headers: Headers::from(ContentType::applicationJson(charset: Charset::UTF_8)) - ); - - /** @And defaults that include the same header */ - $defaults = Headers::fromArray(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); - - /** @When merging defaults under the existing headers */ - $resolved = $request->withMergedHeaders(defaults: $defaults); + /** @Given a request */ + $request = Request::get(url: '/dragons'); - /** @Then the custom header wins over the default */ - self::assertSame('application/json; charset=utf-8', $resolved->headers()->get('Content-Type')); - self::assertSame('application/json', $resolved->headers()->get('Accept')); + /** @When accessing headers */ + /** @Then a Headers instance is returned */ + self::assertInstanceOf(Headers::class, $request->headers()); } public function testHeadersWhenMixedCaseGivenThenLookupIsCaseInsensitive(): void @@ -215,81 +216,90 @@ public function testHeadersWhenMixedCaseGivenThenLookupIsCaseInsensitive(): void self::assertSame('application/json', $request->headers()->get('CONTENT-TYPE')); } - public function testHeadersGetWhenMissingKeyGivenThenReturnsNull(): void + public function testWithHeaderWhenCasingDiffersThenReplacesExistingEntry(): void { - /** @Given a request with no headers */ - $request = Request::get(url: '/dragons'); - - /** @When looking up a non-existent header */ - /** @Then null is returned */ - self::assertNull($request->headers()->get('X-Missing')); - } + /** @Given a request with a Content-Type header stored under mixed case */ + $request = Request::post( + url: '/dragons', + headers: Headers::from(ContentType::applicationJson()) + ); - public function testHeadersWhenRequestCreatedThenExposesHeadersInstance(): void - { - /** @Given a request */ - $request = Request::get(url: '/dragons'); + /** @When replacing using a different casing */ + $updated = $request->withHeader(name: 'content-type', value: 'text/plain'); - /** @When accessing headers */ - /** @Then a Headers instance is returned */ - self::assertInstanceOf(Headers::class, $request->headers()); + /** @Then only one Content-Type entry exists and it carries the new value */ + self::assertSame('text/plain', $updated->headers()->get('Content-Type')); + self::assertCount(1, $updated->headers()->toArray()); } - public function testForWhenNonStandardMethodGivenThenMethodIsPreserved(): void + public function testForWhenQueryParametersGivenThenPreservesArrayInProperty(): void { - /** @When creating a request for a non-standard method */ - $request = Request::for(method: Method::OPTIONS, url: '/dragons'); + /** @Given query parameters */ + $queryParameters = ['sort' => 'name', 'order' => 'asc']; - /** @Then the method is preserved */ - self::assertSame(Method::OPTIONS, $request->method()); + /** @When creating the request with query parameters */ + $request = Request::for(method: Method::GET, url: '/dragons', queryParameters: $queryParameters); + + /** @Then the query parameters are preserved */ + self::assertSame($queryParameters, $request->queryParameters()); } - #[DataProvider('shortcutMethodCases')] - public function testShortcutWhenInvokedThenMethodMatchesExpected(Method $expected, Closure $factory): void + #[DataProvider('shortcutWithDefaultHeadersCases')] + public function testShortcutWhenHeadersOmittedThenHeadersDefaultsToEmptySet(Closure $factory): void { - /** @Given a shortcut factory for a specific HTTP method */ + /** @Given a shortcut factory called without headers */ - /** @When the factory is called */ + /** @When the factory is called without headers */ $request = $factory(); - /** @Then the method matches the expected enum case */ - self::assertSame($expected, $request->method()); + /** @Then the headers default to an empty set */ + self::assertSame([], $request->headers()->toArray()); } - #[DataProvider('noBodyShortcutCases')] - public function testBodylessShortcutWhenInvokedThenBodyIsNull(Closure $factory): void + public function testWithUrlWhenInvokedThenReturnsNewInstanceWithReplacedUrl(): void { - /** @Given a shortcut that does not accept a body */ + /** @Given a request with an original URL */ + $request = Request::get(url: '/dragons'); - /** @When the factory is called */ - $request = $factory(); + /** @When calling withUrl */ + $updated = $request->withUrl(url: '/dragons/42'); - /** @Then the body is null */ - self::assertNull($request->body()); + /** @Then a new instance is returned with the URL replaced */ + self::assertNotSame($request, $updated); + self::assertSame('/dragons/42', $updated->url()); + self::assertSame('/dragons', $request->url()); } - #[DataProvider('bodyShortcutWithBodyCases')] - public function testBodyShortcutWhenBodyGivenThenBodyIsPropagated(Closure $factory, array $body): void + public function testForWhenMethodAndUrlGivenThenAccessorsReturnSuppliedValues(): void { - /** @Given a shortcut that accepts a body */ - - /** @When the factory is called with a body */ - $request = $factory(); + /** @When creating a request for a specific method and URL */ + $request = Request::for(method: Method::GET, url: 'https://api.example.com/dragons'); - /** @Then the body is propagated */ - self::assertSame($body, $request->body()); + /** @Then accessors return the supplied values */ + self::assertSame('https://api.example.com/dragons', $request->url()); + self::assertSame(Method::GET, $request->method()); + self::assertNull($request->body()); + self::assertNull($request->queryParameters()); + self::assertSame([], $request->headers()->toArray()); } - #[DataProvider('bodyShortcutNullBodyCases')] - public function testBodyShortcutWhenBodyOmittedThenBodyIsNull(Closure $factory): void + public function testWithMergedHeadersWhenCustomConflictsWithDefaultThenCustomWins(): void { - /** @Given a shortcut that accepts a body */ + /** @Given a request with a custom Content-Type header */ + $request = Request::post( + url: '/dragons', + headers: Headers::from(ContentType::applicationJson(charset: Charset::UTF_8)) + ); - /** @When the factory is called without a body */ - $request = $factory(); + /** @And defaults that include the same header */ + $defaults = Headers::fromArray(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); - /** @Then the body is null */ - self::assertNull($request->body()); + /** @When merging defaults under the existing headers */ + $resolved = $request->withMergedHeaders(defaults: $defaults); + + /** @Then the custom header wins over the default */ + self::assertSame('application/json; charset=utf-8', $resolved->headers()->get('Content-Type')); + self::assertSame('application/json', $resolved->headers()->get('Accept')); } #[DataProvider('shortcutWithQueryCases')] @@ -306,37 +316,18 @@ public function testShortcutWhenQueryParametersGivenThenQueryParametersArePropag self::assertSame($queryParameters, $request->queryParameters()); } - #[DataProvider('shortcutWithDefaultHeadersCases')] - public function testShortcutWhenHeadersOmittedThenHeadersDefaultsToEmptySet(Closure $factory): void - { - /** @Given a shortcut factory called without headers */ - - /** @When the factory is called without headers */ - $request = $factory(); - - /** @Then the headers default to an empty set */ - self::assertSame([], $request->headers()->toArray()); - } - - #[DataProvider('shortcutWithHeadersCases')] - public function testShortcutWhenHeadersGivenThenHeadersIsPropagated(Closure $factory, Headers $headers): void + public function testWithQueryParametersWhenInvokedThenReturnsNewInstanceWithReplacedQueryParameters(): void { - /** @Given a shortcut factory called with specific headers */ - - /** @When the factory is called with headers */ - $request = $factory(); + /** @Given a request with original query parameters */ + $request = Request::get(url: '/dragons', queryParameters: ['sort' => 'name']); - /** @Then the headers are propagated unchanged */ - self::assertSame($headers, $request->headers()); - } + /** @When calling withQueryParameters */ + $updated = $request->withQueryParameters(queryParameters: ['order' => 'asc']); - public static function bodyShortcutNullBodyCases(): array - { - return [ - 'POST' => [static fn(): Request => Request::post(url: '/dragons')], - 'PUT' => [static fn(): Request => Request::put(url: '/dragons')], - 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons')] - ]; + /** @Then a new instance is returned with the query parameters replaced */ + self::assertNotSame($request, $updated); + self::assertSame(['order' => 'asc'], $updated->queryParameters()); + self::assertSame(['sort' => 'name'], $request->queryParameters()); } public static function noBodyShortcutCases(): array @@ -348,17 +339,6 @@ public static function noBodyShortcutCases(): array ]; } - public static function bodyShortcutWithBodyCases(): array - { - $body = ['name' => 'Smaug', 'type' => 'fire']; - - return [ - 'POST' => [static fn(): Request => Request::post(url: '/dragons', body: $body), $body], - 'PUT' => [static fn(): Request => Request::put(url: '/dragons', body: $body), $body], - 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons', body: $body), $body] - ]; - } - public static function shortcutMethodCases(): array { return [ @@ -371,32 +351,6 @@ public static function shortcutMethodCases(): array ]; } - public static function shortcutWithDefaultHeadersCases(): array - { - return [ - 'GET' => [static fn(): Request => Request::get(url: '/dragons')], - 'POST' => [static fn(): Request => Request::post(url: '/dragons')], - 'PUT' => [static fn(): Request => Request::put(url: '/dragons')], - 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons')], - 'DELETE' => [static fn(): Request => Request::delete(url: '/dragons')], - 'HEAD' => [static fn(): Request => Request::head(url: '/dragons')] - ]; - } - - public static function shortcutWithHeadersCases(): array - { - $headers = Headers::from(ContentType::applicationJson()); - - return [ - 'GET' => [static fn(): Request => Request::get(url: '/dragons', headers: $headers), $headers], - 'POST' => [static fn(): Request => Request::post(url: '/dragons', headers: $headers), $headers], - 'PUT' => [static fn(): Request => Request::put(url: '/dragons', headers: $headers), $headers], - 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons', headers: $headers), $headers], - 'DELETE' => [static fn(): Request => Request::delete(url: '/dragons', headers: $headers), $headers], - 'HEAD' => [static fn(): Request => Request::head(url: '/dragons', headers: $headers), $headers] - ]; - } - public static function shortcutWithQueryCases(): array { $queryParameters = ['sort' => 'name', 'order' => 'asc']; @@ -428,4 +382,50 @@ public static function shortcutWithQueryCases(): array ] ]; } + + public static function shortcutWithHeadersCases(): array + { + $headers = Headers::from(ContentType::applicationJson()); + + return [ + 'GET' => [static fn(): Request => Request::get(url: '/dragons', headers: $headers), $headers], + 'POST' => [static fn(): Request => Request::post(url: '/dragons', headers: $headers), $headers], + 'PUT' => [static fn(): Request => Request::put(url: '/dragons', headers: $headers), $headers], + 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons', headers: $headers), $headers], + 'DELETE' => [static fn(): Request => Request::delete(url: '/dragons', headers: $headers), $headers], + 'HEAD' => [static fn(): Request => Request::head(url: '/dragons', headers: $headers), $headers] + ]; + } + + public static function bodyShortcutNullBodyCases(): array + { + return [ + 'POST' => [static fn(): Request => Request::post(url: '/dragons')], + 'PUT' => [static fn(): Request => Request::put(url: '/dragons')], + 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons')] + ]; + } + + public static function bodyShortcutWithBodyCases(): array + { + $body = ['name' => 'Smaug', 'type' => 'fire']; + + return [ + 'POST' => [static fn(): Request => Request::post(url: '/dragons', body: $body), $body], + 'PUT' => [static fn(): Request => Request::put(url: '/dragons', body: $body), $body], + 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons', body: $body), $body] + ]; + } + + public static function shortcutWithDefaultHeadersCases(): array + { + return [ + 'GET' => [static fn(): Request => Request::get(url: '/dragons')], + 'POST' => [static fn(): Request => Request::post(url: '/dragons')], + 'PUT' => [static fn(): Request => Request::put(url: '/dragons')], + 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons')], + 'DELETE' => [static fn(): Request => Request::delete(url: '/dragons')], + 'HEAD' => [static fn(): Request => Request::head(url: '/dragons')] + ]; + } } diff --git a/tests/Unit/Client/ResponseTest.php b/tests/Unit/Client/ResponseTest.php index c213996..84c92c7 100644 --- a/tests/Unit/Client/ResponseTest.php +++ b/tests/Unit/Client/ResponseTest.php @@ -20,45 +20,16 @@ protected function setUp(): void $this->factory = new Psr17Factory(); } - public function testFromWhen200JsonResponseGivenThenExposesTypedBody(): void - { - /** @Given a 200 response with a JSON body */ - $psrResponse = $this->factory->createResponse(200) - ->withBody($this->factory->createStream('{"id":42,"name":"Hydra"}')); - - /** @When wrapping the PSR response */ - $response = Response::from(response: $psrResponse); - - /** @Then typed body access works correctly */ - self::assertSame(42, $response->body()->get(key: 'id')->toInteger()); - self::assertSame('Hydra', $response->body()->get(key: 'name')->toString()); - self::assertSame(Code::OK, $response->code()); - } - - public function testFromWhen204ResponseGivenThenBodyIsEmptyArray(): void - { - /** @Given a 204 response with no body */ - $psrResponse = $this->factory->createResponse(204); - - /** @When wrapping the PSR response */ - $response = Response::from(response: $psrResponse); - - /** @Then the body array is empty */ - self::assertSame([], $response->body()->toArray()); - self::assertSame(Code::NO_CONTENT, $response->code()); - } - - public function testFromWhenNonJsonBodyGivenThenReturnsSafeEmptyArray(): void + public function testFromWhen500ResponseGivenThenIsError(): void { - /** @Given a 200 response with a non-JSON body */ - $psrResponse = $this->factory->createResponse(200) - ->withBody($this->factory->createStream('plain text')); + /** @Given a 500 response */ + $psrResponse = $this->factory->createResponse(500); /** @When wrapping the PSR response */ $response = Response::from(response: $psrResponse); - /** @Then the body gracefully returns an empty array */ - self::assertSame([], $response->body()->toArray()); + /** @Then isError is true */ + self::assertTrue($response->isError()); } public function testFromWhen200ResponseGivenThenIsSuccess(): void @@ -85,18 +56,6 @@ public function testFromWhen200ResponseGivenThenIsNotError(): void self::assertFalse($response->isError()); } - public function testFromWhen500ResponseGivenThenIsError(): void - { - /** @Given a 500 response */ - $psrResponse = $this->factory->createResponse(500); - - /** @When wrapping the PSR response */ - $response = Response::from(response: $psrResponse); - - /** @Then isError is true */ - self::assertTrue($response->isError()); - } - public function testFromWhen500ResponseGivenThenIsNotSuccess(): void { /** @Given a 500 response */ @@ -109,74 +68,75 @@ public function testFromWhen500ResponseGivenThenIsNotSuccess(): void self::assertFalse($response->isSuccess()); } - public function testHeadersWhenPsrResponseGivenThenAccessibleViaHeadersValueObject(): void + public function testWithWhenNullBodyGivenThenReturnsEmptyArray(): void { - /** @Given a response with two distinct headers */ - $psrResponse = $this->factory->createResponse(200) - ->withHeader('X-Trace', 'abc') - ->withHeader('X-Request-ID', '123'); + /** @Given a status code with no body payload */ + $code = Code::NO_CONTENT; - /** @When wrapping the PSR response */ - $response = Response::from(response: $psrResponse); + /** @When creating the response */ + $response = Response::with(code: $code); - /** @Then headers() returns all headers accessible via the Headers value object */ - self::assertSame('abc', $response->headers()->get('X-Trace')); - self::assertSame('123', $response->headers()->get('X-Request-ID')); + /** @Then body is empty */ + self::assertSame([], $response->body()->toArray()); } - public function testRawWhenPsrResponseWrappedThenReturnsUnderlyingInstance(): void + public function testFromWhen204ResponseGivenThenBodyIsEmptyArray(): void { - /** @Given a PSR response */ - $psrResponse = $this->factory->createResponse(200); + /** @Given a 204 response with no body */ + $psrResponse = $this->factory->createResponse(204); - /** @When wrapping and then unwrapping */ + /** @When wrapping the PSR response */ $response = Response::from(response: $psrResponse); - /** @Then raw() returns the exact original instance */ - self::assertSame($psrResponse, $response->raw()); + /** @Then the body array is empty */ + self::assertSame([], $response->body()->toArray()); + self::assertSame(Code::NO_CONTENT, $response->code()); } - public function testWithWhenCodeAndBodyGivenThenSynthesizesAccessibleResponse(): void + public function testFromWhen200JsonResponseGivenThenExposesTypedBody(): void { - /** @Given a status code and a body payload */ - $code = Code::CREATED; - - /** @And a body payload */ - $body = ['id' => 1]; + /** @Given a 200 response with a JSON body */ + $psrResponse = $this->factory->createResponse(200) + ->withBody($this->factory->createStream('{"id":42,"name":"Hydra"}')); - /** @When synthesizing a response via with() */ - $response = Response::with(code: $code, body: $body); + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); - /** @Then code and body are accessible */ - self::assertSame(Code::CREATED, $response->code()); - self::assertSame(1, $response->body()->get(key: 'id')->toInteger()); - self::assertTrue($response->isSuccess()); - self::assertFalse($response->isError()); + /** @Then typed body access works correctly */ + self::assertSame(42, $response->body()->get(key: 'id')->toInteger()); + self::assertSame('Hydra', $response->body()->get(key: 'name')->toString()); + self::assertSame(Code::OK, $response->code()); } - public function testRawWhenSynthesizedResponseGivenThenThrowsSynthesizedResponseHasNoRaw(): void + public function testFromWhenNonJsonBodyGivenThenReturnsSafeEmptyArray(): void { - /** @Given a synthesized response */ - $response = Response::with(code: Code::OK); + /** @Given a 200 response with a non-JSON body */ + $psrResponse = $this->factory->createResponse(200) + ->withBody($this->factory->createStream('plain text')); - /** @Then SynthesizedResponseHasNoRaw is thrown with the documented message */ - $this->expectException(SynthesizedResponseHasNoRaw::class); - $this->expectExceptionMessage('Response was synthesized via Response::with(...)'); + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); - /** @When calling raw() */ - $response->raw(); + /** @Then the body gracefully returns an empty array */ + self::assertSame([], $response->body()->toArray()); } - public function testWithWhenNullBodyGivenThenReturnsEmptyArray(): void + public function testFromWhenSeekableStreamGivenThenRawIsStillReadable(): void { - /** @Given a status code with no body payload */ - $code = Code::NO_CONTENT; + /** @Given a 200 response with a JSON body in a seekable stream */ + $psrResponse = $this->factory->createResponse(200) + ->withBody($this->factory->createStream('{"name":"Hydra"}')); - /** @When creating the response */ - $response = Response::with(code: $code); + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); - /** @Then body is empty */ - self::assertSame([], $response->body()->toArray()); + /** @Then the body was parsed correctly */ + self::assertSame('Hydra', $response->body()->get(key: 'name')->toString()); + + /** @And the underlying stream is still readable via raw() */ + $raw = $response->raw()->getBody(); + $raw->rewind(); + self::assertSame('{"name":"Hydra"}', $raw->getContents()); } public function testWithWhenHeadersGivenThenExposesViaHeadersAccessor(): void @@ -191,22 +151,48 @@ public function testWithWhenHeadersGivenThenExposesViaHeadersAccessor(): void self::assertSame('abc', $response->headers()->get('X-Trace')); } - public function testFromWhenSeekableStreamGivenThenRawIsStillReadable(): void + public function testFromWhenDeeplyNestedJsonGivenThenDegradesToEmptyArray(): void { - /** @Given a 200 response with a JSON body in a seekable stream */ + /** @Given a JSON string nested deeper than 64 levels */ + $json = str_repeat('{"a":', 65) . '1' . str_repeat('}', 65); $psrResponse = $this->factory->createResponse(200) - ->withBody($this->factory->createStream('{"name":"Hydra"}')); + ->withBody($this->factory->createStream($json)); /** @When wrapping the PSR response */ $response = Response::from(response: $psrResponse); - /** @Then the body was parsed correctly */ - self::assertSame('Hydra', $response->body()->get(key: 'name')->toString()); + /** @Then body degrades gracefully to an empty array */ + self::assertSame([], $response->body()->toArray()); + } - /** @And the underlying stream is still readable via raw() */ - $raw = $response->raw()->getBody(); - $raw->rewind(); - self::assertSame('{"name":"Hydra"}', $raw->getContents()); + public function testRawWhenPsrResponseWrappedThenReturnsUnderlyingInstance(): void + { + /** @Given a PSR response */ + $psrResponse = $this->factory->createResponse(200); + + /** @When wrapping and then unwrapping */ + $response = Response::from(response: $psrResponse); + + /** @Then raw() returns the exact original instance */ + self::assertSame($psrResponse, $response->raw()); + } + + public function testWithWhenCodeAndBodyGivenThenSynthesizesAccessibleResponse(): void + { + /** @Given a status code and a body payload */ + $code = Code::CREATED; + + /** @And a body payload */ + $body = ['id' => 1]; + + /** @When synthesizing a response via with() */ + $response = Response::with(code: $code, body: $body); + + /** @Then code and body are accessible */ + self::assertSame(Code::CREATED, $response->code()); + self::assertSame(1, $response->body()->get(key: 'id')->toInteger()); + self::assertTrue($response->isSuccess()); + self::assertFalse($response->isError()); } public function testFromWhenAdvancedSeekableStreamGivenThenParsesBodyFromStart(): void @@ -228,17 +214,31 @@ public function testFromWhenAdvancedSeekableStreamGivenThenParsesBodyFromStart() self::assertSame('{"name":"Hydra"}', $response->raw()->getBody()->getContents()); } - public function testFromWhenDeeplyNestedJsonGivenThenDegradesToEmptyArray(): void + public function testHeadersWhenPsrResponseGivenThenAccessibleViaHeadersValueObject(): void { - /** @Given a JSON string nested deeper than 64 levels */ - $json = str_repeat('{"a":', 65) . '1' . str_repeat('}', 65); + /** @Given a response with two distinct headers */ $psrResponse = $this->factory->createResponse(200) - ->withBody($this->factory->createStream($json)); + ->withHeader('X-Trace', 'abc') + ->withHeader('X-Request-ID', '123'); /** @When wrapping the PSR response */ $response = Response::from(response: $psrResponse); - /** @Then body degrades gracefully to an empty array */ - self::assertSame([], $response->body()->toArray()); + /** @Then headers() returns all headers accessible via the Headers value object */ + self::assertSame('abc', $response->headers()->get('X-Trace')); + self::assertSame('123', $response->headers()->get('X-Request-ID')); + } + + public function testRawWhenSynthesizedResponseGivenThenThrowsSynthesizedResponseHasNoRaw(): 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(...)'); + + /** @When calling raw() */ + $response->raw(); } } diff --git a/tests/Unit/Client/Transports/InMemoryTransportTest.php b/tests/Unit/Client/Transports/InMemoryTransportTest.php index cc0f2b5..23e3d3f 100644 --- a/tests/Unit/Client/Transports/InMemoryTransportTest.php +++ b/tests/Unit/Client/Transports/InMemoryTransportTest.php @@ -13,6 +13,24 @@ final class InMemoryTransportTest extends TestCase { + public function testSendWhenQueueExhaustedThenThrowsNoMoreResponses(): void + { + /** @Given a transport seeded with one response */ + $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); + + /** @And a request to dispatch */ + $request = Request::get(url: '/dragons'); + + /** @And the seeded response is already consumed */ + $transport->send(request: $request); + + /** @Then NoMoreResponses is thrown on the next call */ + $this->expectException(NoMoreResponses::class); + + /** @When sending a second request */ + $transport->send(request: $request); + } + public function testSendWhenMultipleResponsesQueuedThenServesInFifoOrder(): void { /** @Given a first queued response carrying OK */ @@ -38,37 +56,34 @@ public function testSendWhenMultipleResponsesQueuedThenServesInFifoOrder(): void self::assertSame(Code::CREATED, $drained[1]->code()); } - public function testSendWhenQueueExhaustedThenThrowsNoMoreResponses(): void + public function testSendWhenQueueEmptyThenThrowsNoMoreResponsesImmediately(): void { - /** @Given a transport seeded with one response */ - $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); + /** @Given a transport seeded with zero responses */ + $transport = InMemoryTransport::with(responses: []); /** @And a request to dispatch */ $request = Request::get(url: '/dragons'); - /** @And the seeded response is already consumed */ - $transport->send(request: $request); - - /** @Then NoMoreResponses is thrown on the next call */ + /** @Then NoMoreResponses is thrown immediately */ $this->expectException(NoMoreResponses::class); - /** @When sending a second request */ + /** @When sending a request against the empty queue */ $transport->send(request: $request); } - public function testSendWhenQueueEmptyThenThrowsNoMoreResponsesImmediately(): void + public function testSendWhenSingleResponseQueuedThenReturnsTheQueuedResponse(): void { - /** @Given a transport seeded with zero responses */ - $transport = InMemoryTransport::with(responses: []); + /** @Given a transport seeded with a single CREATED response */ + $transport = InMemoryTransport::with(responses: [Response::with(code: Code::CREATED)]); /** @And a request to dispatch */ $request = Request::get(url: '/dragons'); - /** @Then NoMoreResponses is thrown immediately */ - $this->expectException(NoMoreResponses::class); + /** @When the request is sent */ + $response = $transport->send(request: $request); - /** @When sending a request against the empty queue */ - $transport->send(request: $request); + /** @Then the returned response carries the queued CREATED code */ + self::assertSame(Code::CREATED, $response->code()); } public function testSendWhenQueueEmptyThenExceptionMessageReferencesExhaustedIndex(): void @@ -86,19 +101,4 @@ public function testSendWhenQueueEmptyThenExceptionMessageReferencesExhaustedInd /** @When sending a request against the empty queue */ $transport->send(request: $request); } - - public function testSendWhenSingleResponseQueuedThenReturnsTheQueuedResponse(): void - { - /** @Given a transport seeded with a single CREATED response */ - $transport = InMemoryTransport::with(responses: [Response::with(code: Code::CREATED)]); - - /** @And a request to dispatch */ - $request = Request::get(url: '/dragons'); - - /** @When the request is sent */ - $response = $transport->send(request: $request); - - /** @Then the returned response carries the queued CREATED code */ - self::assertSame(Code::CREATED, $response->code()); - } } diff --git a/tests/Unit/Client/Transports/NetworkTransportTest.php b/tests/Unit/Client/Transports/NetworkTransportTest.php index 18e6752..d583369 100644 --- a/tests/Unit/Client/Transports/NetworkTransportTest.php +++ b/tests/Unit/Client/Transports/NetworkTransportTest.php @@ -28,6 +28,37 @@ protected function setUp(): void $this->factory = new Psr17Factory(); } + public function testSendWhenNoBodyGivenThenForwardsEmptyBody(): void + { + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); + + /** @When sending a request without body */ + $transport->send(request: Request::get(url: 'https://api.example.com/dragons')); + + /** @Then the PSR-7 request body is empty */ + self::assertNotNull($client->captured); + self::assertSame('', (string)$client->captured->getBody()); + } + + public function testSendWhenCustomHeaderMergedThenForwardsToPsrRequest(): void + { + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); + + /** @When sending a request with a custom header merged in */ + $transport->send( + request: Request::get(url: 'https://api.example.com/dragons') + ->withMergedHeaders(defaults: Headers::fromArray(entries: ['X-Correlation-ID' => 'abc-123'])) + ); + + /** @Then the PSR-7 request carries the custom header */ + self::assertNotNull($client->captured); + self::assertSame('abc-123', $client->captured->getHeaderLine('X-Correlation-ID')); + } + public function testSendWhenBodyGivenThenForwardsJsonAndContentTypeHeader(): void { /** @Given a capturing client */ @@ -46,35 +77,33 @@ public function testSendWhenBodyGivenThenForwardsJsonAndContentTypeHeader(): voi self::assertSame('application/json', $client->captured->getHeaderLine('Content-Type')); } - public function testSendWhenNoBodyGivenThenForwardsEmptyBody(): void + public function testSendWhenBodyHasInvalidUtf8ThenSubstitutesAndStillSends(): void { - /** @Given a capturing client */ + /** @Given a transport configured with a capturing client */ $client = CapturingClient::returningStatus(statusCode: 200); $transport = NetworkTransport::with(client: $client, factory: $this->factory); - /** @When sending a request without body */ - $transport->send(request: Request::get(url: 'https://api.example.com/dragons')); + /** @When sending a request whose body contains a non-UTF-8 byte sequence */ + $transport->send( + request: Request::post(url: 'https://api.example.com/dragons', body: ['value' => "\xB0\xB1\xB2"]) + ); - /** @Then the PSR-7 request body is empty */ + /** @Then the PSR-7 request body carries the JSON-escaped replacement character */ self::assertNotNull($client->captured); - self::assertSame('', (string)$client->captured->getBody()); + self::assertStringContainsString('\ufffd', (string)$client->captured->getBody()); } - public function testSendWhenCustomHeaderMergedThenForwardsToPsrRequest(): void + public function testSendWhenSuccessfulPsrResponseGivenThenWrapsInClientResponse(): void { - /** @Given a capturing client */ + /** @Given a client that returns a 200 response */ $client = CapturingClient::returningStatus(statusCode: 200); $transport = NetworkTransport::with(client: $client, factory: $this->factory); - /** @When sending a request with a custom header merged in */ - $transport->send( - request: Request::get(url: 'https://api.example.com/dragons') - ->withMergedHeaders(defaults: Headers::fromArray(entries: ['X-Correlation-ID' => 'abc-123'])) - ); + /** @When sending a request */ + $response = $transport->send(request: Request::get(url: 'https://api.example.com/dragons')); - /** @Then the PSR-7 request carries the custom header */ - self::assertNotNull($client->captured); - self::assertSame('abc-123', $client->captured->getHeaderLine('X-Correlation-ID')); + /** @Then the response code is correct */ + self::assertSame(Code::OK, $response->code()); } public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFailed(): void @@ -161,33 +190,4 @@ public function testSendWhenClientRaisesGenericClientExceptionThenExceptionMessa self::assertStringContainsString('generic failure', $exception->getMessage()); } } - - public function testSendWhenSuccessfulPsrResponseGivenThenWrapsInClientResponse(): void - { - /** @Given a client that returns a 200 response */ - $client = CapturingClient::returningStatus(statusCode: 200); - $transport = NetworkTransport::with(client: $client, factory: $this->factory); - - /** @When sending a request */ - $response = $transport->send(request: Request::get(url: 'https://api.example.com/dragons')); - - /** @Then the response code is correct */ - self::assertSame(Code::OK, $response->code()); - } - - public function testSendWhenBodyHasInvalidUtf8ThenSubstitutesAndStillSends(): void - { - /** @Given a transport configured with a capturing client */ - $client = CapturingClient::returningStatus(statusCode: 200); - $transport = NetworkTransport::with(client: $client, factory: $this->factory); - - /** @When sending a request whose body contains a non-UTF-8 byte sequence */ - $transport->send( - request: Request::post(url: 'https://api.example.com/dragons', body: ['value' => "\xB0\xB1\xB2"]) - ); - - /** @Then the PSR-7 request body carries the JSON-escaped replacement character */ - self::assertNotNull($client->captured); - self::assertStringContainsString('\ufffd', (string)$client->captured->getBody()); - } } diff --git a/tests/Unit/CodeTest.php b/tests/Unit/CodeTest.php index 9b4b7ab..2616561 100644 --- a/tests/Unit/CodeTest.php +++ b/tests/Unit/CodeTest.php @@ -10,26 +10,40 @@ final class CodeTest extends TestCase { - #[DataProvider('messagesDataProvider')] - public function testMessageWhenKnownCodeGivenThenReturnsRfcDescription(Code $code, string $expected): void + public function testIsErrorWhenCodeOkGivenThenReturnsFalse(): void { - /** @Given a Code instance */ - /** @When retrieving the message for the Code */ - $actual = $code->message(); + /** @Given Code::OK */ + $code = Code::OK; - /** @Then the message matches the expected string */ - self::assertSame($expected, $actual); + /** @When invoking isError */ + $actual = $code->isError(); + + /** @Then the result is false */ + self::assertFalse($actual); } - #[DataProvider('codesDataProvider')] - public function testIsValidCodeWhenIntegerGivenThenReturnsExpected(int $code, bool $expected): void + public function testIsSuccessWhenCodeOkGivenThenReturnsTrue(): void { - /** @Given an integer representing an HTTP code */ - /** @When checking if it is a valid HTTP code */ - $actual = Code::isValidCode(code: $code); + /** @Given Code::OK */ + $code = Code::OK; - /** @Then the result matches the expected boolean */ - self::assertSame($expected, $actual); + /** @When invoking isSuccess */ + $actual = $code->isSuccess(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsRedirectionWhenCodeOkGivenThenReturnsFalse(): void + { + /** @Given Code::OK */ + $code = Code::OK; + + /** @When invoking isRedirection */ + $actual = $code->isRedirection(); + + /** @Then the result is false */ + self::assertFalse($actual); } #[DataProvider('errorCodesDataProvider')] @@ -43,6 +57,29 @@ public function testIsErrorCodeWhenIntegerGivenThenReturnsExpected(int $code, bo self::assertSame($expected, $actual); } + public function testIsInformationalWhenCodeOkGivenThenReturnsFalse(): void + { + /** @Given Code::OK */ + $code = Code::OK; + + /** @When invoking isInformational */ + $actual = $code->isInformational(); + + /** @Then the result is false */ + self::assertFalse($actual); + } + + #[DataProvider('codesDataProvider')] + public function testIsValidCodeWhenIntegerGivenThenReturnsExpected(int $code, bool $expected): void + { + /** @Given an integer representing an HTTP code */ + /** @When checking if it is a valid HTTP code */ + $actual = Code::isValidCode(code: $code); + + /** @Then the result matches the expected boolean */ + self::assertSame($expected, $actual); + } + #[DataProvider('successCodesDataProvider')] public function testIsSuccessCodeWhenIntegerGivenThenReturnsExpected(int $code, bool $expected): void { @@ -54,54 +91,29 @@ public function testIsSuccessCodeWhenIntegerGivenThenReturnsExpected(int $code, self::assertSame($expected, $actual); } - public function testIsSuccessWhenCodeOkGivenThenReturnsTrue(): void - { - /** @Given Code::OK */ - $code = Code::OK; - - /** @When invoking isSuccess */ - $actual = $code->isSuccess(); - - /** @Then the result is true */ - self::assertTrue($actual); - } - - public function testIsErrorWhenCodeOkGivenThenReturnsFalse(): void + #[DataProvider('messagesDataProvider')] + public function testMessageWhenKnownCodeGivenThenReturnsRfcDescription(Code $code, string $expected): void { - /** @Given Code::OK */ - $code = Code::OK; - - /** @When invoking isError */ - $actual = $code->isError(); + /** @Given a Code instance */ + /** @When retrieving the message for the Code */ + $actual = $code->message(); - /** @Then the result is false */ - self::assertFalse($actual); + /** @Then the message matches the expected string */ + self::assertSame($expected, $actual); } - public function testIsErrorWhenCodeInternalServerErrorGivenThenReturnsTrue(): void + public function testIsClientErrorWhenCodeBadRequestGivenThenReturnsTrue(): void { - /** @Given Code::INTERNAL_SERVER_ERROR */ - $code = Code::INTERNAL_SERVER_ERROR; + /** @Given Code::BAD_REQUEST */ + $code = Code::BAD_REQUEST; - /** @When invoking isError */ - $actual = $code->isError(); + /** @When invoking isClientError */ + $actual = $code->isClientError(); /** @Then the result is true */ self::assertTrue($actual); } - public function testIsSuccessWhenCodeInternalServerErrorGivenThenReturnsFalse(): void - { - /** @Given Code::INTERNAL_SERVER_ERROR */ - $code = Code::INTERNAL_SERVER_ERROR; - - /** @When invoking isSuccess */ - $actual = $code->isSuccess(); - - /** @Then the result is false */ - self::assertFalse($actual); - } - public function testIsInformationalWhenCodeContinueGivenThenReturnsTrue(): void { /** @Given Code::CONTINUE */ @@ -114,37 +126,37 @@ public function testIsInformationalWhenCodeContinueGivenThenReturnsTrue(): void self::assertTrue($actual); } - public function testIsInformationalWhenCodeEarlyHintsGivenThenReturnsTrue(): void + public function testIsServerErrorWhenCodeBadRequestGivenThenReturnsFalse(): void { - /** @Given Code::EARLY_HINTS */ - $code = Code::EARLY_HINTS; + /** @Given Code::BAD_REQUEST */ + $code = Code::BAD_REQUEST; - /** @When invoking isInformational */ - $actual = $code->isInformational(); + /** @When invoking isServerError */ + $actual = $code->isServerError(); - /** @Then the result is true */ - self::assertTrue($actual); + /** @Then the result is false */ + self::assertFalse($actual); } - public function testIsInformationalWhenCodeOkGivenThenReturnsFalse(): void + public function testIsInformationalWhenCodeEarlyHintsGivenThenReturnsTrue(): void { - /** @Given Code::OK */ - $code = Code::OK; + /** @Given Code::EARLY_HINTS */ + $code = Code::EARLY_HINTS; /** @When invoking isInformational */ $actual = $code->isInformational(); - /** @Then the result is false */ - self::assertFalse($actual); + /** @Then the result is true */ + self::assertTrue($actual); } - public function testIsRedirectionWhenCodeMovedPermanentlyGivenThenReturnsTrue(): void + public function testIsErrorWhenCodeInternalServerErrorGivenThenReturnsTrue(): void { - /** @Given Code::MOVED_PERMANENTLY */ - $code = Code::MOVED_PERMANENTLY; + /** @Given Code::INTERNAL_SERVER_ERROR */ + $code = Code::INTERNAL_SERVER_ERROR; - /** @When invoking isRedirection */ - $actual = $code->isRedirection(); + /** @When invoking isError */ + $actual = $code->isError(); /** @Then the result is true */ self::assertTrue($actual); @@ -162,10 +174,10 @@ public function testIsRedirectionWhenCodeMultipleChoicesGivenThenReturnsTrue(): self::assertTrue($actual); } - public function testIsRedirectionWhenCodePermanentRedirectGivenThenReturnsTrue(): void + public function testIsRedirectionWhenCodeMovedPermanentlyGivenThenReturnsTrue(): void { - /** @Given Code::PERMANENT_REDIRECT */ - $code = Code::PERMANENT_REDIRECT; + /** @Given Code::MOVED_PERMANENTLY */ + $code = Code::MOVED_PERMANENTLY; /** @When invoking isRedirection */ $actual = $code->isRedirection(); @@ -174,37 +186,37 @@ public function testIsRedirectionWhenCodePermanentRedirectGivenThenReturnsTrue() self::assertTrue($actual); } - public function testIsRedirectionWhenCodeOkGivenThenReturnsFalse(): void + public function testIsSuccessWhenCodeInternalServerErrorGivenThenReturnsFalse(): void { - /** @Given Code::OK */ - $code = Code::OK; + /** @Given Code::INTERNAL_SERVER_ERROR */ + $code = Code::INTERNAL_SERVER_ERROR; - /** @When invoking isRedirection */ - $actual = $code->isRedirection(); + /** @When invoking isSuccess */ + $actual = $code->isSuccess(); /** @Then the result is false */ self::assertFalse($actual); } - public function testIsClientErrorWhenCodeBadRequestGivenThenReturnsTrue(): void + public function testIsRedirectionWhenCodePermanentRedirectGivenThenReturnsTrue(): void { - /** @Given Code::BAD_REQUEST */ - $code = Code::BAD_REQUEST; + /** @Given Code::PERMANENT_REDIRECT */ + $code = Code::PERMANENT_REDIRECT; - /** @When invoking isClientError */ - $actual = $code->isClientError(); + /** @When invoking isRedirection */ + $actual = $code->isRedirection(); /** @Then the result is true */ self::assertTrue($actual); } - public function testIsClientErrorWhenCodeUnavailableForLegalReasonsGivenThenReturnsTrue(): void + public function testIsServerErrorWhenCodeInternalServerErrorGivenThenReturnsTrue(): void { - /** @Given Code::UNAVAILABLE_FOR_LEGAL_REASONS */ - $code = Code::UNAVAILABLE_FOR_LEGAL_REASONS; + /** @Given Code::INTERNAL_SERVER_ERROR */ + $code = Code::INTERNAL_SERVER_ERROR; - /** @When invoking isClientError */ - $actual = $code->isClientError(); + /** @When invoking isServerError */ + $actual = $code->isServerError(); /** @Then the result is true */ self::assertTrue($actual); @@ -222,13 +234,13 @@ public function testIsClientErrorWhenCodeInternalServerErrorGivenThenReturnsFals self::assertFalse($actual); } - public function testIsServerErrorWhenCodeInternalServerErrorGivenThenReturnsTrue(): void + public function testIsClientErrorWhenCodeUnavailableForLegalReasonsGivenThenReturnsTrue(): void { - /** @Given Code::INTERNAL_SERVER_ERROR */ - $code = Code::INTERNAL_SERVER_ERROR; + /** @Given Code::UNAVAILABLE_FOR_LEGAL_REASONS */ + $code = Code::UNAVAILABLE_FOR_LEGAL_REASONS; - /** @When invoking isServerError */ - $actual = $code->isServerError(); + /** @When invoking isClientError */ + $actual = $code->isClientError(); /** @Then the result is true */ self::assertTrue($actual); @@ -246,16 +258,16 @@ public function testIsServerErrorWhenCodeNetworkAuthenticationRequiredGivenThenR self::assertTrue($actual); } - public function testIsServerErrorWhenCodeBadRequestGivenThenReturnsFalse(): void + public static function codesDataProvider(): array { - /** @Given Code::BAD_REQUEST */ - $code = Code::BAD_REQUEST; - - /** @When invoking isServerError */ - $actual = $code->isServerError(); - - /** @Then the result is false */ - self::assertFalse($actual); + return [ + 'Invalid code 0' => ['code' => 0, 'expected' => false], + 'Invalid code -1' => ['code' => -1, 'expected' => false], + 'Invalid code 1054' => ['code' => 1054, 'expected' => false], + 'Valid code 200 OK' => ['code' => Code::OK->value, 'expected' => true], + 'Valid code 100 Continue' => ['code' => Code::CONTINUE->value, 'expected' => true], + 'Valid code 500 Internal Server Error' => ['code' => Code::INTERNAL_SERVER_ERROR->value, 'expected' => true] + ]; } public static function messagesDataProvider(): array @@ -304,18 +316,6 @@ public static function messagesDataProvider(): array ]; } - public static function codesDataProvider(): array - { - return [ - 'Invalid code 0' => ['code' => 0, 'expected' => false], - 'Invalid code -1' => ['code' => -1, 'expected' => false], - 'Invalid code 1054' => ['code' => 1054, 'expected' => false], - 'Valid code 200 OK' => ['code' => Code::OK->value, 'expected' => true], - 'Valid code 100 Continue' => ['code' => Code::CONTINUE->value, 'expected' => true], - 'Valid code 500 Internal Server Error' => ['code' => Code::INTERNAL_SERVER_ERROR->value, 'expected' => true] - ]; - } - public static function errorCodesDataProvider(): array { return [ diff --git a/tests/Unit/CookieTest.php b/tests/Unit/CookieTest.php index 4b08a01..88bbceb 100644 --- a/tests/Unit/CookieTest.php +++ b/tests/Unit/CookieTest.php @@ -14,50 +14,117 @@ final class CookieTest extends TestCase { - public function testCreateWhenNameAndValueGivenThenSerializesNameValuePair(): void + #[DataProvider('invalidNameProvider')] + public function testCreateWhenInvalidNameGivenThenThrows(string $name): void { - /** @Given a cookie name and value */ + /** + * @Given an invalid cookie name + * @When Cookie::create is called with that name + * @Then it throws CookieNameIsInvalid + */ + $this->expectException(InvalidArgumentException::class); + + Cookie::create(name: $name, value: 'value'); + } + + public function testExpireWhenInvalidNameGivenThenThrows(): void + { + /** @Then an exception indicating the name is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cookie name is invalid'); + + /** @When expiring a cookie with an invalid name */ + Cookie::expire(name: 'bad name'); + } + + #[DataProvider('invalidValueProvider')] + public function testCreateWhenInvalidValueGivenThenThrows(string $value): void + { + /** + * @Given an invalid cookie value + * @When Cookie::create is called with that value + * @Then it throws CookieValueIsInvalid + */ + $this->expectException(InvalidArgumentException::class); + + Cookie::create(name: 'session', value: $value); + } + + #[DataProvider('invalidPathProvider')] + public function testWithPathWhenInvalidPathGivenThenThrows(string $path): void + { + /** @Given a valid cookie */ $cookie = Cookie::create(name: 'session', value: 'abc'); - /** @When the header is serialized */ - $actual = $cookie->toArray(); + /** @Then an exception indicating the path is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is invalid'); - /** @Then the header contains only the name and value */ - self::assertSame(['Set-Cookie' => ['session=abc']], $actual); + /** @When setting the path to an invalid value */ + $cookie->withPath(path: $path); } - public function testCreateWhenAllAttributesAppliedThenSerializesInCanonicalOrder(): void + public function testSecureWhenInvokedThenLeavesBaseUntouched(): void { - /** @Given a cookie composed with every supported attribute */ - $cookie = Cookie::create(name: 'refresh_token', value: 'opaque-value') - ->secure() - ->httpOnly() - ->withPath(path: '/v1/sessions') - ->withMaxAge(seconds: 604800) - ->withDomain(domain: 'api.example.com') - ->partitioned() - ->withSameSite(sameSite: SameSite::STRICT); + /** @Given a base cookie without the secure flag */ + $base = Cookie::create(name: 'session', value: 'abc'); - /** @When the header is serialized */ - $actual = $cookie->toArray(); + /** @When the secure flag is applied */ + $base->secure(); - /** @Then the header includes every attribute in the canonical order */ - $expected = 'refresh_token=opaque-value; Max-Age=604800; Path=/v1/sessions; ' - . 'Domain=api.example.com; Secure; HttpOnly; SameSite=Strict; Partitioned'; - self::assertSame(['Set-Cookie' => [$expected]], $actual); + /** @Then the base instance remains unchanged */ + self::assertSame(['Set-Cookie' => ['session=abc']], $base->toArray()); } - public function testExpireWhenInvokedThenEmitsEmptyValueMaxAgeZeroAndExpiresEpoch(): void + public function testCreateWhenEmptyValueGivenThenRendersEmpty(): void { - /** @Given a cookie deletion bound to the path used when the cookie was issued */ - $cookie = Cookie::expire(name: 'refresh_token')->withPath(path: '/v1/sessions'); + /** @Given an empty value */ + $cookie = Cookie::create(name: 'session', value: ''); /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the header instructs the browser to discard the cookie via both Max-Age and Expires */ - $expected = 'refresh_token=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/v1/sessions'; - self::assertSame(['Set-Cookie' => [$expected]], $actual); + /** @Then the value is rendered as empty */ + self::assertSame(['Set-Cookie' => ['session=']], $actual); + } + + #[DataProvider('invalidDomainProvider')] + public function testWithDomainWhenInvalidDomainGivenThenThrows(string $domain): void + { + /** @Given a valid cookie */ + $cookie = Cookie::create(name: 'session', value: 'abc'); + + /** @Then an exception indicating the domain is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is invalid'); + + /** @When setting the domain to an invalid value */ + $cookie->withDomain(domain: $domain); + } + + public function testWithValueWhenForbiddenCharacterGivenThenThrows(): void + { + /** @Given a valid cookie */ + $cookie = Cookie::create(name: 'session', value: 'abc'); + + /** @Then an exception indicating the value is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cookie value is invalid'); + + /** @When the value is replaced with one containing forbidden characters */ + $cookie->withValue(value: 'has;semicolon'); + } + + public function testSecureWhenInvokedThenReturnsNewInstanceWithFlag(): void + { + /** @Given a base cookie without the secure flag */ + $base = Cookie::create(name: 'session', value: 'abc'); + + /** @When the secure flag is applied */ + $secured = $base->secure(); + + /** @Then the new instance has the secure flag applied */ + self::assertSame(['Set-Cookie' => ['session=abc; Secure']], $secured->toArray()); } public function testWithValueWhenInvokedThenLeavesOriginalUntouched(): void @@ -72,16 +139,25 @@ public function testWithValueWhenInvokedThenLeavesOriginalUntouched(): void self::assertSame(['Set-Cookie' => ['session=initial']], $original->toArray()); } - public function testWithValueWhenInvokedThenReturnsNewInstanceWithReplacedValue(): void + public function testCreateWhenForbiddenCharacterInValueGivenThenThrows(): void { - /** @Given a cookie with an initial value */ - $original = Cookie::create(name: 'session', value: 'initial'); + /** @Then an exception indicating the value is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); - /** @When a new value is assigned */ - $rotated = $original->withValue(value: 'rotated'); + /** @When creating a cookie with the invalid value */ + Cookie::create(name: 'session', value: 'abc;def'); + } - /** @Then the new instance carries the replaced value */ - self::assertSame(['Set-Cookie' => ['session=rotated']], $rotated->toArray()); + public function testCreateWhenNameAndValueGivenThenSerializesNameValuePair(): void + { + /** @Given a cookie name and value */ + $cookie = Cookie::create(name: 'session', value: 'abc'); + + /** @When the header is serialized */ + $actual = $cookie->toArray(); + + /** @Then the header contains only the name and value */ + self::assertSame(['Set-Cookie' => ['session=abc']], $actual); } public function testWithExpiresWhenNonUtcDateGivenThenRendersInUtcRfcFormat(): void @@ -101,30 +177,6 @@ public function testWithExpiresWhenNonUtcDateGivenThenRendersInUtcRfcFormat(): v ); } - public function testSecureWhenInvokedThenLeavesBaseUntouched(): void - { - /** @Given a base cookie without the secure flag */ - $base = Cookie::create(name: 'session', value: 'abc'); - - /** @When the secure flag is applied */ - $base->secure(); - - /** @Then the base instance remains unchanged */ - self::assertSame(['Set-Cookie' => ['session=abc']], $base->toArray()); - } - - public function testSecureWhenInvokedThenReturnsNewInstanceWithFlag(): void - { - /** @Given a base cookie without the secure flag */ - $base = Cookie::create(name: 'session', value: 'abc'); - - /** @When the secure flag is applied */ - $secured = $base->secure(); - - /** @Then the new instance has the secure flag applied */ - self::assertSame(['Set-Cookie' => ['session=abc; Secure']], $secured->toArray()); - } - public function testWithSameSiteWhenNoneGivenThenAutomaticallyEnablesSecure(): void { /** @Given a cookie without the Secure flag */ @@ -137,18 +189,37 @@ public function testWithSameSiteWhenNoneGivenThenAutomaticallyEnablesSecure(): v self::assertSame(['Set-Cookie' => ['session=abc; Secure; SameSite=None']], $updated->toArray()); } - public function testWithSameSiteWhenNoneGivenOnAlreadySecureCookieThenSerializesBothAttributes(): void + public function testWithValueWhenInvokedThenReturnsNewInstanceWithReplacedValue(): void { - /** @Given a cookie with SameSite=None combined with Secure */ - $cookie = Cookie::create(name: 'session', value: 'abc') + /** @Given a cookie with an initial value */ + $original = Cookie::create(name: 'session', value: 'initial'); + + /** @When a new value is assigned */ + $rotated = $original->withValue(value: 'rotated'); + + /** @Then the new instance carries the replaced value */ + self::assertSame(['Set-Cookie' => ['session=rotated']], $rotated->toArray()); + } + + public function testCreateWhenAllAttributesAppliedThenSerializesInCanonicalOrder(): void + { + /** @Given a cookie composed with every supported attribute */ + $cookie = Cookie::create(name: 'refresh_token', value: 'opaque-value') ->secure() - ->withSameSite(sameSite: SameSite::NONE); + ->httpOnly() + ->withPath(path: '/v1/sessions') + ->withDomain(domain: 'api.example.com') + ->withMaxAge(seconds: 604800) + ->partitioned() + ->withSameSite(sameSite: SameSite::STRICT); /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then both attributes are present */ - self::assertSame(['Set-Cookie' => ['session=abc; Secure; SameSite=None']], $actual); + /** @Then the header includes every attribute in the canonical order */ + $expected = 'refresh_token=opaque-value; Max-Age=604800; Path=/v1/sessions; ' + . 'Domain=api.example.com; Secure; HttpOnly; SameSite=Strict; Partitioned'; + self::assertSame(['Set-Cookie' => [$expected]], $actual); } public function testWithMaxAgeWhenInvokedAfterWithExpiresThenOnlyMaxAgeIsEmitted(): void @@ -164,6 +235,19 @@ public function testWithMaxAgeWhenInvokedAfterWithExpiresThenOnlyMaxAgeIsEmitted self::assertSame(['Set-Cookie' => ['session=abc; Max-Age=3600']], $updated->toArray()); } + public function testExpireWhenInvokedThenEmitsEmptyValueMaxAgeZeroAndExpiresEpoch(): void + { + /** @Given a cookie deletion bound to the path used when the cookie was issued */ + $cookie = Cookie::expire(name: 'refresh_token')->withPath(path: '/v1/sessions'); + + /** @When the header is serialized */ + $actual = $cookie->toArray(); + + /** @Then the header instructs the browser to discard the cookie via both Max-Age and Expires */ + $expected = 'refresh_token=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/v1/sessions'; + self::assertSame(['Set-Cookie' => [$expected]], $actual); + } + public function testWithExpiresWhenInvokedAfterWithMaxAgeThenOnlyExpiresIsEmitted(): void { /** @Given a cookie with a Max-Age attribute */ @@ -176,102 +260,18 @@ public function testWithExpiresWhenInvokedAfterWithMaxAgeThenOnlyExpiresIsEmitte self::assertSame(['Set-Cookie' => ['session=abc; Expires=Tue, 15 Jan 2030 12:00:00 GMT']], $updated->toArray()); } - public function testCreateWhenEmptyValueGivenThenRendersEmpty(): void + public function testWithSameSiteWhenNoneGivenOnAlreadySecureCookieThenSerializesBothAttributes(): void { - /** @Given an empty value */ - $cookie = Cookie::create(name: 'session', value: ''); + /** @Given a cookie with SameSite=None combined with Secure */ + $cookie = Cookie::create(name: 'session', value: 'abc') + ->secure() + ->withSameSite(sameSite: SameSite::NONE); /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the value is rendered as empty */ - self::assertSame(['Set-Cookie' => ['session=']], $actual); - } - - public function testWithValueWhenForbiddenCharacterGivenThenThrows(): void - { - /** @Given a valid cookie */ - $cookie = Cookie::create(name: 'session', value: 'abc'); - - /** @Then an exception indicating the value is invalid is thrown */ - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cookie value is invalid'); - - /** @When the value is replaced with one containing forbidden characters */ - $cookie->withValue(value: 'has;semicolon'); - } - - public function testExpireWhenInvalidNameGivenThenThrows(): void - { - /** @Then an exception indicating the name is invalid is thrown */ - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cookie name is invalid'); - - /** @When expiring a cookie with an invalid name */ - Cookie::expire(name: 'bad name'); - } - - public function testCreateWhenForbiddenCharacterInValueGivenThenThrows(): void - { - /** @Then an exception indicating the value is invalid is thrown */ - $this->expectException(InvalidArgumentException::class); - - /** @When creating a cookie with the invalid value */ - Cookie::create(name: 'session', value: 'abc;def'); - } - - /** - * @Given an invalid cookie name - * @When Cookie::create is called with that name - * @Then it throws CookieNameIsInvalid - */ - #[DataProvider('invalidNameProvider')] - public function testCreateWhenInvalidNameGivenThenThrows(string $name): void - { - $this->expectException(InvalidArgumentException::class); - - Cookie::create(name: $name, value: 'value'); - } - - /** - * @Given an invalid cookie value - * @When Cookie::create is called with that value - * @Then it throws CookieValueIsInvalid - */ - #[DataProvider('invalidValueProvider')] - public function testCreateWhenInvalidValueGivenThenThrows(string $value): void - { - $this->expectException(InvalidArgumentException::class); - - Cookie::create(name: 'session', value: $value); - } - - #[DataProvider('invalidDomainProvider')] - public function testWithDomainWhenInvalidDomainGivenThenThrows(string $domain): void - { - /** @Given a valid cookie */ - $cookie = Cookie::create(name: 'session', value: 'abc'); - - /** @Then an exception indicating the domain is invalid is thrown */ - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('is invalid'); - - /** @When setting the domain to an invalid value */ - $cookie->withDomain(domain: $domain); - } - - #[DataProvider('invalidPathProvider')] - public function testWithPathWhenInvalidPathGivenThenThrows(string $path): void - { - /** @Given a valid cookie */ - $cookie = Cookie::create(name: 'session', value: 'abc'); - - /** @Then an exception indicating the path is invalid is thrown */ - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('is invalid'); - - /** @When setting the path to an invalid value */ - $cookie->withPath(path: $path); + /** @Then both attributes are present */ + self::assertSame(['Set-Cookie' => ['session=abc; Secure; SameSite=None']], $actual); } public static function invalidNameProvider(): array @@ -288,6 +288,15 @@ public static function invalidNameProvider(): array ]; } + public static function invalidPathProvider(): array + { + return [ + 'Path with semicolon' => ['/api;evil'], + 'Path with comma' => ['/api,evil'], + 'Path with control char' => ["/api\x00evil"] + ]; + } + public static function invalidValueProvider(): array { return [ @@ -313,13 +322,4 @@ public static function invalidDomainProvider(): array 'Domain with control char' => ["example\x00.com"] ]; } - - public static function invalidPathProvider(): array - { - return [ - 'Path with semicolon' => ['/api;evil'], - 'Path with comma' => ['/api,evil'], - 'Path with control char' => ["/api\x00evil"] - ]; - } } diff --git a/tests/Unit/FailingTransport.php b/tests/Unit/FailingTransport.php index df9208b..6c0507d 100644 --- a/tests/Unit/FailingTransport.php +++ b/tests/Unit/FailingTransport.php @@ -32,9 +32,9 @@ public static function raisingNetworkFailure(string $reason, RuntimeException $c return new FailingTransport(factory: $factory); } - public static function raisingRequestInvalid(string $reason, RuntimeException $cause): FailingTransport + public static function raisingRequestFailure(string $reason, RuntimeException $cause): FailingTransport { - $factory = static fn(Request $request): HttpException => HttpRequestInvalid::from( + $factory = static fn(Request $request): HttpException => HttpRequestFailed::from( url: $request->url(), method: $request->method(), reason: $reason, @@ -44,9 +44,9 @@ public static function raisingRequestInvalid(string $reason, RuntimeException $c return new FailingTransport(factory: $factory); } - public static function raisingRequestFailure(string $reason, RuntimeException $cause): FailingTransport + public static function raisingRequestInvalid(string $reason, RuntimeException $cause): FailingTransport { - $factory = static fn(Request $request): HttpException => HttpRequestFailed::from( + $factory = static fn(Request $request): HttpException => HttpRequestInvalid::from( url: $request->url(), method: $request->method(), reason: $reason, diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php index 42510c6..155ac9d 100644 --- a/tests/Unit/HeadersTest.php +++ b/tests/Unit/HeadersTest.php @@ -15,83 +15,66 @@ final class HeadersTest extends TestCase { - public function testConstructorWhenEntriesGivenThenExposesEachEntry(): void + public function testGetWhenMissingKeyGivenThenReturnsNull(): void { - /** @Given an array of headers */ - $entries = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; - - /** @When creating Headers from an array */ - $headers = Headers::fromArray(entries: $entries); + /** @Given headers with one entry */ + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); - /** @Then the entries are accessible */ - self::assertSame('application/json', $headers->get('Content-Type')); - self::assertSame('application/json', $headers->get('Accept')); + /** @When looking up a non-existent header */ + /** @Then null is returned */ + self::assertNull($headers->get('X-Missing')); } - public function testFromWhenMultipleHeaderablesGivenThenMergesEntries(): void + public function testWithWhenNewNameGivenThenAppendsHeader(): void { - /** @Given a Content-Type headerable */ - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - - /** @And a Cookie headerable */ - $cookie = Cookie::create(name: 'session', value: 'abc123'); - - /** @When creating Headers from multiple headerables */ - $headers = Headers::from($contentType, $cookie); + /** @Given headers with one entry */ + $headers = Headers::fromArray(entries: ['Accept' => 'application/json']); - /** @Then both header entries are present */ - self::assertTrue($headers->has('Content-Type')); - self::assertTrue($headers->has('Set-Cookie')); - } + /** @When adding a header with a name not already present */ + $updated = $headers->with(name: 'X-Trace-Id', value: 'abc-123'); - public function testFromWhenNoArgumentsGivenThenReturnsEmptyHeaders(): void - { - /** @When creating Headers with no headerable arguments */ - $headers = Headers::from(); + /** @Then the new header is appended and the original is preserved */ + self::assertSame('application/json', $updated->get('Accept')); + self::assertSame('abc-123', $updated->get('X-Trace-Id')); - /** @Then the headers are empty */ - self::assertSame([], $headers->toArray()); + /** @And the original instance is unchanged */ + self::assertNull($headers->get('X-Trace-Id')); } - public function testFromMessageWhenEmptyHeadersGivenThenReturnsEmptyHeaders(): void + public function testHasWhenMissingKeyGivenThenReturnsFalse(): void { - /** @Given a PSR-7 response with no headers */ - $psrResponse = new Psr17Factory()->createResponse(200); - - /** @When building Headers from the message */ - $headers = Headers::fromMessage(message: $psrResponse); + /** @Given empty headers */ + $headers = Headers::fromArray(entries: []); - /** @Then the Headers instance is empty */ - self::assertSame([], $headers->toArray()); + /** @When checking for a non-existent header */ + /** @Then has() returns false */ + self::assertFalse($headers->has('Content-Type')); } - public function testFromMessageWhenMultiValueHeaderGivenThenFoldsWithComma(): void + #[DataProvider('invalidHeaderNameProvider')] + public function testWithWhenInvalidHeaderNameGivenThenThrows(string $name): void { - /** @Given a PSR-7 response with a header that carries multiple values */ - $psrResponse = new Psr17Factory()->createResponse(200) - ->withHeader('Accept', 'application/json') - ->withAddedHeader('Accept', 'text/html'); + /** @Given a valid Headers instance */ + $headers = Headers::fromArray(entries: []); - /** @When building Headers from the message */ - $headers = Headers::fromMessage(message: $psrResponse); + /** @Then an exception indicating the name is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); - /** @Then the multi-value header is folded with a comma separator */ - self::assertSame('application/json, text/html', $headers->get('Accept')); + /** @When adding a header with an invalid name */ + $headers->with(name: $name, value: 'value'); } - public function testApplyToWhenEmptyHeadersGivenThenReturnsMessageUnchanged(): void + #[DataProvider('invalidHeaderValueProvider')] + public function testWithWhenInvalidHeaderValueGivenThenThrows(string $value): void { - /** @Given an empty Headers instance */ + /** @Given a valid Headers instance */ $headers = Headers::fromArray(entries: []); - /** @And a PSR-7 request */ - $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); - - /** @When applying the empty headers to the request */ - $applied = $headers->applyTo(message: $psrRequest); + /** @Then an exception indicating the value is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); - /** @Then the same request instance is returned without modification */ - self::assertSame($psrRequest, $applied); + /** @When adding a header with an invalid value */ + $headers->with(name: 'X-Custom', value: $value); } public function testApplyToWhenEntriesGivenThenAttachesHeaders(): void @@ -109,41 +92,56 @@ public function testApplyToWhenEntriesGivenThenAttachesHeaders(): void self::assertSame('abc', $applied->getHeaderLine('X-Trace')); } - public function testApplyToWhenEntriesGivenThenLeavesOriginalUnchanged(): void + public function testWithWhenExistingNameGivenThenReplacesEntry(): void { - /** @Given a Headers instance with one entry */ - $headers = Headers::fromArray(entries: ['X-Trace' => 'abc']); + /** @Given headers with a Content-Type entry */ + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); - /** @And a PSR-7 request */ - $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); + /** @When replacing the Content-Type value */ + $updated = $headers->with(name: 'Content-Type', value: 'text/plain'); - /** @When applying the headers to the request */ - $headers->applyTo(message: $psrRequest); + /** @Then the value is replaced on the updated instance */ + self::assertSame('text/plain', $updated->get('Content-Type')); - /** @Then the original request is unchanged */ - self::assertSame('', $psrRequest->getHeaderLine('X-Trace')); + /** @And the original instance retains its original value */ + self::assertSame('application/json', $headers->get('Content-Type')); } - public function testGetWhenMixedCaseKeyGivenThenLookupIsCaseInsensitive(): void + #[DataProvider('validHeaderNameProvider')] + public function testFromArrayWhenValidHeaderNameGivenThenAccepts(string $name): void { - /** @Given headers with a mixed-case key */ - $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); + /** @Given a valid header name */ - /** @When looking up with different casing */ - /** @Then the lookup succeeds */ - self::assertSame('application/json', $headers->get('content-type')); - self::assertSame('application/json', $headers->get('CONTENT-TYPE')); - self::assertSame('application/json', $headers->get('Content-Type')); + /** @When creating Headers from an array with that name */ + $headers = Headers::fromArray(entries: [$name => 'value']); + + /** @Then the header is present */ + self::assertTrue($headers->has($name)); } - public function testGetWhenMissingKeyGivenThenReturnsNull(): void + #[DataProvider('invalidHeaderNameProvider')] + public function testFromArrayWhenInvalidHeaderNameGivenThenThrows(string $name): void { - /** @Given headers with one entry */ - $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); + /** @Given an invalid header name */ - /** @When looking up a non-existent header */ - /** @Then null is returned */ - self::assertNull($headers->get('X-Missing')); + /** @Then an exception indicating the name is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is invalid'); + + /** @When creating Headers from an array with that name */ + Headers::fromArray(entries: [$name => 'value']); + } + + #[DataProvider('validHeaderValueProvider')] + public function testFromArrayWhenValidHeaderValueGivenThenAccepts(string $value): void + { + /** @Given a valid header value */ + + /** @When creating Headers from an array with that value */ + $headers = Headers::fromArray(entries: ['X-Custom' => $value]); + + /** @Then the header value is present */ + self::assertSame($value, $headers->get('X-Custom')); } public function testHasWhenMixedCaseKeyGivenThenIsCaseInsensitive(): void @@ -158,27 +156,66 @@ public function testHasWhenMixedCaseKeyGivenThenIsCaseInsensitive(): void self::assertTrue($headers->has('X-Trace')); } - public function testHasWhenMissingKeyGivenThenReturnsFalse(): void + public function testToArrayWhenMultipleEntriesGivenThenReturnsAll(): void { - /** @Given empty headers */ - $headers = Headers::fromArray(entries: []); + /** @Given headers with two entries */ + $headers = Headers::fromArray(entries: ['X-Trace' => 'abc', 'X-Request-ID' => '123']); - /** @When checking for a non-existent header */ - /** @Then has() returns false */ - self::assertFalse($headers->has('Content-Type')); + /** @When converting to array */ + $array = $headers->toArray(); + + /** @Then all entries are present */ + self::assertSame('abc', $array['X-Trace']); + self::assertSame('123', $array['X-Request-ID']); + self::assertCount(2, $array); } - public function testMergedWithWhenOtherHasNewEntriesThenBothAppearInResult(): void + #[DataProvider('invalidHeaderValueProvider')] + public function testFromArrayWhenInvalidHeaderValueGivenThenThrows(string $value): void { - /** @Given headers with one entry */ - $headers = Headers::fromArray(entries: ['Accept' => 'application/json']); + /** @Given an invalid header value */ - /** @When merging with a Headers carrying a default that does not conflict */ - $merged = $headers->mergedWith(other: Headers::fromArray(entries: ['Content-Type' => 'application/json'])); + /** @Then an exception indicating the value is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is invalid'); - /** @Then both entries are present */ - self::assertSame('application/json', $merged->get('Accept')); - self::assertSame('application/json', $merged->get('Content-Type')); + /** @When creating Headers from an array with that value */ + Headers::fromArray(entries: ['X-Custom' => $value]); + } + + public function testWithWhenCasingDiffersThenReplacesExistingEntry(): void + { + /** @Given headers with a Content-Type entry stored under mixed case */ + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); + + /** @When replacing using a different casing */ + $updated = $headers->with(name: 'content-type', value: 'text/plain'); + + /** @Then only one Content-Type entry exists and it carries the new value */ + self::assertSame('text/plain', $updated->get('Content-Type')); + self::assertCount(1, $updated->toArray()); + } + + public function testConstructorWhenEntriesGivenThenExposesEachEntry(): void + { + /** @Given an array of headers */ + $entries = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; + + /** @When creating Headers from an array */ + $headers = Headers::fromArray(entries: $entries); + + /** @Then the entries are accessible */ + self::assertSame('application/json', $headers->get('Content-Type')); + self::assertSame('application/json', $headers->get('Accept')); + } + + public function testFromWhenNoArgumentsGivenThenReturnsEmptyHeaders(): void + { + /** @When creating Headers with no headerable arguments */ + $headers = Headers::from(); + + /** @Then the headers are empty */ + self::assertSame([], $headers->toArray()); } public function testMergedWithWhenOtherCollidesThenExistingEntryWins(): void @@ -196,65 +233,49 @@ public function testMergedWithWhenOtherCollidesThenExistingEntryWins(): void self::assertCount(1, $merged->toArray()); } - public function testMergedWithWhenCasingDiffersThenStillTreatsAsCollision(): void + public function testFromWhenMultipleHeaderablesGivenThenMergesEntries(): void { - /** @Given headers with a lowercase key */ - $headers = Headers::fromArray(entries: ['content-type' => 'application/json; charset=utf-8']); + /** @Given a Content-Type headerable */ + $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - /** @When merging with a Headers using mixed casing */ - $merged = $headers->mergedWith(other: Headers::fromArray(entries: ['Content-Type' => 'application/json'])); + /** @And a Cookie headerable */ + $cookie = Cookie::create(name: 'session', value: 'abc123'); - /** @Then the existing header wins despite different casing */ - self::assertSame('application/json; charset=utf-8', $merged->get('content-type')); + /** @When creating Headers from multiple headerables */ + $headers = Headers::from($contentType, $cookie); - /** @And only one Content-Type entry exists in the merged result */ - self::assertCount(1, $merged->toArray()); + /** @Then both header entries are present */ + self::assertTrue($headers->has('Content-Type')); + self::assertTrue($headers->has('Set-Cookie')); } - public function testWithWhenNewNameGivenThenAppendsHeader(): void + public function testApplyToWhenEntriesGivenThenLeavesOriginalUnchanged(): void { - /** @Given headers with one entry */ - $headers = Headers::fromArray(entries: ['Accept' => 'application/json']); + /** @Given a Headers instance with one entry */ + $headers = Headers::fromArray(entries: ['X-Trace' => 'abc']); - /** @When adding a header with a name not already present */ - $updated = $headers->with(name: 'X-Trace-Id', value: 'abc-123'); + /** @And a PSR-7 request */ + $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); - /** @Then the new header is appended and the original is preserved */ - self::assertSame('application/json', $updated->get('Accept')); - self::assertSame('abc-123', $updated->get('X-Trace-Id')); + /** @When applying the headers to the request */ + $headers->applyTo(message: $psrRequest); - /** @And the original instance is unchanged */ - self::assertNull($headers->get('X-Trace-Id')); + /** @Then the original request is unchanged */ + self::assertSame('', $psrRequest->getHeaderLine('X-Trace')); } - public function testWithWhenExistingNameGivenThenReplacesEntry(): void + public function testGetWhenMixedCaseKeyGivenThenLookupIsCaseInsensitive(): void { - /** @Given headers with a Content-Type entry */ + /** @Given headers with a mixed-case key */ $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); - /** @When replacing the Content-Type value */ - $updated = $headers->with(name: 'Content-Type', value: 'text/plain'); - - /** @Then the value is replaced on the updated instance */ - self::assertSame('text/plain', $updated->get('Content-Type')); - - /** @And the original instance retains its original value */ + /** @When looking up with different casing */ + /** @Then the lookup succeeds */ + self::assertSame('application/json', $headers->get('content-type')); + self::assertSame('application/json', $headers->get('CONTENT-TYPE')); self::assertSame('application/json', $headers->get('Content-Type')); } - public function testWithWhenCasingDiffersThenReplacesExistingEntry(): void - { - /** @Given headers with a Content-Type entry stored under mixed case */ - $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); - - /** @When replacing using a different casing */ - $updated = $headers->with(name: 'content-type', value: 'text/plain'); - - /** @Then only one Content-Type entry exists and it carries the new value */ - self::assertSame('text/plain', $updated->get('Content-Type')); - self::assertCount(1, $updated->toArray()); - } - public function testWithWhenUpperCaseNameGivenThenReplacesExistingEntry(): void { /** @Given headers with a Content-Type entry stored under mixed case */ @@ -268,108 +289,87 @@ public function testWithWhenUpperCaseNameGivenThenReplacesExistingEntry(): void self::assertCount(1, $updated->toArray()); } - public function testWithWhenMultipleHeadersExistThenOtherHeadersArePreserved(): void + public function testMergedWithWhenCasingDiffersThenStillTreatsAsCollision(): void { - /** @Given headers with two entries */ - $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); - - /** @When replacing the Content-Type header */ - $updated = $headers->with(name: 'Content-Type', value: 'text/plain'); - - /** @Then the replaced entry is updated and the other entry is unchanged */ - self::assertSame('text/plain', $updated->get('Content-Type')); - self::assertSame('application/json', $updated->get('Accept')); - self::assertCount(2, $updated->toArray()); - } + /** @Given headers with a lowercase key */ + $headers = Headers::fromArray(entries: ['content-type' => 'application/json; charset=utf-8']); - public function testToArrayWhenMultipleEntriesGivenThenReturnsAll(): void - { - /** @Given headers with two entries */ - $headers = Headers::fromArray(entries: ['X-Trace' => 'abc', 'X-Request-ID' => '123']); + /** @When merging with a Headers using mixed casing */ + $merged = $headers->mergedWith(other: Headers::fromArray(entries: ['Content-Type' => 'application/json'])); - /** @When converting to array */ - $array = $headers->toArray(); + /** @Then the existing header wins despite different casing */ + self::assertSame('application/json; charset=utf-8', $merged->get('content-type')); - /** @Then all entries are present */ - self::assertSame('abc', $array['X-Trace']); - self::assertSame('123', $array['X-Request-ID']); - self::assertCount(2, $array); + /** @And only one Content-Type entry exists in the merged result */ + self::assertCount(1, $merged->toArray()); } - #[DataProvider('validHeaderNameProvider')] - public function testFromArrayWhenValidHeaderNameGivenThenAccepts(string $name): void + public function testFromMessageWhenMultiValueHeaderGivenThenFoldsWithComma(): void { - /** @Given a valid header name */ + /** @Given a PSR-7 response with a header that carries multiple values */ + $psrResponse = new Psr17Factory()->createResponse(200) + ->withHeader('Accept', 'application/json') + ->withAddedHeader('Accept', 'text/html'); - /** @When creating Headers from an array with that name */ - $headers = Headers::fromArray(entries: [$name => 'value']); + /** @When building Headers from the message */ + $headers = Headers::fromMessage(message: $psrResponse); - /** @Then the header is present */ - self::assertTrue($headers->has($name)); + /** @Then the multi-value header is folded with a comma separator */ + self::assertSame('application/json, text/html', $headers->get('Accept')); } - #[DataProvider('invalidHeaderNameProvider')] - public function testFromArrayWhenInvalidHeaderNameGivenThenThrows(string $name): void + public function testMergedWithWhenOtherHasNewEntriesThenBothAppearInResult(): void { - /** @Given an invalid header name */ + /** @Given headers with one entry */ + $headers = Headers::fromArray(entries: ['Accept' => 'application/json']); - /** @Then an exception indicating the name is invalid is thrown */ - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('is invalid'); + /** @When merging with a Headers carrying a default that does not conflict */ + $merged = $headers->mergedWith(other: Headers::fromArray(entries: ['Content-Type' => 'application/json'])); - /** @When creating Headers from an array with that name */ - Headers::fromArray(entries: [$name => 'value']); + /** @Then both entries are present */ + self::assertSame('application/json', $merged->get('Accept')); + self::assertSame('application/json', $merged->get('Content-Type')); } - #[DataProvider('validHeaderValueProvider')] - public function testFromArrayWhenValidHeaderValueGivenThenAccepts(string $value): void + public function testApplyToWhenEmptyHeadersGivenThenReturnsMessageUnchanged(): void { - /** @Given a valid header value */ - - /** @When creating Headers from an array with that value */ - $headers = Headers::fromArray(entries: ['X-Custom' => $value]); - - /** @Then the header value is present */ - self::assertSame($value, $headers->get('X-Custom')); - } + /** @Given an empty Headers instance */ + $headers = Headers::fromArray(entries: []); - #[DataProvider('invalidHeaderValueProvider')] - public function testFromArrayWhenInvalidHeaderValueGivenThenThrows(string $value): void - { - /** @Given an invalid header value */ + /** @And a PSR-7 request */ + $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); - /** @Then an exception indicating the value is invalid is thrown */ - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('is invalid'); + /** @When applying the empty headers to the request */ + $applied = $headers->applyTo(message: $psrRequest); - /** @When creating Headers from an array with that value */ - Headers::fromArray(entries: ['X-Custom' => $value]); + /** @Then the same request instance is returned without modification */ + self::assertSame($psrRequest, $applied); } - #[DataProvider('invalidHeaderNameProvider')] - public function testWithWhenInvalidHeaderNameGivenThenThrows(string $name): void + public function testFromMessageWhenEmptyHeadersGivenThenReturnsEmptyHeaders(): void { - /** @Given a valid Headers instance */ - $headers = Headers::fromArray(entries: []); + /** @Given a PSR-7 response with no headers */ + $psrResponse = new Psr17Factory()->createResponse(200); - /** @Then an exception indicating the name is invalid is thrown */ - $this->expectException(InvalidArgumentException::class); + /** @When building Headers from the message */ + $headers = Headers::fromMessage(message: $psrResponse); - /** @When adding a header with an invalid name */ - $headers->with(name: $name, value: 'value'); + /** @Then the Headers instance is empty */ + self::assertSame([], $headers->toArray()); } - #[DataProvider('invalidHeaderValueProvider')] - public function testWithWhenInvalidHeaderValueGivenThenThrows(string $value): void + public function testWithWhenMultipleHeadersExistThenOtherHeadersArePreserved(): void { - /** @Given a valid Headers instance */ - $headers = Headers::fromArray(entries: []); + /** @Given headers with two entries */ + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); - /** @Then an exception indicating the value is invalid is thrown */ - $this->expectException(InvalidArgumentException::class); + /** @When replacing the Content-Type header */ + $updated = $headers->with(name: 'Content-Type', value: 'text/plain'); - /** @When adding a header with an invalid value */ - $headers->with(name: 'X-Custom', value: $value); + /** @Then the replaced entry is updated and the other entry is unchanged */ + self::assertSame('text/plain', $updated->get('Content-Type')); + self::assertSame('application/json', $updated->get('Accept')); + self::assertCount(2, $updated->toArray()); } public static function validHeaderNameProvider(): array @@ -382,6 +382,15 @@ public static function validHeaderNameProvider(): array ]; } + public static function validHeaderValueProvider(): array + { + return [ + 'application/json' => ['application/json'], + 'text/plain with charset' => ['text/plain; charset=utf-8'], + 'Value with horizontal tab' => ["has\ttab"] + ]; + } + public static function invalidHeaderNameProvider(): array { return [ @@ -395,15 +404,6 @@ public static function invalidHeaderNameProvider(): array ]; } - public static function validHeaderValueProvider(): array - { - return [ - 'application/json' => ['application/json'], - 'text/plain with charset' => ['text/plain; charset=utf-8'], - 'Value with horizontal tab' => ["has\ttab"] - ]; - } - public static function invalidHeaderValueProvider(): array { return [ diff --git a/tests/Unit/HttpBuilderTest.php b/tests/Unit/HttpBuilderTest.php index 50e436d..ade94f8 100644 --- a/tests/Unit/HttpBuilderTest.php +++ b/tests/Unit/HttpBuilderTest.php @@ -18,52 +18,49 @@ final class HttpBuilderTest extends TestCase { - public function testCreateWhenInvokedThenReturnsEmptyBuilder(): void + public function testWithBaseUrlWhenHttpGivenThenAccepts(): void { - /** @When calling Http::create() */ + /** @Given an empty builder */ $builder = Http::create(); - /** @Then a builder instance is returned */ - self::assertInstanceOf(HttpBuilder::class, $builder); + /** @When setting a valid http:// base URL */ + $updated = $builder->withBaseUrl(url: 'http://localhost:8080'); + + /** @Then a new builder instance is returned without throwing */ + self::assertNotSame($builder, $updated); } - public function testWithTransportWhenInvokedThenReturnsNewBuilder(): void + public function testWithBaseUrlWhenHttpsGivenThenAccepts(): void { /** @Given an empty builder */ - $original = Http::create(); - - /** @And a fresh transport */ - $transport = NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), - factory: new Psr17Factory() - ); + $builder = Http::create(); - /** @When calling withTransport */ - $updated = $original->withTransport(transport: $transport); + /** @When setting a valid https:// base URL */ + $updated = $builder->withBaseUrl(url: 'https://api.example.com'); - /** @Then a new builder instance is returned */ - self::assertNotSame($original, $updated); + /** @Then a new builder instance is returned without throwing */ + self::assertNotSame($builder, $updated); } - public function testWithTransportWhenInvokedThenOriginalBuilderStillThrows(): void + public function testCreateWhenInvokedThenReturnsEmptyBuilder(): void { - /** @Given an empty builder */ - $original = Http::create(); + /** @When calling Http::create() */ + $builder = Http::create(); - /** @And a fresh transport */ - $transport = NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), - factory: new Psr17Factory() - ); + /** @Then a builder instance is returned */ + self::assertInstanceOf(HttpBuilder::class, $builder); + } - /** @And the original builder receives a new transport */ - $original->withTransport(transport: $transport); + public function testWithBaseUrlWhenEmptyStringGivenThenAccepts(): void + { + /** @Given an empty builder */ + $builder = Http::create(); - /** @Then the original builder still throws on build */ - $this->expectException(HttpConfigurationInvalid::class); + /** @When setting an empty base URL */ + $updated = $builder->withBaseUrl(url: ''); - /** @When calling build on the original builder */ - $original->build(); + /** @Then a new builder instance is returned without throwing */ + self::assertNotSame($builder, $updated); } public function testWithBaseUrlWhenInvokedThenReturnsNewBuilder(): void @@ -78,47 +75,49 @@ public function testWithBaseUrlWhenInvokedThenReturnsNewBuilder(): void self::assertNotSame($original, $updated); } - public function testWithBaseUrlWhenInvokedThenOriginalBuilderStillThrows(): void + public function testWithBaseUrlWhenUppercaseHttpsGivenThenAccepts(): void { /** @Given an empty builder */ - $original = Http::create(); - - /** @And the original builder receives a new base URL */ - $original->withBaseUrl(url: 'https://api.example.com'); + $builder = Http::create(); - /** @Then the original builder still throws on build */ - $this->expectException(HttpConfigurationInvalid::class); + /** @When setting a base URL with uppercase scheme */ + $updated = $builder->withBaseUrl(url: 'HTTPS://api.example.com'); - /** @When calling build on the original builder */ - $original->build(); + /** @Then a new builder instance is returned without throwing */ + self::assertNotSame($builder, $updated); } - public function testBuildWhenTransportMissingThenThrowsHttpConfigurationInvalid(): void + public function testWithTransportWhenInvokedThenReturnsNewBuilder(): void { - /** @Given a builder with no transport */ - $builder = Http::create()->withBaseUrl(url: 'https://api.example.com'); + /** @Given an empty builder */ + $original = Http::create(); - /** @Then HttpConfigurationInvalid is thrown */ - $this->expectException(HttpConfigurationInvalid::class); - $this->expectExceptionMessage('Transport is required to build Http.'); + /** @And a fresh transport */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: new Psr17Factory() + ); - /** @When calling build */ - $builder->build(); + /** @When calling withTransport */ + $updated = $original->withTransport(transport: $transport); + + /** @Then a new builder instance is returned */ + self::assertNotSame($original, $updated); } - public function testBuildWhenBaseUrlMissingThenThrowsHttpConfigurationInvalid(): void + public function testWithWhenInvokedDirectlyThenReturnsWorkingHttp(): void { - /** @Given a builder with no base URL */ - $builder = Http::create()->withTransport( - transport: InMemoryTransport::with(responses: []) - ); + /** @Given a transport seeded with one response */ + $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); - /** @Then HttpConfigurationInvalid is thrown */ - $this->expectException(HttpConfigurationInvalid::class); - $this->expectExceptionMessage('Base URL is required to build Http.'); + /** @When constructing Http directly via Http::with */ + $http = Http::with(baseUrl: 'https://api.example.com', transport: $transport); - /** @When calling build */ - $builder->build(); + /** @And a simple GET request */ + $request = Request::get(url: '/dragons'); + + /** @Then the instance can send requests and returns the correct response */ + self::assertSame(Code::OK, $http->send(request: $request)->code()); } public function testBuildWhenFullyConfiguredThenProducesWorkingHttp(): void @@ -139,32 +138,40 @@ public function testBuildWhenFullyConfiguredThenProducesWorkingHttp(): void self::assertSame(Code::OK, $response->code()); } - public function testWithWhenInvokedDirectlyThenReturnsWorkingHttp(): void + public function testWithBaseUrlWhenInvokedThenOriginalBuilderStillThrows(): void { - /** @Given a transport seeded with one response */ - $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); + /** @Given an empty builder */ + $original = Http::create(); - /** @When constructing Http directly via Http::with */ - $http = Http::with(baseUrl: 'https://api.example.com', transport: $transport); + /** @And the original builder receives a new base URL */ + $original->withBaseUrl(url: 'https://api.example.com'); - /** @And a simple GET request */ - $request = Request::get(url: '/dragons'); + /** @Then the original builder still throws on build */ + $this->expectException(HttpConfigurationInvalid::class); - /** @Then the instance can send requests and returns the correct response */ - self::assertSame(Code::OK, $http->send(request: $request)->code()); + /** @When calling build on the original builder */ + $original->build(); } - public function testWithBaseUrlWhenJavascriptSchemeGivenThenThrowsBaseUrlIsInvalid(): void + public function testWithTransportWhenInvokedThenOriginalBuilderStillThrows(): void { /** @Given an empty builder */ - $builder = Http::create(); + $original = Http::create(); - /** @Then an exception indicating the base URL is invalid is thrown */ - $this->expectException(BaseUrlIsInvalid::class); - $this->expectExceptionMessage('Base URL is invalid'); + /** @And a fresh transport */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: new Psr17Factory() + ); - /** @When setting a javascript: scheme base URL */ - $builder->withBaseUrl(url: 'javascript:alert(1)'); + /** @And the original builder receives a new transport */ + $original->withTransport(transport: $transport); + + /** @Then the original builder still throws on build */ + $this->expectException(HttpConfigurationInvalid::class); + + /** @When calling build on the original builder */ + $original->build(); } public function testWithBaseUrlWhenFtpSchemeGivenThenThrowsBaseUrlIsInvalid(): void @@ -179,16 +186,19 @@ public function testWithBaseUrlWhenFtpSchemeGivenThenThrowsBaseUrlIsInvalid(): v $builder->withBaseUrl(url: 'ftp://example.com'); } - public function testWithBaseUrlWhenProtocolRelativeGivenThenThrowsBaseUrlIsInvalid(): void + public function testBuildWhenBaseUrlMissingThenThrowsHttpConfigurationInvalid(): void { - /** @Given an empty builder */ - $builder = Http::create(); + /** @Given a builder with no base URL */ + $builder = Http::create()->withTransport( + transport: InMemoryTransport::with(responses: []) + ); - /** @Then an exception indicating the base URL is invalid is thrown */ - $this->expectException(BaseUrlIsInvalid::class); + /** @Then HttpConfigurationInvalid is thrown */ + $this->expectException(HttpConfigurationInvalid::class); + $this->expectExceptionMessage('Base URL is required to build Http.'); - /** @When setting a protocol-relative base URL */ - $builder->withBaseUrl(url: '//host'); + /** @When calling build */ + $builder->build(); } public function testWithBaseUrlWhenControlCharGivenThenThrowsBaseUrlIsInvalid(): void @@ -203,52 +213,42 @@ public function testWithBaseUrlWhenControlCharGivenThenThrowsBaseUrlIsInvalid(): $builder->withBaseUrl(url: "https://api.example.com\x00"); } - public function testWithBaseUrlWhenHttpsGivenThenAccepts(): void - { - /** @Given an empty builder */ - $builder = Http::create(); - - /** @When setting a valid https:// base URL */ - $updated = $builder->withBaseUrl(url: 'https://api.example.com'); - - /** @Then a new builder instance is returned without throwing */ - self::assertNotSame($builder, $updated); - } - - public function testWithBaseUrlWhenHttpGivenThenAccepts(): void + public function testBuildWhenTransportMissingThenThrowsHttpConfigurationInvalid(): void { - /** @Given an empty builder */ - $builder = Http::create(); + /** @Given a builder with no transport */ + $builder = Http::create()->withBaseUrl(url: 'https://api.example.com'); - /** @When setting a valid http:// base URL */ - $updated = $builder->withBaseUrl(url: 'http://localhost:8080'); + /** @Then HttpConfigurationInvalid is thrown */ + $this->expectException(HttpConfigurationInvalid::class); + $this->expectExceptionMessage('Transport is required to build Http.'); - /** @Then a new builder instance is returned without throwing */ - self::assertNotSame($builder, $updated); + /** @When calling build */ + $builder->build(); } - public function testWithBaseUrlWhenEmptyStringGivenThenAccepts(): void + public function testWithBaseUrlWhenJavascriptSchemeGivenThenThrowsBaseUrlIsInvalid(): void { /** @Given an empty builder */ $builder = Http::create(); - /** @When setting an empty base URL */ - $updated = $builder->withBaseUrl(url: ''); + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + $this->expectExceptionMessage('Base URL is invalid'); - /** @Then a new builder instance is returned without throwing */ - self::assertNotSame($builder, $updated); + /** @When setting a javascript: scheme base URL */ + $builder->withBaseUrl(url: 'javascript:alert(1)'); } - public function testWithBaseUrlWhenUppercaseHttpsGivenThenAccepts(): void + public function testWithBaseUrlWhenProtocolRelativeGivenThenThrowsBaseUrlIsInvalid(): void { /** @Given an empty builder */ $builder = Http::create(); - /** @When setting a base URL with uppercase scheme */ - $updated = $builder->withBaseUrl(url: 'HTTPS://api.example.com'); + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); - /** @Then a new builder instance is returned without throwing */ - self::assertNotSame($builder, $updated); + /** @When setting a protocol-relative base URL */ + $builder->withBaseUrl(url: '//host'); } public function testWithBaseUrlWhenSchemeEmbeddedInPathGivenThenThrowsBaseUrlIsInvalid(): void diff --git a/tests/Unit/HttpTest.php b/tests/Unit/HttpTest.php index 3429842..b093287 100644 --- a/tests/Unit/HttpTest.php +++ b/tests/Unit/HttpTest.php @@ -27,45 +27,99 @@ protected function setUp(): void $this->factory = new Psr17Factory(); } - public function testSendWhenTransportRespondsThenReturnsResponseWithMatchingCode(): void + public function testSendWhenBodyGivenThenSendsJsonPayload(): void { - /** @Given a transport seeded with a 200 response */ + /** @Given a transport seeded with a 201 response */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), + client: CapturingClient::returningStatus(statusCode: 201), factory: $this->factory )) ->build(); - /** @When sending a valid request */ - $response = $http->send(request: Request::get(url: '/dragons')); + /** @When sending a request with a JSON body */ + $response = $http->send( + request: Request::post(url: '/dragons', body: ['name' => 'Hydra']) + ); /** @Then the response code is correct */ - self::assertSame(Code::OK, $response->code()); + self::assertSame(Code::CREATED, $response->code()); } - public function testSendWhenBaseUrlEndsWithSlashAndPathLeadsWithSlashThenNoDoubleSlash(): void + public function testSendWhenQueryGivenThenAppendsAsRfc3986(): void { - /** @Given a transport seeded with a 200 response and a base URL ending in slash */ + /** @Given a transport seeded with a 200 response */ $http = Http::create() - ->withBaseUrl(url: 'https://api.example.com/') + ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( client: CapturingClient::returningStatus(statusCode: 200), factory: $this->factory )) ->build(); - /** @When sending a request whose path starts with a slash */ - $response = $http->send(request: Request::get(url: '/dragons')); + /** @When sending a request with query parameters */ + $response = $http->send( + request: Request::get(url: '/dragons', queryParameters: ['sort' => 'name', 'order' => 'asc']) + ); - /** @Then the response is returned without double slash in the URL */ + /** @Then the response code is correct */ self::assertSame(Code::OK, $response->code()); } - public function testSendWhenQueryGivenThenAppendsAsRfc3986(): void + public function testWithWhenHttpGivenThenAcceptsWithoutThrowing(): void { - /** @Given a transport seeded with a 200 response */ + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @When constructing Http with a valid http:// base URL */ + $http = Http::with(baseUrl: 'http://localhost:8080', transport: $transport); + + /** @Then an Http instance is returned without throwing */ + self::assertInstanceOf(Http::class, $http); + } + + public function testWithWhenHttpsGivenThenAcceptsWithoutThrowing(): void + { + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @When constructing Http with a valid https:// base URL */ + $http = Http::with(baseUrl: 'https://api.example.com', transport: $transport); + + /** @Then an Http instance is returned without throwing */ + self::assertInstanceOf(Http::class, $http); + } + + public function testSendWhenQueryProvidedThenAppendsAsQueryString(): void + { + /** @Given an Http instance and a query payload */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: 'https://api.example.com', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request with query parameters */ + $request = Request::get(url: '/dragons', queryParameters: ['sort' => 'name']); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the composed URI includes the encoded query string */ + self::assertNotNull($client->captured); + self::assertSame('https://api.example.com/dragons?sort=name', (string)$client->captured->getUri()); + } + + public function testSendWhenSchemePathGivenThenThrowsMalformedPath(): void + { + /** @Given an Http instance with a base URL */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( @@ -74,92 +128,214 @@ public function testSendWhenQueryGivenThenAppendsAsRfc3986(): void )) ->build(); - /** @When sending a request with query parameters */ - $response = $http->send( - request: Request::get(url: '/dragons', queryParameters: ['sort' => 'name', 'order' => 'asc']) + /** @Then MalformedPath is thrown */ + $this->expectException(MalformedPath::class); + + /** @When sending a request whose path contains a scheme */ + $http->send(request: Request::get(url: 'javascript:alert(1)')); + } + + public function testWithWhenFtpSchemeGivenThenThrowsBaseUrlIsInvalid(): void + { + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory ); - /** @Then the response code is correct */ - self::assertSame(Code::OK, $response->code()); + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When constructing Http with an ftp:// base URL */ + Http::with(baseUrl: 'ftp://example.com', transport: $transport); } - public function testSendWhenBodyGivenThenSendsJsonPayload(): void + public function testWithWhenControlCharGivenThenThrowsBaseUrlIsInvalid(): void { - /** @Given a transport seeded with a 201 response */ + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When constructing Http with a base URL containing a control character */ + Http::with(baseUrl: "https://api.example.com\x00", transport: $transport); + } + + public function testWithWhenEmptyStringGivenThenAcceptsWithoutThrowing(): void + { + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @When constructing Http with an empty base URL */ + $http = Http::with(baseUrl: '', transport: $transport); + + /** @Then an Http instance is returned without throwing */ + self::assertInstanceOf(Http::class, $http); + } + + public function testSendWhenControlCharsInPathGivenThenThrowsMalformedPath(): void + { + /** @Given an Http instance with a base URL */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 201), + client: CapturingClient::returningStatus(statusCode: 200), factory: $this->factory )) ->build(); - /** @When sending a request with a JSON body */ - $response = $http->send( - request: Request::post(url: '/dragons', body: ['name' => 'Hydra']) + /** @Then MalformedPath is thrown */ + $this->expectException(MalformedPath::class); + + /** @When sending a request whose path contains control characters */ + $http->send(request: Request::get(url: "/dragons\x00/evil")); + } + + public function testSendWhenEmptyQueryArrayGivenThenNoTrailingQuestionMark(): void + { + /** @Given an Http instance and an empty query array */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: 'https://api.example.com', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request with an empty query array */ + $request = Request::get(url: '/dragons', queryParameters: []); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the composed URI has no trailing question mark */ + self::assertNotNull($client->captured); + self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); + } + + public function testWithWhenJavascriptSchemeGivenThenThrowsBaseUrlIsInvalid(): void + { + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory ); - /** @Then the response code is correct */ - self::assertSame(Code::CREATED, $response->code()); + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When constructing Http with a javascript: scheme base URL */ + Http::with(baseUrl: 'javascript:alert(1)', transport: $transport); } - public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFailed(): void + public function testWithWhenProtocolRelativeGivenThenThrowsBaseUrlIsInvalid(): void { - /** @Given a PSR-18 client that throws NetworkExceptionInterface */ + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When constructing Http with a protocol-relative base URL */ + Http::with(baseUrl: '//host', transport: $transport); + } + + public function testSendWhenNetworkExceptionRaisedThenPreservesPreviousChain(): void + { + /** @Given a network exception */ + $networkException = new PsrNetworkException('timeout'); + + /** @And an Http instance with a transport that throws it */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: ThrowingClient::throwing(exception: new PsrNetworkException('connection refused')), + client: ThrowingClient::throwing(exception: $networkException), factory: $this->factory )) ->build(); - /** @Then HttpNetworkFailed is thrown */ - $this->expectException(HttpNetworkFailed::class); - /** @When sending the request */ - $http->send(request: Request::get(url: '/dragons')); + try { + $http->send(request: Request::get(url: '/dragons')); + self::fail('HttpNetworkFailed was expected.'); + } catch (HttpNetworkFailed $exception) { + /** @Then the previous exception is preserved in the chain */ + self::assertSame($networkException, $exception->getPrevious()); + } } - public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void + public function testSendWhenProtocolRelativePathGivenThenThrowsMalformedPath(): void { - /** @Given a PSR-18 client that throws RequestExceptionInterface */ + /** @Given an Http instance with a base URL */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: ThrowingClient::throwing(exception: new PsrRequestException('bad request')), + client: CapturingClient::returningStatus(statusCode: 200), factory: $this->factory )) ->build(); - /** @Then HttpRequestInvalid is thrown */ - $this->expectException(HttpRequestInvalid::class); + /** @Then MalformedPath is thrown without invoking the transport */ + $this->expectException(MalformedPath::class); + + /** @When sending a request whose path is protocol-relative */ + $http->send(request: Request::get(url: '//evil.example.com/attack')); + } + + public function testSendWhenBaseUrlEmptyAndRelativePathGivenThenUsesPathDirectly(): void + { + /** @Given an Http instance with an empty base URL */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: '', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request with a relative path */ + $request = Request::get(url: '/dragons'); /** @When sending the request */ - $http->send(request: Request::get(url: '/dragons')); + $http->send(request: $request); + + /** @Then the PSR-7 request URI is the path as-is */ + self::assertNotNull($client->captured); + self::assertSame('/dragons', (string)$client->captured->getUri()); } - public function testSendWhenGenericClientExceptionRaisedThenThrowsHttpRequestFailed(): void + public function testSendWhenSchemePathGivenThenMalformedPathExposesOffendingPath(): void { - /** @Given a PSR-18 client that throws a generic ClientExceptionInterface */ + /** @Given an Http instance with a base URL */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: ThrowingClient::throwing(exception: new PsrClientException('generic failure')), + client: CapturingClient::returningStatus(statusCode: 200), factory: $this->factory )) ->build(); - /** @Then HttpRequestFailed is thrown */ - $this->expectException(HttpRequestFailed::class); + /** @And a request whose path contains a scheme */ + $request = Request::get(url: 'https://attacker.com/steal'); - /** @When sending the request */ - $http->send(request: Request::get(url: '/dragons')); + try { + /** @When sending the request */ + $http->send(request: $request); + } catch (MalformedPath $exception) { + /** @Then the exception exposes the offending path */ + self::assertSame('https://attacker.com/steal', $exception->path()); + } } - public function testSendWhenProtocolRelativePathGivenThenThrowsMalformedPath(): void + public function testSendWhenTransportRespondsThenReturnsResponseWithMatchingCode(): void { - /** @Given an Http instance with a base URL */ + /** @Given a transport seeded with a 200 response */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( @@ -168,14 +344,14 @@ public function testSendWhenProtocolRelativePathGivenThenThrowsMalformedPath(): )) ->build(); - /** @Then MalformedPath is thrown without invoking the transport */ - $this->expectException(MalformedPath::class); + /** @When sending a valid request */ + $response = $http->send(request: Request::get(url: '/dragons')); - /** @When sending a request whose path is protocol-relative */ - $http->send(request: Request::get(url: '//evil.example.com/attack')); + /** @Then the response code is correct */ + self::assertSame(Code::OK, $response->code()); } - public function testSendWhenSchemePathGivenThenThrowsMalformedPath(): void + public function testSendWhenSchemePathGivenThenChainsPathContainsSchemeAsPrevious(): void { /** @Given an Http instance with a base URL */ $http = Http::create() @@ -186,103 +362,92 @@ public function testSendWhenSchemePathGivenThenThrowsMalformedPath(): void )) ->build(); - /** @Then MalformedPath is thrown */ - $this->expectException(MalformedPath::class); + /** @And a request whose path contains a scheme */ + $request = Request::get(url: 'https://attacker.com/steal'); - /** @When sending a request whose path contains a scheme */ - $http->send(request: Request::get(url: 'javascript:alert(1)')); + /** @When sending the request */ + try { + $http->send(request: $request); + self::fail('MalformedPath was expected.'); + } catch (MalformedPath $exception) { + /** @Then the previous exception carries the offending path and a scheme-related reason */ + $previous = $exception->getPrevious(); + self::assertNotNull($previous); + self::assertStringContainsString('https://attacker.com/steal', $previous->getMessage()); + self::assertStringContainsString('scheme', $previous->getMessage()); + } } - public function testSendWhenControlCharsInPathGivenThenThrowsMalformedPath(): void + public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFailed(): void { - /** @Given an Http instance with a base URL */ + /** @Given a PSR-18 client that throws NetworkExceptionInterface */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), + client: ThrowingClient::throwing(exception: new PsrNetworkException('connection refused')), factory: $this->factory )) ->build(); - /** @Then MalformedPath is thrown */ - $this->expectException(MalformedPath::class); + /** @Then HttpNetworkFailed is thrown */ + $this->expectException(HttpNetworkFailed::class); - /** @When sending a request whose path contains control characters */ - $http->send(request: Request::get(url: "/dragons\x00/evil")); + /** @When sending the request */ + $http->send(request: Request::get(url: '/dragons')); } - public function testSendWhenNetworkExceptionRaisedThenPreservesPreviousChain(): void + public function testSendWhenGenericClientExceptionRaisedThenThrowsHttpRequestFailed(): void { - /** @Given a network exception */ - $networkException = new PsrNetworkException('timeout'); - - /** @And an Http instance with a transport that throws it */ + /** @Given a PSR-18 client that throws a generic ClientExceptionInterface */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: ThrowingClient::throwing(exception: $networkException), + client: ThrowingClient::throwing(exception: new PsrClientException('generic failure')), factory: $this->factory )) ->build(); + /** @Then HttpRequestFailed is thrown */ + $this->expectException(HttpRequestFailed::class); + /** @When sending the request */ - try { - $http->send(request: Request::get(url: '/dragons')); - self::fail('HttpNetworkFailed was expected.'); - } catch (HttpNetworkFailed $exception) { - /** @Then the previous exception is preserved in the chain */ - self::assertSame($networkException, $exception->getPrevious()); - } + $http->send(request: Request::get(url: '/dragons')); } - public function testSendWhenSchemePathGivenThenChainsPathContainsSchemeAsPrevious(): void + public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void { - /** @Given an Http instance with a base URL */ + /** @Given a PSR-18 client that throws RequestExceptionInterface */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), + client: ThrowingClient::throwing(exception: new PsrRequestException('bad request')), factory: $this->factory )) ->build(); - /** @And a request whose path contains a scheme */ - $request = Request::get(url: 'https://attacker.com/steal'); + /** @Then HttpRequestInvalid is thrown */ + $this->expectException(HttpRequestInvalid::class); /** @When sending the request */ - try { - $http->send(request: $request); - self::fail('MalformedPath was expected.'); - } catch (MalformedPath $exception) { - /** @Then the previous exception carries the offending path and a scheme-related reason */ - $previous = $exception->getPrevious(); - self::assertNotNull($previous); - self::assertStringContainsString('https://attacker.com/steal', $previous->getMessage()); - self::assertStringContainsString('scheme', $previous->getMessage()); - } + $http->send(request: Request::get(url: '/dragons')); } - public function testSendWhenSchemePathGivenThenMalformedPathExposesOffendingPath(): void + public function testSendWhenBaseUrlEndsWithSlashAndPathLeadsWithSlashThenNoDoubleSlash(): void { - /** @Given an Http instance with a base URL */ + /** @Given a transport seeded with a 200 response and a base URL ending in slash */ $http = Http::create() - ->withBaseUrl(url: 'https://api.example.com') + ->withBaseUrl(url: 'https://api.example.com/') ->withTransport(transport: NetworkTransport::with( client: CapturingClient::returningStatus(statusCode: 200), factory: $this->factory )) ->build(); - /** @And a request whose path contains a scheme */ - $request = Request::get(url: 'https://attacker.com/steal'); + /** @When sending a request whose path starts with a slash */ + $response = $http->send(request: Request::get(url: '/dragons')); - try { - /** @When sending the request */ - $http->send(request: $request); - } catch (MalformedPath $exception) { - /** @Then the exception exposes the offending path */ - self::assertSame('https://attacker.com/steal', $exception->path()); - } + /** @Then the response is returned without double slash in the URL */ + self::assertSame(Code::OK, $response->code()); } public function testSendWhenControlCharPathGivenThenChainsPathContainsControlCharsAsPrevious(): void @@ -312,26 +477,6 @@ public function testSendWhenControlCharPathGivenThenChainsPathContainsControlCha } } - public function testSendWhenBaseUrlEmptyAndRelativePathGivenThenUsesPathDirectly(): void - { - /** @Given an Http instance with an empty base URL */ - $client = CapturingClient::returningStatus(statusCode: 200); - $http = Http::with(baseUrl: '', transport: NetworkTransport::with( - client: $client, - factory: $this->factory - )); - - /** @And a request with a relative path */ - $request = Request::get(url: '/dragons'); - - /** @When sending the request */ - $http->send(request: $request); - - /** @Then the PSR-7 request URI is the path as-is */ - self::assertNotNull($client->captured); - self::assertSame('/dragons', (string)$client->captured->getUri()); - } - public function testSendWhenBaseUrlEndsWithSlashAndPathLeadsWithSlashThenSingleSlashJoinsThem(): void { /** @Given an Http instance with a trailing slash on the base URL */ @@ -352,46 +497,6 @@ public function testSendWhenBaseUrlEndsWithSlashAndPathLeadsWithSlashThenSingleS self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); } - public function testSendWhenBaseUrlWithoutTrailingSlashAndPathWithoutLeadingSlashThenJoinsWithSingleSlash(): void - { - /** @Given an Http instance without trailing slash on the base URL */ - $client = CapturingClient::returningStatus(statusCode: 200); - $http = Http::with(baseUrl: 'https://api.example.com', transport: NetworkTransport::with( - client: $client, - factory: $this->factory - )); - - /** @And a request whose path lacks a leading slash */ - $request = Request::get(url: 'dragons'); - - /** @When sending the request */ - $http->send(request: $request); - - /** @Then the composed URI joins them with exactly one slash */ - self::assertNotNull($client->captured); - self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); - } - - public function testSendWhenQueryProvidedThenAppendsAsQueryString(): void - { - /** @Given an Http instance and a query payload */ - $client = CapturingClient::returningStatus(statusCode: 200); - $http = Http::with(baseUrl: 'https://api.example.com', transport: NetworkTransport::with( - client: $client, - factory: $this->factory - )); - - /** @And a request with query parameters */ - $request = Request::get(url: '/dragons', queryParameters: ['sort' => 'name']); - - /** @When sending the request */ - $http->send(request: $request); - - /** @Then the composed URI includes the encoded query string */ - self::assertNotNull($client->captured); - self::assertSame('https://api.example.com/dragons?sort=name', (string)$client->captured->getUri()); - } - public function testSendWhenCustomTransportRaisesNetworkFailureThenExceptionCarriesRequestContext(): void { /** @Given a custom transport that wraps a non-PSR network error and re-raises via the documented factory */ @@ -415,174 +520,69 @@ public function testSendWhenCustomTransportRaisesNetworkFailureThenExceptionCarr } } - public function testSendWhenCustomTransportRaisesRequestInvalidThenExceptionCarriesRequestContext(): void + public function testSendWhenCustomTransportRaisesRequestFailureThenExceptionCarriesRequestContext(): void { - /** @Given a custom transport that maps an upstream validation error to HttpRequestInvalid */ + /** @Given a custom transport that maps an upstream cURL error to HttpRequestFailed */ $http = Http::with( baseUrl: 'https://api.example.com', - transport: FailingTransport::raisingRequestInvalid( - reason: 'Upstream validator rejected the payload.', - cause: new RuntimeException('validator: required field missing') + transport: FailingTransport::raisingRequestFailure( + reason: 'cURL handle exhausted retries.', + cause: new RuntimeException('curl: too many retries') ) ); try { /** @When sending a request through the custom transport */ - $http->send(request: Request::patch(url: '/dragons')); - self::fail('HttpRequestInvalid was expected.'); - } catch (HttpRequestInvalid $exception) { + $http->send(request: Request::put(url: '/dragons')); + self::fail('HttpRequestFailed was expected.'); + } catch (HttpRequestFailed $exception) { /** @Then the exception carries the originating URL, method, and reason */ self::assertSame('https://api.example.com/dragons', $exception->url()); - self::assertSame(Method::PATCH, $exception->method()); - self::assertSame('Upstream validator rejected the payload.', $exception->reason()); + self::assertSame(Method::PUT, $exception->method()); + self::assertSame('cURL handle exhausted retries.', $exception->reason()); } } - public function testSendWhenCustomTransportRaisesRequestFailureThenExceptionCarriesRequestContext(): void + public function testSendWhenCustomTransportRaisesRequestInvalidThenExceptionCarriesRequestContext(): void { - /** @Given a custom transport that maps an upstream cURL error to HttpRequestFailed */ + /** @Given a custom transport that maps an upstream validation error to HttpRequestInvalid */ $http = Http::with( baseUrl: 'https://api.example.com', - transport: FailingTransport::raisingRequestFailure( - reason: 'cURL handle exhausted retries.', - cause: new RuntimeException('curl: too many retries') + transport: FailingTransport::raisingRequestInvalid( + reason: 'Upstream validator rejected the payload.', + cause: new RuntimeException('validator: required field missing') ) ); try { /** @When sending a request through the custom transport */ - $http->send(request: Request::put(url: '/dragons')); - self::fail('HttpRequestFailed was expected.'); - } catch (HttpRequestFailed $exception) { + $http->send(request: Request::patch(url: '/dragons')); + self::fail('HttpRequestInvalid was expected.'); + } catch (HttpRequestInvalid $exception) { /** @Then the exception carries the originating URL, method, and reason */ self::assertSame('https://api.example.com/dragons', $exception->url()); - self::assertSame(Method::PUT, $exception->method()); - self::assertSame('cURL handle exhausted retries.', $exception->reason()); + self::assertSame(Method::PATCH, $exception->method()); + self::assertSame('Upstream validator rejected the payload.', $exception->reason()); } } - public function testSendWhenEmptyQueryArrayGivenThenNoTrailingQuestionMark(): void + public function testSendWhenBaseUrlWithoutTrailingSlashAndPathWithoutLeadingSlashThenJoinsWithSingleSlash(): void { - /** @Given an Http instance and an empty query array */ + /** @Given an Http instance without trailing slash on the base URL */ $client = CapturingClient::returningStatus(statusCode: 200); $http = Http::with(baseUrl: 'https://api.example.com', transport: NetworkTransport::with( client: $client, factory: $this->factory )); - /** @And a request with an empty query array */ - $request = Request::get(url: '/dragons', queryParameters: []); + /** @And a request whose path lacks a leading slash */ + $request = Request::get(url: 'dragons'); /** @When sending the request */ $http->send(request: $request); - /** @Then the composed URI has no trailing question mark */ + /** @Then the composed URI joins them with exactly one slash */ self::assertNotNull($client->captured); self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); } - - public function testWithWhenJavascriptSchemeGivenThenThrowsBaseUrlIsInvalid(): void - { - /** @Given a transport seeded with a response */ - $transport = NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), - factory: $this->factory - ); - - /** @Then an exception indicating the base URL is invalid is thrown */ - $this->expectException(BaseUrlIsInvalid::class); - - /** @When constructing Http with a javascript: scheme base URL */ - Http::with(baseUrl: 'javascript:alert(1)', transport: $transport); - } - - public function testWithWhenFtpSchemeGivenThenThrowsBaseUrlIsInvalid(): void - { - /** @Given a transport seeded with a response */ - $transport = NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), - factory: $this->factory - ); - - /** @Then an exception indicating the base URL is invalid is thrown */ - $this->expectException(BaseUrlIsInvalid::class); - - /** @When constructing Http with an ftp:// base URL */ - Http::with(baseUrl: 'ftp://example.com', transport: $transport); - } - - public function testWithWhenProtocolRelativeGivenThenThrowsBaseUrlIsInvalid(): void - { - /** @Given a transport seeded with a response */ - $transport = NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), - factory: $this->factory - ); - - /** @Then an exception indicating the base URL is invalid is thrown */ - $this->expectException(BaseUrlIsInvalid::class); - - /** @When constructing Http with a protocol-relative base URL */ - Http::with(baseUrl: '//host', transport: $transport); - } - - public function testWithWhenControlCharGivenThenThrowsBaseUrlIsInvalid(): void - { - /** @Given a transport seeded with a response */ - $transport = NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), - factory: $this->factory - ); - - /** @Then an exception indicating the base URL is invalid is thrown */ - $this->expectException(BaseUrlIsInvalid::class); - - /** @When constructing Http with a base URL containing a control character */ - Http::with(baseUrl: "https://api.example.com\x00", transport: $transport); - } - - public function testWithWhenHttpsGivenThenAcceptsWithoutThrowing(): void - { - /** @Given a transport seeded with a response */ - $transport = NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), - factory: $this->factory - ); - - /** @When constructing Http with a valid https:// base URL */ - $http = Http::with(baseUrl: 'https://api.example.com', transport: $transport); - - /** @Then an Http instance is returned without throwing */ - self::assertInstanceOf(Http::class, $http); - } - - public function testWithWhenHttpGivenThenAcceptsWithoutThrowing(): void - { - /** @Given a transport seeded with a response */ - $transport = NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), - factory: $this->factory - ); - - /** @When constructing Http with a valid http:// base URL */ - $http = Http::with(baseUrl: 'http://localhost:8080', transport: $transport); - - /** @Then an Http instance is returned without throwing */ - self::assertInstanceOf(Http::class, $http); - } - - public function testWithWhenEmptyStringGivenThenAcceptsWithoutThrowing(): void - { - /** @Given a transport seeded with a response */ - $transport = NetworkTransport::with( - client: CapturingClient::returningStatus(statusCode: 200), - factory: $this->factory - ); - - /** @When constructing Http with an empty base URL */ - $http = Http::with(baseUrl: '', transport: $transport); - - /** @Then an Http instance is returned without throwing */ - self::assertInstanceOf(Http::class, $http); - } } diff --git a/tests/Unit/Server/HeadersTest.php b/tests/Unit/Server/HeadersTest.php index 4784010..007c55a 100644 --- a/tests/Unit/Server/HeadersTest.php +++ b/tests/Unit/Server/HeadersTest.php @@ -13,30 +13,62 @@ final class HeadersTest extends TestCase { - public function testNoContentWhenInvokedThenCarriesDefaultContentType(): void + public function testWithoutHeaderWhenAbsentThenIsNoOp(): void { - /** @When a no-content response is created */ + /** @Given an HTTP response without the target header */ $response = Response::noContent(); - /** @Then the response carries the default Content-Type header */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $response->getHeaders()); + /** @When the missing header is requested to be removed */ + $actual = $response->withoutHeader('X-Trace'); + + /** @Then the headers remain unchanged */ + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testWithHeaderWhenChainedWithDistinctKeysThenBothPresentAlongsideDefault(): void + public function testWithHeaderWhenHeaderAbsentThenCreatesIt(): void { - /** @Given an HTTP response */ + /** @Given an HTTP response without the target header */ $response = Response::noContent(); - /** @When two distinct custom headers are added in a chain */ - $actual = $response - ->withHeader('X-ID', '100') - ->withHeader('X-NAME', 'Xpto'); + /** @When the header is replaced (i.e., set) */ + $actual = $response->withHeader('X-Trace', 'value'); - /** @Then both custom headers are present alongside the default Content-Type */ - self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => ['100'], 'X-NAME' => ['Xpto']], - $actual->getHeaders() - ); + /** @Then the header is created with the given value */ + self::assertSame(['value'], $actual->getHeader('X-Trace')); + } + + public function testGetHeaderWhenHeaderMissingThenReturnsEmptyArray(): void + { + /** @Given an HTTP response with no custom headers */ + $response = Response::noContent(); + + /** @When we retrieve a missing header */ + $actual = $response->getHeader('Non-Existent-Header'); + + /** @Then the header is returned as an empty array */ + self::assertSame([], $actual); + } + + public function testNoContentWhenContentTypeIsPdfThenHeaderReflectsIt(): void + { + /** @Given the Content-Type header set to application/pdf */ + $contentType = ContentType::applicationPdf(); + + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response carries Content-Type: application/pdf */ + self::assertTrue($actual->hasHeader('Content-Type')); + self::assertSame('application/pdf', $actual->getHeaderLine('Content-Type')); + } + + public function testNoContentWhenInvokedThenCarriesDefaultContentType(): void + { + /** @When a no-content response is created */ + $response = Response::noContent(); + + /** @Then the response carries the default Content-Type header */ + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $response->getHeaders()); } public function testWithHeaderWhenSameHeaderSetTwiceThenLastValueWins(): void @@ -56,116 +88,139 @@ public function testWithHeaderWhenSameHeaderSetTwiceThenLastValueWins(): void self::assertSame(['Content-Type' => ['application/json; charset=ISO-8859-1']], $actual->getHeaders()); } - public function testGetHeaderWhenHeaderMissingThenReturnsEmptyArray(): void + public function testNoContentWhenContentTypeIsHtmlThenHeaderReflectsIt(): void { - /** @Given an HTTP response with no custom headers */ - $response = Response::noContent(); + /** @Given the Content-Type header set to text/html */ + $contentType = ContentType::textHtml(); - /** @When we retrieve a missing header */ - $actual = $response->getHeader('Non-Existent-Header'); + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); - /** @Then the header is returned as an empty array */ - self::assertSame([], $actual); + /** @Then the response carries Content-Type: text/html */ + self::assertSame('text/html', $actual->getHeaderLine('Content-Type')); } - public function testWithAddedHeaderWhenDistinctValueGivenThenAppendsToExistingHeader(): void + public function testNoContentWhenContentTypeIsJsonThenHeaderReflectsIt(): void { - /** @Given an HTTP response with a custom header */ - $response = Response::noContent()->withHeader('X-Trace', 'first'); + /** @Given the Content-Type header set to application/json */ + $contentType = ContentType::applicationJson(); - /** @When a distinct value is added to the same header */ - $actual = $response->withAddedHeader('X-Trace', 'second'); + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); - /** @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')); + /** @Then the response carries Content-Type: application/json */ + self::assertSame('application/json', $actual->getHeaderLine('Content-Type')); } - public function testWithAddedHeaderWhenHeaderAbsentThenCreatesItWithGivenValue(): void + public function testWithoutHeaderWhenCaseMismatchedThenStillRemovesHeader(): void { - /** @Given an HTTP response without the target header */ - $response = Response::noContent(); + /** @Given an HTTP response with a custom header */ + $response = Response::noContent()->withHeader('X-Trace', 'value'); - /** @When a value is added for the absent header */ - $actual = $response->withAddedHeader('X-Trace', 'only-value'); + /** @When the header is removed using a differently cased name */ + $actual = $response->withoutHeader('x-trace'); - /** @Then the header is created carrying the given value */ - self::assertSame(['only-value'], $actual->getHeader('X-Trace')); - self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['only-value']], - $actual->getHeaders() - ); + /** @Then the header is no longer present */ + self::assertFalse($actual->hasHeader('X-Trace')); + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testWithAddedHeaderWhenCaseMismatchedThenMatchesExistingHeader(): void + public function testWithHeaderWhenCaseMismatchedThenReplacesExistingHeader(): void { /** @Given an HTTP response with a custom header */ $response = Response::noContent()->withHeader('X-Trace', 'first'); - /** @When a value is added using a differently cased name */ - $actual = $response->withAddedHeader('x-trace', 'second'); + /** @When the header is replaced using a differently cased name */ + $actual = $response->withHeader('x-trace', 'second'); - /** @Then the value is appended preserving the original case of the header name */ - self::assertSame(['first', 'second'], $actual->getHeader('X-Trace')); + /** @Then the original casing is preserved and the value replaced */ + self::assertSame(['second'], $actual->getHeader('X-Trace')); self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['first', 'second']], + ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['second']], $actual->getHeaders() ); } - public function testWithoutHeaderWhenCaseMismatchedThenStillRemovesHeader(): void + public function testNoContentWhenContentTypeIsPlainTextThenHeaderReflectsIt(): void { - /** @Given an HTTP response with a custom header */ - $response = Response::noContent()->withHeader('X-Trace', 'value'); + /** @Given the Content-Type header set to text/plain */ + $contentType = ContentType::textPlain(); - /** @When the header is removed using a differently cased name */ - $actual = $response->withoutHeader('x-trace'); + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); - /** @Then the header is no longer present */ - self::assertFalse($actual->hasHeader('X-Trace')); - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + /** @Then the response carries Content-Type: text/plain */ + self::assertSame('text/plain', $actual->getHeaderLine('Content-Type')); } - public function testWithoutHeaderWhenAbsentThenIsNoOp(): void + public function testNoContentWhenHeaderableEmitsStringValueThenWrapsItInList(): void { - /** @Given an HTTP response without the target header */ - $response = Response::noContent(); + /** @Given a Headerable whose toArray() emits a string value (not a list) */ + $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); - /** @When the missing header is requested to be removed */ - $actual = $response->withoutHeader('X-Trace'); + /** @When a response is created with that header */ + $actual = Response::noContent($userAgent); - /** @Then the headers remain unchanged */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + /** @Then the header is preserved as a single-entry list */ + self::assertSame(['MyApp/1.2.3'], $actual->getHeader('User-Agent')); } - public function testWithHeaderWhenHeaderAbsentThenCreatesIt(): void + public function testNoContentWhenContentTypeIsOctetStreamThenHeaderReflectsIt(): void { - /** @Given an HTTP response without the target header */ - $response = Response::noContent(); + /** @Given the Content-Type header set to application/octet-stream */ + $contentType = ContentType::applicationOctetStream(); - /** @When the header is replaced (i.e., set) */ - $actual = $response->withHeader('X-Trace', 'value'); + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); - /** @Then the header is created with the given value */ - self::assertSame(['value'], $actual->getHeader('X-Trace')); + /** @Then the response carries Content-Type: application/octet-stream */ + self::assertSame('application/octet-stream', $actual->getHeaderLine('Content-Type')); } - public function testWithHeaderWhenCaseMismatchedThenReplacesExistingHeader(): void + public function testWithAddedHeaderWhenCaseMismatchedThenMatchesExistingHeader(): void { /** @Given an HTTP response with a custom header */ $response = Response::noContent()->withHeader('X-Trace', 'first'); - /** @When the header is replaced using a differently cased name */ - $actual = $response->withHeader('x-trace', 'second'); + /** @When a value is added using a differently cased name */ + $actual = $response->withAddedHeader('x-trace', 'second'); - /** @Then the original casing is preserved and the value replaced */ - self::assertSame(['second'], $actual->getHeader('X-Trace')); + /** @Then the value is appended preserving the original case of the header name */ + self::assertSame(['first', 'second'], $actual->getHeader('X-Trace')); self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['second']], + ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['first', 'second']], $actual->getHeaders() ); } + public function testWithAddedHeaderWhenHeaderAbsentThenCreatesItWithGivenValue(): void + { + /** @Given an HTTP response without the target header */ + $response = Response::noContent(); + + /** @When a value is added for the absent header */ + $actual = $response->withAddedHeader('X-Trace', 'only-value'); + + /** @Then the header is created carrying the given value */ + self::assertSame(['only-value'], $actual->getHeader('X-Trace')); + self::assertSame( + ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['only-value']], + $actual->getHeaders() + ); + } + + public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt(): void + { + /** @Given the Content-Type header set to application/x-www-form-urlencoded */ + $contentType = ContentType::applicationFormUrlencoded(); + + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response carries Content-Type: application/x-www-form-urlencoded */ + self::assertSame('application/x-www-form-urlencoded', $actual->getHeaderLine('Content-Type')); + } + public function testNoContentWhenMultipleHeaderablesGivenThenCacheControlIsPresent(): void { /** @Given a Cache-Control header */ @@ -181,6 +236,19 @@ 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 testNoContentWhenMultipleHeaderablesGivenThenContentTypeReplacesDefault(): void { /** @Given a Cache-Control header */ @@ -224,88 +292,20 @@ public function testNoContentWhenCacheControlWithEveryDirectiveGivenThenHeaderRe self::assertSame($cacheControl->toArray(), $actual->getHeaders()); } - public function testNoContentWhenContentTypeIsPdfThenHeaderReflectsIt(): void - { - /** @Given the Content-Type header set to application/pdf */ - $contentType = ContentType::applicationPdf(); - - /** @When the response is created with the Content-Type */ - $actual = Response::noContent($contentType); - - /** @Then the response carries Content-Type: application/pdf */ - self::assertTrue($actual->hasHeader('Content-Type')); - self::assertSame('application/pdf', $actual->getHeaderLine('Content-Type')); - } - - public function testNoContentWhenContentTypeIsHtmlThenHeaderReflectsIt(): void - { - /** @Given the Content-Type header set to text/html */ - $contentType = ContentType::textHtml(); - - /** @When the response is created with the Content-Type */ - $actual = Response::noContent($contentType); - - /** @Then the response carries Content-Type: text/html */ - self::assertSame('text/html', $actual->getHeaderLine('Content-Type')); - } - - public function testNoContentWhenContentTypeIsJsonThenHeaderReflectsIt(): void - { - /** @Given the Content-Type header set to application/json */ - $contentType = ContentType::applicationJson(); - - /** @When the response is created with the Content-Type */ - $actual = Response::noContent($contentType); - - /** @Then the response carries Content-Type: application/json */ - self::assertSame('application/json', $actual->getHeaderLine('Content-Type')); - } - - public function testNoContentWhenContentTypeIsPlainTextThenHeaderReflectsIt(): void - { - /** @Given the Content-Type header set to text/plain */ - $contentType = ContentType::textPlain(); - - /** @When the response is created with the Content-Type */ - $actual = Response::noContent($contentType); - - /** @Then the response carries Content-Type: text/plain */ - self::assertSame('text/plain', $actual->getHeaderLine('Content-Type')); - } - - public function testNoContentWhenContentTypeIsOctetStreamThenHeaderReflectsIt(): void - { - /** @Given the Content-Type header set to application/octet-stream */ - $contentType = ContentType::applicationOctetStream(); - - /** @When the response is created with the Content-Type */ - $actual = Response::noContent($contentType); - - /** @Then the response carries Content-Type: application/octet-stream */ - self::assertSame('application/octet-stream', $actual->getHeaderLine('Content-Type')); - } - - public function testNoContentWhenHeaderableEmitsStringValueThenWrapsItInList(): void - { - /** @Given a Headerable whose toArray() emits a string value (not a list) */ - $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); - - /** @When a response is created with that header */ - $actual = Response::noContent($userAgent); - - /** @Then the header is preserved as a single-entry list */ - self::assertSame(['MyApp/1.2.3'], $actual->getHeader('User-Agent')); - } - - public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt(): void + public function testWithHeaderWhenChainedWithDistinctKeysThenBothPresentAlongsideDefault(): void { - /** @Given the Content-Type header set to application/x-www-form-urlencoded */ - $contentType = ContentType::applicationFormUrlencoded(); + /** @Given an HTTP response */ + $response = Response::noContent(); - /** @When the response is created with the Content-Type */ - $actual = Response::noContent($contentType); + /** @When two distinct custom headers are added in a chain */ + $actual = $response + ->withHeader('X-ID', '100') + ->withHeader('X-NAME', 'Xpto'); - /** @Then the response carries Content-Type: application/x-www-form-urlencoded */ - self::assertSame('application/x-www-form-urlencoded', $actual->getHeaderLine('Content-Type')); + /** @Then both custom headers are present alongside the default Content-Type */ + self::assertSame( + ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => ['100'], 'X-NAME' => ['Xpto']], + $actual->getHeaders() + ); } } diff --git a/tests/Unit/Server/RequestTest.php b/tests/Unit/Server/RequestTest.php index a806d60..75d0403 100644 --- a/tests/Unit/Server/RequestTest.php +++ b/tests/Unit/Server/RequestTest.php @@ -20,6 +20,19 @@ protected function setUp(): void $this->factory = new Psr17Factory(); } + public function testDecodeWhenUriGivenThenExposesAsString(): void + { + /** @Given a full URI on the server request */ + $expectedUri = 'https://api.example.com/v1/dragons?sort=name&order=asc'; + $serverRequest = new ServerRequest(method: 'GET', uri: $expectedUri); + + /** @When decoding the URI */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->toString(); + + /** @Then the URI string matches */ + self::assertSame($expectedUri, $actual); + } + public function testDecodeWhenBodyGivenThenExposesTypedAccessors(): void { /** @Given a payload to send */ @@ -52,6 +65,31 @@ public function testDecodeWhenBodyGivenThenExposesTypedAccessors(): void self::assertSame($payload['is_legendary'], $actual->get(key: 'is_legendary')->toBoolean()); } + public function testDecodeWhenRouteAttributeIsScalarThenExposesIt(): void + { + /** @Given a scalar route attribute value */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withAttribute('__route__', 'dragon-id'); + + /** @When decoding the route attribute */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->get(key: 'id'); + + /** @Then the value is returned */ + self::assertSame('dragon-id', $actual->toString()); + } + + public function testMethodWhenPostRequestGivenThenReturnsPostEnum(): void + { + /** @Given a POST server request */ + $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com'); + + /** @When asking for the typed method */ + $actual = Request::from(request: $serverRequest)->method(); + + /** @Then the Method enum is returned */ + self::assertSame(Method::POST, $actual); + } + public function testDecodeWhenRouteHasSingleAttributeThenExposesIt(): void { /** @Given a route attribute carrying a single id */ @@ -65,123 +103,101 @@ public function testDecodeWhenRouteHasSingleAttributeThenExposesIt(): void self::assertSame('dragon-id', $actual->toString()); } - public function testDecodeWhenRouteHasMultipleAttributesThenExposesEach(): void + public function testDecodeWhenStreamAdvancedThenStillParsesFromStart(): void { - /** @Given a set of route attributes */ - $attributes = ['id' => 'dragon-id', 'skill' => 'dragon-skill', 'weight' => 6000.00]; + /** @Given a seekable stream advanced past its start */ + $stream = $this->factory->createStream('{"name":"Hydra"}'); + $stream->getContents(); - /** @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]); + /** @And a server request using that stream */ + $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com') + ->withBody($stream); - /** @When decoding each attribute */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + /** @When decoding the request body */ + $decoded = Request::from(request: $serverRequest)->decode()->body(); - /** @Then each typed accessor matches */ - self::assertSame($attributes['id'], $route->get(key: 'id')->toString()); - self::assertSame($attributes['skill'], $route->get(key: 'skill')->toString()); - self::assertSame($attributes['weight'], $route->get(key: 'weight')->toFloat()); + /** @Then the body parses correctly despite the stream position */ + self::assertSame('Hydra', $decoded->get(key: 'name')->toString()); + + /** @And the stream is rewound so it can be re-read */ + self::assertSame('{"name":"Hydra"}', $stream->getContents()); } - #[DataProvider('attributeConversionsProvider')] - public function testDecodeWhenAttributeTypedConversionRequestedThenReturnsExpectedValue( - string $key, - mixed $value, - string $method, - mixed $expected + #[DataProvider('httpMethodsProvider')] + public function testMethodWhenAnyHttpVerbGivenThenReturnsMatchingEnum( + string $methodString, + Method $expectedMethod ): void { - /** @Given a route attribute with the provided value */ - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', $key => $value]); + /** @Given a server request with the specified HTTP verb */ + $serverRequest = new ServerRequest(method: $methodString, uri: 'https://api.example.com'); - /** @When converting through the typed accessor */ - $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->get(key: $key)->$method(); + /** @When asking for the typed method */ + $actual = Request::from(request: $serverRequest)->method(); - /** @Then the converted value matches the expected one */ - self::assertSame($expected, $actual); + /** @Then the Method enum matches */ + self::assertSame($expectedMethod, $actual); + self::assertSame($methodString, $actual->value); } - public function testDecodeWhenRouteAttributeIsScalarThenExposesIt(): void + public function testDecodeWhenInvalidJsonBodyGivenThenReturnsEmptyArray(): void { - /** @Given a scalar route attribute value */ - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withAttribute('__route__', 'dragon-id'); + /** @Given a non-JSON body */ + $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + ->withBody($this->factory->createStream('{not valid json]')); - /** @When decoding the route attribute */ - $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->get(key: 'id'); + /** @When decoding */ + $decoded = Request::from(request: $serverRequest)->decode(); - /** @Then the value is returned */ - self::assertSame('dragon-id', $actual->toString()); + /** @Then the body gracefully returns an empty array */ + self::assertSame([], $decoded->body()->toArray()); } - public function testDecodeWhenSlimStyleRouteObjectGivenThenResolvesArguments(): void + public function testDecodeWhenManualAttributesInjectedThenExposesValues(): void { - /** @Given a Slim-style route object that stores params in getArguments() */ - $routeObject = new class { - public function getArguments(): array - { - return ['id' => '42', 'email' => 'dragon@fire.com']; - } - }; - - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withAttribute('__route__', $routeObject); + /** @Given a request manually injecting route params via withAttribute() */ + $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + ->withAttribute('__route__', ['id' => 'manually-injected', 'status' => 'active']); - /** @When decoding the route */ + /** @When decoding */ $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - /** @Then the params resolve from the object */ - self::assertSame('42', $route->get(key: 'id')->toString()); - self::assertSame(42, $route->get(key: 'id')->toInteger()); - self::assertSame('dragon@fire.com', $route->get(key: 'email')->toString()); + /** @Then the injected values are returned */ + self::assertSame('manually-injected', $route->get(key: 'id')->toString()); + self::assertSame('active', $route->get(key: 'status')->toString()); } - public function testDecodeWhenMezzioStyleRouteResultGivenThenResolvesMatchedParams(): void + public function testDecodeWhenQueryParamsAbsentThenSafeDefaultsReturned(): void { - /** @Given a Mezzio-style route result object that uses getMatchedParams() */ - $routeResult = new class { - public function getMatchedParams(): array - { - return ['id' => '99', 'slug' => 'fire-dragon']; - } - }; - - $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) - ->withAttribute('routeResult', $routeResult); + /** @Given a server request with no query parameters */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com'); - /** @When decoding using the known-attribute scan */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + /** @When decoding the query parameters */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->queryParameters(); - /** @Then the params resolve correctly */ - self::assertSame('99', $route->get(key: 'id')->toString()); - self::assertSame('fire-dragon', $route->get(key: 'slug')->toString()); + /** @Then safe defaults are returned */ + self::assertSame([], $actual->toArray()); + self::assertSame('', $actual->get(key: 'sort')->toString()); + self::assertSame(0, $actual->get(key: 'page')->toInteger()); + self::assertSame(0.00, $actual->get(key: 'price')->toFloat()); + self::assertFalse($actual->get(key: 'active')->toBoolean()); } - public function testDecodeWhenSymfonyStyleRouteParamsGivenThenResolvesWithExplicitName(): void + public function testDecodeWhenRouteHasMultipleAttributesThenExposesEach(): void { - /** @Given Symfony stores route params under _route_params */ - $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) - ->withAttribute('_route_params', ['id' => '7', 'category' => 'legendary']); - - /** @When decoding with the custom route attribute name */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(name: '_route_params'); - - /** @Then the params resolve correctly */ - self::assertSame('7', $route->get(key: 'id')->toString()); - self::assertSame('legendary', $route->get(key: 'category')->toString()); - } + /** @Given a set of route attributes */ + $attributes = ['id' => 'dragon-id', 'skill' => 'dragon-skill', 'weight' => 6000.00]; - public function testDecodeWhenSymfonyAttributePresentThenFallbackScanFindsIt(): void - { - /** @Given Symfony stores params under _route_params and default __route__ is absent */ - $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) - ->withAttribute('_route_params', ['id' => '55']); + /** @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 decoding with the default route() */ + /** @When decoding each attribute */ $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - /** @Then the fallback scan finds params under _route_params */ - self::assertSame('55', $route->get(key: 'id')->toString()); + /** @Then each typed accessor matches */ + self::assertSame($attributes['id'], $route->get(key: 'id')->toString()); + self::assertSame($attributes['skill'], $route->get(key: 'skill')->toString()); + self::assertSame($attributes['weight'], $route->get(key: 'weight')->toFloat()); } public function testDecodeWhenDirectAttributesPresentThenFallbackResolves(): void @@ -199,18 +215,23 @@ public function testDecodeWhenDirectAttributesPresentThenFallbackResolves(): voi self::assertSame('user@example.com', $route->get(key: 'email')->toString()); } - public function testDecodeWhenManualAttributesInjectedThenExposesValues(): void + public function testDecodeWhenQueryParamsPresentThenExposesTypedAccessors(): void { - /** @Given a request manually injecting route params via withAttribute() */ - $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) - ->withAttribute('__route__', ['id' => 'manually-injected', 'status' => 'active']); + /** @Given query parameters present on the request URI */ + $queryParams = ['sort' => 'name', 'order' => 'asc', 'limit' => '50', 'active' => 'true']; - /** @When decoding */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withQueryParams($queryParams); - /** @Then the injected values are returned */ - self::assertSame('manually-injected', $route->get(key: 'id')->toString()); - self::assertSame('active', $route->get(key: 'status')->toString()); + /** @When decoding the query parameters */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->queryParameters(); + + /** @Then every accessor matches */ + self::assertSame($queryParams, $actual->toArray()); + self::assertSame($queryParams['sort'], $actual->get(key: 'sort')->toString()); + self::assertSame($queryParams['order'], $actual->get(key: 'order')->toString()); + self::assertSame(50, $actual->get(key: 'limit')->toInteger()); + self::assertTrue($actual->get(key: 'active')->toBoolean()); } public function testDecodeWhenRouteObjectExposesPublicPropertyThenResolvesIt(): void @@ -231,27 +252,53 @@ public function testDecodeWhenRouteObjectExposesPublicPropertyThenResolvesIt(): self::assertSame('Hydra', $route->get(key: 'name')->toString()); } - public function testDecodeWhenRouteObjectExposesNonArrayMethodAndPropertyThenFallsBackToEmpty(): void + public function testDecodeWhenSlimStyleRouteObjectGivenThenResolvesArguments(): void { - /** @Given a route object whose matching method and property both return non-array values */ + /** @Given a Slim-style route object that stores params in getArguments() */ $routeObject = new class { - public string $arguments = 'not-an-array'; - - public function getArguments(): string + public function getArguments(): array { - return 'not-an-array'; + return ['id' => '42', 'email' => 'dragon@fire.com']; } }; - /** @And a server request carrying that object under the canonical route key */ - $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') ->withAttribute('__route__', $routeObject); - /** @When decoding any route attribute */ + /** @When decoding the route */ $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - /** @Then safe defaults are returned because no array could be extracted */ - self::assertSame('', $route->get(key: 'id')->toString()); + /** @Then the params resolve from the object */ + self::assertSame('42', $route->get(key: 'id')->toString()); + self::assertSame(42, $route->get(key: 'id')->toInteger()); + self::assertSame('dragon@fire.com', $route->get(key: 'email')->toString()); + } + + public function testDecodeWhenSymfonyAttributePresentThenFallbackScanFindsIt(): void + { + /** @Given Symfony stores params under _route_params and default __route__ is absent */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('_route_params', ['id' => '55']); + + /** @When decoding with the default route() */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then the fallback scan finds params under _route_params */ + self::assertSame('55', $route->get(key: 'id')->toString()); + } + + public function testDecodeWhenEmptyStreamAndNonArrayParsedBodyThenReturnsEmpty(): void + { + /** @Given an empty stream and a non-array parsed body */ + $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com') + ->withBody($this->factory->createStream('')) + ->withParsedBody(null); + + /** @When decoding */ + $decoded = Request::from(request: $serverRequest)->decode(); + + /** @Then the body gracefully returns an empty array */ + self::assertSame([], $decoded->body()->toArray()); } public function testDecodeWhenNoRouteAttributesGivenThenSafeDefaultsAreReturned(): void @@ -296,80 +343,80 @@ public function testDecodeWhenParsedBodyPresentAndStreamEmptyThenUsesParsedBody( self::assertSame($payload['is_legendary'], $actual->get(key: 'is_legendary')->toBoolean()); } - public function testDecodeWhenUriGivenThenExposesAsString(): void + public function testDecodeWhenMezzioStyleRouteResultGivenThenResolvesMatchedParams(): void { - /** @Given a full URI on the server request */ - $expectedUri = 'https://api.example.com/v1/dragons?sort=name&order=asc'; - $serverRequest = new ServerRequest(method: 'GET', uri: $expectedUri); + /** @Given a Mezzio-style route result object that uses getMatchedParams() */ + $routeResult = new class { + public function getMatchedParams(): array + { + return ['id' => '99', 'slug' => 'fire-dragon']; + } + }; - /** @When decoding the URI */ - $actual = Request::from(request: $serverRequest)->decode()->uri()->toString(); + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('routeResult', $routeResult); - /** @Then the URI string matches */ - self::assertSame($expectedUri, $actual); + /** @When decoding using the known-attribute scan */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then the params resolve correctly */ + self::assertSame('99', $route->get(key: 'id')->toString()); + self::assertSame('fire-dragon', $route->get(key: 'slug')->toString()); } - public function testDecodeWhenQueryParamsPresentThenExposesTypedAccessors(): void + public function testDecodeWhenSymfonyStyleRouteParamsGivenThenResolvesWithExplicitName(): void { - /** @Given query parameters present on the request URI */ - $queryParams = ['sort' => 'name', 'order' => 'asc', 'limit' => '50', 'active' => 'true']; - + /** @Given Symfony stores route params under _route_params */ $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) - ->withQueryParams($queryParams); + ->withAttribute('_route_params', ['id' => '7', 'category' => 'legendary']); - /** @When decoding the query parameters */ - $actual = Request::from(request: $serverRequest)->decode()->uri()->queryParameters(); + /** @When decoding with the custom route attribute name */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(name: '_route_params'); - /** @Then every accessor matches */ - self::assertSame($queryParams, $actual->toArray()); - self::assertSame($queryParams['sort'], $actual->get(key: 'sort')->toString()); - self::assertSame($queryParams['order'], $actual->get(key: 'order')->toString()); - self::assertSame(50, $actual->get(key: 'limit')->toInteger()); - self::assertTrue($actual->get(key: 'active')->toBoolean()); + /** @Then the params resolve correctly */ + self::assertSame('7', $route->get(key: 'id')->toString()); + self::assertSame('legendary', $route->get(key: 'category')->toString()); } - public function testDecodeWhenQueryParamsAbsentThenSafeDefaultsReturned(): void - { - /** @Given a server request with no query parameters */ - $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com'); + #[DataProvider('attributeConversionsProvider')] + public function testDecodeWhenAttributeTypedConversionRequestedThenReturnsExpectedValue( + string $key, + mixed $value, + string $method, + mixed $expected + ): void { + /** @Given a route attribute with the provided value */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', $key => $value]); - /** @When decoding the query parameters */ - $actual = Request::from(request: $serverRequest)->decode()->uri()->queryParameters(); + /** @When converting through the typed accessor */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->get(key: $key)->$method(); - /** @Then safe defaults are returned */ - self::assertSame([], $actual->toArray()); - self::assertSame('', $actual->get(key: 'sort')->toString()); - self::assertSame(0, $actual->get(key: 'page')->toInteger()); - self::assertSame(0.00, $actual->get(key: 'price')->toFloat()); - self::assertFalse($actual->get(key: 'active')->toBoolean()); + /** @Then the converted value matches the expected one */ + self::assertSame($expected, $actual); } - public function testMethodWhenPostRequestGivenThenReturnsPostEnum(): void + public function testDecodeWhenRouteObjectExposesNonArrayMethodAndPropertyThenFallsBackToEmpty(): void { - /** @Given a POST server request */ - $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com'); - - /** @When asking for the typed method */ - $actual = Request::from(request: $serverRequest)->method(); + /** @Given a route object whose matching method and property both return non-array values */ + $routeObject = new class { + public string $arguments = 'not-an-array'; - /** @Then the Method enum is returned */ - self::assertSame(Method::POST, $actual); - } + public function getArguments(): string + { + return 'not-an-array'; + } + }; - #[DataProvider('httpMethodsProvider')] - public function testMethodWhenAnyHttpVerbGivenThenReturnsMatchingEnum( - string $methodString, - Method $expectedMethod - ): void { - /** @Given a server request with the specified HTTP verb */ - $serverRequest = new ServerRequest(method: $methodString, uri: 'https://api.example.com'); + /** @And a server request carrying that object under the canonical route key */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('__route__', $routeObject); - /** @When asking for the typed method */ - $actual = Request::from(request: $serverRequest)->method(); + /** @When decoding any route attribute */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - /** @Then the Method enum matches */ - self::assertSame($expectedMethod, $actual); - self::assertSame($methodString, $actual->value); + /** @Then safe defaults are returned because no array could be extracted */ + self::assertSame('', $route->get(key: 'id')->toString()); } public static function httpMethodsProvider(): array @@ -419,51 +466,4 @@ public static function attributeConversionsProvider(): array 'Non-scalar attribute conversion toBoolean defaults to false' => ['meta', ['x' => 1], 'toBoolean', false] ]; } - - public function testDecodeWhenInvalidJsonBodyGivenThenReturnsEmptyArray(): void - { - /** @Given a non-JSON body */ - $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) - ->withBody($this->factory->createStream('{not valid json]')); - - /** @When decoding */ - $decoded = Request::from(request: $serverRequest)->decode(); - - /** @Then the body gracefully returns an empty array */ - self::assertSame([], $decoded->body()->toArray()); - } - - public function testDecodeWhenStreamAdvancedThenStillParsesFromStart(): void - { - /** @Given a seekable stream advanced past its start */ - $stream = $this->factory->createStream('{"name":"Hydra"}'); - $stream->getContents(); - - /** @And a server request using that stream */ - $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com') - ->withBody($stream); - - /** @When decoding the request body */ - $decoded = Request::from(request: $serverRequest)->decode()->body(); - - /** @Then the body parses correctly despite the stream position */ - self::assertSame('Hydra', $decoded->get(key: 'name')->toString()); - - /** @And the stream is rewound so it can be re-read */ - self::assertSame('{"name":"Hydra"}', $stream->getContents()); - } - - public function testDecodeWhenEmptyStreamAndNonArrayParsedBodyThenReturnsEmpty(): void - { - /** @Given an empty stream and a non-array parsed body */ - $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com') - ->withBody($this->factory->createStream('')) - ->withParsedBody(null); - - /** @When decoding */ - $decoded = Request::from(request: $serverRequest)->decode(); - - /** @Then the body gracefully returns an empty array */ - self::assertSame([], $decoded->body()->toArray()); - } } diff --git a/tests/Unit/Server/ResponseTest.php b/tests/Unit/Server/ResponseTest.php index be29935..24ecd62 100644 --- a/tests/Unit/Server/ResponseTest.php +++ b/tests/Unit/Server/ResponseTest.php @@ -23,751 +23,720 @@ final class ResponseTest extends TestCase { - #[DataProvider('responseFromProvider')] - public function testFromWhenCodeAndBodyGivenThenRendersBodyWithMatchingStatus( - Code $code, - mixed $body, - string $expectedBody - ): void { - /** @Given a specific status code and body */ - /** @When creating the HTTP response via the generic from method */ - $actual = Response::from(body: $body, code: $code); - - /** @Then the protocol version is "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response matches the expected output */ - self::assertSame($expectedBody, $actual->getBody()->__toString()); - - /** @And the status code matches the provided code */ - self::assertSame($code->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); + public function testGetBodyWhenDetachedThenSizeIsNull(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @And the reason phrase matches the code message */ - self::assertSame($code->message(), $actual->getReasonPhrase()); + /** @When detaching the underlying resource */ + $stream->detach(); - /** @And the default Content-Type is application/json; charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + /** @Then the size collapses to null */ + self::assertNull($stream->getSize()); } - public function testOkWhenBodyGivenThenReturnsResponseWithStatus200(): void + public function testGetBodyWhenClosedThenIsNotReadable(): void { - /** @Given a body with data */ - $body = ['id' => PHP_INT_MAX, 'name' => 'Drakengard Firestorm', 'type' => 'Dragon', 'weight' => 6000.00]; + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the response is created with the body */ - $actual = Response::ok(body: $body); + /** @When the stream is closed */ + $stream->close(); - /** @Then the response carries the body encoded as JSON and a 200 status */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(Code::OK->value, $actual->getStatusCode()); - self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - self::assertSame(Code::OK->message(), $actual->getReasonPhrase()); - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + /** @Then the stream is no longer readable */ + self::assertFalse($stream->isReadable()); } - public function testCreatedWhenBodyGivenThenReturnsResponseWithStatus201(): void + public function testGetBodyWhenClosedThenIsNotSeekable(): void { - /** @Given a body with data */ - $body = ['id' => 1, 'name' => 'New Resource', 'type' => 'Item', 'weight' => 100.00]; + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the response is created with the body */ - $actual = Response::created(body: $body); + /** @When the stream is closed */ + $stream->close(); - /** @Then the response carries the body and a 201 status */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(Code::CREATED->value, $actual->getStatusCode()); - self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - self::assertSame(Code::CREATED->message(), $actual->getReasonPhrase()); + /** @Then the stream is no longer seekable */ + self::assertFalse($stream->isSeekable()); } - public function testAcceptedWhenBodyGivenThenReturnsResponseWithStatus202(): void + public function testGetBodyWhenClosedThenIsNotWritable(): void { - /** @Given a body with data */ - $body = ['id' => 1, 'status' => 'Processing']; + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the response is created with the body */ - $actual = Response::accepted(body: $body); + /** @When the stream is closed */ + $stream->close(); - /** @Then the response carries the body and a 202 status */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(Code::ACCEPTED->value, $actual->getStatusCode()); - self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - self::assertSame(Code::ACCEPTED->message(), $actual->getReasonPhrase()); + /** @Then the stream is no longer writable */ + self::assertFalse($stream->isWritable()); } - public function testNoContentWhenInvokedThenReturnsEmptyBodyWithStatus204(): void + public function testGetBodyWhenClosedThenEofReturnsFalse(): void { - /** @When the response is created without body */ - $actual = Response::noContent(); + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @Then the body is empty and the status is 204 */ - self::assertEmpty($actual->getBody()->__toString()); - self::assertSame(Code::NO_CONTENT->value, $actual->getStatusCode()); - self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - self::assertSame(Code::NO_CONTENT->message(), $actual->getReasonPhrase()); + /** @When the stream is closed */ + $stream->close(); + + /** @Then the detached stream reports it has not reached EOF */ + self::assertFalse($stream->eof()); } - public function testBadRequestWhenBodyGivenThenReturnsResponseWithStatus400(): void + public function testGetBodyWhenClosedThenReportsNullSize(): void { - /** @Given a body with error details */ - $body = ['error' => 'Invalid request', 'message' => 'The request body is malformed.']; + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the response is created with the body */ - $actual = Response::badRequest(body: $body); + /** @When the stream is closed */ + $stream->close(); - /** @Then the status is 400 */ - self::assertSame(Code::BAD_REQUEST->value, $actual->getStatusCode()); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + /** @Then the stream reports null size */ + self::assertNull($stream->getSize()); } - public function testUnauthorizedWhenBodyGivenThenReturnsResponseWithStatus401(): void + public function testGetBodyWhenInvokedThenStreamIsReadable(): void { - /** @Given a body with error details */ - $body = ['error' => 'Unauthorized', 'message' => 'Authentication is required.']; + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the response is created with the body */ - $actual = Response::unauthorized(body: $body); + /** @When inspecting the stream */ + $isReadable = $stream->isReadable(); - /** @Then the status is 401 */ - self::assertSame(Code::UNAUTHORIZED->value, $actual->getStatusCode()); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + /** @Then the stream is readable */ + self::assertTrue($isReadable); } - public function testForbiddenWhenBodyGivenThenReturnsResponseWithStatus403(): void + public function testGetBodyWhenInvokedThenStreamIsSeekable(): void { - /** @Given a body with error details */ - $body = ['error' => 'Forbidden', 'message' => 'You do not have permission to access this resource.']; + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the response is created with the body */ - $actual = Response::forbidden(body: $body); + /** @When inspecting the stream */ + $isSeekable = $stream->isSeekable(); - /** @Then the status is 403 */ - self::assertSame(Code::FORBIDDEN->value, $actual->getStatusCode()); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + /** @Then the stream is seekable */ + self::assertTrue($isSeekable); } - public function testNotFoundWhenBodyGivenThenReturnsResponseWithStatus404(): void + public function testGetBodyWhenInvokedThenStreamIsWritable(): void { - /** @Given a body with error details */ - $body = ['error' => 'Not found', 'message' => 'The requested resource could not be found.']; + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the response is created with the body */ - $actual = Response::notFound(body: $body); + /** @When inspecting the stream */ + $isWritable = $stream->isWritable(); - /** @Then the status is 404 */ - self::assertSame(Code::NOT_FOUND->value, $actual->getStatusCode()); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + /** @Then the stream is writable */ + self::assertTrue($isWritable); } - public function testConflictWhenBodyGivenThenReturnsResponseWithStatus409(): void + public function testGetBodyWhenDetachedThenIsNoLongerReadable(): void { - /** @Given a body with conflict details */ - $body = ['error' => 'Conflict', 'message' => 'There is a conflict with the current state of the resource.']; + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the response is created with the body */ - $actual = Response::conflict(body: $body); + /** @When detaching the underlying resource */ + $stream->detach(); - /** @Then the status is 409 */ - self::assertSame(Code::CONFLICT->value, $actual->getStatusCode()); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + /** @Then the stream is no longer readable */ + self::assertFalse($stream->isReadable()); } - public function testUnprocessableEntityWhenBodyGivenThenReturnsResponseWithStatus422(): void + public function testWithBodyWhenInvokedThenReplacesBodyContent(): void { - /** @Given a body with validation errors */ - $body = ['error' => 'Validation Failed', 'message' => 'The input data did not pass validation.']; + /** @Given an HTTP response without body */ + $response = Response::ok(body: null); - /** @When the response is created with the body */ - $actual = Response::unprocessableEntity(body: $body); + /** @And a fresh PSR-7 stream carrying the replacement bytes */ + $replacement = new Psr17Factory()->createStream('This is a new body'); - /** @Then the status is 422 */ - self::assertSame(Code::UNPROCESSABLE_ENTITY->value, $actual->getStatusCode()); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + /** @When the body is replaced */ + $actual = $response->withBody($replacement); + + /** @Then the response body matches the new content */ + self::assertSame('This is a new body', $actual->getBody()->__toString()); } - public function testInternalServerErrorWhenBodyGivenThenReturnsResponseWithStatus500(): void + public function testGetBodyWhenContentsReadThenStreamReachesEof(): void { - /** @Given a body with error details */ - $body = ['code' => 10000, 'message' => 'An unexpected error occurred on the server.']; + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the response is created with the body */ - $actual = Response::internalServerError(body: $body); + /** @When reading all contents to advance the cursor */ + $stream->getContents(); - /** @Then the status is 500 */ - self::assertSame(Code::INTERNAL_SERVER_ERROR->value, $actual->getStatusCode()); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + /** @Then EOF is signaled */ + self::assertTrue($stream->eof()); } - public function testBadGatewayWhenBodyGivenThenReturnsResponseWithStatus502(): void + public function testGetBodyWhenClosedTwiceThenSecondCloseIsANoOp(): void { - /** @Given a body with upstream failure details */ - $body = ['error' => 'Bad Gateway', 'message' => 'The upstream server returned an invalid response.']; + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the response is created with the body */ - $actual = Response::badGateway(body: $body); + /** @And the stream is already closed */ + $stream->close(); - /** @Then the status is 502 */ - self::assertSame(Code::BAD_GATEWAY->value, $actual->getStatusCode()); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + /** @When closing the stream a second time */ + $stream->close(); + + /** @Then the stream remains detached and reports null size */ + self::assertNull($stream->getSize()); } - public function testServiceUnavailableWhenBodyGivenThenReturnsResponseWithStatus503(): void + public function testGetBodyWhenSeekedToOffsetThenTellMatchesOffset(): void { - /** @Given a body with service downtime details */ - $body = ['error' => 'Service Unavailable', 'message' => 'The service is temporarily unavailable.']; + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the response is created with the body */ - $actual = Response::serviceUnavailable(body: $body); + /** @When seeking past the opening brace */ + $stream->seek(1); - /** @Then the status is 503 */ - self::assertSame(Code::SERVICE_UNAVAILABLE->value, $actual->getStatusCode()); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + /** @Then the position reports the seeked offset */ + self::assertSame(1, $stream->tell()); } - #[DataProvider('bodyProviderData')] - public function testOkWhenAnyBodyShapeGivenThenSerializesToExpectedString(mixed $body, string $expected): void + public function testGetBodyWhenClosedThenReadRaisesNonReadableError(): void { - /** @Given the body contains the provided data */ - /** @When we create an HTTP response with the given body */ - $actual = Response::ok(body: $body); + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @Then the body matches the expected output */ - self::assertSame($expected, $actual->getBody()->__toString()); + /** @And the stream is closed */ + $stream->close(); + + /** @Then reading raises a non-readable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + + /** @When reading from the closed stream */ + $stream->read(1); } - public function testWithBodyWhenInvokedThenReplacesBodyContent(): void + public function testGetBodyWhenClosedThenSeekRaisesNonSeekableError(): void { - /** @Given an HTTP response without body */ - $response = Response::ok(body: null); + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @And a fresh PSR-7 stream carrying the replacement bytes */ - $replacement = new Psr17Factory()->createStream('This is a new body'); + /** @And the stream is closed */ + $stream->close(); - /** @When the body is replaced */ - $actual = $response->withBody($replacement); + /** @Then seeking raises a non-seekable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not seekable.'); - /** @Then the response body matches the new content */ - self::assertSame('This is a new body', $actual->getBody()->__toString()); + /** @When seeking on the closed stream */ + $stream->seek(0); } - public function testWithStatusWhenInvokedThenReturnsResponseWithUpdatedCode(): void + public function testOkWhenBodyGivenThenReturnsResponseWithStatus200(): void { - /** @Given an HTTP response */ - $response = Response::noContent(); + /** @Given a body with data */ + $body = ['id' => PHP_INT_MAX, 'name' => 'Drakengard Firestorm', 'type' => 'Dragon', 'weight' => 6000.00]; - /** @When calling withStatus with a new code */ - $updated = $response->withStatus(Code::OK->value); + /** @When the response is created with the body */ + $actual = Response::ok(body: $body); - /** @Then the returned response reflects the new status code */ - self::assertSame(Code::OK->value, $updated->getStatusCode()); + /** @Then the response carries the body encoded as JSON and a 200 status */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(Code::OK->value, $actual->getStatusCode()); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + self::assertSame(Code::OK->message(), $actual->getReasonPhrase()); + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testWithStatusWhenCustomReasonPhraseGivenThenReasonPhraseIsHonored(): void + public function testGetBodyWhenClosedThenWriteRaisesNonWritableError(): void { - /** @Given an HTTP response */ - $response = Response::ok(body: null); + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When calling withStatus with a custom reason phrase */ - $updated = $response->withStatus(Code::OK->value, 'All Good'); + /** @And the stream is closed */ + $stream->close(); - /** @Then the custom reason phrase is returned */ - self::assertSame('All Good', $updated->getReasonPhrase()); + /** @Then writing raises a non-writable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not writable.'); + + /** @When writing to the closed stream */ + $stream->write('payload'); } - public function testWithStatusWhenEmptyReasonPhraseGivenThenEnumDerivedPhraseIsUsed(): void + public function testGetBodyWhenDetachedThenReturnsUnderlyingResource(): void { - /** @Given an HTTP response */ - $response = Response::ok(body: null); + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When calling withStatus with an empty reason phrase */ - $updated = $response->withStatus(Code::OK->value); + /** @When detaching the underlying resource */ + $resource = $stream->detach(); - /** @Then the enum-derived phrase is returned */ - self::assertSame(Code::OK->message(), $updated->getReasonPhrase()); + /** @Then the returned value is a resource */ + self::assertIsResource($resource); } - public function testWithStatusWhenCustomPhraseSetThenSubsequentWithHeaderPreservesIt(): void + public function testGetBodyWhenInvokedThenStreamStartsAtPositionZero(): void { - /** @Given an HTTP response with a custom reason phrase */ - $response = Response::ok(body: null)->withStatus(Code::OK->value, 'All Good'); + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When adding a header to that response */ - $updated = $response->withHeader('X-Trace-Id', 'abc'); + /** @When inspecting position before reading */ + $tell = $stream->tell(); - /** @Then the custom reason phrase is still returned */ - self::assertSame('All Good', $updated->getReasonPhrase()); + /** @Then the position starts at zero */ + self::assertSame(0, $tell); } - public function testGetBodyWhenInvokedThenStreamIsReadable(): void + public function testGetBodyWhenSizeRequestedThenMatchesPayloadLength(): void { /** @Given a response with a body */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When inspecting the stream */ - $isReadable = $stream->isReadable(); + /** @When asking the stream for its size */ + $size = $stream->getSize(); - /** @Then the stream is readable */ - self::assertTrue($isReadable); + /** @Then the size matches the encoded payload length */ + self::assertSame(strlen('{"name":"Hydra"}'), $size); } - public function testGetBodyWhenInvokedThenStreamIsWritable(): void + public function testGetBodyWhenReadInChunksThenReturnsContentSegments(): void { /** @Given a response with a body */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When inspecting the stream */ - $isWritable = $stream->isWritable(); + /** @When reading a small chunk from the beginning */ + $chunk = $stream->read(4); - /** @Then the stream is writable */ - self::assertTrue($isWritable); + /** @Then the chunk matches the leading bytes of the encoded payload */ + self::assertSame('{"na', $chunk); } - public function testGetBodyWhenInvokedThenStreamIsSeekable(): void + public function testGetBodyWhenClosedThenTellRaisesMissingResourceError(): void { /** @Given a response with a body */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When inspecting the stream */ - $isSeekable = $stream->isSeekable(); + /** @And the stream is closed */ + $stream->close(); - /** @Then the stream is seekable */ - self::assertTrue($isSeekable); + /** @Then telling the position raises a missing-resource error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No resource available.'); + + /** @When asking for the position */ + $stream->tell(); } - public function testGetBodyWhenContentsReadThenReturnsTheWrittenJsonWithoutRequiringRewind(): void + public function testGetBodyWhenInvokedThenStreamIsNotAtEofBeforeReading(): void { /** @Given a response with a body */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When reading the stream contents directly */ - $contents = $stream->getContents(); + /** @When inspecting EOF before reading */ + $eof = $stream->eof(); - /** @Then the contents match the encoded body without needing a manual rewind */ - self::assertSame('{"name":"Hydra"}', $contents); + /** @Then EOF is not yet reached */ + self::assertFalse($eof); } - public function testGetBodyWhenInvokedThenStreamStartsAtPositionZero(): void + public function testCreatedWhenBodyGivenThenReturnsResponseWithStatus201(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @Given a body with data */ + $body = ['id' => 1, 'name' => 'New Resource', 'type' => 'Item', 'weight' => 100.00]; - /** @When inspecting position before reading */ - $tell = $stream->tell(); + /** @When the response is created with the body */ + $actual = Response::created(body: $body); - /** @Then the position starts at zero */ - self::assertSame(0, $tell); + /** @Then the response carries the body and a 201 status */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(Code::CREATED->value, $actual->getStatusCode()); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + self::assertSame(Code::CREATED->message(), $actual->getReasonPhrase()); + } + + public function testAcceptedWhenBodyGivenThenReturnsResponseWithStatus202(): void + { + /** @Given a body with data */ + $body = ['id' => 1, 'status' => 'Processing']; + + /** @When the response is created with the body */ + $actual = Response::accepted(body: $body); + + /** @Then the response carries the body and a 202 status */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(Code::ACCEPTED->value, $actual->getStatusCode()); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + self::assertSame(Code::ACCEPTED->message(), $actual->getReasonPhrase()); } - public function testGetBodyWhenInvokedThenStreamIsNotAtEofBeforeReading(): void + public function testConflictWhenBodyGivenThenReturnsResponseWithStatus409(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @Given a body with conflict details */ + $body = ['error' => 'Conflict', 'message' => 'There is a conflict with the current state of the resource.']; - /** @When inspecting EOF before reading */ - $eof = $stream->eof(); + /** @When the response is created with the body */ + $actual = Response::conflict(body: $body); - /** @Then EOF is not yet reached */ - self::assertFalse($eof); + /** @Then the status is 409 */ + self::assertSame(Code::CONFLICT->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } - public function testGetBodyWhenContentsReadThenStreamReachesEof(): void + public function testGetBodyWhenReadLengthIsZeroThenRaisesNonReadableError(): void { /** @Given a response with a body */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When reading all contents to advance the cursor */ - $stream->getContents(); + /** @Then reading raises a non-readable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); - /** @Then EOF is signaled */ - self::assertTrue($stream->eof()); + /** @When reading with a non-positive length */ + $stream->read(0); } - public function testGetBodyWhenSizeRequestedThenMatchesPayloadLength(): void + public function testNoContentWhenInvokedThenReturnsEmptyBodyWithStatus204(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - - /** @When asking the stream for its size */ - $size = $stream->getSize(); + /** @When the response is created without body */ + $actual = Response::noContent(); - /** @Then the size matches the encoded payload length */ - self::assertSame(strlen('{"name":"Hydra"}'), $size); + /** @Then the body is empty and the status is 204 */ + self::assertEmpty($actual->getBody()->__toString()); + self::assertSame(Code::NO_CONTENT->value, $actual->getStatusCode()); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + self::assertSame(Code::NO_CONTENT->message(), $actual->getReasonPhrase()); } - public function testGetBodyWhenClosedThenReportsNullSize(): void + public function testNotFoundWhenBodyGivenThenReturnsResponseWithStatus404(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @Given a body with error details */ + $body = ['error' => 'Not found', 'message' => 'The requested resource could not be found.']; - /** @When the stream is closed */ - $stream->close(); + /** @When the response is created with the body */ + $actual = Response::notFound(body: $body); - /** @Then the stream reports null size */ - self::assertNull($stream->getSize()); + /** @Then the status is 404 */ + self::assertSame(Code::NOT_FOUND->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } - public function testGetBodyWhenClosedThenIsNotReadable(): void + #[DataProvider('bodyProviderData')] + public function testOkWhenAnyBodyShapeGivenThenSerializesToExpectedString(mixed $body, string $expected): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - - /** @When the stream is closed */ - $stream->close(); + /** @Given the body contains the provided data */ + /** @When we create an HTTP response with the given body */ + $actual = Response::ok(body: $body); - /** @Then the stream is no longer readable */ - self::assertFalse($stream->isReadable()); + /** @Then the body matches the expected output */ + self::assertSame($expected, $actual->getBody()->__toString()); } - public function testGetBodyWhenClosedThenIsNotWritable(): void + public function testForbiddenWhenBodyGivenThenReturnsResponseWithStatus403(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @Given a body with error details */ + $body = ['error' => 'Forbidden', 'message' => 'You do not have permission to access this resource.']; - /** @When the stream is closed */ - $stream->close(); + /** @When the response is created with the body */ + $actual = Response::forbidden(body: $body); - /** @Then the stream is no longer writable */ - self::assertFalse($stream->isWritable()); + /** @Then the status is 403 */ + self::assertSame(Code::FORBIDDEN->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } - public function testGetBodyWhenClosedThenIsNotSeekable(): void + public function testGetBodyWhenClosedThenGetContentsRaisesNonReadableError(): void { /** @Given a response with a body */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the stream is closed */ + /** @And the stream is closed */ $stream->close(); - /** @Then the stream is no longer seekable */ - self::assertFalse($stream->isSeekable()); + /** @Then reading the contents raises a non-readable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + + /** @When asking for the full contents */ + $stream->getContents(); } - public function testGetBodyWhenClosedThenEofReturnsFalse(): void + public function testGetBodyWhenMetadataRequestedWithoutKeyThenReturnsArray(): void { /** @Given a response with a body */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When the stream is closed */ - $stream->close(); + /** @When asking for the full metadata map */ + $metadata = $stream->getMetadata(); - /** @Then the detached stream reports it has not reached EOF */ - self::assertFalse($stream->eof()); + /** @Then the metadata is exposed as an array */ + self::assertIsArray($metadata); } - public function testGetBodyWhenReadInChunksThenReturnsContentSegments(): void + public function testBadGatewayWhenBodyGivenThenReturnsResponseWithStatus502(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @Given a body with upstream failure details */ + $body = ['error' => 'Bad Gateway', 'message' => 'The upstream server returned an invalid response.']; - /** @When reading a small chunk from the beginning */ - $chunk = $stream->read(4); + /** @When the response is created with the body */ + $actual = Response::badGateway(body: $body); - /** @Then the chunk matches the leading bytes of the encoded payload */ - self::assertSame('{"na', $chunk); + /** @Then the status is 502 */ + self::assertSame(Code::BAD_GATEWAY->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } - public function testGetBodyWhenSeekedToOffsetThenTellMatchesOffset(): void + public function testBadRequestWhenBodyGivenThenReturnsResponseWithStatus400(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @Given a body with error details */ + $body = ['error' => 'Invalid request', 'message' => 'The request body is malformed.']; - /** @When seeking past the opening brace */ - $stream->seek(1); + /** @When the response is created with the body */ + $actual = Response::badRequest(body: $body); - /** @Then the position reports the seeked offset */ - self::assertSame(1, $stream->tell()); + /** @Then the status is 400 */ + self::assertSame(Code::BAD_REQUEST->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } - public function testGetBodyWhenSeekedToOffsetThenSubsequentReadsResumeFromThatOffset(): void + public function testWithStatusWhenInvokedThenReturnsResponseWithUpdatedCode(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @Given an HTTP response */ + $response = Response::noContent(); - /** @When seeking past the opening brace */ - $stream->seek(1); + /** @When calling withStatus with a new code */ + $updated = $response->withStatus(Code::OK->value); - /** @Then the next read starts at the seeked offset */ - self::assertSame('"', $stream->read(1)); + /** @Then the returned response reflects the new status code */ + self::assertSame(Code::OK->value, $updated->getStatusCode()); } - public function testGetBodyWhenStreamWrittenAdditionalDataThenReturnsByteCount(): void + public function testGetBodyWhenMetadataKeyRequestedAfterCloseThenReturnsNull(): void { - /** @Given a response stream positioned at end-of-file */ + /** @Given a closed response stream */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - $stream->seek(0, SEEK_END); + $stream->close(); - /** @When appending one byte via the StreamInterface write() */ - $written = $stream->write('+'); + /** @When asking for a specific metadata key */ + $value = $stream->getMetadata('mode'); - /** @Then the write returns the byte count */ - self::assertSame(1, $written); + /** @Then null is returned */ + self::assertNull($value); } - public function testGetBodyWhenStreamWrittenAdditionalDataThenContentsGrowAccordingly(): void - { - /** @Given a response stream positioned at end-of-file */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - $stream->seek(0, SEEK_END); + #[DataProvider('responseFromProvider')] + public function testFromWhenCodeAndBodyGivenThenRendersBodyWithMatchingStatus( + Code $code, + mixed $body, + string $expectedBody + ): void { + /** @Given a specific status code and body */ + /** @When creating the HTTP response via the generic from method */ + $actual = Response::from(body: $body, code: $code); - /** @When appending one byte via the StreamInterface write() */ - $stream->write('+'); + /** @Then the protocol version is "1.1" */ + self::assertSame('1.1', $actual->getProtocolVersion()); - /** @Then the stream size grows accordingly */ - self::assertSame(strlen('{"name":"Hydra"}+'), $stream->getSize()); - } + /** @And the body of the response matches the expected output */ + self::assertSame($expectedBody, $actual->getBody()->__toString()); - public function testGetBodyWhenMetadataRequestedWithoutKeyThenReturnsArray(): void - { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @And the status code matches the provided code */ + self::assertSame($code->value, $actual->getStatusCode()); + self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - /** @When asking for the full metadata map */ - $metadata = $stream->getMetadata(); + /** @And the reason phrase matches the code message */ + self::assertSame($code->message(), $actual->getReasonPhrase()); - /** @Then the metadata is exposed as an array */ - self::assertIsArray($metadata); + /** @And the default Content-Type is application/json; charset=utf-8 */ + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testGetBodyWhenMetadataRequestedForModeKeyThenExposesUnderlyingResourceMode(): void + public function testOkWhenArbitraryObjectGivenThenThrowsBodyTypeIsUnsupported(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @Given an arbitrary object that is not a Mapper, BackedEnum, or UnitEnum */ + $body = new Dragon(name: 'Drakengard Firestorm', weight: 6000.0); - /** @When asking for the stream mode key */ - $mode = $stream->getMetadata('mode'); + /** @Then an exception indicating the body type is unsupported is thrown */ + $this->expectException(BodyTypeIsUnsupported::class); + $this->expectExceptionMessage('Response body type is not supported'); - /** @Then the value reflects the in-memory resource mode */ - self::assertSame('w+b', $mode); + /** @When creating a response with the arbitrary object */ + Response::ok(body: $body); } - public function testGetBodyWhenMetadataRequestedAfterCloseThenReturnsEmptyArray(): void + public function testUnauthorizedWhenBodyGivenThenReturnsResponseWithStatus401(): void { - /** @Given a closed response stream */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - $stream->close(); + /** @Given a body with error details */ + $body = ['error' => 'Unauthorized', 'message' => 'Authentication is required.']; - /** @When asking for the full metadata map */ - $metadata = $stream->getMetadata(); + /** @When the response is created with the body */ + $actual = Response::unauthorized(body: $body); - /** @Then an empty array is returned */ - self::assertSame([], $metadata); + /** @Then the status is 401 */ + self::assertSame(Code::UNAUTHORIZED->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } - public function testGetBodyWhenMetadataKeyRequestedAfterCloseThenReturnsNull(): void + public function testGetBodyWhenStreamWrittenAdditionalDataThenReturnsByteCount(): void { - /** @Given a closed response stream */ + /** @Given a response stream positioned at end-of-file */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - $stream->close(); + $stream->seek(0, SEEK_END); - /** @When asking for a specific metadata key */ - $value = $stream->getMetadata('mode'); + /** @When appending one byte via the StreamInterface write() */ + $written = $stream->write('+'); - /** @Then null is returned */ - self::assertNull($value); + /** @Then the write returns the byte count */ + self::assertSame(1, $written); } - public function testGetBodyWhenDetachedThenReturnsUnderlyingResource(): void + public function testGetBodyWhenMetadataRequestedAfterCloseThenReturnsEmptyArray(): void { - /** @Given a response with a body */ + /** @Given a closed response stream */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + $stream->close(); - /** @When detaching the underlying resource */ - $resource = $stream->detach(); + /** @When asking for the full metadata map */ + $metadata = $stream->getMetadata(); - /** @Then the returned value is a resource */ - self::assertIsResource($resource); + /** @Then an empty array is returned */ + self::assertSame([], $metadata); } - public function testGetBodyWhenDetachedThenSizeIsNull(): void + public function testResponseFacadeForbidsInstantiationThroughAPrivateConstructor(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @Given the reflection of the public Response façade */ + $reflection = new ReflectionClass(Response::class); - /** @When detaching the underlying resource */ - $stream->detach(); + /** @And the constructor reflected from that class */ + $constructor = $reflection->getMethod('__construct'); - /** @Then the size collapses to null */ - self::assertNull($stream->getSize()); + /** @When invoking the empty private constructor on a bare instance */ + $constructor->invoke($reflection->newInstanceWithoutConstructor()); + + /** @Then the constructor is private to prevent direct instantiation */ + self::assertTrue($constructor->isPrivate()); } - public function testGetBodyWhenDetachedThenIsNoLongerReadable(): void + public function testWithStatusWhenCustomReasonPhraseGivenThenReasonPhraseIsHonored(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @Given an HTTP response */ + $response = Response::ok(body: null); - /** @When detaching the underlying resource */ - $stream->detach(); + /** @When calling withStatus with a custom reason phrase */ + $updated = $response->withStatus(Code::OK->value, 'All Good'); - /** @Then the stream is no longer readable */ - self::assertFalse($stream->isReadable()); + /** @Then the custom reason phrase is returned */ + self::assertSame('All Good', $updated->getReasonPhrase()); } - public function testGetBodyWhenClosedTwiceThenSecondCloseIsANoOp(): void + public function testServiceUnavailableWhenBodyGivenThenReturnsResponseWithStatus503(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - - /** @And the stream is already closed */ - $stream->close(); + /** @Given a body with service downtime details */ + $body = ['error' => 'Service Unavailable', 'message' => 'The service is temporarily unavailable.']; - /** @When closing the stream a second time */ - $stream->close(); + /** @When the response is created with the body */ + $actual = Response::serviceUnavailable(body: $body); - /** @Then the stream remains detached and reports null size */ - self::assertNull($stream->getSize()); + /** @Then the status is 503 */ + self::assertSame(Code::SERVICE_UNAVAILABLE->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } - public function testGetBodyWhenClosedThenTellRaisesMissingResourceError(): void + public function testWithStatusWhenEmptyReasonPhraseGivenThenEnumDerivedPhraseIsUsed(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - - /** @And the stream is closed */ - $stream->close(); + /** @Given an HTTP response */ + $response = Response::ok(body: null); - /** @Then telling the position raises a missing-resource error */ - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No resource available.'); + /** @When calling withStatus with an empty reason phrase */ + $updated = $response->withStatus(Code::OK->value); - /** @When asking for the position */ - $stream->tell(); + /** @Then the enum-derived phrase is returned */ + self::assertSame(Code::OK->message(), $updated->getReasonPhrase()); } - public function testGetBodyWhenClosedThenSeekRaisesNonSeekableError(): void + public function testGetBodyWhenSeekedToOffsetThenSubsequentReadsResumeFromThatOffset(): void { /** @Given a response with a body */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @And the stream is closed */ - $stream->close(); - - /** @Then seeking raises a non-seekable error */ - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream is not seekable.'); + /** @When seeking past the opening brace */ + $stream->seek(1); - /** @When seeking on the closed stream */ - $stream->seek(0); + /** @Then the next read starts at the seeked offset */ + self::assertSame('"', $stream->read(1)); } - public function testGetBodyWhenClosedThenReadRaisesNonReadableError(): void + public function testInternalServerErrorWhenBodyGivenThenReturnsResponseWithStatus500(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - - /** @And the stream is closed */ - $stream->close(); + /** @Given a body with error details */ + $body = ['code' => 10000, 'message' => 'An unexpected error occurred on the server.']; - /** @Then reading raises a non-readable error */ - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream is not readable.'); + /** @When the response is created with the body */ + $actual = Response::internalServerError(body: $body); - /** @When reading from the closed stream */ - $stream->read(1); + /** @Then the status is 500 */ + self::assertSame(Code::INTERNAL_SERVER_ERROR->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } - public function testGetBodyWhenReadLengthIsZeroThenRaisesNonReadableError(): void + public function testUnprocessableEntityWhenBodyGivenThenReturnsResponseWithStatus422(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + /** @Given a body with validation errors */ + $body = ['error' => 'Validation Failed', 'message' => 'The input data did not pass validation.']; - /** @Then reading raises a non-readable error */ - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream is not readable.'); + /** @When the response is created with the body */ + $actual = Response::unprocessableEntity(body: $body); - /** @When reading with a non-positive length */ - $stream->read(0); + /** @Then the status is 422 */ + self::assertSame(Code::UNPROCESSABLE_ENTITY->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } - public function testGetBodyWhenClosedThenWriteRaisesNonWritableError(): void + public function testWithStatusWhenCustomPhraseSetThenSubsequentWithHeaderPreservesIt(): void { - /** @Given a response with a body */ - $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - - /** @And the stream is closed */ - $stream->close(); + /** @Given an HTTP response with a custom reason phrase */ + $response = Response::ok(body: null)->withStatus(Code::OK->value, 'All Good'); - /** @Then writing raises a non-writable error */ - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream is not writable.'); + /** @When adding a header to that response */ + $updated = $response->withHeader('X-Trace-Id', 'abc'); - /** @When writing to the closed stream */ - $stream->write('payload'); + /** @Then the custom reason phrase is still returned */ + self::assertSame('All Good', $updated->getReasonPhrase()); } - public function testResponseFacadeForbidsInstantiationThroughAPrivateConstructor(): void + public function testGetBodyWhenStreamWrittenAdditionalDataThenContentsGrowAccordingly(): void { - /** @Given the reflection of the public Response façade */ - $reflection = new ReflectionClass(Response::class); - - /** @And the constructor reflected from that class */ - $constructor = $reflection->getMethod('__construct'); + /** @Given a response stream positioned at end-of-file */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + $stream->seek(0, SEEK_END); - /** @When invoking the empty private constructor on a bare instance */ - $constructor->invoke($reflection->newInstanceWithoutConstructor()); + /** @When appending one byte via the StreamInterface write() */ + $stream->write('+'); - /** @Then the constructor is private to prevent direct instantiation */ - self::assertTrue($constructor->isPrivate()); + /** @Then the stream size grows accordingly */ + self::assertSame(strlen('{"name":"Hydra"}+'), $stream->getSize()); } - public function testGetBodyWhenClosedThenGetContentsRaisesNonReadableError(): void + public function testGetBodyWhenContentsReadThenReturnsTheWrittenJsonWithoutRequiringRewind(): void { /** @Given a response with a body */ $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @And the stream is closed */ - $stream->close(); - - /** @Then reading the contents raises a non-readable error */ - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream is not readable.'); + /** @When reading the stream contents directly */ + $contents = $stream->getContents(); - /** @When asking for the full contents */ - $stream->getContents(); + /** @Then the contents match the encoded body without needing a manual rewind */ + self::assertSame('{"name":"Hydra"}', $contents); } - public function testOkWhenArbitraryObjectGivenThenThrowsBodyTypeIsUnsupported(): void + public function testGetBodyWhenMetadataRequestedForModeKeyThenExposesUnderlyingResourceMode(): void { - /** @Given an arbitrary object that is not a Mapper, BackedEnum, or UnitEnum */ - $body = new Dragon(name: 'Drakengard Firestorm', weight: 6000.0); - - /** @Then an exception indicating the body type is unsupported is thrown */ - $this->expectException(BodyTypeIsUnsupported::class); - $this->expectExceptionMessage('Response body type is not supported'); + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); - /** @When creating a response with the arbitrary object */ - Response::ok(body: $body); - } + /** @When asking for the stream mode key */ + $mode = $stream->getMetadata('mode'); - public static function responseFromProvider(): array - { - return [ - 'I am a teapot' => [ - 'code' => Code::IM_A_TEAPOT, - 'body' => 'Short and stout', - 'expectedBody' => 'Short and stout' - ], - 'OK with array body' => [ - 'code' => Code::OK, - 'body' => ['status' => 'success'], - 'expectedBody' => '{"status":"success"}' - ], - 'Accepted with null body' => [ - 'code' => Code::ACCEPTED, - 'body' => null, - 'expectedBody' => '' - ], - 'Not Found with string body' => [ - 'code' => Code::NOT_FOUND, - 'body' => 'Resource not found', - 'expectedBody' => 'Resource not found' - ], - 'Internal Server Error with complex body' => [ - 'code' => Code::INTERNAL_SERVER_ERROR, - 'body' => ['error' => ['code' => 500, 'message' => 'Crash']], - 'expectedBody' => '{"error":{"code":500,"message":"Crash"}}' - ] - ]; + /** @Then the value reflects the in-memory resource mode */ + self::assertSame('w+b', $mode); } public static function bodyProviderData(): array @@ -823,4 +792,35 @@ public static function bodyProviderData(): array ] ]; } + + public static function responseFromProvider(): array + { + return [ + 'I am a teapot' => [ + 'code' => Code::IM_A_TEAPOT, + 'body' => 'Short and stout', + 'expectedBody' => 'Short and stout' + ], + 'OK with array body' => [ + 'code' => Code::OK, + 'body' => ['status' => 'success'], + 'expectedBody' => '{"status":"success"}' + ], + 'Accepted with null body' => [ + 'code' => Code::ACCEPTED, + 'body' => null, + 'expectedBody' => '' + ], + 'Not Found with string body' => [ + 'code' => Code::NOT_FOUND, + 'body' => 'Resource not found', + 'expectedBody' => 'Resource not found' + ], + 'Internal Server Error with complex body' => [ + 'code' => Code::INTERNAL_SERVER_ERROR, + 'body' => ['error' => ['code' => 500, 'message' => 'Crash']], + 'expectedBody' => '{"error":{"code":500,"message":"Crash"}}' + ] + ]; + } } diff --git a/tests/Unit/Server/ResponseWithCookiesTest.php b/tests/Unit/Server/ResponseWithCookiesTest.php index b422010..64a4ace 100644 --- a/tests/Unit/Server/ResponseWithCookiesTest.php +++ b/tests/Unit/Server/ResponseWithCookiesTest.php @@ -15,22 +15,42 @@ final class ResponseWithCookiesTest extends TestCase { - public function testOkWhenSingleCookieGivenThenSetCookieHeaderReflectsConfiguration(): void + public function testOkWhenCookiesAndOtherHeadersGivenThenAllPreserved(): void { - /** @Given a fully configured cookie */ - $cookie = Cookie::create(name: 'session', value: 'abc') + /** @Given a cookie */ + $cookie = Cookie::create(name: 'session', value: 'abc')->secure()->httpOnly(); + + /** @And a content type */ + $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + + /** @And a cache control directive */ + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()); + + /** @When the response is built with all of them */ + $response = Response::ok(['ok' => true], $contentType, $cacheControl, $cookie); + + /** @Then every header is preserved */ + self::assertSame(['application/json; charset=utf-8'], $response->getHeader('Content-Type')); + self::assertSame(['no-cache'], $response->getHeader('Cache-Control')); + self::assertSame(['session=abc; Secure; HttpOnly'], $response->getHeader('Set-Cookie')); + } + + public function testNoContentWhenExpireCookieGivenThenInstructsBrowserToDiscard(): void + { + /** @Given an expiration cookie with the same path used on set */ + $cookie = Cookie::expire(name: 'refresh_token') ->secure() ->httpOnly() - ->withPath(path: '/') - ->withMaxAge(seconds: 604800) + ->withPath(path: '/v1/sessions') ->withSameSite(sameSite: SameSite::STRICT); - /** @When the response is built with the cookie */ - $response = Response::ok(['ok' => true], $cookie); + /** @When a no-content response is built with the cookie */ + $response = Response::noContent($cookie); - /** @Then the Set-Cookie header reflects the cookie configuration */ + /** @Then the Set-Cookie header instructs the browser to discard the cookie */ self::assertSame( - ['session=abc; Max-Age=604800; Path=/; Secure; HttpOnly; SameSite=Strict'], + ['refresh_token=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; ' + . 'Path=/v1/sessions; Secure; HttpOnly; SameSite=Strict'], $response->getHeader('Set-Cookie') ); } @@ -39,17 +59,17 @@ public function testOkWhenMultipleCookiesGivenThenEachIsPreservedAsSeparateHeade { /** @Given an access cookie */ $accessCookie = Cookie::create(name: 'access_token', value: 'aaa') - ->httpOnly() ->secure() + ->httpOnly() ->withPath(path: '/'); /** @And a refresh cookie */ $refreshCookie = Cookie::create(name: 'refresh_token', value: 'bbb') - ->httpOnly() ->secure() - ->withSameSite(sameSite: SameSite::STRICT) + ->httpOnly() ->withPath(path: '/v1/sessions') - ->withMaxAge(seconds: 604800); + ->withMaxAge(seconds: 604800) + ->withSameSite(sameSite: SameSite::STRICT); /** @When the response is built with both cookies */ $response = Response::ok(['ok' => true], $accessCookie, $refreshCookie); @@ -64,42 +84,22 @@ public function testOkWhenMultipleCookiesGivenThenEachIsPreservedAsSeparateHeade ); } - public function testOkWhenCookiesAndOtherHeadersGivenThenAllPreserved(): void - { - /** @Given a cookie */ - $cookie = Cookie::create(name: 'session', value: 'abc')->httpOnly()->secure(); - - /** @And a content type */ - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - - /** @And a cache control directive */ - $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()); - - /** @When the response is built with all of them */ - $response = Response::ok(['ok' => true], $contentType, $cacheControl, $cookie); - - /** @Then every header is preserved */ - self::assertSame(['application/json; charset=utf-8'], $response->getHeader('Content-Type')); - self::assertSame(['no-cache'], $response->getHeader('Cache-Control')); - self::assertSame(['session=abc; Secure; HttpOnly'], $response->getHeader('Set-Cookie')); - } - - public function testNoContentWhenExpireCookieGivenThenInstructsBrowserToDiscard(): void + public function testOkWhenSingleCookieGivenThenSetCookieHeaderReflectsConfiguration(): void { - /** @Given an expiration cookie with the same path used on set */ - $cookie = Cookie::expire(name: 'refresh_token') - ->httpOnly() + /** @Given a fully configured cookie */ + $cookie = Cookie::create(name: 'session', value: 'abc') ->secure() - ->withSameSite(sameSite: SameSite::STRICT) - ->withPath(path: '/v1/sessions'); + ->httpOnly() + ->withPath(path: '/') + ->withMaxAge(seconds: 604800) + ->withSameSite(sameSite: SameSite::STRICT); - /** @When a no-content response is built with the cookie */ - $response = Response::noContent($cookie); + /** @When the response is built with the cookie */ + $response = Response::ok(['ok' => true], $cookie); - /** @Then the Set-Cookie header instructs the browser to discard the cookie */ + /** @Then the Set-Cookie header reflects the cookie configuration */ self::assertSame( - ['refresh_token=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; ' - . 'Path=/v1/sessions; Secure; HttpOnly; SameSite=Strict'], + ['session=abc; Max-Age=604800; Path=/; Secure; HttpOnly; SameSite=Strict'], $response->getHeader('Set-Cookie') ); } diff --git a/tests/Unit/UserAgentTest.php b/tests/Unit/UserAgentTest.php index 00f62d8..1ebccce 100644 --- a/tests/Unit/UserAgentTest.php +++ b/tests/Unit/UserAgentTest.php @@ -24,41 +24,38 @@ public function testFromWhenProductOnlyGivenThenRendersProductToken(): void self::assertSame(['User-Agent' => 'MyApp'], $header); } - public function testFromWhenEmptyVersionGivenThenEquivalentToProductOnly(): void + public function testToArrayWhenInvokedRepeatedlyThenReturnsSameValue(): void { - /** @Given a product token with an explicitly empty version */ - $userAgent = UserAgent::from(product: 'MyApp', version: ''); + /** @Given a UserAgent value object */ + $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); - /** @When reading the header array */ - $header = $userAgent->toArray(); + /** @When calling toArray multiple times */ + $first = $userAgent->toArray(); + $second = $userAgent->toArray(); - /** @Then the header carries only the product token */ - self::assertSame(['User-Agent' => 'MyApp'], $header); + /** @Then both calls return identical arrays */ + self::assertSame($first, $second); } - public function testFromWhenProductAndVersionGivenThenRendersProductSlashVersion(): void + public function testFromWhenEmptyVersionGivenThenEquivalentToProductOnly(): void { - /** @Given a product token and a version */ - $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); + /** @Given a product token with an explicitly empty version */ + $userAgent = UserAgent::from(product: 'MyApp', version: ''); /** @When reading the header array */ $header = $userAgent->toArray(); - /** @Then the header contains the product and version combined */ - self::assertSame(['User-Agent' => 'MyApp/1.2.3'], $header); + /** @Then the header carries only the product token */ + self::assertSame(['User-Agent' => 'MyApp'], $header); } - public function testToArrayWhenInvokedRepeatedlyThenReturnsSameValue(): void + public function testFromWhenValidProductOnlyGivenThenNoExceptionIsThrown(): void { - /** @Given a UserAgent value object */ - $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); - - /** @When calling toArray multiple times */ - $first = $userAgent->toArray(); - $second = $userAgent->toArray(); + /** @When constructing with a valid product token */ + $userAgent = UserAgent::from(product: 'ValidApp'); - /** @Then both calls return identical arrays */ - self::assertSame($first, $second); + /** @Then the header is rendered without error */ + self::assertSame(['User-Agent' => 'ValidApp'], $userAgent->toArray()); } public function testFromWhenEmptyProductGivenThenThrowsUserAgentProductIsEmpty(): void @@ -71,14 +68,34 @@ public function testFromWhenEmptyProductGivenThenThrowsUserAgentProductIsEmpty() UserAgent::from(product: ''); } - public function testFromWhenProductWithControlCharGivenThenThrowsUserAgentValueIsInvalid(): void + public function testFromWhenValidProductAndVersionGivenThenNoExceptionIsThrown(): void + { + /** @When constructing with a valid product and version */ + $userAgent = UserAgent::from(product: 'ValidApp', version: '2.0'); + + /** @Then the header is rendered without error */ + self::assertSame(['User-Agent' => 'ValidApp/2.0'], $userAgent->toArray()); + } + + public function testFromWhenProductWithLfGivenThenThrowsUserAgentValueIsInvalid(): void { /** @Then an exception indicating the product token is invalid is thrown */ $this->expectException(UserAgentValueIsInvalid::class); - $this->expectExceptionMessage('is invalid'); - /** @When constructing with a product containing a control character */ - UserAgent::from(product: "MyApp\x00"); + /** @When constructing with a product containing a line feed */ + UserAgent::from(product: "My\nApp"); + } + + public function testFromWhenProductAndVersionGivenThenRendersProductSlashVersion(): void + { + /** @Given a product token and a version */ + $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); + + /** @When reading the header array */ + $header = $userAgent->toArray(); + + /** @Then the header contains the product and version combined */ + self::assertSame(['User-Agent' => 'MyApp/1.2.3'], $header); } public function testFromWhenProductWithSlashGivenThenThrowsUserAgentValueIsInvalid(): void @@ -90,13 +107,14 @@ public function testFromWhenProductWithSlashGivenThenThrowsUserAgentValueIsInval UserAgent::from(product: 'My/App'); } - public function testFromWhenProductWithLfGivenThenThrowsUserAgentValueIsInvalid(): void + public function testFromWhenProductWithControlCharGivenThenThrowsUserAgentValueIsInvalid(): void { /** @Then an exception indicating the product token is invalid is thrown */ $this->expectException(UserAgentValueIsInvalid::class); + $this->expectExceptionMessage('is invalid'); - /** @When constructing with a product containing a line feed */ - UserAgent::from(product: "My\nApp"); + /** @When constructing with a product containing a control character */ + UserAgent::from(product: "MyApp\x00"); } public function testFromWhenVersionWithControlCharGivenThenThrowsUserAgentValueIsInvalid(): void @@ -109,22 +127,4 @@ public function testFromWhenVersionWithControlCharGivenThenThrowsUserAgentValueI /** @When constructing with a version containing a control character */ UserAgent::from(product: 'MyApp', version: "1\x002"); } - - public function testFromWhenValidProductOnlyGivenThenNoExceptionIsThrown(): void - { - /** @When constructing with a valid product token */ - $userAgent = UserAgent::from(product: 'ValidApp'); - - /** @Then the header is rendered without error */ - self::assertSame(['User-Agent' => 'ValidApp'], $userAgent->toArray()); - } - - public function testFromWhenValidProductAndVersionGivenThenNoExceptionIsThrown(): void - { - /** @When constructing with a valid product and version */ - $userAgent = UserAgent::from(product: 'ValidApp', version: '2.0'); - - /** @Then the header is rendered without error */ - self::assertSame(['User-Agent' => 'ValidApp/2.0'], $userAgent->toArray()); - } }