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 @@
+