diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..22aac70 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +/tests export-ignore +/vendor export-ignore + +/LICENSE export-ignore +/Makefile export-ignore +/README.md export-ignore +/phpmd.xml export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/infection.json.dist export-ignore + +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore diff --git a/Makefile b/Makefile index 9bf35fe..1b3026e 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,17 @@ DOCKER_RUN = docker run --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.2 -.PHONY: configure test test-no-coverage review show-reports clean +.PHONY: configure test test-file test-no-coverage review show-reports clean configure: @${DOCKER_RUN} composer update --optimize-autoloader -test: review +test: @${DOCKER_RUN} composer tests -test-no-coverage: review +test-file: + @${DOCKER_RUN} composer tests-file-no-coverage ${FILE} + +test-no-coverage: @${DOCKER_RUN} composer tests-no-coverage review: @@ -19,4 +22,4 @@ show-reports: clean: @sudo chown -R ${USER}:${USER} ${PWD} - @rm -rf report vendor + @rm -rf report vendor .phpunit.cache diff --git a/composer.json b/composer.json index a8a1df1..95ec214 100644 --- a/composer.json +++ b/composer.json @@ -8,9 +8,6 @@ "minimum-stability": "stable", "keywords": [ "psr", - "psr-4", - "psr-7", - "psr-12", "http", "http-code", "tiny-blocks", @@ -24,6 +21,10 @@ "homepage": "https://github.com/gustavofreze" } ], + "support": { + "issues": "https://github.com/tiny-blocks/http/issues", + "source": "https://github.com/tiny-blocks/http" + }, "config": { "sort-packages": true, "allow-plugins": { @@ -42,15 +43,16 @@ }, "require": { "php": "^8.2", - "tiny-blocks/serializer": "^3.0", + "tiny-blocks/serializer": "^3", "psr/http-message": "^1.1", "ext-mbstring": "*" }, "require-dev": { - "infection/infection": "^0.27", "phpmd/phpmd": "^2.15", - "phpunit/phpunit": "^10.5", - "squizlabs/php_codesniffer": "^3.8" + "phpunit/phpunit": "^11", + "phpstan/phpstan": "^1", + "infection/infection": "^0.29", + "squizlabs/php_codesniffer": "^3.10" }, "suggest": { "ext-mbstring": "Provides multibyte-specific string functions that help us deal with multibyte encodings in PHP." @@ -58,13 +60,15 @@ "scripts": { "phpcs": "phpcs --standard=PSR12 --extensions=php ./src", "phpmd": "phpmd ./src text phpmd.xml --suffixes php --exclude /src/HttpCode.php --exclude /src/Internal/Response --ignore-violations-on-exit", + "phpstan": "phpstan analyse -c phpstan.neon.dist --quiet --no-progress", "test": "phpunit --log-junit=report/coverage/junit.xml --coverage-xml=report/coverage/coverage-xml --coverage-html=report/coverage/coverage-html tests", "test-mutation": "infection --only-covered --logger-html=report/coverage/mutation-report.html --coverage=report/coverage --min-msi=100 --min-covered-msi=100 --threads=4", "test-no-coverage": "phpunit --no-coverage", "test-mutation-no-coverage": "infection --only-covered --min-msi=100 --threads=4", "review": [ "@phpcs", - "@phpmd" + "@phpmd", + "@phpstan" ], "tests": [ "@test", @@ -73,6 +77,9 @@ "tests-no-coverage": [ "@test-no-coverage", "@test-mutation-no-coverage" + ], + "tests-file-no-coverage": [ + "@test-no-coverage" ] } } diff --git a/infection.json.dist b/infection.json.dist index dcff8e5..739162f 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -1,22 +1,25 @@ { - "$schema": "vendor/infection/infection/resources/schema.json", - "tmpDir": "report/", - "logs": { - "text": "report/logs/infection-text.log", - "summary": "report/logs/infection-summary.log" - }, + "timeout": 10, + "testFramework": "phpunit", + "tmpDir": "report/infection/", "source": { "directories": [ "src" ] }, - "timeout": 10, + "logs": { + "text": "report/infection/logs/infection-text.log", + "summary": "report/infection/logs/infection-summary.log" + }, "mutators": { "@default": true, - "LogicalOr": false, - "InstanceOf_": false, - "UnwrapArrayMap": false, + "CastInt": false, + "CastString": false, + "MatchArmRemoval": false, "MethodCallRemoval": false }, - "testFramework": "phpunit" + "phpUnit": { + "configDir": "", + "customPath": "./vendor/bin/phpunit" + } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..ffd0c26 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,11 @@ +parameters: + paths: + - src + level: 9 + tmpDir: report/phpstan + ignoreErrors: + - '#function fread expects#' + - '#expects object, mixed given#' + - '#expects resource, resource#' + - '#value type specified in iterable#' + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml b/phpunit.xml index a325068..7f080dd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,35 +1,35 @@ + beStrictAboutOutputDuringTests="true"> - - - - - - - + + + src + + - tests + tests + + + + + + + + - - - src - - - diff --git a/src/HttpCode.php b/src/HttpCode.php index 3a51b1a..a62397f 100644 --- a/src/HttpCode.php +++ b/src/HttpCode.php @@ -1,5 +1,7 @@ detach(); fclose($resource); } - public function detach(): mixed + public function detach() { $resource = $this->resource; $this->resource = null; @@ -49,7 +61,9 @@ public function getSize(): ?int return null; } - return intval(fstat($this->resource)['size']); + $size = fstat($this->resource); + + return is_array($size) ? (int)$size['size'] : null; } public function tell(): int @@ -58,7 +72,7 @@ public function tell(): int throw new MissingResourceStream(); } - return ftell($this->resource); + return (int)ftell($this->resource); } public function eof(): bool @@ -86,7 +100,7 @@ public function read(int $length): string throw new NonReadableStream(); } - return fread($this->resource, $length); + return (string)fread($this->resource, $length); } public function write(string $string): int @@ -95,7 +109,7 @@ public function write(string $string): int throw new NonWritableStream(); } - return fwrite($this->resource, $string); + return (int)fwrite($this->resource, $string); } public function isReadable(): bool @@ -117,11 +131,11 @@ public function isWritable(): bool $mode = $this->metaData->getMode(); - return strstr($mode, 'x') - || strstr($mode, 'w') - || strstr($mode, 'c') - || strstr($mode, 'a') - || strstr($mode, '+'); + return str_contains($mode, 'x') + || str_contains($mode, 'w') + || str_contains($mode, 'c') + || str_contains($mode, 'a') + || str_contains($mode, '+'); } public function isSeekable(): bool @@ -136,7 +150,7 @@ public function getContents(): string } if (!$this->contentFetched) { - $this->content = stream_get_contents($this->resource); + $this->content = (string)stream_get_contents($this->resource); $this->contentFetched = true; } diff --git a/src/Internal/Stream/StreamFactory.php b/src/Internal/Stream/StreamFactory.php index e9cfde9..52695cc 100644 --- a/src/Internal/Stream/StreamFactory.php +++ b/src/Internal/Stream/StreamFactory.php @@ -1,5 +1,7 @@ write(string: json_encode($data->toArray())); - $stream->rewind(); - - return $stream; - } - - if (is_object($data)) { - $stream->write(string: json_encode(get_object_vars($data))); - $stream->rewind(); - - return $stream; - } - - if (is_scalar($data) || is_array($data)) { - $stream->write(string: json_encode($data)); - $stream->rewind(); - - return $stream; - } + $dataToWrite = match (true) { + is_a($data, Serializer::class) => $data->toJson(), + is_object($data) => (string)json_encode(get_object_vars($data)), + is_scalar($data) || is_array($data) => (string)json_encode($data, JSON_PRESERVE_ZERO_FRACTION), + default => '' + }; - $stream->write(string: ''); + $stream->write(string: $dataToWrite); $stream->rewind(); return $stream; diff --git a/src/Internal/Stream/StreamMetaData.php b/src/Internal/Stream/StreamMetaData.php index 40b5e97..7a85248 100644 --- a/src/Internal/Stream/StreamMetaData.php +++ b/src/Internal/Stream/StreamMetaData.php @@ -1,5 +1,7 @@ message(); + /** @Then the message should match the expected string */ self::assertEquals($expected, $actual); } - /** - * @dataProvider providerForTestIsHttpCode - */ + #[DataProvider('httpCodesDataProvider')] public function testIsHttpCode(int $httpCode, bool $expected): void { + /** @Given an integer representing an HTTP code */ + /** @When checking if it is a valid HTTP code */ $actual = HttpCode::isHttpCode(httpCode: $httpCode); + /** @Then the result should match the expected boolean */ self::assertEquals($expected, $actual); } - public static function providerForTestMessage(): array + public static function messagesDataProvider(): array { return [ - [ - 'httpCode' => HttpCode::CONTINUE, - 'expected' => '100 Continue' - ], - [ + 'OK message' => [ 'httpCode' => HttpCode::OK, 'expected' => '200 OK' ], - [ + 'Created message' => [ 'httpCode' => HttpCode::CREATED, 'expected' => '201 Created' ], - [ - 'httpCode' => HttpCode::NON_AUTHORITATIVE_INFORMATION, - 'expected' => '203 Non Authoritative Information' + 'Continue message' => [ + 'httpCode' => HttpCode::CONTINUE, + 'expected' => '100 Continue' ], - [ + 'Permanent Redirect message' => [ 'httpCode' => HttpCode::PERMANENT_REDIRECT, 'expected' => '308 Permanent Redirect' ], - [ - 'httpCode' => HttpCode::PERMANENT_REDIRECT, - 'expected' => '308 Permanent Redirect' + 'Internal Server Error message' => [ + 'httpCode' => HttpCode::INTERNAL_SERVER_ERROR, + 'expected' => '500 Internal Server Error' + ], + 'Non Authoritative Information message' => [ + 'httpCode' => HttpCode::NON_AUTHORITATIVE_INFORMATION, + 'expected' => '203 Non Authoritative Information' ], - [ + 'Proxy Authentication Required message' => [ 'httpCode' => HttpCode::PROXY_AUTHENTICATION_REQUIRED, 'expected' => '407 Proxy Authentication Required' ], - [ - 'httpCode' => HttpCode::INTERNAL_SERVER_ERROR, - 'expected' => '500 Internal Server Error' - ] ]; } - public static function providerForTestIsHttpCode(): array + public static function httpCodesDataProvider(): array { return [ - [ - 'httpCode' => HttpCode::CONTINUE->value, - 'expected' => true + 'Invalid code 0' => [ + 'httpCode' => 0, + 'expected' => false ], - [ - 'httpCode' => HttpCode::OK->value, - 'expected' => true + 'Invalid code -1' => [ + 'httpCode' => -1, + 'expected' => false ], - [ + 'Invalid code 1054' => [ 'httpCode' => 1054, 'expected' => false ], - [ - 'httpCode' => 0, - 'expected' => false + 'Valid code 200 OK' => [ + 'httpCode' => HttpCode::OK->value, + 'expected' => true ], - [ - 'httpCode' => -1, - 'expected' => false + 'Valid code 100 Continue' => [ + 'httpCode' => HttpCode::CONTINUE->value, + 'expected' => true ], - [ + 'Valid code 500 Internal Server Error' => [ 'httpCode' => HttpCode::INTERNAL_SERVER_ERROR->value, 'expected' => true ] diff --git a/tests/HttpResponseTest.php b/tests/HttpResponseTest.php index c5fbdc4..2c79057 100644 --- a/tests/HttpResponseTest.php +++ b/tests/HttpResponseTest.php @@ -1,20 +1,23 @@ getBody()->__toString()); self::assertEquals($expected, $response->getBody()->getContents()); self::assertEquals(HttpCode::OK->value, $response->getStatusCode()); @@ -22,13 +25,13 @@ public function testResponseOk(mixed $data, mixed $expected): void self::assertEquals($this->defaultHeaderFrom(code: HttpCode::OK), $response->getHeaders()); } - /** - * @dataProvider providerData - */ + #[DataProvider('providerData')] public function testResponseCreated(mixed $data, mixed $expected): void { + /** @Given a valid HTTP response with status Created */ $response = HttpResponse::created(data: $data); + /** @Then verify that the response body and headers are correct */ self::assertEquals($expected, $response->getBody()->__toString()); self::assertEquals($expected, $response->getBody()->getContents()); self::assertEquals(HttpCode::CREATED->value, $response->getStatusCode()); @@ -36,13 +39,13 @@ public function testResponseCreated(mixed $data, mixed $expected): void self::assertEquals($this->defaultHeaderFrom(code: HttpCode::CREATED), $response->getHeaders()); } - /** - * @dataProvider providerData - */ + #[DataProvider('providerData')] public function testResponseAccepted(mixed $data, mixed $expected): void { + /** @Given a valid HTTP response with status Accepted */ $response = HttpResponse::accepted(data: $data); + /** @Then verify that the response body and headers are correct */ self::assertEquals($expected, $response->getBody()->__toString()); self::assertEquals($expected, $response->getBody()->getContents()); self::assertEquals(HttpCode::ACCEPTED->value, $response->getStatusCode()); @@ -52,8 +55,10 @@ public function testResponseAccepted(mixed $data, mixed $expected): void public function testResponseNoContent(): void { + /** @Given a valid HTTP response with status No Content */ $response = HttpResponse::noContent(); + /** @Then verify that the response body is empty and headers are correct */ self::assertEquals('', $response->getBody()->__toString()); self::assertEquals('', $response->getBody()->getContents()); self::assertEquals(HttpCode::NO_CONTENT->value, $response->getStatusCode()); @@ -61,13 +66,13 @@ public function testResponseNoContent(): void self::assertEquals($this->defaultHeaderFrom(code: HttpCode::NO_CONTENT), $response->getHeaders()); } - /** - * @dataProvider providerData - */ + #[DataProvider('providerData')] public function testResponseBadRequest(mixed $data, mixed $expected): void { + /** @Given a valid HTTP response with status Bad Request */ $response = HttpResponse::badRequest(data: $data); + /** @Then verify that the response body and headers are correct */ self::assertEquals($expected, $response->getBody()->__toString()); self::assertEquals($expected, $response->getBody()->getContents()); self::assertEquals(HttpCode::BAD_REQUEST->value, $response->getStatusCode()); @@ -75,13 +80,13 @@ public function testResponseBadRequest(mixed $data, mixed $expected): void self::assertEquals($this->defaultHeaderFrom(code: HttpCode::BAD_REQUEST), $response->getHeaders()); } - /** - * @dataProvider providerData - */ + #[DataProvider('providerData')] public function testResponseNotFound(mixed $data, mixed $expected): void { + /** @Given a valid HTTP response with status Not Found */ $response = HttpResponse::notFound(data: $data); + /** @Then verify that the response body and headers are correct */ self::assertEquals($expected, $response->getBody()->__toString()); self::assertEquals($expected, $response->getBody()->getContents()); self::assertEquals(HttpCode::NOT_FOUND->value, $response->getStatusCode()); @@ -89,13 +94,13 @@ public function testResponseNotFound(mixed $data, mixed $expected): void self::assertEquals($this->defaultHeaderFrom(code: HttpCode::NOT_FOUND), $response->getHeaders()); } - /** - * @dataProvider providerData - */ + #[DataProvider('providerData')] public function testResponseConflict(mixed $data, mixed $expected): void { + /** @Given a valid HTTP response with status Conflict */ $response = HttpResponse::conflict(data: $data); + /** @Then verify that the response body and headers are correct */ self::assertEquals($expected, $response->getBody()->__toString()); self::assertEquals($expected, $response->getBody()->getContents()); self::assertEquals(HttpCode::CONFLICT->value, $response->getStatusCode()); @@ -103,13 +108,13 @@ public function testResponseConflict(mixed $data, mixed $expected): void self::assertEquals($this->defaultHeaderFrom(code: HttpCode::CONFLICT), $response->getHeaders()); } - /** - * @dataProvider providerData - */ + #[DataProvider('providerData')] public function testResponseUnprocessableEntity(mixed $data, mixed $expected): void { + /** @Given a valid HTTP response with status Unprocessable Entity */ $response = HttpResponse::unprocessableEntity(data: $data); + /** @Then verify that the response body and headers are correct */ self::assertEquals($expected, $response->getBody()->__toString()); self::assertEquals($expected, $response->getBody()->getContents()); self::assertEquals(HttpCode::UNPROCESSABLE_ENTITY->value, $response->getStatusCode()); @@ -117,13 +122,13 @@ public function testResponseUnprocessableEntity(mixed $data, mixed $expected): v self::assertEquals($this->defaultHeaderFrom(code: HttpCode::UNPROCESSABLE_ENTITY), $response->getHeaders()); } - /** - * @dataProvider providerData - */ + #[DataProvider('providerData')] public function testResponseInternalServerError(mixed $data, mixed $expected): void { + /** @Given a valid HTTP response with status Internal Server Error */ $response = HttpResponse::internalServerError(data: $data); + /** @Then verify that the response body and headers are correct */ self::assertEquals($expected, $response->getBody()->__toString()); self::assertEquals($expected, $response->getBody()->getContents()); self::assertEquals(HttpCode::INTERNAL_SERVER_ERROR->value, $response->getStatusCode()); @@ -134,29 +139,29 @@ public function testResponseInternalServerError(mixed $data, mixed $expected): v public static function providerData(): array { return [ - [ - 'data' => new Xyz(value: 10), - 'expected' => '{"value":10}' - ], - [ - 'data' => new Xpto(value: 9.99), - 'expected' => (new Xpto(value: 9.99))->toJson() - ], - [ + 'Null value' => [ 'data' => null, 'expected' => null ], - [ + 'Empty string' => [ 'data' => '', 'expected' => '""' ], - [ + 'Boolean true value' => [ 'data' => true, 'expected' => 'true' ], - [ + 'Large integer value' => [ 'data' => 10000000000, 'expected' => '10000000000' + ], + 'Xyz object serialization' => [ + 'data' => new Xyz(value: 10), + 'expected' => '{"value":10}' + ], + 'Xpto object serialization with toJson' => [ + 'data' => new Xpto(value: 9.99), + 'expected' => (new Xpto(value: 9.99))->toJson() ] ]; } diff --git a/tests/Internal/HttpHeadersTest.php b/tests/Internal/HttpHeadersTest.php index 5988c72..a467aa8 100644 --- a/tests/Internal/HttpHeadersTest.php +++ b/tests/Internal/HttpHeadersTest.php @@ -1,5 +1,7 @@ addFrom(key: 'X-Custom-Header', value: 'value1') ->addFrom(key: 'X-Custom-Header', value: 'value2') ->removeFrom(key: 'X-Custom-Header'); + /** @Then all headers should be removed */ self::assertTrue($actual->hasNoHeaders()); self::assertFalse($actual->hasHeader(key: 'X-Custom-Header')); } public function testAddFromCode(): void { + /** @Given HttpHeaders */ $actual = HttpHeaders::build()->addFromCode(code: HttpCode::OK); - $expected = ['Status' => [HttpCode::OK->message()]]; - self::assertEquals($expected, $actual->toArray()); + /** @Then the Status header should be added with the correct value */ + self::assertEquals(['Status' => [HttpCode::OK->message()]], $actual->toArray()); } public function testAddFromContentType(): void { + /** @Given HttpHeaders */ $headers = HttpHeaders::build()->addFromContentType(header: HttpContentType::APPLICATION_JSON); + + /** @When adding a Content-Type header */ $actual = $headers->toArray(); - $expected = ['Content-Type' => [HttpContentType::APPLICATION_JSON->value]]; - self::assertEquals($expected, $actual); + /** @Then the Content-Type header should match the expected value */ + self::assertEquals(['Content-Type' => [HttpContentType::APPLICATION_JSON->value]], $actual); } public function testGetHeader(): void { + /** @Given HttpHeaders with duplicate headers */ $headers = HttpHeaders::build() ->addFrom(key: 'X-Custom-Header', value: 'value1') ->addFrom(key: 'X-Custom-Header', value: 'value2'); + + /** @When retrieving the header */ $actual = $headers->getHeader(key: 'X-Custom-Header'); - $expected = ['value1', 'value2']; - self::assertEquals($expected, $actual); + /** @Then the header values should match the expected array */ + self::assertEquals(['value1', 'value2'], $actual); } public function testToArrayWithNonUniqueValues(): void { + /** @Given HttpHeaders with duplicate values for a single header */ $headers = HttpHeaders::build() ->addFrom(key: 'X-Custom-Header', value: 'value1') ->addFrom(key: 'X-Custom-Header', value: 'value1'); + + /** @When converting the headers to an array */ $actual = $headers->toArray(); - $expected = ['X-Custom-Header' => ['value1']]; - self::assertEquals($expected, $actual); + /** @Then duplicate values should be collapsed into a single entry */ + self::assertEquals(['X-Custom-Header' => ['value1']], $actual); } } diff --git a/tests/Internal/ResponseTest.php b/tests/Internal/ResponseTest.php index a7422b8..1d0a6d2 100644 --- a/tests/Internal/ResponseTest.php +++ b/tests/Internal/ResponseTest.php @@ -1,5 +1,7 @@ [HttpCode::OK->message()], 'Content-Type' => [HttpContentType::APPLICATION_JSON->value] - ]; - - self::assertEquals($expected, $response->getHeaders()); + ], $response->getHeaders()); } public function testGetProtocolVersion(): void { + /** @Given a Response */ $response = Response::from(code: HttpCode::OK, data: [], headers: null); + /** @Then the protocol version should be 1.1 */ self::assertEquals('1.1', $response->getProtocolVersion()); } public function testGetHeaders(): void { + /** @Given a Response with specific headers */ $headers = HttpHeaders::build()->addFromContentType(header: HttpContentType::APPLICATION_JSON); $response = Response::from(code: HttpCode::OK, data: [], headers: $headers); - $expected = [HttpContentType::APPLICATION_JSON->value]; + /** @Then the Response should return the correct headers */ self::assertEquals($headers->toArray(), $response->getHeaders()); - self::assertEquals($expected, $response->getHeader(name: 'Content-Type')); + self::assertEquals([HttpContentType::APPLICATION_JSON->value], $response->getHeader(name: 'Content-Type')); } public function testHasHeader(): void { + /** @Given a Response with a specific Content-Type header */ $headers = HttpHeaders::build()->addFromContentType(header: HttpContentType::TEXT_PLAIN); $response = Response::from(code: HttpCode::OK, data: [], headers: $headers); - $expected = [HttpContentType::TEXT_PLAIN->value]; + /** @Then the Response should correctly indicate that it has the Content-Type header */ self::assertTrue($response->hasHeader(name: 'Content-Type')); - self::assertEquals($expected, $response->getHeader(name: 'Content-Type')); + self::assertEquals([HttpContentType::TEXT_PLAIN->value], $response->getHeader(name: 'Content-Type')); } public function testGetHeaderLine(): void { + /** @Given a Response with a specific Content-Type header */ $headers = HttpHeaders::build()->addFromContentType(header: HttpContentType::APPLICATION_JSON); $response = Response::from(code: HttpCode::OK, data: [], headers: $headers); + /** @Then the header line should match the expected value */ self::assertEquals(HttpContentType::APPLICATION_JSON->value, $response->getHeaderLine(name: 'Content-Type')); } public function testWithHeader(): void { + /** @Given a Response */ $value = '2850bf62-8383-4e9f-b237-d41247a1df3b'; $response = Response::from(code: HttpCode::OK, data: [], headers: null); - $response->withHeader(name: 'Token', value: $value); - $expected = [$value]; + /** @When adding a new header */ + $response->withHeader(name: 'Token', value: $value); - self::assertEquals($expected, $response->getHeader(name: 'Token')); + /** @Then the new header should be included in the Response */ + self::assertEquals([$value], $response->getHeader(name: 'Token')); } public function testWithoutHeader(): void { + /** @Given a Response with default headers */ $response = Response::from(code: HttpCode::OK, data: [], headers: null); - $response->withoutHeader('Status'); - $expected = [HttpContentType::APPLICATION_JSON->value]; + /** @When removing the Status header */ + $response->withoutHeader(name: 'Status'); + + /** @Then the Status header should be empty and Content-Type should remain intact */ self::assertEmpty($response->getHeader(name: 'Status')); - self::assertEquals($expected, $response->getHeader(name: 'Content-Type')); + self::assertEquals([HttpContentType::APPLICATION_JSON->value], $response->getHeader(name: 'Content-Type')); } public function testExceptionWhenBadMethodCallOnWithBody(): void { + /** @Given a Response */ $response = Response::from(code: HttpCode::OK, data: [], headers: null); + /** @Then a BadMethodCall exception should be thrown when calling withBody */ self::expectException(BadMethodCall::class); self::expectExceptionMessage('Method cannot be used.'); + /** @When attempting to call withBody */ $response->withBody(body: StreamFactory::from(data: [])); } public function testExceptionWhenBadMethodCallOnWithStatus(): void { + /** @Given a Response */ $response = Response::from(code: HttpCode::OK, data: [], headers: null); + /** @Then a BadMethodCall exception should be thrown when calling withStatus */ self::expectException(BadMethodCall::class); self::expectExceptionMessage('Method cannot be used.'); + /** @When attempting to call withStatus */ $response->withStatus(code: HttpCode::OK->value); } public function testExceptionWhenBadMethodCallOnWithAddedHeader(): void { + /** @Given a Response */ $response = Response::from(code: HttpCode::OK, data: [], headers: null); + /** @Then a BadMethodCall exception should be thrown when calling withAddedHeader */ self::expectException(BadMethodCall::class); self::expectExceptionMessage('Method cannot be used.'); + /** @When attempting to call withAddedHeader */ $response->withAddedHeader(name: '', value: ''); } public function testExceptionWhenBadMethodCallOnWithProtocolVersion(): void { + /** @Given a Response */ $response = Response::from(code: HttpCode::OK, data: [], headers: null); + /** @Then a BadMethodCall exception should be thrown when calling withProtocolVersion */ self::expectException(BadMethodCall::class); self::expectExceptionMessage('Method cannot be used.'); + /** @When attempting to call withProtocolVersion */ $response->withProtocolVersion(version: ''); } } diff --git a/tests/Internal/Stream/StreamTest.php b/tests/Internal/Stream/StreamTest.php index fbdbbda..f298729 100644 --- a/tests/Internal/Stream/StreamTest.php +++ b/tests/Internal/Stream/StreamTest.php @@ -1,14 +1,18 @@ resource); + + /** @When retrieving metadata */ + $actual = $stream->getMetadata(); + $expected = StreamMetaData::from(data: stream_get_meta_data($this->resource))->toArray(); + + /** @Then the metadata should match the expected values */ + self::assertEquals($expected['uri'], $actual['uri']); + self::assertEquals($expected['mode'], $actual['mode']); + self::assertEquals($expected['seekable'], $actual['seekable']); + self::assertEquals($expected['streamType'], $actual['streamType']); + } + + public function testCloseWithoutResource(): void { + /** @Given a stream that has already been closed */ $stream = Stream::from(resource: $this->resource); $stream->close(); + /** @When closing the stream again */ + $stream->close(); + + /** @Then the stream should remain closed and detached */ self::assertFalse($stream->isReadable()); self::assertFalse($stream->isWritable()); self::assertFalse($stream->isSeekable()); self::assertFalse(is_resource($this->resource)); } - public function testCloseWithoutResource(): void + public function testCloseDetachesResource(): void { + /** @Given a stream resource */ $stream = Stream::from(resource: $this->resource); - $stream->close(); + + /** @When the stream is closed */ $stream->close(); + /** @Then the stream should be detached and no longer readable, writable, or seekable */ self::assertFalse($stream->isReadable()); self::assertFalse($stream->isWritable()); self::assertFalse($stream->isSeekable()); self::assertFalse(is_resource($this->resource)); } - public function testEofReturnsTrueAtEndOfStream(): void + public function testSeekMovesCursorPosition(): void { + /** @Given a stream with data */ $stream = Stream::from(resource: $this->resource); - $stream->write(string: 'Hello'); - $eofBeforeRead = $stream->eof(); - $stream->read(length: 5); + $stream->write(string: 'Hello, world!'); - self::assertTrue($stream->eof()); - self::assertTrue($stream->isReadable()); - self::assertFalse($eofBeforeRead); + /** @When seeking to a specific position */ + $stream->seek(offset: 7); + $tellAfterFirstSeek = $stream->tell(); + $stream->seek(offset: 0, whence: SEEK_END); + + /** @Then the cursor position should be updated correctly */ + self::assertTrue($stream->isWritable()); + self::assertTrue($stream->isSeekable()); + self::assertEquals(7, $tellAfterFirstSeek); + self::assertEquals(13, $stream->tell()); } - public function testGetMetadata(): void + public function testGetSizeReturnsCorrectSize(): void { + /** @Given a stream */ $stream = Stream::from(resource: $this->resource); - $actual = $stream->getMetadata(); - $expected = StreamMetaData::from(data: stream_get_meta_data($this->resource))->toArray(); - self::assertEquals($expected['uri'], $actual['uri']); - self::assertEquals($expected['mode'], $actual['mode']); - self::assertEquals($expected['seekable'], $actual['seekable']); - self::assertEquals($expected['streamType'], $actual['streamType']); + /** @When writing to the stream */ + $sizeBeforeWrite = $stream->getSize(); + $stream->write(string: 'Hello, world!'); + + /** @Then the size should be updated correctly */ + self::assertEquals(0, $sizeBeforeWrite); + self::assertEquals(13, $stream->getSize()); } - public function testGetMetadataWhenKeyIsUnknown(): void + public function testIsWritableForCreateMode(): void { - $stream = Stream::from(resource: $this->resource); - $actual = $stream->getMetadata(key: 'UNKNOWN'); + /** @Given a file that does not exist */ + unlink($this->temporary); - self::assertNull($actual); + /** @When opening the stream in create mode ('x') */ + $stream = Stream::from(resource: fopen($this->temporary, 'x')); + + /** @Then the stream should be writable */ + self::assertTrue($stream->isWritable()); } - public function testSeekMovesCursorPosition(): void + #[DataProvider('modesDataProvider')] + public function testIsWritableForVariousModes(string $mode, bool $expected): void { - $stream = Stream::from(resource: $this->resource); - $stream->write(string: 'Hello, world!'); - $stream->seek(offset: 7); - $tellAfterFirstSeek = $stream->tell(); - $stream->seek(offset: 0, whence: SEEK_END); + /** @Given a stream opened in a specific mode */ + $stream = Stream::from(resource: fopen('php://memory', $mode)); - self::assertTrue($stream->isWritable()); - self::assertTrue($stream->isSeekable()); - self::assertEquals(7, $tellAfterFirstSeek); - self::assertEquals(13, $stream->tell()); + /** @Then check if the stream is writable based on the mode */ + self::assertEquals($expected, $stream->isWritable()); } public function testRewindResetsCursorPosition(): void { + /** @Given a stream with data */ $stream = Stream::from(resource: $this->resource); $stream->write(string: 'Hello, world!'); + + /** @When rewinding the stream */ $stream->seek(offset: 7); $stream->rewind(); + /** @Then the cursor position should be reset to the beginning */ self::assertEquals(0, $stream->tell()); } - public function testGetSizeReturnsCorrectSize(): void + public function testEofReturnsTrueAtEndOfStream(): void { + /** @Given a stream with data */ $stream = Stream::from(resource: $this->resource); - $sizeBeforeWrite = $stream->getSize(); - $stream->write(string: 'Hello, world!'); + $stream->write(string: 'Hello'); - self::assertEquals(0, $sizeBeforeWrite); - self::assertEquals(13, $stream->getSize()); + /** @When reaching the end of the stream */ + $eofBeforeRead = $stream->eof(); + $stream->read(length: 5); + + /** @Then EOF should return true */ + self::assertTrue($stream->eof()); + self::assertTrue($stream->isReadable()); + self::assertFalse($eofBeforeRead); } - public function testGetSizeReturnsNullWhenWithoutResource(): void + public function testGetMetadataWhenKeyIsUnknown(): void { + /** @Given a stream */ $stream = Stream::from(resource: $this->resource); - $stream->close(); - self::assertNull($stream->getSize()); + /** @When retrieving metadata for an unknown key */ + $actual = $stream->getMetadata(key: 'UNKNOWN'); + + /** @Then the result should be null */ + self::assertNull($actual); } - public function testExceptionWhenMissingResourceStreamOnTell(): void + public function testToStringRewindsStreamIfNotSeekable(): void { + /** @Given a stream */ $stream = Stream::from(resource: $this->resource); - self::expectException(MissingResourceStream::class); - self::expectExceptionMessage('No resource available.'); + /** @When writing and converting the stream to string */ + $stream->write(string: 'Hello, world!'); - $stream->close(); - $stream->tell(); + /** @Then the content should match the written data */ + self::assertEquals('Hello, world!', (string)$stream); } - public function testToStringRewindsStreamIfNotSeekable(): void + public function testGetSizeReturnsNullWhenWithoutResource(): void { + /** @Given a stream that has been closed */ $stream = Stream::from(resource: $this->resource); - $stream->write(string: 'Hello, world!'); - $actual = (string)$stream; + $stream->close(); - self::assertEquals('Hello, world!', $actual); + /** @Then getSize should return null */ + self::assertNull($stream->getSize()); } public function testExceptionWhenNonSeekableStream(): void { + /** @Given a stream */ $stream = Stream::from(resource: $this->resource); + /** @When attempting to seek on a closed stream */ self::expectException(NonSeekableStream::class); self::expectExceptionMessage('Stream is not seekable.'); @@ -156,8 +210,10 @@ public function testExceptionWhenNonSeekableStream(): void public function testExceptionWhenNonWritableStream(): void { + /** @Given a read-only stream */ $stream = Stream::from(resource: fopen($this->temporary, 'r')); + /** @When attempting to write to the stream */ self::expectException(NonWritableStream::class); self::expectExceptionMessage('Stream is not writable.'); @@ -166,21 +222,61 @@ public function testExceptionWhenNonWritableStream(): void public function testExceptionWhenNonReadableStreamOnRead(): void { + /** @Given a write-only stream */ $stream = Stream::from(resource: fopen($this->temporary, 'w')); + /** @When attempting to read from the stream */ self::expectException(NonReadableStream::class); self::expectExceptionMessage('Stream is not readable.'); $stream->read(length: 13); } + public function testExceptionWhenInvalidResourceProvided(): void + { + /** @Given an invalid resource (e.g., a string) */ + $resource = 'not_a_resource'; + + /** @Then an InvalidResource exception should be thrown */ + $this->expectException(InvalidResource::class); + $this->expectExceptionMessage('The provided value is not a valid resource.'); + + /** @When calling the from method with an invalid resource */ + Stream::from(resource: $resource); + } + + public function testExceptionWhenMissingResourceStreamOnTell(): void + { + /** @Given a stream */ + $stream = Stream::from(resource: $this->resource); + + /** @When attempting to call tell on a closed stream */ + self::expectException(MissingResourceStream::class); + self::expectExceptionMessage('No resource available.'); + + $stream->close(); + $stream->tell(); + } + public function testExceptionWhenNonReadableStreamOnGetContents(): void { + /** @Given a write-only stream */ $stream = Stream::from(resource: fopen($this->temporary, 'w')); + /** @When attempting to get contents of the stream */ self::expectException(NonReadableStream::class); self::expectExceptionMessage('Stream is not readable.'); $stream->getContents(); } + + public static function modesDataProvider(): array + { + return [ + 'Read mode (r)' => ['mode' => 'r', 'expected' => false], + 'Write mode (w)' => ['mode' => 'w', 'expected' => true], + 'Append mode (a)' => ['mode' => 'a', 'expected' => true], + 'Mixed read/write mode (r+)' => ['mode' => 'r+', 'expected' => true] + ]; + } } diff --git a/tests/Mock/Xpto.php b/tests/Models/Xpto.php similarity index 87% rename from tests/Mock/Xpto.php rename to tests/Models/Xpto.php index 5c4564f..b82369b 100644 --- a/tests/Mock/Xpto.php +++ b/tests/Models/Xpto.php @@ -1,6 +1,6 @@