diff --git a/.gitattributes b/.gitattributes index def2077..f6e4027 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,10 @@ # Ignore files for distribution archives. -/.gitattributes export-ignore -/.gitignore export-ignore -/phpcs.xml export-ignore -/phpmd.xml export-ignore -/phpstan.neon export-ignore -/tests export-ignore -/renovate.json export-ignore +/.logs export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.phpunit.cache export-ignore +/phpcs.xml export-ignore +/phpmd.xml export-ignore +/phpstan.neon export-ignore +/renovate.json export-ignore +/tests export-ignore diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index 2b0943b..f78ff4e 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -53,10 +53,40 @@ jobs: run: composer lint continue-on-error: ${{ vars.CI_LINT_IGNORE_FAILURE == '1' }} - - name: Run tests - run: composer test + - name: Run unit tests + run: composer test-coverage continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }} + - name: Run BDD tests + run: composer test-bdd + continue-on-error: ${{ vars.CI_TEST_IGNORE_FAILURE == '1' }} + + - name: Upload coverage report as an artifact + uses: actions/upload-artifact@v4 + with: + name: ${{github.job}}-code-coverage-report-${{ matrix.php-versions }} + path: .logs + include-hidden-files: true + if-no-files-found: error + + - name: Upload test results to Codecov + uses: codecov/test-results-action@v1 + if: ${{ env.CODECOV_TOKEN != '' }} + with: + files: .logs/phpunit/junit.xml,.logs/behat/test_results/default.xml + fail_ci_if_error: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload coverage report to Codecov + uses: codecov/codecov-action@v5 + if: ${{ env.CODECOV_TOKEN != '' }} + with: + files: .logs/phpunit/cobertura.xml,.logs/behat/cobertura.xml + fail_ci_if_error: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Setup tmate session if: ${{ !cancelled() && github.event.inputs.enable_terminal }} uses: mxschmitt/action-tmate@v3 diff --git a/.gitignore b/.gitignore index 7579f74..86dcda3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ -vendor -composer.lock +/.logs +/.phpunit.cache +/composer.lock +/vendor diff --git a/README.md b/README.md index 37e6f54..13ff4f2 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ [![GitHub Issues](https://img.shields.io/github/issues/drevops/behat-phpserver.svg)](https://github.com/drevops/behat-phpserver/issues) [![GitHub Pull Requests](https://img.shields.io/github/issues-pr/drevops/behat-phpserver.svg)](https://github.com/drevops/behat-phpserver/pulls) [![Test PHP](https://github.com/drevops/behat-phpserver/actions/workflows/test-php.yml/badge.svg)](https://github.com/drevops/behat-phpserver/actions/workflows/test-php.yml) +[![codecov](https://codecov.io/gh/drevops/behat-phpserver/graph/badge.svg?token=KZCCZXN5C4)](https://codecov.io/gh/drevops/behat-phpserver) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/drevops/behat-phpserver) [![Total Downloads](https://poser.pugx.org/drevops/behat-phpserver/downloads)](https://packagist.org/packages/drevops/behat-phpserver) ![LICENSE](https://img.shields.io/github/license/drevops/behat-phpserver) @@ -193,6 +194,7 @@ composer lint-fix ```bash composer test +composer test-bdd ``` --- diff --git a/apiserver/index.php b/apiserver/index.php index 7ddb222..930c09c 100644 --- a/apiserver/index.php +++ b/apiserver/index.php @@ -51,19 +51,21 @@ declare(strict_types=1); +namespace DrevOps\BehatPhpServer\ApiServer; + class ApiServer { /** * The received requests. * - * @var array + * @var array */ protected array $requests = []; /** * The queued responses. * - * @var array + * @var array */ protected array $responses = []; @@ -116,8 +118,8 @@ public function __destruct() { */ public function handleRequest(): void { $request = new Request( - is_scalar($_SERVER['REQUEST_METHOD']) && is_string($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET', - is_scalar($_SERVER['REQUEST_URI']) ? (string) strtok(strval($_SERVER['REQUEST_URI']), '?') : '/', + isset($_SERVER['REQUEST_METHOD']) && is_scalar($_SERVER['REQUEST_METHOD']) && is_string($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET', + isset($_SERVER['REQUEST_URI']) && is_scalar($_SERVER['REQUEST_URI']) ? (string) strtok(strval($_SERVER['REQUEST_URI']), '?') : '/', getallheaders(), file_get_contents('php://input') ?: '' ); @@ -160,7 +162,7 @@ public function handleRequest(): void { $this->responses[] = $response; } - $this->handleResponse(new \Response(201, 'Created')); + $this->handleResponse(new Response(201, 'Created')); } else { $this->requests[] = $request; @@ -171,7 +173,7 @@ public function handleRequest(): void { else { $response = array_shift($this->responses); - if (!$response instanceof \Response) { + if (!$response instanceof Response) { throw new \Exception(sprintf('Invalid response in queue: %s', print_r($response, TRUE)), 500); } @@ -183,10 +185,10 @@ public function handleRequest(): void { /** * Send the response. * - * @param \Response $response + * @param \DrevOps\BehatPhpServer\ApiServer\Response $response * The response object. */ - protected function handleResponse(\Response $response): void { + protected function handleResponse(Response $response): void { $response->headers += [ 'X-Received-Requests' => (string) count($this->requests), 'X-Queued-Responses' => (string) count($this->responses), @@ -198,10 +200,10 @@ protected function handleResponse(\Response $response): void { /** * Send the response. * - * @param \Response $response + * @param \DrevOps\BehatPhpServer\ApiServer\Response $response * The response object. */ - public static function sendResponse(\Response $response): void { + public static function sendResponse(Response $response): void { // Set the full status line manually to include the custom reason. $protocol = is_scalar($_SERVER['SERVER_PROTOCOL']) ? strval($_SERVER['SERVER_PROTOCOL']) : 'HTTP/1.1'; header(sprintf('%s %s %s', $protocol, $response->code, $response->reason)); @@ -268,7 +270,13 @@ final public function __construct( * The response object. */ public static function fromArray(array $data): static { - $data['method'] = $data['method'] ?? 'GET'; + $data += [ + 'method' => 'GET', + 'code' => 200, + 'reason' => 'OK', + 'headers' => [], + 'body' => '', + ]; if (!is_string($data['method'])) { throw new \InvalidArgumentException('Method must be a string.'); @@ -282,11 +290,24 @@ public static function fromArray(array $data): static { throw new \InvalidArgumentException('Response code is required.'); } + $data['code'] = intval($data['code']); + + if ($data['code'] < 100 || $data['code'] > 599) { + throw new \InvalidArgumentException('Response code must be a number between 100 and 599.'); + } + $data['headers'] = $data['headers'] ?? []; if (!is_array($data['headers'])) { throw new \InvalidArgumentException('Headers must be an array.'); } + // Check that both keys and values are strings. + foreach ($data['headers'] as $header_name => $header_value) { + if (!is_string($header_name) || !is_scalar($header_value)) { + throw new \InvalidArgumentException(sprintf('Header "%s" value must be a string.', $header_name)); + } + } + $data['headers'] = array_map(fn($value): string => is_scalar($value) ? strval($value) : '', $data['headers']); if (isset($data['body'])) { @@ -294,34 +315,26 @@ public static function fromArray(array $data): static { throw new \InvalidArgumentException('Body must be a string.'); } - $response_body = base64_decode($data['body']); - // @phpstan-ignore-next-line - if ($response_body === FALSE) { - throw new \InvalidArgumentException('Body is not a valid base64 encoded string.'); - } - - $data['body'] = $response_body; - } - else { - $data['body'] = ''; + $data['body'] = base64_decode($data['body']); } - if (!empty($data['reason']) && !is_string($data['reason'])) { + if (empty($data['reason']) || !is_string($data['reason'])) { throw new \InvalidArgumentException('Reason must be a string.'); } - $data['reason'] = $data['reason'] ?: 'OK'; - return new static($data['code'], $data['reason'], $data['headers'], $data['body']); } } -$server = new ApiServer(); +// Allow to skip the script run. +if (getenv('SCRIPT_RUN_SKIP') != 1) { + $server = new ApiServer(); -try { - $server->handleRequest(); -} -catch (\Throwable $throwable) { - ApiServer::sendResponse(new \Response($throwable->getCode(), $throwable->getMessage(), [], ['error' => $throwable->getMessage()])); + try { + $server->handleRequest(); + } + catch (\Throwable $throwable) { + ApiServer::sendResponse(new Response($throwable->getCode(), $throwable->getMessage(), [], ['error' => $throwable->getMessage()])); + } } diff --git a/behat.yml b/behat.yml index 853dd8f..0e7c79b 100644 --- a/behat.yml +++ b/behat.yml @@ -25,3 +25,21 @@ default: sessions: default: browserkit_http: ~ + DVDoug\Behat\CodeCoverage\Extension: + filter: + include: + directories: + '%paths.base%/src': ~ + reports: + text: + showColors: true + showOnlySummary: true + html: + target: '%paths.base%/.logs/behat/.coverage-html' + cobertura: + target: '%paths.base%/.logs/behat/cobertura.xml' + + formatters: + pretty: true + junit: + output_path: '%paths.base%/.logs/behat/test_results' diff --git a/composer.json b/composer.json index 36a39ba..3745f6e 100644 --- a/composer.json +++ b/composer.json @@ -27,10 +27,12 @@ "behat/mink-browserkit-driver": "^2.2", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "drupal/coder": "^8.3", + "dvdoug/behat-code-coverage": "^5.3", "ergebnis/composer-normalize": "^2.45", "escapestudios/symfony2-coding-standard": "^3.15", "friends-of-behat/mink-extension": "^2.7.5", "phpstan/phpstan": "^2", + "phpunit/phpunit": "^11", "rector/rector": "^2.0", "squizlabs/php_codesniffer": "^3.11.2", "symfony/http-client": "^6 || ^7.2.2" @@ -41,6 +43,14 @@ "autoload": { "psr-0": { "DrevOps\\BehatPhpServer": "src/" + }, + "classmap": [ + "apiserver" + ] + }, + "autoload-dev": { + "psr-4": { + "DrevOps\\BehatPhpServer\\Tests\\": "tests/phpunit" } }, "config": { @@ -60,6 +70,8 @@ "phpcbf" ], "reset": "rm -Rf vendor composer.lock", - "test": "behat" + "test": "phpunit --no-coverage", + "test-bdd": "behat", + "test-coverage": "php -d pcov.directory=. vendor/bin/phpunit" } } diff --git a/phpcs.xml b/phpcs.xml index e8321af..20e227c 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -14,4 +14,23 @@ apiserver tests/ + + + *.Test\.php + *.TestCase\.php + *.test + + + + + *.Test\.php + *.TestCase\.php + *.test + + + + *.Test\.php + *.TestCase\.php + *.test + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..85c4d30 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,45 @@ + + + + + + + + tests/phpunit + + + + + apiserver + + + tests + + + + + + + + + + + + diff --git a/src/DrevOps/BehatPhpServer/ApiServerContext.php b/src/DrevOps/BehatPhpServer/ApiServerContext.php index 6655d7a..e07e4cb 100644 --- a/src/DrevOps/BehatPhpServer/ApiServerContext.php +++ b/src/DrevOps/BehatPhpServer/ApiServerContext.php @@ -38,6 +38,7 @@ class ApiServerContext extends PhpServerContext { * """ * { * "code": 200, + * "reason": "OK", * "headers": { * "Content-Type": "application/json" * }, @@ -85,9 +86,9 @@ protected function prepareData(string $data): array { $data += [ 'code' => 200, // @todo Validate reason. - 'reason' => '', + 'reason' => 'OK', 'headers' => [], - 'body' => NULL, + 'body' => '', ]; if (!is_numeric($data['code'])) { diff --git a/tests/behat/features/apiserver.feature b/tests/behat/features/apiserver.feature index 0f3c892..340478c 100644 --- a/tests/behat/features/apiserver.feature +++ b/tests/behat/features/apiserver.feature @@ -10,12 +10,13 @@ Feature: API server. And the response header should contain "X-Received-Requests" with value "0" And the response header should contain "X-Queued-Responses" with value "0" - @apiserver + @apiserver @wip1 Scenario: Assert that a single API response is returned correctly Given API will respond with: """ { "code": 200, + "reason": "OK", "headers": { "Content-Type": "application/json" }, @@ -42,7 +43,7 @@ Feature: API server. And the response should contain "Slug" And the response should contain "test-slug-1" - @apiserver + @apiserver @wip2 Scenario: Assert that multiple API responses are returned correctly Given the API will respond with: """ diff --git a/tests/phpunit/Unit/ResponseApiServerUnitTest.php b/tests/phpunit/Unit/ResponseApiServerUnitTest.php new file mode 100644 index 0000000..802519c --- /dev/null +++ b/tests/phpunit/Unit/ResponseApiServerUnitTest.php @@ -0,0 +1,104 @@ + $data + * The data to test. + * @param \DrevOps\BehatPhpServer\ApiServer\Response|null $expected + * The expected response. + * @param string|null $exception + * The expected exception message. + */ + #[DataProvider('dataProviderFromArray')] + public function testFromArray(array $data, ?Response $expected, ?string $exception = NULL): void { + if ($exception) { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($exception); + } + + $actual = Response::fromArray($data); + + if (!$exception) { + $this->assertEquals($expected, $actual); + } + } + + /** + * Data provider for testFromArray(). + * + * @return array> + * The test data. + */ + public static function dataProviderFromArray(): array { + return [ + // Valid data. + [['code' => 200], new Response(), NULL], + [['code' => 404], new Response(404), NULL], + [['code' => 200, 'reason' => 'OK'], new Response(200, 'OK'), NULL], + + [['code' => 500], new Response(500), NULL], + [['code' => 500, 'reason' => 'Custom error'], new Response(500, 'Custom error'), NULL], + + [ + ['code' => 200, 'reason' => 'OK', 'headers' => ['Content-Type' => 'application/json']], + new Response(200, 'OK', ['Content-Type' => 'application/json']), + NULL, + ], + + [ + ['code' => 200, 'reason' => 'OK', 'headers' => ['Content-Type' => 'application/json'], 'body' => ''], + new Response(200, 'OK', ['Content-Type' => 'application/json'], ''), + NULL, + ], + + [ + ['code' => 200, 'reason' => 'OK', 'headers' => ['customheader' => 'customheadervalue'], 'body' => base64_encode('Hello, World!')], + new Response(200, 'OK', ['customheader' => 'customheadervalue', 'Content-Length' => '7'], 'Hello, World!'), + NULL, + ], + + // Invalid: method. + [['method' => 123], new Response(), 'Method must be a string.'], + [['method' => []], new Response(), 'Method must be a string.'], + [['method' => 'OTHER'], new Response(), 'Unsupported HTTP method "OTHER". Supported methods are GET, POST, PUT, DELETE.'], + + // Invalid: reason. + [['code' => 200, 'reason' => ''], new Response(200), 'Reason must be a string.'], + [['code' => 200, 'reason' => []], new Response(200), 'Reason must be a string.'], + + // Invalid: code. + [['code' => ''], new Response(), 'Response code is required.'], + [['code' => 'status'], new Response(), 'Response code must be a number between 100 and 599.'], + [['code' => 2], new Response(), 'Response code must be a number between 100 and 599.'], + [['code' => 600], new Response(), 'Response code must be a number between 100 and 599.'], + + // Invalid: headers. + [['code' => 200, 'headers' => ''], new Response(200), 'Headers must be an array.'], + [['code' => 200, 'headers' => 'invalid'], new Response(200), 'Headers must be an array.'], + [['code' => 200, 'headers' => [123]], new Response(200), 'Header "0" value must be a string.'], + [ + ['code' => 200, 'headers' => ['header' => [123]]], + new Response(200), + 'Header "header" value must be a string.', + ], + + // Invalid: body. + [['code' => 200, 'body' => []], new Response(200), 'Body must be a string.'], + + ]; + } + +}