diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21749c2..febe1d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: strategy: max-parallel: 10 matrix: - php: [ '8.0', '8.1', '8.2', '8.3' ] + php: [ '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] steps: - name: Set up PHP @@ -20,7 +20,7 @@ jobs: tools: composer:v2 - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download dependencies run: composer update --no-interaction --prefer-dist diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 261ce63..38b3286 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -9,12 +9,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.5' coverage: none - name: Download dependencies @@ -32,12 +32,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.5' coverage: none - name: Download dependencies @@ -55,12 +55,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.5' coverage: none - name: Download dependencies diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 84512f8..48d4163 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -49,7 +49,9 @@ ], 'phpdoc_separation' => true, 'phpdoc_single_line_var_spacing' => true, - 'phpdoc_to_comment' => true, + 'phpdoc_to_comment' => [ + 'ignored_tags' => ['var'], + ], 'phpdoc_trim' => true, 'phpdoc_var_without_name' => true, 'return_type_declaration' => [ diff --git a/CHANGELOG-4.0.md b/CHANGELOG-4.0.md index 312ff6c..28d8e59 100644 --- a/CHANGELOG-4.0.md +++ b/CHANGELOG-4.0.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [4.3.0] - 2026-03-20 +### Added +- PHP 8.4 support +- PHP 8.5 support +- Configurable default headers support via `headers` module configuration +- Improved test coverage with unit and functional tests + +### Changed +- Upgraded PHPStan to v2 +- Upgraded Psalm to v6 +- Updated static analysis workflow to use PHP 8.5 + ## [4.2.0] - 2024-03-15 ### Added - PHP 8.3 support diff --git a/README.md b/README.md index 9ea4a77..2a86298 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,23 @@ modules: ``` The `application` property is a relative path to file which returns your `Slim\App` instance. + +You can also configure default headers that will be sent with every request using the `headers` option: + +```yaml +modules: + enabled: + - REST: + depends: DoclerLabs\CodeceptionSlimModule\Module\Slim + + config: + DoclerLabs\CodeceptionSlimModule\Module\Slim: + application: path/to/application.php + headers: + Content-Type: application/json + Accept: application/json +``` + Here is the minimum `application.php` content: ```php @@ -68,7 +85,6 @@ return $app; ## Testing your API endpoints ```php - class UserCest { public function getUserReturnsWithEmail(FunctionalTester $I): void @@ -86,3 +102,32 @@ class UserCest } } ``` + +### With default headers + +When you configure default `headers` in your suite configuration, you no longer need to set them manually in each test: + +```php +class UserCest +{ + // Content-Type and Accept headers are already set via module config + public function getUserReturnsWithEmail(FunctionalTester $I): void + { + $I->sendGET('/users/John'); + + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson( + [ + 'email' => 'john.doe@example.com', + ] + ); + } + + public function createUserReturnsCreated(FunctionalTester $I): void + { + $I->sendPOST('/users', ['name' => 'Jane', 'email' => 'jane.doe@example.com']); + + $I->seeResponseCodeIs(201); + } +} +``` diff --git a/composer.json b/composer.json index ca88229..ed0048e 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ "autoload-dev": { "psr-4": { "DoclerLabs\\CodeceptionSlimModule\\Test\\": "test/support", - "DoclerLabs\\CodeceptionSlimModule\\Test\\Functional\\": "test/suite/functional" + "DoclerLabs\\CodeceptionSlimModule\\Test\\Functional\\": "test/suite/functional", + "DoclerLabs\\CodeceptionSlimModule\\Test\\Unit\\": "test/suite/unit" } }, "config": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 2aa6d72..71c745a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,8 +3,12 @@ includes: parameters: level: max - checkMissingIterableValueType: false + ignoreErrors: + - + identifier: missingType.iterableValue paths: - src fileExtensions: - php + bootstrapFiles: + - vendor/autoload.php diff --git a/psalm.xml b/psalm.xml index 483973b..0e454dc 100644 --- a/psalm.xml +++ b/psalm.xml @@ -13,4 +13,9 @@ + + + + + diff --git a/src/Lib/Connector/SlimPsr7.php b/src/Lib/Connector/SlimPsr7.php index b7404ce..56b6c40 100644 --- a/src/Lib/Connector/SlimPsr7.php +++ b/src/Lib/Connector/SlimPsr7.php @@ -4,6 +4,7 @@ namespace DoclerLabs\CodeceptionSlimModule\Lib\Connector; +use Psr\Container\ContainerInterface; use Psr\Http\Message\UploadedFileInterface; use Slim\App; use Slim\Psr7\Cookies; @@ -16,11 +17,15 @@ use Symfony\Component\BrowserKit\Request as BrowserKitRequest; use Symfony\Component\BrowserKit\Response as BrowserKitResponse; +/** + * @extends AbstractBrowser + */ class SlimPsr7 extends AbstractBrowser { - /** @var App */ - private $app; + /** @var App */ + private App $app; + /** @param App $app */ public function setApp(App $app): void { $this->app = $app; @@ -104,9 +109,6 @@ private function convertToHeaders(array $serverVariables): Headers /** * Convert uploaded file list to UploadedFile instances. * - * @param array $files List of uploaded file instances, that implements `Psr\Http\Message\UploadedFileInterface`, - * or meta data about uploaded file items from $_FILES, indexed with field name. - * * @return array */ private function convertFiles(array $files): array @@ -114,23 +116,22 @@ private function convertFiles(array $files): array $uploadedFiles = []; foreach ($files as $fieldName => $file) { if ($file instanceof UploadedFileInterface) { - $uploadedFiles[$fieldName] = $file; - } elseif (!isset($file['tmp_name']) && !isset($file['name'])) { - $uploadedFiles[$fieldName] = $this->createUploadedFile($file); + $uploadedFiles[(string)$fieldName] = $file; + } elseif ( + is_array($file) + && isset($file['tmp_name'], $file['name']) + ) { + /** @var array{tmp_name: string, name: string, type?: string|null, size?: int|null, error?: int} $file */ + $uploadedFiles[(string)$fieldName] = new UploadedFile( + $file['tmp_name'], + $file['name'], + $file['type'] ?? null, + $file['size'] ?? null, + $file['error'] ?? UPLOAD_ERR_OK + ); } } return $uploadedFiles; } - - private function createUploadedFile(array $file): UploadedFile - { - return new UploadedFile( - $file['tmp_name'], - $file['name'], - $file['type'], - $file['size'], - $file['error'] - ); - } } diff --git a/src/Module/Slim.php b/src/Module/Slim.php index 10a6ecc..f95fca8 100644 --- a/src/Module/Slim.php +++ b/src/Module/Slim.php @@ -10,6 +10,7 @@ use Codeception\Lib\Framework; use Codeception\TestInterface; use DoclerLabs\CodeceptionSlimModule\Lib\Connector\SlimPsr7; +use Psr\Container\ContainerInterface; use Slim\App; /** @@ -20,6 +21,7 @@ * ### Slim 4.x * * * application - Relative path to file which bootstrap and returns your `Slim\App` instance. + * * headers - Default headers to be sent with every request (optional). * * #### Example (`test/suite/functional.suite.yml`) * ```yaml @@ -27,6 +29,8 @@ * config: * DoclerLabs\CodeceptionSlimModule\Module\Slim: * application: 'app/bootstrap.php' + * headers: + * Content-Type: application/json * ``` * * ## Public Properties @@ -45,22 +49,26 @@ * config: * DoclerLabs\CodeceptionSlimModule\Module\Slim: * application: 'app/bootstrap.php' + * headers: + * Content-Type: application/json * ``` */ class Slim extends Framework { - /** @var App */ - public $app; + /** @var App */ + public App $app; - /** @var array */ protected array $requiredFields = ['application']; - /** @var string */ - private $applicationPath; + protected array $config = ['headers' => []]; + + private string $applicationPath; public function _initialize(): void { - $applicationPath = Configuration::projectDir() . $this->config['application']; + /** @var string $configApplication */ + $configApplication = $this->config['application']; + $applicationPath = Configuration::projectDir() . $configApplication; if (!is_readable($applicationPath)) { throw new ModuleConfigException( static::class, @@ -75,11 +83,10 @@ public function _initialize(): void public function _before(TestInterface $test): void { - /* @noinspection PhpIncludeInspection */ - $this->app = require $this->applicationPath; + $app = require $this->applicationPath; // Check if app instance is ready. - if (!$this->app instanceof App) { + if (!$app instanceof App) { throw new ConfigurationException( sprintf( "Unable to bootstrap slim application.\n Application file must return with `%s` instance.", @@ -88,11 +95,17 @@ public function _before(TestInterface $test): void ); } + $this->app = $app; + $connector = new SlimPsr7(); $connector->setApp($this->app); $this->client = $connector; + /** @var array $headers */ + $headers = $this->config['headers']; + $this->headers = $headers; + parent::_before($test); } } diff --git a/test/suite/functional.suite.yml b/test/suite/functional.suite.yml index c797368..560e68d 100644 --- a/test/suite/functional.suite.yml +++ b/test/suite/functional.suite.yml @@ -8,3 +8,5 @@ modules: config: DoclerLabs\CodeceptionSlimModule\Module\Slim: application: test/support/Fake/application.php + headers: + X-Default-Header: default-value diff --git a/test/suite/functional/TestAppCest.php b/test/suite/functional/TestAppCest.php index 6513368..9127289 100644 --- a/test/suite/functional/TestAppCest.php +++ b/test/suite/functional/TestAppCest.php @@ -14,7 +14,7 @@ public function convertGetRequest(FunctionalTester $I): void $I->haveHttpHeader('Cookie', 'name=value; name2=value2; name3=value3'); $I->haveServerParameter('custom', 'server'); - $I->sendGET('/hello/John/Doe', ['foo' => 'bar']); + $I->sendGet('/hello/John/Doe', ['foo' => 'bar']); $I->seeResponseCodeIs(200); $response = json_decode($I->grabResponse(), true); @@ -44,10 +44,11 @@ public function convertGetRequest(FunctionalTester $I): void // Check headers. $I->assertSame( [ - 'User-Agent' => ['Symfony BrowserKit'], - 'Custom' => ['header'], - 'Cookie' => ['name=value; name2=value2; name3=value3'], - 'Host' => ['localhost'], + 'User-Agent' => ['Symfony BrowserKit'], + 'X-Default-Header' => ['default-value'], + 'Custom' => ['header'], + 'Cookie' => ['name=value; name2=value2; name3=value3'], + 'Host' => ['localhost'], ], $response['headers'], 'Header parameters are not identical.' @@ -74,7 +75,7 @@ public function convertPostMultipartFormDataRequest(FunctionalTester $I): void $I->haveHttpHeader('Cookie', 'name=value; name2=value2; name3=value3'); $I->haveServerParameter('custom', 'server'); - $I->sendPOST('/hello/John/Doe?query=value', ['foo' => 'bar']); + $I->sendPost('/hello/John/Doe?query=value', ['foo' => 'bar']); $I->seeResponseCodeIs(200); $response = json_decode($I->grabResponse(), true); @@ -104,10 +105,11 @@ public function convertPostMultipartFormDataRequest(FunctionalTester $I): void // Check headers. $I->assertSame( [ - 'User-Agent' => ['Symfony BrowserKit'], - 'Content-Type' => ['multipart/form-data'], - 'Cookie' => ['name=value; name2=value2; name3=value3'], - 'Host' => ['localhost'], + 'User-Agent' => ['Symfony BrowserKit'], + 'X-Default-Header' => ['default-value'], + 'Content-Type' => ['multipart/form-data'], + 'Cookie' => ['name=value; name2=value2; name3=value3'], + 'Host' => ['localhost'], ], $response['headers'], 'Header parameters are not identical.' @@ -134,7 +136,7 @@ public function convertPostJsonRequest(FunctionalTester $I): void $I->haveHttpHeader('Cookie', 'name=value; name2=value2; name3=value3'); $I->haveServerParameter('custom', 'server'); - $I->sendPOST('/hello/John/Doe?query=value', ['foo' => 'bar']); + $I->sendPost('/hello/John/Doe?query=value', ['foo' => 'bar']); $I->seeResponseCodeIs(200); $response = json_decode($I->grabResponse(), true); @@ -164,10 +166,11 @@ public function convertPostJsonRequest(FunctionalTester $I): void // Check headers. $I->assertSame( [ - 'User-Agent' => ['Symfony BrowserKit'], - 'Content-Type' => ['application/json'], - 'Cookie' => ['name=value; name2=value2; name3=value3'], - 'Host' => ['localhost'], + 'User-Agent' => ['Symfony BrowserKit'], + 'X-Default-Header' => ['default-value'], + 'Content-Type' => ['application/json'], + 'Cookie' => ['name=value; name2=value2; name3=value3'], + 'Host' => ['localhost'], ], $response['headers'], 'Header parameters are not identical.' @@ -187,4 +190,150 @@ public function convertPostJsonRequest(FunctionalTester $I): void // Check uploaded files. $I->assertSame([], $response['uploaded_files'], 'Uploaded file parameters are not identical.'); } + + public function convertPutRequest(FunctionalTester $I): void + { + $I->haveHttpHeader('Content-type', 'application/json'); + + $I->sendPut('/echo', ['foo' => 'bar']); + + $I->seeResponseCodeIs(200); + $response = json_decode($I->grabResponse(), true); + + $I->assertSame('PUT', $response['method'], 'Method is not identical.'); + $I->assertSame('{"foo":"bar"}', $response['body'], 'Request body is not identical.'); + $I->assertSame(['foo' => 'bar'], $response['parsed_body'], 'Parsed request body is not identical.'); + } + + public function convertPatchRequest(FunctionalTester $I): void + { + $I->haveHttpHeader('Content-type', 'application/json'); + + $I->sendPatch('/echo', ['key' => 'value']); + + $I->seeResponseCodeIs(200); + $response = json_decode($I->grabResponse(), true); + + $I->assertSame('PATCH', $response['method'], 'Method is not identical.'); + $I->assertSame('{"key":"value"}', $response['body'], 'Request body is not identical.'); + $I->assertSame(['key' => 'value'], $response['parsed_body'], 'Parsed request body is not identical.'); + } + + public function convertDeleteRequest(FunctionalTester $I): void + { + $I->sendDelete('/echo'); + + $I->seeResponseCodeIs(200); + $response = json_decode($I->grabResponse(), true); + + $I->assertSame('DELETE', $response['method'], 'Method is not identical.'); + } + + public function convertPostWithFileUpload(FunctionalTester $I): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test_upload_'); + file_put_contents($tmpFile, 'file content here'); + + $I->haveHttpHeader('Content-type', 'multipart/form-data'); + $I->sendPost( + '/upload', + [], + [ + 'attachment' => [ + 'tmp_name' => $tmpFile, + 'name' => 'test.txt', + 'type' => 'text/plain', + 'size' => filesize($tmpFile), + 'error' => UPLOAD_ERR_OK, + ], + ] + ); + + $I->seeResponseCodeIs(200); + $response = json_decode($I->grabResponse(), true); + + $I->assertSame('POST', $response['method'], 'Method is not identical.'); + $I->assertCount(1, $response['uploaded_files'], 'Expected one uploaded file.'); + $I->assertSame('test.txt', $response['uploaded_files']['attachment']['filename']); + $I->assertSame('text/plain', $response['uploaded_files']['attachment']['media_type']); + $I->assertSame('file content here', $response['uploaded_files']['attachment']['content']); + $I->assertSame(UPLOAD_ERR_OK, $response['uploaded_files']['attachment']['error']); + + @unlink($tmpFile); + } + + public function handleNon200StatusCode(FunctionalTester $I): void + { + $I->sendGet('/status/404'); + + $I->seeResponseCodeIs(404); + } + + public function handleServerErrorStatusCode(FunctionalTester $I): void + { + $I->sendGet('/status/500'); + + $I->seeResponseCodeIs(500); + } + + public function handleRedirectResponse(FunctionalTester $I): void + { + $I->stopFollowingRedirects(); + + $I->sendGet('/redirect'); + + $I->seeResponseCodeIs(302); + $I->seeHttpHeader('Location', '/hello/John/Doe'); + + $I->startFollowingRedirects(); + } + + public function handleEmptyResponse(FunctionalTester $I): void + { + $I->sendGet('/empty'); + + $I->seeResponseCodeIs(204); + $I->assertSame('', $I->grabResponse(), 'Response body should be empty.'); + } + + public function convertMinimalGetRequest(FunctionalTester $I): void + { + $I->sendGet('/echo'); + + $I->seeResponseCodeIs(200); + $response = json_decode($I->grabResponse(), true); + + $I->assertSame('GET', $response['method'], 'Method is not identical.'); + $I->assertSame('http://localhost/echo', $response['uri'], 'Uri is not identical.'); + $I->assertSame([], $response['query_params'], 'Query parameters should be empty.'); + $I->assertSame([], $response['cookie_params'], 'Cookie parameters should be empty.'); + $I->assertNull($response['parsed_body'], 'Parsed body should be null.'); + } + + public function convertPostWithEmptyBody(FunctionalTester $I): void + { + $I->haveHttpHeader('Content-type', 'application/json'); + + $I->sendPost('/echo', ''); + + $I->seeResponseCodeIs(200); + $response = json_decode($I->grabResponse(), true); + + $I->assertSame('POST', $response['method'], 'Method is not identical.'); + $I->assertSame('', $response['body'], 'Request body should be empty.'); + } + + public function configHeadersAreSentWithEveryRequest(FunctionalTester $I): void + { + $I->sendGet('/echo'); + + $I->seeResponseCodeIs(200); + $response = json_decode($I->grabResponse(), true); + + $I->assertSame( + 'default-value', + $response['headers']['X-Default-Header'][0] ?? null, + 'Default header from config should be present.' + ); + } } diff --git a/test/suite/unit.suite.yml b/test/suite/unit.suite.yml new file mode 100644 index 0000000..55442f9 --- /dev/null +++ b/test/suite/unit.suite.yml @@ -0,0 +1,4 @@ +actor: UnitTester +modules: + enabled: + - Asserts: diff --git a/test/suite/unit/Lib/Connector/SlimPsr7Test.php b/test/suite/unit/Lib/Connector/SlimPsr7Test.php new file mode 100644 index 0000000..f93e27d --- /dev/null +++ b/test/suite/unit/Lib/Connector/SlimPsr7Test.php @@ -0,0 +1,277 @@ +connector = new SlimPsr7(); + } + + public function testConvertToHeadersStripsHttpPrefix(): void + { + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest('http://localhost/test', 'GET', [], [], [], ['HTTP_ACCEPT' => 'text/html']) + ); + + $this->assertSame(['text/html'], $request->getHeader('Accept')); + } + + public function testConvertToHeadersReplacesUnderscoresWithDashes(): void + { + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest('http://localhost/test', 'GET', [], [], [], ['HTTP_ACCEPT_LANGUAGE' => 'en-US']) + ); + + $this->assertSame(['en-US'], $request->getHeader('Accept-Language')); + } + + public function testConvertToHeadersTransformsCaseCorrectly(): void + { + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest('http://localhost/test', 'GET', [], [], [], ['HTTP_X_CUSTOM_HEADER' => 'value']) + ); + + $this->assertSame(['value'], $request->getHeader('X-Custom-Header')); + } + + public function testConvertToHeadersDecodesHtmlEntities(): void + { + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest('http://localhost/test', 'GET', [], [], [], ['HTTP_X&HEADER' => 'value']) + ); + + $this->assertSame(['value'], $request->getHeader('X&header')); + } + + public function testConvertToHeadersIgnoresNonHttpServerVars(): void + { + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest( + 'http://localhost/test', + 'GET', + [], + [], + [], + [ + 'SERVER_NAME' => 'localhost', + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED' => 'test-value', + ] + ) + ); + + $this->assertSame(['test-value'], $request->getHeader('X-Forwarded')); + $this->assertSame([], $request->getHeader('Server-Name')); + $this->assertSame([], $request->getHeader('Remote-Addr')); + } + + public function testConvertFilesWithUploadedFileInterface(): void + { + $uploadedFile = $this->createMock(UploadedFileInterface::class); + + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest('http://localhost/test', 'POST', [], ['avatar' => $uploadedFile]) + ); + + $this->assertSame($uploadedFile, $request->getUploadedFiles()['avatar']); + } + + public function testConvertFilesWithArrayDescriptor(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test_'); + file_put_contents($tmpFile, 'content'); + + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest( + 'http://localhost/test', + 'POST', + [], + [ + 'document' => [ + 'tmp_name' => $tmpFile, + 'name' => 'doc.pdf', + 'type' => 'application/pdf', + 'size' => 7, + 'error' => UPLOAD_ERR_OK, + ], + ] + ) + ); + + $uploadedFiles = $request->getUploadedFiles(); + $this->assertInstanceOf(UploadedFile::class, $uploadedFiles['document']); + $this->assertSame('doc.pdf', $uploadedFiles['document']->getClientFilename()); + $this->assertSame('application/pdf', $uploadedFiles['document']->getClientMediaType()); + $this->assertSame(UPLOAD_ERR_OK, $uploadedFiles['document']->getError()); + + @unlink($tmpFile); + } + + public function testConvertFilesWithPartialArrayUsesDefaults(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test_'); + file_put_contents($tmpFile, 'content'); + + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest( + 'http://localhost/test', + 'POST', + [], + [ + 'file' => [ + 'tmp_name' => $tmpFile, + 'name' => 'file.txt', + ], + ] + ) + ); + + $uploadedFiles = $request->getUploadedFiles(); + $this->assertInstanceOf(UploadedFile::class, $uploadedFiles['file']); + $this->assertSame('file.txt', $uploadedFiles['file']->getClientFilename()); + $this->assertSame(UPLOAD_ERR_OK, $uploadedFiles['file']->getError()); + + @unlink($tmpFile); + } + + public function testConvertFilesSkipsInvalidEntries(): void + { + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest( + 'http://localhost/test', + 'POST', + [], + [ + 'invalid_string' => 'not-a-file', + 'invalid_array' => ['some' => 'data'], + ] + ) + ); + + $this->assertEmpty($request->getUploadedFiles()); + } + + public function testConvertFilesWithMultipleFiles(): void + { + $tmpFile1 = tempnam(sys_get_temp_dir(), 'test_'); + $tmpFile2 = tempnam(sys_get_temp_dir(), 'test_'); + file_put_contents($tmpFile1, 'a'); + file_put_contents($tmpFile2, 'b'); + + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest( + 'http://localhost/test', + 'POST', + [], + [ + 'file1' => [ + 'tmp_name' => $tmpFile1, + 'name' => 'one.txt', + ], + 'file2' => [ + 'tmp_name' => $tmpFile2, + 'name' => 'two.txt', + ], + ] + ) + ); + + $uploadedFiles = $request->getUploadedFiles(); + $this->assertCount(2, $uploadedFiles); + $this->assertSame('one.txt', $uploadedFiles['file1']->getClientFilename()); + $this->assertSame('two.txt', $uploadedFiles['file2']->getClientFilename()); + + @unlink($tmpFile1); + @unlink($tmpFile2); + } + + public function testConvertRequestGetDoesNotPassParametersAsParsedBody(): void + { + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest('http://localhost/test', 'GET', ['foo' => 'bar']) + ); + + $parsedBody = $request->getParsedBody(); + $this->assertNotSame(['foo' => 'bar'], $parsedBody, 'GET parameters should not be in parsed body.'); + } + + public function testConvertRequestPostSetsParsedBody(): void + { + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest('http://localhost/test', 'POST', ['foo' => 'bar']) + ); + + $this->assertSame(['foo' => 'bar'], $request->getParsedBody()); + } + + public function testConvertRequestPreservesMethodAndUri(): void + { + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest('http://localhost/path?key=val', 'PUT') + ); + + $this->assertSame('PUT', $request->getMethod()); + $this->assertSame('http://localhost/path?key=val', (string)$request->getUri()); + } + + public function testConvertRequestPreservesBody(): void + { + $request = $this->doRequestCapturingSlimRequest( + new BrowserKitRequest( + 'http://localhost/test', + 'POST', + [], + [], + [], + [], + '{"raw":"json"}' + ) + ); + + $this->assertSame('{"raw":"json"}', (string)$request->getBody()); + } + + private function doRequestCapturingSlimRequest(BrowserKitRequest $browserKitRequest): ServerRequestInterface + { + $capturedRequest = null; + + $app = $this->createMock(App::class); + $app->method('handle') + ->willReturnCallback(function (ServerRequestInterface $request) use (&$capturedRequest): ResponseInterface { + $capturedRequest = $request; + + return new Response(); + }); + + $this->connector->setApp($app); + $this->connector->request( + $browserKitRequest->getMethod(), + $browserKitRequest->getUri(), + $browserKitRequest->getParameters(), + $browserKitRequest->getFiles(), + $browserKitRequest->getServer(), + $browserKitRequest->getContent() + ); + + $this->assertNotNull($capturedRequest, 'App::handle() was not called'); + + return $capturedRequest; + } +} diff --git a/test/suite/unit/Module/SlimTest.php b/test/suite/unit/Module/SlimTest.php new file mode 100644 index 0000000..ec73038 --- /dev/null +++ b/test/suite/unit/Module/SlimTest.php @@ -0,0 +1,89 @@ +createSlimModule(['application' => 'non/existent/path.php']); + + $this->expectException(ModuleConfigException::class); + $this->expectExceptionMessage('Application file does not exist or is not readable'); + + $module->_initialize(); + } + + public function testBeforeThrowsWhenAppNotReturned(): void + { + $fakePath = 'test/support/Fake/invalid_application.php'; + $module = $this->createSlimModule(['application' => $fakePath]); + $module->_initialize(); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('Unable to bootstrap slim application'); + + $module->_before($this->createMock(TestInterface::class)); + } + + public function testBeforeSetsAppAndClient(): void + { + $module = $this->createSlimModule(['application' => 'test/support/Fake/application.php']); + $module->_initialize(); + + $module->_before($this->createMock(TestInterface::class)); + + $this->assertInstanceOf(App::class, $module->app); + + $clientProperty = new ReflectionProperty($module, 'client'); + $client = $clientProperty->getValue($module); + $this->assertInstanceOf(SlimPsr7::class, $client); + } + + public function testBeforeAppliesConfigHeaders(): void + { + $headers = ['X-Custom' => 'value', 'Accept' => 'application/json']; + $module = $this->createSlimModule( + [ + 'application' => 'test/support/Fake/application.php', + 'headers' => $headers, + ] + ); + $module->_initialize(); + + $module->_before($this->createMock(TestInterface::class)); + + $headersProperty = new ReflectionProperty($module, 'headers'); + $this->assertSame($headers, $headersProperty->getValue($module)); + } + + public function testBeforeAppliesEmptyHeadersByDefault(): void + { + $module = $this->createSlimModule(['application' => 'test/support/Fake/application.php']); + $module->_initialize(); + + $module->_before($this->createMock(TestInterface::class)); + + $headersProperty = new ReflectionProperty($module, 'headers'); + $this->assertSame([], $headersProperty->getValue($module)); + } + + private function createSlimModule(array $config): Slim + { + $moduleContainer = $this->createMock(ModuleContainer::class); + + return new Slim($moduleContainer, $config); + } +} diff --git a/test/support/Fake/application.php b/test/support/Fake/application.php index 53a17bc..742a11a 100644 --- a/test/support/Fake/application.php +++ b/test/support/Fake/application.php @@ -11,39 +11,54 @@ $app = AppFactory::create(); $app->addBodyParsingMiddleware(); -$app->any( - '/hello/{firstname}/{lastname}', - static function (ServerRequestInterface $request, ResponseInterface $response) { - $response->getBody()->write( - json_encode( - [ - 'method' => $request->getMethod(), - 'uri' => (string)$request->getUri(), - 'attributes' => $request->getAttributes(), - 'query_params' => $request->getQueryParams(), - 'body' => (string)$request->getBody(), - 'parsed_body' => $request->getParsedBody(), - 'server_params' => $request->getServerParams(), - 'headers' => $request->getHeaders(), - 'cookie_params' => $request->getCookieParams(), - 'uploaded_files' => array_map( - static function (UploadedFileInterface $uploadedFile) { - return [ - 'filename' => $uploadedFile->getClientFilename(), - 'media_type' => $uploadedFile->getClientMediaType(), - 'size' => $uploadedFile->getSize(), - 'error' => $uploadedFile->getError(), - 'content' => (string)$uploadedFile->getStream(), - ]; - }, - $request->getUploadedFiles() - ), - ] - ) - ); - - return $response; - } -); + +$echoHandler = static function (ServerRequestInterface $request, ResponseInterface $response) { + $response->getBody()->write( + json_encode( + [ + 'method' => $request->getMethod(), + 'uri' => (string)$request->getUri(), + 'attributes' => $request->getAttributes(), + 'query_params' => $request->getQueryParams(), + 'body' => (string)$request->getBody(), + 'parsed_body' => $request->getParsedBody(), + 'server_params' => $request->getServerParams(), + 'headers' => $request->getHeaders(), + 'cookie_params' => $request->getCookieParams(), + 'uploaded_files' => array_map( + static function (UploadedFileInterface $uploadedFile) { + return [ + 'filename' => $uploadedFile->getClientFilename(), + 'media_type' => $uploadedFile->getClientMediaType(), + 'size' => $uploadedFile->getSize(), + 'error' => $uploadedFile->getError(), + 'content' => (string)$uploadedFile->getStream(), + ]; + }, + $request->getUploadedFiles() + ), + ] + ) + ); + + return $response; +}; + +$app->any('/hello/{firstname}/{lastname}', $echoHandler); +$app->any('/echo', $echoHandler); + +$app->get('/status/{code}', static function (ServerRequestInterface $request, ResponseInterface $response, array $args) { + return $response->withStatus((int)$args['code']); +}); + +$app->get('/redirect', static function (ServerRequestInterface $request, ResponseInterface $response) { + return $response->withStatus(302)->withHeader('Location', '/hello/John/Doe'); +}); + +$app->get('/empty', static function (ServerRequestInterface $request, ResponseInterface $response) { + return $response->withStatus(204); +}); + +$app->post('/upload', $echoHandler); return $app; diff --git a/test/support/Fake/invalid_application.php b/test/support/Fake/invalid_application.php new file mode 100644 index 0000000..476e8ba --- /dev/null +++ b/test/support/Fake/invalid_application.php @@ -0,0 +1,5 @@ +