From 0fec5f3648fb3e7762d669de649a0fb7bb355d22 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:20:03 +0100 Subject: [PATCH 01/51] Prepare tests environment --- .github/workflows/release.yaml | 4 +++- .gitignore | 6 ++++++ composer.json | 12 +++++++---- composer.lock | 2 +- formwork/.php-cs-fixer.php | 5 +++-- formwork/.rector.php | 1 + formwork/config/system.yaml | 1 + formwork/phpstan.neon | 1 + phpunit.xml.dist | 18 +++++++++++++++++ tests/Environment.php | 30 +++++++++++++++++++++++++++ tests/TestCase.php | 37 ++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 17 ++++++++++++++++ 12 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 phpunit.xml.dist create mode 100644 tests/Environment.php create mode 100644 tests/TestCase.php create mode 100644 tests/bootstrap.php diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ac7a37760..cb5666a65 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -42,7 +42,9 @@ jobs: zip -r formwork-${{ github.event.release.tag_name }}.zip . -x \ \*.git/\* \ \*.github/\* \ - \*node_modules/\* + \*node_modules/\* \ + \tests/\* \ + \phpunit.xml.dist - name: Upload release assets uses: svenstaro/upload-release-action@v2 diff --git a/.gitignore b/.gitignore index 980665405..0fc4607dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .DS_Store .php-cs-fixer.cache +.phpunit.cache + +phpunit.xml /assets/* /backup/* @@ -20,4 +23,7 @@ /site/users/accounts/* /site/users/images/* +/tests/coverage/ +/tests/tmp/ + !.gitkeep diff --git a/composer.json b/composer.json index c9f7fb844..f4b123d18 100644 --- a/composer.json +++ b/composer.json @@ -45,11 +45,13 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", "phpstan/phpstan": "^2.0.3", - "rector/rector": "^2.0.3" + "rector/rector": "^2.0.3", + "phpunit/phpunit": "^12.0" }, "scripts": { - "fix": "php-cs-fixer fix --config=formwork/.php-cs-fixer.php --cache-file=formwork/.php-cs-fixer.cache --verbose", - "fix:check": "php-cs-fixer check --config=formwork/.php-cs-fixer.php --cache-file=formwork/.php-cs-fixer.cache", + "fix": "php-cs-fixer fix --config=formwork/.php-cs-fixer.php --verbose", + "fix:check": "php-cs-fixer check --config=formwork/.php-cs-fixer.php", + "fix:tests": "php-cs-fixer fix tests/ --config=formwork/.php-cs-fixer.php --verbose", "phpstan": "phpstan analyse --configuration=formwork/phpstan.neon", "phpstan:baseline": "phpstan analyse --configuration=formwork/phpstan.neon --generate-baseline=formwork/phpstan-baseline.neon", "rector": "rector --config=formwork/.rector.php", @@ -57,6 +59,8 @@ "serve": [ "Composer\\Config::disableProcessTimeout", "php bin/serve" - ] + ], + "test": "phpunit", + "test:coverage": "phpunit -d pcov.enabled=1 --coverage-html tests/coverage" } } diff --git a/composer.lock b/composer.lock index 0f8a9b2e0..d9ac1327e 100644 --- a/composer.lock +++ b/composer.lock @@ -3759,4 +3759,4 @@ }, "platform-dev": {}, "plugin-api-version": "2.9.0" -} +} \ No newline at end of file diff --git a/formwork/.php-cs-fixer.php b/formwork/.php-cs-fixer.php index 8fc98ff69..d91f92eed 100644 --- a/formwork/.php-cs-fixer.php +++ b/formwork/.php-cs-fixer.php @@ -4,7 +4,7 @@ $finder = Finder::create() ->in(dirname(__DIR__)) - ->exclude(['cache', 'formwork/views', 'panel/node_modules', 'panel/views', 'site/plugins', 'site/templates']); + ->exclude(['cache', 'formwork/views', 'panel/node_modules', 'panel/views', 'site/plugins', 'site/templates', 'tests']); $config = new Config(); @@ -51,4 +51,5 @@ 'single_quote' => true, 'string_implicit_backslashes' => true, ]) - ->setFinder($finder); + ->setFinder($finder) + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache'); diff --git a/formwork/.rector.php b/formwork/.rector.php index e24db1ae9..6ded80a45 100644 --- a/formwork/.rector.php +++ b/formwork/.rector.php @@ -37,6 +37,7 @@ dirname(__DIR__) . '/panel/views', dirname(__DIR__) . '/site/templates', dirname(__DIR__) . '/site/plugins', + dirname(__DIR__) . '/tests', dirname(__DIR__) . '/vendor', ChangeSwitchToMatchRector::class, ClosureToArrowFunctionRector::class, diff --git a/formwork/config/system.yaml b/formwork/config/system.yaml index 9c6319fd5..2a16248a3 100644 --- a/formwork/config/system.yaml +++ b/formwork/config/system.yaml @@ -20,6 +20,7 @@ backup: - 'cache/*' - 'site/auth/*' - 'site/sessions/*' + - 'tests/*' - 'vendor/*' - '*node_modules/*' diff --git a/formwork/phpstan.neon b/formwork/phpstan.neon index 77d5b9839..4e28aad95 100644 --- a/formwork/phpstan.neon +++ b/formwork/phpstan.neon @@ -12,6 +12,7 @@ parameters: - ../panel/views - ../site/templates - ../site/plugins + - ../tests - ../vendor scanFiles: - ../index.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 000000000..80decdf40 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + ./tests + + + + + + ./formwork/src + + + ./formwork/src/Images/Exif/tables + ./formwork/src/Utils/scripts + + + diff --git a/tests/Environment.php b/tests/Environment.php new file mode 100644 index 000000000..57544665a --- /dev/null +++ b/tests/Environment.php @@ -0,0 +1,30 @@ + true, + 'fileinfo' => true, + 'gd' => true, + 'mbstring' => true, + 'openssl' => true, + 'zip' => true, + ]; + + public static function enableExtension(string $extension): void + { + self::$extensions[$extension] = true; + } + + public static function disableExtension(string $extension): void + { + self::$extensions[$extension] = false; + } + + public static function isExtensionEnabled(string $extension): bool + { + return self::$extensions[$extension] ?? false; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 000000000..aeedd69f2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,37 @@ +getFileName()); + + if (FileSystem::exists($dir . '/fixtures/functions.php')) { + require_once $dir . '/fixtures/functions.php'; + } + } + + protected function setUpTempDirectory(): void + { + if (!FileSystem::isDirectory(TESTS_TMP_PATH, assertExists: false)) { + FileSystem::createDirectory(TESTS_TMP_PATH); + } + } + + protected function tearDownTempDirectory(): void + { + if (FileSystem::isDirectory(TESTS_TMP_PATH, assertExists: false)) { + FileSystem::deleteDirectory(TESTS_TMP_PATH, recursive: true); + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 000000000..041bcbbfc --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,17 @@ +addPsr4('Formwork\Tests\\', TESTS_PATH); +$autoloader->register(); From 30dc3378e1b794bfc48e81277e65c9d2e7778d8a Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:23:20 +0100 Subject: [PATCH 02/51] Add tests for the `Utils` namespace --- tests/Utils/ArrTest.php | 1361 +++++++++++++++++ tests/Utils/ConstraintTest.php | 178 +++ tests/Utils/DateTest.php | 98 ++ tests/Utils/FileSystemTest.php | 970 ++++++++++++ tests/Utils/Fixtures/ArrayableFixture.php | 15 + tests/Utils/Fixtures/FileSystemFixture.php | 195 +++ tests/Utils/Fixtures/StringableFixture.php | 15 + tests/Utils/Fixtures/TraversableFixture.php | 16 + .../Utils/Fixtures/files/mimetype/invalid.svg | 1 + .../Utils/Fixtures/files/mimetype/sample.html | 9 + .../Utils/Fixtures/files/mimetype/sample.yaml | 1 + tests/Utils/Fixtures/files/mimetype/valid.svg | 1 + tests/Utils/Fixtures/files/tmp/dir/.hidden | 1 + tests/Utils/Fixtures/files/tmp/dir/b.txt | 1 + tests/Utils/Fixtures/files/tmp/dir/sample.txt | 1 + .../Utils/Fixtures/files/tmp/dir/subdir/a.txt | 1 + tests/Utils/Fixtures/files/tmp/sample.txt | 1 + tests/Utils/Fixtures/files/tmp/symlink | 1 + tests/Utils/Fixtures/functions.php | 101 ++ tests/Utils/HtmlTest.php | 56 + tests/Utils/MimeTypeTest.php | 63 + tests/Utils/PathTest.php | 209 +++ tests/Utils/StrTest.php | 164 ++ tests/Utils/TextTest.php | 76 + tests/Utils/UriTest.php | 165 ++ 25 files changed, 3700 insertions(+) create mode 100644 tests/Utils/ArrTest.php create mode 100644 tests/Utils/ConstraintTest.php create mode 100644 tests/Utils/DateTest.php create mode 100644 tests/Utils/FileSystemTest.php create mode 100644 tests/Utils/Fixtures/ArrayableFixture.php create mode 100644 tests/Utils/Fixtures/FileSystemFixture.php create mode 100644 tests/Utils/Fixtures/StringableFixture.php create mode 100644 tests/Utils/Fixtures/TraversableFixture.php create mode 100644 tests/Utils/Fixtures/files/mimetype/invalid.svg create mode 100644 tests/Utils/Fixtures/files/mimetype/sample.html create mode 100644 tests/Utils/Fixtures/files/mimetype/sample.yaml create mode 100644 tests/Utils/Fixtures/files/mimetype/valid.svg create mode 100644 tests/Utils/Fixtures/files/tmp/dir/.hidden create mode 100644 tests/Utils/Fixtures/files/tmp/dir/b.txt create mode 100644 tests/Utils/Fixtures/files/tmp/dir/sample.txt create mode 100644 tests/Utils/Fixtures/files/tmp/dir/subdir/a.txt create mode 100644 tests/Utils/Fixtures/files/tmp/sample.txt create mode 120000 tests/Utils/Fixtures/files/tmp/symlink create mode 100644 tests/Utils/Fixtures/functions.php create mode 100644 tests/Utils/HtmlTest.php create mode 100644 tests/Utils/MimeTypeTest.php create mode 100644 tests/Utils/PathTest.php create mode 100644 tests/Utils/StrTest.php create mode 100644 tests/Utils/TextTest.php create mode 100644 tests/Utils/UriTest.php diff --git a/tests/Utils/ArrTest.php b/tests/Utils/ArrTest.php new file mode 100644 index 000000000..606f43858 --- /dev/null +++ b/tests/Utils/ArrTest.php @@ -0,0 +1,1361 @@ + [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ], + 'date' => '2025-03-29', + ]; + + $this->assertSame('2025-03-29', Arr::get($data, 'date')); + $this->assertSame('Sempronius', Arr::get($data, 'user.name')); + $this->assertNull(Arr::get($data, 'user.age')); + } + + public function testHas(): void + { + $data = [ + 'user' => [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ], + 'date' => '2025-03-29', + ]; + + $this->assertTrue(Arr::has($data, 'date')); + $this->assertTrue(Arr::has($data, 'user.email')); + $this->assertFalse(Arr::has($data, 'user.age')); + } + + public function testSet(): void + { + $data = [ + 'user' => [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ], + 'date' => '2025-03-29', + ]; + + Arr::set($data, 'user.age', 30); + $this->assertSame(30, Arr::get($data, 'user.age')); + + Arr::set($data, 'roles.available', 'admin'); + $this->assertSame('admin', Arr::get($data, 'roles.available')); + + Arr::set($data, 'date', '2025-04-01'); + $this->assertSame('2025-04-01', Arr::get($data, 'date')); + } + + public function testRemove(): void + { + $data = [ + 'user' => [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ], + 'date' => '2025-03-29', + ]; + + Arr::remove($data, 'user.email'); + $this->assertFalse(Arr::has($data, 'user.email')); + + Arr::remove($data, 'roles.available'); + $this->assertFalse(Arr::has($data, 'roles.available')); + + Arr::remove($data, 'date'); + $this->assertFalse(Arr::has($data, 'date')); + } + + public function testFlatten(): void + { + $data = [ + 'app' => [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ], + + ]; + + $expected = [ + 'app.theme' => 'dark', + 'app.notifications' => true, + 'app.roles' => ['admin', 'editor'], + 'app.permissions' => ['pages', 'files'], + 'app.cache.enabled' => true, + 'app.cache.duration' => 3600, + ]; + + $this->assertSame($expected, Arr::dot($data)); + } + + public function testExpand(): void + { + $data = [ + 'app' => [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ], + ]; + + $dot = [ + 'app.theme' => 'dark', + 'app.notifications' => true, + 'app.roles' => ['admin', 'editor'], + 'app.permissions' => ['pages', 'files'], + 'app.cache.enabled' => true, + 'app.cache.duration' => 3600, + ]; + + $this->assertSame($data, Arr::undot($dot)); + } + + public function testPull(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + Arr::pull($data, 'banana'); + $this->assertNotContains('banana', $data); + } + + public function testSplice(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $removed = Arr::splice($data, 1, 2, ['orange', 'grapes']); + + $this->assertSame(['banana', 'cherry'], $removed); + $this->assertSame(['apple', 'orange', 'grapes', 'banana'], $data); + } + + public function testSpliceWithAssociativeArray(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $removed = Arr::splice($data, 1, 1, ['age' => 30]); + + $this->assertSame(['email' => 'sempronius@example.com'], $removed); + $this->assertSame(['name' => 'Sempronius', 'age' => 30, 'country' => 'Italy'], $data); + } + + public function testSpliceWithNegativeOffset(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $removed = Arr::splice($data, -2, 1, ['age' => 30]); + + $this->assertSame(['email' => 'sempronius@example.com'], $removed); + $this->assertSame(['name' => 'Sempronius', 'age' => 30, 'country' => 'Italy'], $data); + } + + public function testSpliceWithNegativeLength(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $removed = Arr::splice($data, 0, -1, ['age' => 30]); + + $this->assertSame(['name' => 'Sempronius', 'email' => 'sempronius@example.com'], $removed); + $this->assertSame(['age' => 30, 'country' => 'Italy'], $data); + } + + public function testSpliceThrowsOnDuplicateKeys(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Cannot replace 1 items from offset 1: some keys in the replacement array are the same of the resulting array'); + Arr::splice($data, 1, 1, ['country' => 'Canada']); + } + + public function testMove(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + Arr::moveItem($data, 0, 2); + $this->assertSame(['banana', 'cherry', 'apple', 'banana'], $data); + } + + public function testEntries(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $expected = [ + ['name', 'Sempronius'], + ['email', 'sempronius@example.com'], + ['country', 'Italy'], + ]; + + $this->assertSame($expected, Arr::entries($data)); + } + + public function testNth(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertSame('cherry', Arr::nth($data, 2)); + $this->assertNull(Arr::nth($data, 5)); + } + + public function testAt(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertSame('cherry', Arr::at($data, 2)); + $this->assertNull(Arr::at($data, 5)); + + $this->assertSame('banana', Arr::at($data, -1)); + $this->assertNull(Arr::at($data, -5)); + } + + public function testIndex(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertSame(1, Arr::indexOf($data, 'banana')); + $this->assertNull(Arr::indexOf($data, 'orange')); + } + + public function testKey(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $this->assertSame('email', Arr::keyOf($data, 'sempronius@example.com')); + $this->assertNull(Arr::keyOf($data, 'Brazil')); + } + + public function testDuplicates(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertSame([3 => 'banana'], Arr::duplicates($data)); + } + + public function testAppendMissing(): void + { + $data = [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ]; + + $expected = [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor', 'user'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + 'language' => 'en', + ]; + + $result = Arr::appendMissing($data, ['roles' => ['admin', 'editor', 'user'], 'language' => 'en', 'cache' => false]); + $this->assertSame($expected, $result); + } + + public function testExtend(): void + { + // Test basic array extension with associative arrays + $base = [ + 'app' => [ + 'name' => 'Formwork', + 'debug' => false, + ], + 'cache' => [ + 'enabled' => true, + ], + ]; + + $extension = [ + 'app' => [ + 'version' => '2.0', + 'debug' => true, + ], + 'database' => [ + 'host' => 'localhost', + ], + ]; + + $expected = [ + 'app' => [ + 'name' => 'Formwork', + 'debug' => true, + 'version' => '2.0', + ], + 'cache' => [ + 'enabled' => true, + ], + 'database' => [ + 'host' => 'localhost', + ], + ]; + + $result = Arr::extend($base, $extension); + $this->assertSame($expected, $result); + } + + public function testExtendWithListConcatenation(): void + { + // Test that lists are concatenated instead of merged element-by-element + $base = [ + 'roles' => ['admin', 'editor'], + 'permissions' => ['read', 'write'], + ]; + + $extension = [ + 'roles' => ['user', 'guest'], + ]; + + $expected = [ + 'roles' => ['admin', 'editor', 'user', 'guest'], + 'permissions' => ['read', 'write'], + ]; + + $result = Arr::extend($base, $extension); + $this->assertSame($expected, $result); + } + + public function testExtendWithDeeplyNested(): void + { + // Test deeply nested structures + $base = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + ], + 'features' => ['search', 'backup'], + ], + ], + ]; + + $extension = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'lifetime' => 7200, + 'driver' => 'file', + ], + 'features' => ['images'], + 'logging' => true, + ], + ], + ]; + + $expected = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'enabled' => true, + 'lifetime' => 7200, + 'driver' => 'file', + ], + 'features' => ['search', 'backup', 'images'], + 'logging' => true, + ], + ], + ]; + + $result = Arr::extend($base, $extension); + $this->assertSame($expected, $result); + } + + public function testExtendWithMultipleArrays(): void + { + // Test extending with multiple arrays + $base = [ + 'a' => 1, + 'b' => ['x' => 10], + ]; + + $ext1 = [ + 'b' => ['y' => 20], + 'c' => 3, + ]; + + $ext2 = [ + 'a' => 2, + 'd' => 4, + ]; + + $expected = [ + 'a' => 2, + 'b' => ['x' => 10, 'y' => 20], + 'c' => 3, + 'd' => 4, + ]; + + $result = Arr::extend($base, $ext1, $ext2); + $this->assertSame($expected, $result); + } + + public function testExtendWithEmptyArrays(): void + { + // Test extending empty arrays + $result = Arr::extend([], ['a' => 1]); + $this->assertSame(['a' => 1], $result); + + $result = Arr::extend(['a' => 1], []); + $this->assertSame(['a' => 1], $result); + + $result = Arr::extend([], []); + $this->assertSame([], $result); + } + + public function testExtendWithMixedTypes(): void + { + // Test that scalar values override arrays and vice versa + $base = [ + 'scalar_to_array' => 'value', + 'array_to_scalar' => ['a', 'b'], + 'keep_scalar' => 42, + ]; + + $extension = [ + 'scalar_to_array' => ['new' => 'array'], + 'array_to_scalar' => 'string', + 'keep_scalar' => 100, + ]; + + $expected = [ + 'scalar_to_array' => ['new' => 'array'], + 'array_to_scalar' => 'string', + 'keep_scalar' => 100, + ]; + + $result = Arr::extend($base, $extension); + $this->assertSame($expected, $result); + } + + public function testOverride(): void + { + // Test basic override functionality + $base = [ + 'app' => [ + 'name' => 'Formwork', + 'debug' => false, + ], + 'cache' => [ + 'enabled' => true, + ], + ]; + + $override = [ + 'app' => [ + 'debug' => true, + ], + 'database' => [ + 'host' => 'localhost', + ], + ]; + + $expected = [ + 'app' => [ + 'name' => 'Formwork', + 'debug' => true, + ], + 'cache' => [ + 'enabled' => true, + ], + 'database' => [ + 'host' => 'localhost', + ], + ]; + + $result = Arr::override($base, $override); + $this->assertSame($expected, $result); + } + + public function testOverrideReplacingLists(): void + { + // Test that lists are completely replaced, not merged + $base = [ + 'roles' => ['admin', 'editor'], + 'permissions' => ['read', 'write', 'delete'], + ]; + + $override = [ + 'roles' => ['user', 'guest'], + ]; + + $expected = [ + 'roles' => ['user', 'guest'], + 'permissions' => ['read', 'write', 'delete'], + ]; + + $result = Arr::override($base, $override); + $this->assertSame($expected, $result); + } + + public function testOverrideWithDeeplyNested(): void + { + // Test deeply nested structures with lists + $base = [ + 'config' => [ + 'system' => [ + 'features' => ['search', 'backup'], + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + 'stores' => ['file', 'redis'], + ], + ], + ], + ]; + + $override = [ + 'config' => [ + 'system' => [ + 'features' => ['images'], + 'cache' => [ + 'lifetime' => 7200, + 'stores' => ['memcached'], + ], + ], + ], + ]; + + $expected = [ + 'config' => [ + 'system' => [ + 'features' => ['images'], + 'cache' => [ + 'enabled' => true, + 'lifetime' => 7200, + 'stores' => ['memcached'], + ], + ], + ], + ]; + + $result = Arr::override($base, $override); + $this->assertSame($expected, $result); + } + + public function testOverrideWithMultipleArrays(): void + { + // Test overriding with multiple arrays + $base = [ + 'a' => 1, + 'b' => ['x' => 10, 'y' => [1, 2, 3]], + ]; + + $override1 = [ + 'b' => ['y' => [4, 5]], + 'c' => 3, + ]; + + $override2 = [ + 'a' => 2, + 'd' => 4, + ]; + + $expected = [ + 'a' => 2, + 'b' => ['x' => 10, 'y' => [4, 5]], + 'c' => 3, + 'd' => 4, + ]; + + $result = Arr::override($base, $override1, $override2); + $this->assertSame($expected, $result); + } + + public function testOverrideWithEmptyArrays(): void + { + // Test overriding with empty arrays + $result = Arr::override([], ['a' => 1]); + $this->assertSame(['a' => 1], $result); + + $result = Arr::override(['a' => 1], []); + $this->assertSame(['a' => 1], $result); + + $result = Arr::override([], []); + $this->assertSame([], $result); + } + + public function testOverrideReplacingListWithEmptyList(): void + { + // Test replacing a list with an empty list + $base = [ + 'items' => [1, 2, 3], + 'other' => 'value', + ]; + + $override = [ + 'items' => [], + ]; + + $expected = [ + 'items' => [], + 'other' => 'value', + ]; + + $result = Arr::override($base, $override); + $this->assertSame($expected, $result); + } + + public function testOverrideWithMixedTypes(): void + { + // Test that mixed types are properly replaced + $base = [ + 'scalar_to_array' => 'value', + 'array_to_scalar' => ['a', 'b'], + 'list_to_assoc' => [1, 2, 3], + 'assoc_to_list' => ['x' => 1, 'y' => 2], + ]; + + $override = [ + 'scalar_to_array' => ['new' => 'array'], + 'array_to_scalar' => 'string', + 'list_to_assoc' => ['a' => 1], + 'assoc_to_list' => [1, 2], + ]; + + $expected = [ + 'scalar_to_array' => ['new' => 'array'], + 'array_to_scalar' => 'string', + 'list_to_assoc' => ['a' => 1], + 'assoc_to_list' => [1, 2], + ]; + + $result = Arr::override($base, $override); + $this->assertSame($expected, $result); + } + + public function testExclude(): void + { + // Test basic exclusion functionality + $array = [ + 'name' => 'Formwork', + 'version' => '2.0', + 'debug' => true, + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + ], + ]; + + $exclusion = [ + 'debug' => true, + 'cache' => [ + 'lifetime' => 3600, + ], + ]; + + $expected = [ + 'name' => 'Formwork', + 'version' => '2.0', + 'cache' => [ + 'enabled' => true, + ], + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeRemovingEmptyArrays(): void + { + // Test that empty arrays resulting from recursion are removed + $array = [ + 'settings' => [ + 'cache' => [ + 'enabled' => true, + ], + ], + 'other' => 'value', + ]; + + $exclusion = [ + 'settings' => [ + 'cache' => [ + 'enabled' => true, + ], + ], + ]; + + $expected = [ + 'other' => 'value', + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeWithDeeplyNested(): void + { + // Test deeply nested exclusions + $array = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + 'driver' => 'file', + ], + 'features' => [ + 'search' => true, + 'backup' => false, + ], + ], + 'other' => 'preserved', + ], + ]; + + $exclusion = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'lifetime' => 3600, + ], + 'features' => [ + 'backup' => false, + ], + ], + ], + ]; + + $expected = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'enabled' => true, + 'driver' => 'file', + ], + 'features' => [ + 'search' => true, + ], + ], + 'other' => 'preserved', + ], + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeWithMultipleArrays(): void + { + // Test excluding with multiple exclusion arrays + $array = [ + 'a' => 1, + 'b' => 2, + 'c' => 3, + 'd' => 4, + ]; + + $exc1 = ['a' => 1]; + $exc2 = ['c' => 3]; + + $expected = [ + 'b' => 2, + 'd' => 4, + ]; + + $result = Arr::exclude($array, $exc1, $exc2); + $this->assertSame($expected, $result); + } + + public function testExcludePreservingNonMatchingValues(): void + { + // Test that non-matching values are preserved + $array = [ + 'name' => 'Formwork', + 'debug' => true, + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + ], + ]; + + $exclusion = [ + 'debug' => false, + 'cache' => [ + 'lifetime' => 7200, + ], + ]; + + $expected = [ + 'name' => 'Formwork', + 'debug' => true, + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + ], + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeWithEmptyArray(): void + { + // Test with empty exclusion array + $array = [ + 'a' => 1, + 'b' => 2, + ]; + + $result = Arr::exclude($array, []); + $this->assertSame($array, $result); + } + + public function testExcludeAllValues(): void + { + // Test when all items are excluded + $array = [ + 'a' => 1, + 'b' => 2, + ]; + + $exclusion = [ + 'a' => 1, + 'b' => 2, + ]; + + $expected = []; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeWithNonExistentKeys(): void + { + // Test excluding keys that don't exist in the array + $array = [ + 'a' => 1, + 'b' => 2, + ]; + + $exclusion = [ + 'c' => 3, + 'd' => 4, + ]; + + $expected = [ + 'a' => 1, + 'b' => 2, + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeWithMixedTypes(): void + { + // Test excluding with mixed scalar and array values + $array = [ + 'scalar' => 'value', + 'number' => 42, + 'nested' => [ + 'a' => 1, + 'b' => 2, + ], + 'preserve_me' => 'keep', + ]; + + $exclusion = [ + 'scalar' => 'value', + 'nested' => [ + 'a' => 1, + ], + ]; + + $expected = [ + 'number' => 42, + 'nested' => [ + 'b' => 2, + ], + 'preserve_me' => 'keep', + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeDoesNotMatchScalarWithArray(): void + { + // Test that array exclusions don't match scalar values + $array = [ + 'value' => 'scalar', + ]; + + $exclusion = [ + 'value' => ['array'], + ]; + + $expected = [ + 'value' => 'scalar', + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testRandom(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertContains(Arr::random($data), $data); + $this->assertNull(Arr::random([])); + } + + public function testShuffle(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $shuffled = Arr::shuffle($data); + + $this->assertSameSize($data, $shuffled); + + foreach ($shuffled as $fruit) { + $this->assertContains($fruit, $data); + } + + $this->assertSame(['test'], Arr::shuffle(['test'])); + } + + public function testShufflePreservingKeys(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $shuffled = Arr::shuffle($data, preserveKeys: true); + $this->assertSameSize($data, $shuffled); + + foreach ($shuffled as $key => $value) { + $this->assertArrayHasKey($key, $data); + $this->assertSame($data[$key], $value); + } + } + + public function testIsAssociative(): void + { + $user = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $fruits = [ + 'apple', + 'banana', + 'cherry', + 'banana', + ]; + + $this->assertTrue(Arr::isAssociative($user)); + $this->assertFalse(Arr::isAssociative($fruits)); + $this->assertFalse(Arr::isAssociative([])); + } + + public function testMap(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $expected = [ + 'name' => 'SEMPRONIUS', + 'email' => 'SEMPRONIUS@EXAMPLE.COM', + 'country' => 'ITALY', + ]; + + $this->assertSame($expected, Arr::map($data, fn($data) => strtoupper($data))); + } + + public function testMapKeys(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $expected = [ + 'USER_NAME' => 'Sempronius', + 'USER_EMAIL' => 'sempronius@example.com', + 'USER_COUNTRY' => 'Italy', + ]; + + $this->assertSame($expected, Arr::mapKeys($data, fn($key) => 'USER_' . strtoupper($key))); + } + + public function testFilter(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $expected = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + ]; + + $this->assertSame($expected, Arr::filter($data, fn($value, $key) => $value !== 'Italy')); + } + + public function testReject(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $expected = [ + 'email' => 'sempronius@example.com', + ]; + + $this->assertSame($expected, Arr::reject($data, fn($value, $key) => !str_contains($value, '@'))); + } + + public function testEvery(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertTrue(Arr::every($data, fn($value) => is_string($value))); + $this->assertFalse(Arr::every($data, fn($value) => $value === 'banana')); + } + + public function testSome(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertTrue(Arr::some($data, fn($value) => $value === 'banana')); + $this->assertFalse(Arr::some($data, fn($value) => $value === 'orange')); + } + + public function testFind(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertSame('banana', Arr::find($data, fn($value) => $value === 'banana')); + $this->assertNull(Arr::find($data, fn($value) => $value === 'orange')); + } + + public function testPluck(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5], + ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3], + ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8], + ]; + + $expected = [ + 'Item 1', + 'Item 2', + 'Item 3', + ]; + + $this->assertSame($expected, Arr::extract($data, 'name')); + } + + public function testGroupBy(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5], + ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3], + ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8], + + ]; + + $expected = [ + 'A' => [ + ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5], + ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3], + ], + 'B' => [ + ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8], + ], + ]; + + $this->assertSame($expected, Arr::group($data, fn($item) => $item['group'])); + + // Test with Stringable values + $this->assertSame($expected, Arr::group($data, fn($item) => new StringableFixture($item['group']))); + } + + public function testCollapse(): void + { + $nested = [ + 'a', + ['b', 'c'], + [['d', 'e'], 'f'], + ]; + + $this->assertSame($nested, Arr::flatten($nested, depth: 0)); + $this->assertSame(['a', 'b', 'c', ['d', 'e'], 'f'], Arr::flatten($nested, depth: 1)); + + $this->assertSame(['a', 'b', 'c', 'd', 'e', 'f'], Arr::flatten($nested)); + } + + public function testCollapseWithArrayableObjects(): void + { + $nested = [ + 'a', + new ArrayableFixture(['b', 'c']), + new TraversableFixture([new ArrayableFixture(['d', 'e']), 'f']), + ]; + + $this->assertSame(['a', 'b', 'c', 'd', 'e', 'f'], Arr::flatten($nested)); + } + + public function testFlattenThrowsOnNegativeDepth(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('expects a non-negative depth'); + Arr::flatten([], depth: -1); + } + + public function testSort(): void + { + $fruits = ['banana', 'apple', 'cherry']; + + $this->assertSame([1 => 'apple', 0 => 'banana', 2 => 'cherry'], Arr::sort($fruits)); + + $this->assertSame([2 => 'cherry', 0 => 'banana', 1 => 'apple'], Arr::sort($fruits, direction: SORT_DESC)); + + $this->assertSame(['apple', 'banana', 'cherry'], Arr::sort($fruits, preserveKeys: false)); + + $this->assertSame(['cherry', 'banana', 'apple'], Arr::sort($fruits, direction: SORT_DESC, preserveKeys: false)); + } + + public function testSortWithCallable(): void + { + $expected = [ + ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3], + ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5], + ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8], + ]; + + $data = [ + ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5], + ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3], + ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8], + ]; + + $this->assertSame($expected, Arr::sort($data, sortBy: fn($a, $b) => $a['count'] <=> $b['count'], preserveKeys: false)); + } + + public function testSortWithOrderArray(): void + { + $expected = [ + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + 'notifications' => true, + 'theme' => 'dark', + ]; + + $data = [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ]; + + $this->assertSame($expected, Arr::sort($data, sortBy: ['roles' => 0, 'permissions' => 1, 'cache' => 2, 'notifications' => 3, 'theme' => 4])); + } + + public function testSortWithOrderWithoutPreservingKeys(): void + { + $data = [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ]; + + $expected = [ + ['admin', 'editor'], + ['pages', 'files'], + [ + 'enabled' => true, + 'duration' => 3600, + ], + true, + 'dark', + ]; + + $this->assertSame($expected, Arr::sort($data, sortBy: ['roles' => 0, 'permissions' => 1, 'cache' => 2, 'notifications' => 3, 'theme' => 4], preserveKeys: false)); + } + + public function testSortThrowsOnInvalidDirection(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('only accepts SORT_ASC and SORT_DESC as "direction" option'); + Arr::sort([], direction: 123); + } + + public function testSortThrowsOnInvalidType(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('only accepts SORT_REGULAR, SORT_NUMERIC, SORT_STRING and SORT_NATURAL as "type" option'); + Arr::sort([], type: 123); + } + + public function testSortThrowsOnInvalidSortByCount(): void + { + $data = [ + 'app' => [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ], + ]; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Cannot sort array: the $sortBy array must have the same number of items as the array to sort'); + Arr::sort($data, sortBy: []); + } + + public function testSortThrowsOnMissingSortByKey(): void + { + $data = [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ]; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Cannot sort array: key "test" from the $sortBy array is not present in the array to sort'); + Arr::sort($data, sortBy: ['roles' => 0, 'permissions' => 1, 'cache' => 2, 'notifications' => 3, 'test' => 4]); + } + + public function testToArray(): void + { + $user = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $fruits = [ + 'apple', + 'banana', + 'cherry', + 'banana', + ]; + + $this->assertSame($user, Arr::from($user)); + $this->assertSame($fruits, Arr::from(new TraversableFixture($fruits))); + $this->assertSame($user, Arr::from(new ArrayableFixture($user))); + } + + public function testFromThrowsOnInvalidType(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Cannot convert to array an object of type string'); + Arr::from('invalid_value'); + } + + public function testFromEntries(): void + { + $entries = [ + ['name', 'Sempronius'], + ['email', 'sempronius@example.com'], + ['country', 'Italy'], + ]; + + $expected = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $this->assertSame($expected, Arr::fromEntries($entries)); + } +} diff --git a/tests/Utils/ConstraintTest.php b/tests/Utils/ConstraintTest.php new file mode 100644 index 000000000..66e8769ee --- /dev/null +++ b/tests/Utils/ConstraintTest.php @@ -0,0 +1,178 @@ +assertTrue(Constraint::isTruthy($value)); + } + + $nonTruthyValues = [false, 0, 'false', '0', 'off', 'no', null, '', [], 2, 'random']; + foreach ($nonTruthyValues as $nonTruthyValue) { + $this->assertFalse(Constraint::isTruthy($nonTruthyValue)); + } + } + + public function testIsFalsy(): void + { + $falsyValues = [false, 0, 'false', '0', 'off', 'no']; + foreach ($falsyValues as $value) { + $this->assertTrue(Constraint::isFalsy($value)); + } + + $nonFalsyValues = [true, 1, 'true', '1', 'on', 'yes', null, '', [], 2, 'random']; + foreach ($nonFalsyValues as $nonFalsyValue) { + $this->assertFalse(Constraint::isFalsy($nonFalsyValue)); + } + } + + public function testIsEmpty(): void + { + $emptyValues = [null, '', []]; + foreach ($emptyValues as $value) { + $this->assertTrue(Constraint::isEmpty($value)); + } + + $nonEmptyValues = [false, 0, 'false', '0', 'off', 'no', true, 1, 'true', '1', 'on', 'yes', 2, 'random']; + foreach ($nonEmptyValues as $nonEmptyValue) { + $this->assertFalse(Constraint::isEmpty($nonEmptyValue)); + } + } + + public function testIsEqual(): void + { + // Strict comparison + $this->assertTrue(Constraint::isEqualTo(1, 1, true)); + $this->assertFalse(Constraint::isEqualTo(1, '1', true)); + + // Non-strict comparison + $this->assertTrue(Constraint::isEqualTo(1, '1', false)); + $this->assertFalse(Constraint::isEqualTo(1, 2, false)); + } + + public function testIsNotEqual(): void + { + // Strict comparison + $this->assertTrue(Constraint::isNotEqualTo(1, '1', true)); + $this->assertFalse(Constraint::isNotEqualTo(1, 1, true)); + + // Non-strict comparison + $this->assertTrue(Constraint::isNotEqualTo(1, 2, false)); + $this->assertFalse(Constraint::isNotEqualTo(1, '1', false)); + } + + public function testIsGreaterThan(): void + { + $this->assertTrue(Constraint::isGreaterThan(2, 1)); + $this->assertFalse(Constraint::isGreaterThan(1, 1)); + $this->assertFalse(Constraint::isGreaterThan(1, 2)); + } + + public function testIsGreaterThanOrEqual(): void + { + $this->assertTrue(Constraint::isGreaterThanOrEqualTo(2, 1)); + $this->assertTrue(Constraint::isGreaterThanOrEqualTo(1, 1)); + $this->assertFalse(Constraint::isGreaterThanOrEqualTo(1, 2)); + } + + public function testIsLessThan(): void + { + $this->assertTrue(Constraint::isLessThan(1, 2)); + $this->assertFalse(Constraint::isLessThan(1, 1)); + $this->assertFalse(Constraint::isLessThan(2, 1)); + } + + public function testIsLessThanOrEqual(): void + { + $this->assertTrue(Constraint::isLessThanOrEqualTo(1, 2)); + $this->assertTrue(Constraint::isLessThanOrEqualTo(1, 1)); + $this->assertFalse(Constraint::isLessThanOrEqualTo(2, 1)); + } + + public function testMatches(): void + { + $this->assertTrue(Constraint::matchesRegex('hello', '/^h.*o$/')); + $this->assertFalse(Constraint::matchesRegex('hello', '/^H.*O$/i')); + } + + public function testMatchesWithoutEntireMatch(): void + { + $this->assertTrue(Constraint::matchesRegex('hello', '/e/', entireMatch: false)); + $this->assertFalse(Constraint::matchesRegex('hello', '/e/', entireMatch: true)); + } + + public function testIsInRange(): void + { + $this->assertTrue(Constraint::isInRange(5.25, 1, 10)); + $this->assertTrue(Constraint::isInRange(5, 1, 10)); + $this->assertFalse(Constraint::isInRange(11, 1, 10)); + + $this->assertTrue(Constraint::isInRange(M_PI, 10, 1)); + $this->assertFalse(Constraint::isInRange(-1, 10, 1)); + + $this->assertFalse(Constraint::isInRange(1, 1, 10, includeMin: false)); + $this->assertFalse(Constraint::isInRange(10, 1, 10, includeMax: false)); + } + + public function testIsInRangeWithIntegerRange(): void + { + $this->assertTrue(Constraint::isInIntegerRange(5, 1, 10)); + $this->assertFalse(Constraint::isInIntegerRange(11, 1, 10)); + $this->assertFalse(Constraint::isInIntegerRange(-1, 10, 1)); + + $this->assertFalse(Constraint::isInIntegerRange(1, 1, 10, includeMin: false)); + $this->assertFalse(Constraint::isInIntegerRange(10, 1, 10, includeMax: false)); + } + + public function testIsInRangeWithStep(): void + { + $this->assertTrue(Constraint::isInIntegerRange(4, 0, 10, step: 2)); + $this->assertFalse(Constraint::isInIntegerRange(5, 0, 10, step: 2)); + } + + public function testIsType(): void + { + $this->assertTrue(Constraint::isOfType(123, 'int')); + $this->assertTrue(Constraint::isOfType('hello', 'string')); + $this->assertTrue(Constraint::isOfType([], 'array')); + $this->assertTrue(Constraint::isOfType(12.34, 'float')); + $this->assertTrue(Constraint::isOfType(true, 'bool')); + $this->assertTrue(Constraint::isOfType(new stdClass(), 'stdClass')); + + $this->assertFalse(Constraint::isOfType(123, 'string')); + $this->assertFalse(Constraint::isOfType('hello', 'array')); + $this->assertFalse(Constraint::isOfType([], 'int')); + $this->assertFalse(Constraint::isOfType(12.34, 'bool')); + $this->assertFalse(Constraint::isOfType(true, 'float')); + $this->assertFalse(Constraint::isOfType(new stdClass(), 'array')); + } + + public function testIsTypeWithUnionTypes(): void + { + $this->assertTrue(Constraint::isOfType(123, 'int|string', unionTypes: true)); + $this->assertTrue(Constraint::isOfType('hello', 'int|string', unionTypes: true)); + $this->assertTrue(Constraint::isOfType(new stdClass(), 'array|stdClass', unionTypes: true)); + + $this->assertFalse(Constraint::isOfType(12.34, 'int|string', unionTypes: true)); + $this->assertFalse(Constraint::isOfType(new stdClass(), 'int|string', unionTypes: true)); + } + + public function testHasKeys(): void + { + $array = ['a' => 1, 'b' => 2, 'c' => 3]; + + $this->assertTrue(Constraint::hasKeys($array, ['a', 'b'])); + $this->assertTrue(Constraint::hasKeys($array, [])); + $this->assertFalse(Constraint::hasKeys($array, ['a', 'd'])); + } +} diff --git a/tests/Utils/DateTest.php b/tests/Utils/DateTest.php new file mode 100644 index 000000000..334efdc6c --- /dev/null +++ b/tests/Utils/DateTest.php @@ -0,0 +1,98 @@ + '%s fa', + 'date.distance.in' => 'tra %s', + 'date.duration.days' => ['giorno', 'giorni'], + 'date.duration.hours' => ['ora', 'ore'], + 'date.duration.minutes' => ['minuto', 'minuti'], + 'date.duration.months' => ['mese', 'mesi'], + 'date.duration.seconds' => ['secondo', 'secondi'], + 'date.duration.weeks' => ['settimana', 'settimane'], + 'date.duration.years' => ['anno', 'anni'], + 'date.months.long' => ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'], + 'date.months.short' => ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'], + 'date.now' => 'adesso', + 'date.weekdays.long' => ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato'], + 'date.weekdays.short' => ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'], + ]; + + public function testToTimestamp(): void + { + $this->assertSame(1869436800, Date::toTimestamp('2029-03-29', 'Y-m-d')); + $this->assertSame(1869436800, Date::toTimestamp('29/03/2029', ['Y-m-d', 'd/m/Y'])); + $this->assertSame(1869436800, Date::toTimestamp('2029-03-29', '')); + } + + public function testToTimestampThrowsOnInvalidDate(): void + { + $this->expectException(InvalidArgumentException::class); + Date::toTimestamp('invalid-date', 'Y-m-d'); + } + + public function testFormatToPattern(): void + { + $this->assertSame('YYYY-MM-DD', Date::formatToPattern('Y-m-d')); + $this->assertSame('DD/MM/YYYY hh:mm:ss', Date::formatToPattern('d/m/Y H:i:s')); + $this->assertSame('DD/MM/YYYY [at] hh:mm:ss', Date::formatToPattern('d/m/Y \a\t H:i:s')); + } + + public function testPatternToFormat(): void + { + $this->assertSame('Y-m-d', Date::patternToFormat('YYYY-MM-DD')); + $this->assertSame('d/m/Y H:i:s', Date::patternToFormat('DD/MM/YYYY hh:mm:ss')); + $this->assertSame('l d F Y \a\t h:i:s A \o\' \c\l\o\c\k', Date::patternToFormat('DDDD DD MMMM YYYY [at] HH:mm:ss A [o\' clock]')); + } + + public function testFormat(): void + { + $dateTime = new DateTime('2029-03-29 15:30:00'); + + $translation = new Translation('it', $this->translation); + + $this->assertSame('Gio, 29 Mar 2029 15:30:00 +0000', Date::formatDateTime(new DateTime('2029-03-29 15:30:00'), 'r', $translation)); + $this->assertSame('Giovedì 29 Marzo 2029', Date::formatDateTime($dateTime, 'l d F Y', $translation)); + } + + public function testFormatWithTimestamp(): void + { + $translation = new Translation('it', $this->translation); + + $this->assertSame('Gio, 29 Mar 2029 10:10:00 +0000', Date::formatTimestamp(1869473400, 'r', $translation)); + $this->assertSame('Giovedì 29 Marzo 2029', Date::formatTimestamp(1869473400, 'l d F Y', $translation)); + } + + public function testFormatDistance(): void + { + $translation = new Translation('it', $this->translation); + + $now = time(); + + $this->assertSame('adesso', Date::formatDateTimeAsDistance(new DateTime('@' . $now), $translation, $now)); + $this->assertSame('5 giorni fa', Date::formatDateTimeAsDistance(new DateTime('@' . ($now - 5 * 86400)), $translation, $now)); + $this->assertSame('tra 3 ore', Date::formatDateTimeAsDistance(new DateTime('@' . ($now + 3 * 3600)), $translation, $now)); + } + + public function testFormatDistanceWithTimestamp(): void + { + $translation = new Translation('it', $this->translation); + + $now = time(); + + $this->assertSame('adesso', Date::formatTimestampAsDistance($now, $translation, $now)); + $this->assertSame('2 mesi fa', Date::formatTimestampAsDistance($now - 60 * 86400, $translation, $now)); + $this->assertSame('tra 10 minuti', Date::formatTimestampAsDistance($now + 10 * 60, $translation, $now)); + } +} diff --git a/tests/Utils/FileSystemTest.php b/tests/Utils/FileSystemTest.php new file mode 100644 index 000000000..22c41181a --- /dev/null +++ b/tests/Utils/FileSystemTest.php @@ -0,0 +1,970 @@ +tearDownTempDirectory(); + } + + public function testNormalize(): void + { + $this->assertSame('path/to/directory', FileSystem::normalizePath('path\to/directory')); + $this->assertSame('path/to/directory', FileSystem::normalizePath('path//to\directory')); + } + + public function testJoinPaths(): void + { + $this->assertSame('path/to/directory/file.txt', FileSystem::joinPaths('path/to', 'directory', 'file.txt')); + $this->assertSame('path/to/directory/file.txt', FileSystem::joinPaths('path\to\\', '/directory/', '\file.txt')); + } + + public function testResolve(): void + { + $this->assertSame('/var/www/html', FileSystem::resolvePath('/var/www/html/../html')); + $this->assertSame('C:/Projects/Formwork', FileSystem::resolvePath('C:\Projects\Formwork\..\Formwork')); + } + + public function testBasename(): void + { + $this->assertSame('file', FileSystem::name('/path/to/file.txt')); + $this->assertSame('directory', FileSystem::name('/path/to/directory/')); + } + + public function testExtension(): void + { + $this->assertSame('txt', FileSystem::extension('/path/to/file.txt')); + $this->assertSame('', FileSystem::extension('/path/to/directory/')); + } + + public function testCwd(): void + { + $this->assertSame(getcwd(), FileSystem::cwd()); + } + + #[RunInSeparateProcess] + public function testCwdThrowsOnUnresolved(): void + { + FileSystemFixture::disable('cwd'); + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('Cannot get current working directory'); + FileSystem::cwd(); + } + + public function testIsVisible(): void + { + $this->assertTrue(FileSystem::isVisible('/path/to/visibleFile.txt')); + $this->assertFalse(FileSystem::isVisible('/path/to/.hiddenFile.txt')); + } + + public function testMimeType(): void + { + $this->assertSame('text/plain', FileSystem::mimeType(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testExists(): void + { + $this->assertTrue(FileSystem::exists(TESTS_TMP_PATH . '/sample.txt')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/nonexistent.txt')); + } + + public function testAssertExists(): void + { + FileSystem::assertExists(TESTS_TMP_PATH . '/sample.txt'); + $this->assertTrue(true); + + $this->expectException(FileNotFoundException::class); + FileSystem::assertExists(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + public function testAssertNotExists(): void + { + FileSystem::assertExists(TESTS_TMP_PATH . '/nonexistent.txt', false); + $this->assertTrue(true); + + $this->expectException(FileSystemException::class); + FileSystem::assertExists(TESTS_TMP_PATH . '/sample.txt', false); + } + + public function testIsReadable(): void + { + $this->assertTrue(FileSystem::isReadable(TESTS_TMP_PATH . '/sample.txt')); + + $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); + chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o444); + + $this->assertFalse(FileSystem::isReadable(TESTS_TMP_PATH . '/sample.txt')); + + chmod(TESTS_TMP_PATH . '/sample.txt', $mode); + } + + public function testIsWritable(): void + { + $this->assertTrue(FileSystem::isWritable(TESTS_TMP_PATH . '/sample.txt')); + + $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); + chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o222); + + $this->assertFalse(FileSystem::isWritable(TESTS_TMP_PATH . '/sample.txt')); + + chmod(TESTS_TMP_PATH . '/sample.txt', $mode); + } + + public function testIsFile(): void + { + $this->assertTrue(FileSystem::isFile(TESTS_TMP_PATH . '/sample.txt')); + $this->assertFalse(FileSystem::isFile(TESTS_TMP_PATH . '/dir')); + } + + public function testIsDirectory(): void + { + $this->assertTrue(FileSystem::isDirectory(TESTS_TMP_PATH . '/dir')); + $this->assertTrue(FileSystem::isDirectory(TESTS_TMP_PATH . '/emptydir')); + $this->assertFalse(FileSystem::isDirectory(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testIsEmptyDirectory(): void + { + $this->assertTrue(FileSystem::isEmptyDirectory(TESTS_TMP_PATH . '/emptydir')); + $this->assertFalse(FileSystem::isEmptyDirectory(TESTS_TMP_PATH . '/dir')); + $this->assertFalse(FileSystem::isEmptyDirectory(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testIsLink(): void + { + $this->assertTrue(FileSystem::isLink(TESTS_TMP_PATH . '/symlink')); + $this->assertFalse(FileSystem::isLink(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testAccessTime(): void + { + $this->assertIsInt(FileSystem::accessTime(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testAccessTimeThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::accessTime(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testAccessTimeThrowsOnSystemError(): void + { + FileSystemFixture::disable('fileatime'); + $this->expectException(FileSystemException::class); + FileSystem::accessTime(TESTS_TMP_PATH . '/dir/sample.txt'); + } + + public function testCreationTime(): void + { + $this->assertIsInt(FileSystem::creationTime(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testCreationTimeThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::creationTime(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testCreationTimeThrowsOnSystemError(): void + { + FileSystemFixture::disable('filectime'); + $this->expectException(FileSystemException::class); + FileSystem::creationTime(TESTS_TMP_PATH . '/dir/sample.txt'); + } + + public function testLastModifiedTime(): void + { + $this->assertIsInt(FileSystem::lastModifiedTime(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testLastModifiedTimeThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::lastModifiedTime(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testLastModifiedTimeThrowsOnSystemError(): void + { + FileSystemFixture::disable('filemtime'); + $this->expectException(FileSystemException::class); + FileSystem::lastModifiedTime(TESTS_TMP_PATH . '/dir/sample.txt'); + } + + public function testDirectoryModifiedSince(): void + { + $this->assertTrue(FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/dir', 0)); + $this->assertFalse(FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/dir', time() + 3600)); + } + + public function testDirectoryModifiedSinceRecursively(): void + { + touch(TESTS_TMP_PATH . '/dir', 0); + touch(TESTS_TMP_PATH . '/dir/subdir', 0); + $this->assertTrue(FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/dir', 10)); + } + + public function testDirectoryModifiedThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/sample.txt', 0); + } + + public function testTouch(): void + { + $this->assertTrue(FileSystem::touch(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testTouchThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::touch(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testTouchThrowsOnSystemError(): void + { + FileSystemFixture::disable('touch'); + $this->expectException(FileSystemException::class); + FileSystem::touch(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testMode(): void + { + $this->assertIsInt(FileSystem::mode(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testModeThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::mode(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testModeThrowsOnSystemError(): void + { + FileSystemFixture::disable('fileperms'); + $this->expectException(FileSystemException::class); + FileSystem::mode(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testSize(): void + { + $this->assertIsInt(FileSystem::size(TESTS_TMP_PATH . '/sample.txt')); + $this->assertIsInt(FileSystem::size(TESTS_TMP_PATH . '/dir')); + } + + public function testSizeThrowsOnUnsupportedType(): void + { + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('unsupported file type'); + FileSystem::size('/dev/null'); + } + + public function testFileSizeReturnsSize(): void + { + $this->assertIsInt(FileSystem::fileSize(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testFileSizeThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::fileSize(TESTS_TMP_PATH . '/dir'); + } + + public function testFileSizeThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::fileSize(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testFileSizeThrowsOnSystemError(): void + { + FileSystemFixture::disable('filesize'); + $this->expectException(FileSystemException::class); + FileSystem::fileSize(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testDirectorySizeReturnsSize(): void + { + $this->assertIsInt(FileSystem::directorySize(TESTS_TMP_PATH . '/dir')); + } + + public function testDirectorySizeThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::directorySize(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testDirectorySizeThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::directorySize(TESTS_TMP_PATH . '/nonexistentdir'); + } + + #[RunInSeparateProcess] + public function testDirectorySizeThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('filesize'); + $this->expectException(FileSystemException::class); + FileSystem::directorySize(TESTS_TMP_PATH . '/dir'); + } + + public function testDelete(): void + { + $this->assertTrue(FileSystem::delete(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testDeleteThrowsOnUnsupportedType(): void + { + // Create a FIFO special file for testing + posix_mkfifo(TESTS_TMP_PATH . '/myfifo', 0o600); + + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('unsupported file type'); + + try { + FileSystem::delete(TESTS_TMP_PATH . '/myfifo'); + } finally { + // Clean up the FIFO file + unlink(TESTS_TMP_PATH . '/myfifo'); + } + } + + public function testDeleteFile(): void + { + $this->assertTrue(FileSystem::deleteFile(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testDeleteFileThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::deleteFile(TESTS_TMP_PATH . '/dir'); + } + + public function testDeleteFileThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::deleteFile(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testDeleteFileThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('unlink'); + $this->expectException(FileSystemException::class); + FileSystem::deleteFile(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testDeleteDirectory(): void + { + $this->assertTrue(FileSystem::deleteDirectory(TESTS_TMP_PATH . '/emptydir')); + } + + public function testDeleteDirectoryThrowsOnNotEmpty(): void + { + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('must be empty to be deleted'); + FileSystem::deleteDirectory(TESTS_TMP_PATH . '/dir'); + } + + public function testDeleteDirectoryRecursively(): void + { + $this->assertTrue(FileSystem::deleteDirectory(TESTS_TMP_PATH . '/dir', recursive: true)); + } + + public function testDeleteDirectoryThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::deleteDirectory(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testDeleteDirectoryThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::deleteDirectory(TESTS_TMP_PATH . '/nonexistentdir'); + } + + #[RunInSeparateProcess] + public function testDeleteDirectoryThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('rmdir'); + $this->expectException(FileSystemException::class); + FileSystem::deleteDirectory(TESTS_TMP_PATH . '/emptydir'); + } + + public function testDeleteLink(): void + { + $this->assertTrue(FileSystem::deleteLink(TESTS_TMP_PATH . '/symlink')); + } + + public function testDeleteLinkThrowsOnNotLink(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::deleteLink(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testDeleteLinkThrowsOnLinkNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::deleteLink(TESTS_TMP_PATH . '/nonexistentlink'); + } + + #[RunInSeparateProcess] + public function testDeleteLinkThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('unlink'); + $this->expectException(FileSystemException::class); + FileSystem::deleteLink(TESTS_TMP_PATH . '/symlink'); + } + + public function testCopy(): void + { + $this->assertTrue(FileSystem::copy(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt')); + + $this->assertTrue(FileSystem::copy(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy')); + + $this->assertTrue(FileSystem::copy(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_copy')); + } + + public function testCopyThrowsOnUnsupportedType(): void + { + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('unsupported file type'); + FileSystem::copy('/dev/null', TESTS_TMP_PATH . '/null_copy'); + } + + public function testCopyFile(): void + { + $this->assertTrue(FileSystem::copyFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt')); + } + + public function testCopyFileThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::copyFile(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy'); + } + + public function testCopyFileThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::copyFile(TESTS_TMP_PATH . '/nonexistent.txt', TESTS_TMP_PATH . '/nonexistent_copy.txt'); + } + + public function testCopyFileThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::copyFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample.txt'); + } + + #[RunInSeparateProcess] + public function testCopyFileThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('copy'); + $this->expectException(FileSystemException::class); + FileSystem::copyFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt'); + } + + public function testCopyDirectory(): void + { + $this->assertTrue(FileSystem::copyDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy')); + } + + public function testCopyDirectoryThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::copyDirectory(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt'); + } + + public function testCopyDirectoryThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::copyDirectory(TESTS_TMP_PATH . '/nonexistentdir', TESTS_TMP_PATH . '/nonexistentdir_copy'); + } + + public function testCopyDirectoryThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::copyDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir'); + } + + #[RunInSeparateProcess] + public function testCopyDirectoryThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('copy'); + $this->expectException(FileSystemException::class); + FileSystem::copyDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy'); + } + + public function testCopyLink(): void + { + $this->assertTrue(FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_copy')); + } + + public function testCopyLinkWithOverwrite(): void + { + $this->assertTrue(FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/dir/b.txt', overwrite: true)); + } + + public function testCopyLinkThrowsOnNotLink(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::copyLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_link'); + } + + public function testCopyLinkThrowsOnLinkNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::copyLink(TESTS_TMP_PATH . '/nonexistentlink', TESTS_TMP_PATH . '/nonexistentlink_copy'); + } + + public function testCopyLinkThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink'); + } + + #[RunInSeparateProcess] + public function testCopyLinkThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('symlink'); + $this->expectException(FileSystemException::class); + FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_copy'); + } + + public function testMove(): void + { + $this->assertTrue(FileSystem::move(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/sample.txt')); + + $this->assertTrue(FileSystem::move(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/dir')); + + $this->assertTrue(FileSystem::move(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_moved')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/symlink')); + } + + public function testMoveThrowsOnUnsupportedType(): void + { + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('unsupported file type'); + FileSystem::move('/dev/null', TESTS_TMP_PATH . '/null_moved'); + } + + public function testMoveFile(): void + { + $this->assertTrue(FileSystem::moveFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testMoveFileThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::moveFile(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved'); + } + + public function testMoveFileThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::moveFile(TESTS_TMP_PATH . '/nonexistent.txt', TESTS_TMP_PATH . '/nonexistent_moved.txt'); + } + + public function testMoveFileThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::moveFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample.txt'); + } + + #[RunInSeparateProcess] + public function testMoveFileThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('rename'); + $this->expectException(FileSystemException::class); + FileSystem::moveFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt'); + } + + public function testMoveDirectory(): void + { + $this->assertTrue(FileSystem::moveDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/dir')); + } + + public function testMoveDirectoryThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::moveDirectory(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt'); + } + + public function testMoveDirectoryThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::moveDirectory(TESTS_TMP_PATH . '/nonexistentdir', TESTS_TMP_PATH . '/nonexistentdir_moved'); + } + + public function testMoveDirectoryThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::moveDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir'); + } + + #[RunInSeparateProcess] + public function testMoveDirectoryThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('copy'); + $this->expectException(FileSystemException::class); + FileSystem::moveDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved'); + } + + public function testMoveLink(): void + { + $this->assertTrue(FileSystem::moveLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_moved')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/symlink')); + } + + public function testMoveLinkThrowsOnNotLink(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::moveLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved'); + } + + public function testMoveLinkThrowsOnLinkNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::moveLink(TESTS_TMP_PATH . '/nonexistentlink', TESTS_TMP_PATH . '/nonexistentlink_moved'); + } + + public function testMoveLinkThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::moveLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink'); + } + + #[RunInSeparateProcess] + public function testMoveLinkThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('symlink'); + $this->expectException(FileSystemException::class); + FileSystem::moveLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_moved'); + } + + public function testRead(): void + { + $this->assertSame("This is a sample text file for testing purposes.\n", FileSystem::read(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testReadThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::read(TESTS_TMP_PATH . '/dir'); + } + + public function testReadThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::read(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + public function testReadThrowsOnFileUnreadable(): void + { + $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); + chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o444); + + $this->expectException(FileSystemException::class); + FileSystem::read(TESTS_TMP_PATH . '/sample.txt'); + + chmod(TESTS_TMP_PATH . '/sample.txt', $mode); + } + + #[RunInSeparateProcess] + public function testReadThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('file_get_contents'); + $this->expectException(FileSystemException::class); + FileSystem::read(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testListContents(): void + { + $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir')); + $this->assertCount(3, $contents); + } + + public function testListContentsIncludingHidden(): void + { + $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir', FileSystem::LIST_ALL)); + $this->assertContains('.hidden', $contents); + } + + public function testListContentsFilesOnly(): void + { + $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir', FileSystem::LIST_FILES)); + $this->assertNotContains('subdir', $contents); + } + + public function testListContentsDirectoriesOnly(): void + { + $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir', FileSystem::LIST_DIRECTORIES)); + $this->assertNotContains('.hidden', $contents); + $this->assertNotContains('b.txt', $contents); + $this->assertNotContains('sample.txt', $contents); + } + + public function testListContentsExcludingEmptyDirectories(): void + { + $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH, FileSystem::LIST_VISIBLE | FileSystem::LIST_EXCLUDE_EMPTY_DIRECTORIES)); + $this->assertNotContains('emptydir', $contents); + } + + public function testListContentsThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testListContentsThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/nonexistentdir')); + } + + #[RunInSeparateProcess] + public function testListContentsThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('opendir'); + $this->expectException(FileSystemException::class); + iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir')); + } + + public function testListContentsRecursively(): void + { + $contents = iterator_to_array(FileSystem::listRecursive(TESTS_TMP_PATH)); + $this->assertContains('dir/subdir/a.txt', $contents); + } + + public function testListRecursiveThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + iterator_to_array(FileSystem::listRecursive(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testListFiles(): void + { + $files = iterator_to_array(FileSystem::listFiles(TESTS_TMP_PATH . '/dir')); + $this->assertContains('b.txt', $files); + $this->assertContains('sample.txt', $files); + $this->assertNotContains('subdir', $files); + $this->assertNotContains('.hidden', $files); + } + + public function testListFilesIncludingHidden(): void + { + $files = iterator_to_array(FileSystem::listFiles(TESTS_TMP_PATH . '/dir', includeHidden: true)); + $this->assertContains('.hidden', $files); + } + + public function testListDirectories(): void + { + $dirs = iterator_to_array(FileSystem::listDirectories(TESTS_TMP_PATH . '/dir')); + $this->assertContains('subdir', $dirs); + $this->assertNotContains('b.txt', $dirs); + $this->assertNotContains('sample.txt', $dirs); + $this->assertNotContains('.hidden', $dirs); + } + + public function testListDirectoriesIncludingHidden(): void + { + $dirs = iterator_to_array(FileSystem::listDirectories(TESTS_TMP_PATH . '/dir', includeHidden: true)); + $this->assertContains('.hiddendir', $dirs); + } + + public function testListDirectoriesExcludingEmpty(): void + { + $dirs = iterator_to_array(FileSystem::listDirectories(TESTS_TMP_PATH, includeEmpty: false)); + $this->assertNotContains('emptydir', $dirs); + } + + public function testReadLink(): void + { + $this->assertSame('sample.txt', FileSystem::readLink(TESTS_TMP_PATH . '/symlink')); + } + + public function testReadLinkThrowsOnNotLink(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::readLink(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testReadLinkThrowsOnLinkNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::readLink(TESTS_TMP_PATH . '/nonexistentlink'); + } + + #[RunInSeparateProcess] + public function testReadLinkThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('readlink'); + $this->expectException(FileSystemException::class); + FileSystem::readLink(TESTS_TMP_PATH . '/symlink'); + } + + public function testCreateFile(): void + { + $this->assertTrue(FileSystem::createFile(TESTS_TMP_PATH . '/newfile.txt')); + $this->assertTrue(FileSystem::exists(TESTS_TMP_PATH . '/newfile.txt')); + } + + #[RunInSeparateProcess] + public function testCreateFileThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('fopen'); + $this->expectException(FileSystemException::class); + FileSystem::createFile(TESTS_TMP_PATH . '/newfile.txt'); + } + + public function testCreateTemporaryFile(): void + { + $tempFile = FileSystem::createTemporaryFile(TESTS_TMP_PATH, '_tmp'); + $this->assertTrue(FileSystem::exists($tempFile)); + $this->assertStringStartsWith('_tmp', basename($tempFile)); + } + + #[RunInSeparateProcess] + public function testCreateTempFileThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('fopen'); + $this->expectException(FileSystemException::class); + FileSystem::createTemporaryFile(TESTS_TMP_PATH, '_tmp'); + } + + public function testWrite(): void + { + FileSystem::write(TESTS_TMP_PATH . '/newfile.txt', 'Hello, World!'); + $this->assertSame('Hello, World!', FileSystem::read(TESTS_TMP_PATH . '/newfile.txt')); + } + + public function testWriteToExistingFile(): void + { + chmod(TESTS_TMP_PATH . '/sample.txt', 0o644); + FileSystem::write(TESTS_TMP_PATH . '/sample.txt', 'Hello, World!'); + $this->assertSame('Hello, World!', FileSystem::read(TESTS_TMP_PATH . '/sample.txt')); + $this->assertSame(0o644, FileSystem::mode(TESTS_TMP_PATH . '/sample.txt') & 0o777); + } + + public function testWriteThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::write(TESTS_TMP_PATH . '/dir', 'Hello, World!'); + } + + public function testWriteThrowsOnFileUnwritable(): void + { + $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); + chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o222); + + $this->expectException(FileSystemException::class); + FileSystem::write(TESTS_TMP_PATH . '/sample.txt', 'Hello, World!'); + + chmod(TESTS_TMP_PATH . '/sample.txt', $mode); + } + + #[RunInSeparateProcess] + public function testWriteThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('file_put_contents'); + $this->expectException(FileSystemException::class); + FileSystem::write(TESTS_TMP_PATH . '/newfile.txt', 'Hello, World!'); + } + + public function testCreateDirectory(): void + { + $this->assertTrue(FileSystem::createDirectory(TESTS_TMP_PATH . '/newdir')); + $this->assertTrue(FileSystem::exists(TESTS_TMP_PATH . '/newdir')); + } + + #[RunInSeparateProcess] + public function testCreateDirectoryThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('mkdir'); + $this->expectException(FileSystemException::class); + FileSystem::createDirectory(TESTS_TMP_PATH . '/newdir'); + } + + public function testCreateLink(): void + { + $this->assertTrue(FileSystem::createLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/newlink')); + $this->assertTrue(FileSystem::isLink(TESTS_TMP_PATH . '/newlink')); + } + + #[RunInSeparateProcess] + public function testCreateLinkThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('symlink'); + $this->expectException(FileSystemException::class); + FileSystem::createLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/newlink'); + } + + public function testFormatSize(): void + { + $this->assertSame('0 B', FileSystem::formatSize(-1)); + $this->assertSame('0 B', FileSystem::formatSize(0)); + $this->assertSame('1 B', FileSystem::formatSize(1)); + $this->assertSame('1 KB', FileSystem::formatSize(1024)); + $this->assertSame('1 MB', FileSystem::formatSize(1024 ** 2)); + $this->assertSame('1 GB', FileSystem::formatSize(1024 ** 3)); + $this->assertSame('1 TB', FileSystem::formatSize(1024 ** 4)); + $this->assertSame('1024 TB', FileSystem::formatSize(1024 ** 5)); + } + + public function testShorthandToBytes(): void + { + $this->assertSame(1048576, FileSystem::shorthandToBytes('1M')); + $this->assertSame(1073741824, FileSystem::shorthandToBytes('1G')); + $this->assertSame(512, FileSystem::shorthandToBytes('512')); + $this->assertSame(2048, FileSystem::shorthandToBytes('2K')); + } + + public function testShorthandToBytesThrowsOnInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::shorthandToBytes('1T'); + } + + public function testRandomName(): void + { + $name1 = FileSystem::randomName(); + $name2 = FileSystem::randomName(); + $this->assertNotSame($name1, $name2); + $this->assertSame(16, strlen($name1)); + $this->assertSame(16, strlen($name2)); + } + + public function testRandomNameWithPrefix(): void + { + $name = FileSystem::randomName('test_'); + $this->assertStringStartsWith('test_', $name); + } +} diff --git a/tests/Utils/Fixtures/ArrayableFixture.php b/tests/Utils/Fixtures/ArrayableFixture.php new file mode 100644 index 000000000..d41f6643f --- /dev/null +++ b/tests/Utils/Fixtures/ArrayableFixture.php @@ -0,0 +1,15 @@ +data; + } +} diff --git a/tests/Utils/Fixtures/FileSystemFixture.php b/tests/Utils/Fixtures/FileSystemFixture.php new file mode 100644 index 000000000..d398105eb --- /dev/null +++ b/tests/Utils/Fixtures/FileSystemFixture.php @@ -0,0 +1,195 @@ + true, + 'fileatime' => true, + 'filectime' => true, + 'filemtime' => true, + 'touch' => true, + 'fileperms' => true, + 'filesize' => true, + 'unlink' => true, + 'rmdir' => true, + 'copy' => true, + 'symlink' => true, + 'readlink' => true, + 'rename' => true, + 'file_get_contents' => true, + 'file_put_contents' => true, + 'opendir' => true, + 'fopen' => true, + 'mkdir' => true, + ]; + + public static function enable(string $function): void + { + self::$enabledFunctions[$function] = true; + } + + public static function disable(string $function): void + { + self::$enabledFunctions[$function] = false; + } + + public static function enableAll(): void + { + foreach (array_keys(self::$enabledFunctions) as $function) { + self::enable($function); + } + } + + public static function disableAll(): void + { + foreach (array_keys(self::$enabledFunctions) as $function) { + self::disable($function); + } + } + + public static function cwd(): string|false + { + if (self::$enabledFunctions['cwd']) { + return getcwd(); + } + return false; + } + + public static function fileatime(string $filename): int|false + { + if (self::$enabledFunctions['fileatime']) { + return fileatime($filename); + } + return false; + } + + public static function filectime(string $filename): int|false + { + if (self::$enabledFunctions['filectime']) { + return filectime($filename); + } + return false; + } + + public static function filemtime(string $filename): int|false + { + if (self::$enabledFunctions['filemtime']) { + return filemtime($filename); + } + return false; + } + + public static function touch(string $filename, ?int $time = null, ?int $atime = null): bool + { + if (!self::$enabledFunctions['touch']) { + return false; + } + return touch($filename, $time, $atime); + } + + public static function fileperms(string $filename): int|false + { + if (self::$enabledFunctions['fileperms']) { + return fileperms($filename); + } + return false; + } + + public static function filesize(string $filename): int|false + { + if (self::$enabledFunctions['filesize']) { + return filesize($filename); + } + return false; + } + + public static function unlink(string $filename): bool + { + if (!self::$enabledFunctions['unlink']) { + return false; + } + return unlink($filename); + } + + public static function rmdir(string $dirname): bool + { + if (!self::$enabledFunctions['rmdir']) { + return false; + } + return rmdir($dirname); + } + + public static function copy(string $from, string $to): bool + { + if (!self::$enabledFunctions['copy']) { + return false; + } + return copy($from, $to); + } + + public static function symlink(string $target, string $link): bool + { + if (!self::$enabledFunctions['symlink']) { + return false; + } + return symlink($target, $link); + } + + public static function readlink(string $path): string|false + { + if (!self::$enabledFunctions['readlink']) { + return false; + } + return readlink($path); + } + + public static function rename(string $from, string $to): bool + { + if (!self::$enabledFunctions['rename']) { + return false; + } + return rename($from, $to); + } + + public static function file_get_contents(string $filename): string|false + { + if (!self::$enabledFunctions['file_get_contents']) { + return false; + } + return file_get_contents($filename); + } + + public static function file_put_contents(string $filename, mixed $data, int $flags = 0): int|false + { + if (!self::$enabledFunctions['file_put_contents']) { + return false; + } + return file_put_contents($filename, $data, $flags); + } + + public static function opendir(string $directory): mixed + { + if (!self::$enabledFunctions['opendir']) { + return false; + } + return opendir($directory); + } + + public static function fopen(string $filename, string $mode): mixed + { + if (!self::$enabledFunctions['fopen']) { + return false; + } + return fopen($filename, $mode); + } + + public static function mkdir(string $directory, int $mode = 0o777, bool $recursive = false): bool + { + if (!self::$enabledFunctions['mkdir']) { + return false; + } + return mkdir($directory, $mode, $recursive); + } +} diff --git a/tests/Utils/Fixtures/StringableFixture.php b/tests/Utils/Fixtures/StringableFixture.php new file mode 100644 index 000000000..c721734da --- /dev/null +++ b/tests/Utils/Fixtures/StringableFixture.php @@ -0,0 +1,15 @@ +value; + } +} diff --git a/tests/Utils/Fixtures/TraversableFixture.php b/tests/Utils/Fixtures/TraversableFixture.php new file mode 100644 index 000000000..c23a54390 --- /dev/null +++ b/tests/Utils/Fixtures/TraversableFixture.php @@ -0,0 +1,16 @@ +data = $data; + } +} diff --git a/tests/Utils/Fixtures/files/mimetype/invalid.svg b/tests/Utils/Fixtures/files/mimetype/invalid.svg new file mode 100644 index 000000000..76c62df1e --- /dev/null +++ b/tests/Utils/Fixtures/files/mimetype/invalid.svg @@ -0,0 +1 @@ + + + + Sample HTML + + + This is a sample HTML file. + + diff --git a/tests/Utils/Fixtures/files/mimetype/sample.yaml b/tests/Utils/Fixtures/files/mimetype/sample.yaml new file mode 100644 index 000000000..470446edd --- /dev/null +++ b/tests/Utils/Fixtures/files/mimetype/sample.yaml @@ -0,0 +1 @@ +title: Sample YAML file diff --git a/tests/Utils/Fixtures/files/mimetype/valid.svg b/tests/Utils/Fixtures/files/mimetype/valid.svg new file mode 100644 index 000000000..474ea3392 --- /dev/null +++ b/tests/Utils/Fixtures/files/mimetype/valid.svg @@ -0,0 +1 @@ + diff --git a/tests/Utils/Fixtures/files/tmp/dir/.hidden b/tests/Utils/Fixtures/files/tmp/dir/.hidden new file mode 100644 index 000000000..185c42002 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/dir/.hidden @@ -0,0 +1 @@ +This is a sample text file for testing purposes. diff --git a/tests/Utils/Fixtures/files/tmp/dir/b.txt b/tests/Utils/Fixtures/files/tmp/dir/b.txt new file mode 100644 index 000000000..185c42002 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/dir/b.txt @@ -0,0 +1 @@ +This is a sample text file for testing purposes. diff --git a/tests/Utils/Fixtures/files/tmp/dir/sample.txt b/tests/Utils/Fixtures/files/tmp/dir/sample.txt new file mode 100644 index 000000000..185c42002 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/dir/sample.txt @@ -0,0 +1 @@ +This is a sample text file for testing purposes. diff --git a/tests/Utils/Fixtures/files/tmp/dir/subdir/a.txt b/tests/Utils/Fixtures/files/tmp/dir/subdir/a.txt new file mode 100644 index 000000000..185c42002 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/dir/subdir/a.txt @@ -0,0 +1 @@ +This is a sample text file for testing purposes. diff --git a/tests/Utils/Fixtures/files/tmp/sample.txt b/tests/Utils/Fixtures/files/tmp/sample.txt new file mode 100644 index 000000000..185c42002 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/sample.txt @@ -0,0 +1 @@ +This is a sample text file for testing purposes. diff --git a/tests/Utils/Fixtures/files/tmp/symlink b/tests/Utils/Fixtures/files/tmp/symlink new file mode 120000 index 000000000..bdbc09545 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/symlink @@ -0,0 +1 @@ +sample.txt \ No newline at end of file diff --git a/tests/Utils/Fixtures/functions.php b/tests/Utils/Fixtures/functions.php new file mode 100644 index 000000000..3af75a686 --- /dev/null +++ b/tests/Utils/Fixtures/functions.php @@ -0,0 +1,101 @@ +assertSame('class1 class2', Html::classes(['class1', 'class2'])); + $this->assertSame('class1 class3', Html::classes(['class1' => true, 'class2' => false, 'class3' => true])); + $this->assertSame('', Html::classes([])); + } + + public function testAttribute(): void + { + $this->assertSame('disabled', Html::attribute('disabled', true)); + $this->assertSame('data-value="123"', Html::attribute('data-value', 123)); + $this->assertSame('data-list="item1 item2 item3"', Html::attribute('data-list', ['item1', 'item2', 'item3'])); + $this->assertSame('', Html::attribute('hidden', false)); + } + + public function testAttributes(): void + { + $attributes = [ + 'disabled' => true, + 'data-value' => 123, + 'data-list' => ['item1', 'item2', 'item3'], + 'hidden' => false, + ]; + $this->assertSame('disabled data-value="123" data-list="item1 item2 item3"', Html::attributes($attributes)); + } + + public function testTag(): void + { + $this->assertSame('Content', Html::tag('div', ['class' => 'container'], 'Content')); + $this->assertSame('', Html::tag('input', ['type' => 'text', 'disabled' => true])); + } + + public function testTagThrowsOnVoidWithContent(): void + { + $this->expectException(InvalidArgumentException::class); + Html::tag('img', [], 'Content'); + } + + public function testIsVoid(): void + { + $this->assertTrue(Html::isVoid('img')); + $this->assertFalse(Html::isVoid('div')); + } +} diff --git a/tests/Utils/MimeTypeTest.php b/tests/Utils/MimeTypeTest.php new file mode 100644 index 000000000..527f81bf9 --- /dev/null +++ b/tests/Utils/MimeTypeTest.php @@ -0,0 +1,63 @@ +assertSame('image/jpeg', MimeType::fromExtension('jpg')); + $this->assertSame('text/plain', MimeType::fromExtension('txt')); + $this->assertSame('application/pdf', MimeType::fromExtension('pdf')); + $this->assertSame('application/octet-stream', MimeType::fromExtension('unknown_extension')); + } + + public function testFromFile(): void + { + $this->assertSame('text/html', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/sample.html')); + $this->assertSame('text/yaml', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/sample.yaml')); + } + + public function testFromFileWithSvg(): void + { + $this->assertSame('image/svg+xml', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/valid.svg')); + $this->assertSame('application/octet-stream', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/invalid.svg')); + } + + public function testFromFileThrowsOnDisabledFileinfo(): void + { + Environment::disableExtension('fileinfo'); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('requires the extension "fileinfo" to be enabled'); + MimeType::fromFile(__DIR__ . '/fixtures/MimeType/sample.html'); + } + + public function testExtensions(): void + { + $this->assertSame(['jpg', 'jpeg', 'jpe'], MimeType::getAssociatedExtensions('image/jpeg')); + $this->assertSame([], MimeType::getAssociatedExtensions('unknown/mime-type')); + } + + public function testToExtension(): void + { + $this->assertSame('jpg', MimeType::toExtension('image/jpeg')); + } + + public function testType(): void + { + $extensionTypes = MimeType::extensionTypes(); + $this->assertIsArray($extensionTypes); + } +} diff --git a/tests/Utils/PathTest.php b/tests/Utils/PathTest.php new file mode 100644 index 000000000..a7731a1ca --- /dev/null +++ b/tests/Utils/PathTest.php @@ -0,0 +1,209 @@ +assertTrue(Path::isAbsolute('/foo/bar')); + $this->assertFalse(Path::isAbsolute('./foo/bar')); + $this->assertFalse(Path::isAbsolute('../foo/bar')); + + // Windows-style + $this->assertTrue(Path::isAbsolute('C:\foo\bar', '\\')); + $this->assertFalse(Path::isAbsolute('.\foo\bar', '\\')); + $this->assertFalse(Path::isAbsolute('..\foo\bar', '\\')); + } + + public function testHasSeparators(): void + { + $this->assertTrue(Path::isSeparator('/')); + $this->assertTrue(Path::isSeparator('\\')); + $this->assertFalse(Path::isSeparator('invalid_separator')); + } + + public function testIsRelative(): void + { + // POSIX-style + $this->assertTrue(Path::isRelativeTo('/foo/bar/baz', '/foo/bar')); + $this->assertFalse(Path::isRelativeTo('/foo/bar/baz', '/bar/foo')); + + // Windows-style + $this->assertTrue(Path::isRelativeTo('C:\foo\bar\baz', 'C:\foo\bar', '\\')); + $this->assertFalse(Path::isRelativeTo('D:\foo\bar\baz', 'D:\bar\foo', '\\')); + } + + public function testIsRelativeToThrowsOnNonAbsolutePath(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$path must be an absolute path'); + Path::isRelativeTo('../foo/bar/baz', '/foo/bar'); + } + + public function testIsRelativeToThrowsOnNonAbsoluteBase(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$base must be an absolute path'); + Path::isRelativeTo('C:\foo\bar\baz', 'foo\bar', '\\'); + } + + public function testNormalize(): void + { + // POSIX-style + $this->assertSame('fixtures/b/c.js', Path::normalize('./fixtures///b/../b/c.js')); + $this->assertSame('/bar', Path::normalize('/foo/../../../bar')); + $this->assertSame('a/b', Path::normalize('a//b//../b')); + $this->assertSame('a/b/c', Path::normalize('a//b//./c')); + $this->assertSame('a/b', Path::normalize('a//b//.')); + $this->assertSame('/x/y/z', Path::normalize('/a/b/c/../../../x/y/z')); + $this->assertSame('/foo/bar', Path::normalize('///..//./foo/.//bar')); + $this->assertSame('bar/', Path::normalize('bar/foo../../')); + $this->assertSame('bar', Path::normalize('bar/foo../..')); + $this->assertSame('bar/baz', Path::normalize('bar/foo../../baz')); + $this->assertSame('bar/foo../', Path::normalize('bar/foo../')); + $this->assertSame('bar/foo..', Path::normalize('bar/foo..')); + $this->assertSame('../../bar', Path::normalize('../foo../../../bar')); + $this->assertSame('../../bar', Path::normalize('../.../.././.../../../bar')); + $this->assertSame('../../../../../bar', Path::normalize('../../../foo/../../../bar')); + $this->assertSame('../../../../../../', Path::normalize('../../../foo/../../../bar/../../')); + $this->assertSame('../../', Path::normalize('../foobar/barfoo/foo/../../../bar/../../')); + $this->assertSame('../../../../baz', Path::normalize('../.../../foobar/../../../bar/../../baz')); + $this->assertSame('foo/bar/baz', Path::normalize('foo/bar\baz')); + + // Windows-style + $this->assertSame('fixtures\b\c.js', Path::normalize('./fixtures///b/../b/c.js', '\\')); + $this->assertSame('\bar', Path::normalize('/foo/../../../bar', '\\')); + $this->assertSame('a\b', Path::normalize('a//b//../b', '\\')); + $this->assertSame('a\b\c', Path::normalize('a//b//./c', '\\')); + $this->assertSame('a\b', Path::normalize('a//b//.', '\\')); + $this->assertSame('\x\y\z', Path::normalize('/a/b/c/../../../x/y/z', '\\')); + $this->assertSame('C:', Path::normalize('C:', '\\')); + $this->assertSame('C:..\abc', Path::normalize('C:..\abc', '\\')); + $this->assertSame('C:..\..\def', Path::normalize('C:..\..\abc\..\def', '\\')); + $this->assertSame('C:', Path::normalize('C:\.', '\\')); + $this->assertSame('file:stream', Path::normalize('file:stream', '\\')); + $this->assertSame('bar\\', Path::normalize('bar\foo..\..\\', '\\')); + $this->assertSame('bar', Path::normalize('bar\foo..\..', '\\')); + $this->assertSame('bar\baz', Path::normalize('bar\foo..\..\baz', '\\')); + $this->assertSame('bar\foo..\\', Path::normalize('bar\foo..\\', '\\')); + $this->assertSame('bar\foo..', Path::normalize('bar\foo..', '\\')); + $this->assertSame('..\..\bar', Path::normalize('..\foo..\..\..\bar', '\\')); + $this->assertSame('..\..\bar', Path::normalize('..\...\..\.\...\..\..\bar', '\\')); + $this->assertSame('..\..\..\..\..\bar', Path::normalize('../../../foo/../../../bar', '\\')); + $this->assertSame('..\..\..\..\..\..\\', Path::normalize('../../../foo/../../../bar/../../', '\\')); + $this->assertSame('..\..\\', Path::normalize('../foobar/barfoo/foo/../../../bar/../../', '\\')); + $this->assertSame('..\..\..\..\baz', Path::normalize('../.../../foobar/../../../bar/../../baz', '\\')); + $this->assertSame('foo\bar\baz', Path::normalize('foo/bar\baz', '\\')); + } + + public function testNormalizeThrowsOnInvalidSeparator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid directory separator'); + Path::normalize('some/path', 'invalid_separator'); + } + + public function testSegments(): void + { + // POSIX-style + $this->assertSame(['', 'var', 'www', 'html', 'index.php'], Path::split('/var/www/html/index.php')); + $this->assertSame(['home', 'user', 'documents', 'file.txt'], Path::split('home/user/documents/file.txt')); + + // Windows-style + $this->assertSame(['C:', 'Program Files', 'App', 'app.exe'], Path::split('C:\Program Files\App\app.exe', '\\')); + $this->assertSame(['D:', 'Data', 'Projects', 'project.docx'], Path::split('D:\Data\Projects\project.docx', '\\')); + } + + public function testJoin(): void + { + // POSIX-style + $this->assertSame('/var/www/html/index.php', Path::join(['/var', 'www', 'html', 'index.php'])); + $this->assertSame('home/user/documents/file.txt', Path::join(['home', 'user', 'documents', 'file.txt'])); + + // Windows-style + $this->assertSame('C:\Program Files\App\app.exe', Path::join(['C:', 'Program Files', 'App', 'app.exe'], '\\')); + $this->assertSame('D:\Data\Projects\project.docx', Path::join(['D:', 'Data', 'Projects', 'project.docx'], '\\')); + } + + public function testJoinThrowsOnInvalidSeparator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid directory separator'); + Path::join(['some', 'path'], 'invalid_separator'); + } + + public function testResolve(): void + { + // POSIX-style + $this->assertSame('/var/file/', Path::resolve('../file/', '/var/lib')); + $this->assertSame('/file/', Path::resolve('/../file/', '/var/lib')); + $this->assertSame('', Path::resolve('../../..', 'a/b/c')); + $this->assertSame('/absolute/', Path::resolve('/absolute/', './some/dir')); + $this->assertSame('/foo/tmp.3/cycles/root.js', Path::resolve('../tmp.3/cycles/root.js', '/foo/tmp.3/')); + + // Windows-style + $this->assertSame('c:\blah\a', Path::resolve('c:../a', 'c:/blah\blah', '\\')); + $this->assertSame('d:\e.exe', Path::resolve('\e.exe', 'd:\a/b\c/d', '\\')); + $this->assertSame('c:\some\file', Path::resolve('c:/some/file', 'c:/ignore', '\\')); + $this->assertSame('d:\ignore\some\dir\\', Path::resolve('d:some/dir//', 'd:/ignore', '\\')); + $this->assertSame('c:\\', Path::resolve('//', 'c:/', '\\')); + $this->assertSame('c:\dir', Path::resolve('//dir', 'c:/', '\\')); + $this->assertSame('c:\some\dir', Path::resolve('///some//dir', 'c:/', '\\')); + $this->assertSame('C:\foo\tmp.3\cycles\root.js', Path::resolve('..\tmp.3\cycles\root.js', 'C:\foo\tmp.3\\', '\\')); + } + + public function testResolveThrowsOnInvalidSeparator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid directory separator'); + Path::resolve('some/path', 'some/base', 'invalid_separator'); + } + + public function testRelativeTo(): void + { + // POSIX-style + $this->assertSame('html/index.php', Path::makeRelative('/var/www/html/index.php', '/var/www')); + $this->assertSame('../documents/file.txt', Path::makeRelative('/home/user/documents/file.txt', '/home/user/downloads')); + $this->assertSame('../../../etc/config.yaml', Path::makeRelative('/etc/config.yaml', '/var/www/html/')); + + // Windows-style + $this->assertSame('App\app.exe', Path::makeRelative('C:\Program Files\App\app.exe', 'C:\Program Files', '\\')); + $this->assertSame('..\Projects\project.docx', Path::makeRelative('D:\Data\Projects\project.docx', 'D:\Data\Downloads', '\\')); + } + + public function testMakeRelativeThrowsOnNonAbsolutePath(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$path must be an absolute path'); + Path::makeRelative('relative/path', '/absolute/base'); + } + + public function testMakeRelativeThrowsOnNonAbsoluteBase(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$base must be an absolute path'); + Path::makeRelative('/absolute/path', 'relative/base'); + } + + public function testMakeRelativeThrowsOnInvalidSeparator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid directory separator'); + Path::makeRelative('/some/path', '/some/base', 'invalid_separator'); + } + + public function testMakeRelativeThrowsOnIncompatibleDrives(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$path and $base must have a compatible drive letter'); + Path::makeRelative('C:\folder\file.txt', 'D:\folder', '\\'); + } +} diff --git a/tests/Utils/StrTest.php b/tests/Utils/StrTest.php new file mode 100644 index 000000000..c9a261ff8 --- /dev/null +++ b/tests/Utils/StrTest.php @@ -0,0 +1,164 @@ +assertTrue(Str::startsWith('Hello, world!', 'Hello')); + $this->assertFalse(Str::startsWith('Hello, world!', 'world')); + + $this->assertTrue(Str::startsWith('Hello, world!', '')); + } + + public function testEndsWith(): void + { + $this->assertTrue(Str::endsWith('Hello, world!', 'world!')); + $this->assertFalse(Str::endsWith('Hello, world!', 'Hello')); + + $this->assertTrue(Str::endsWith('Hello, world!', '')); + } + + public function testContains(): void + { + $this->assertTrue(Str::contains('Hello, world!', 'lo, wo')); + $this->assertFalse(Str::contains('Hello, world!', 'planet')); + + $this->assertTrue(Str::contains('Hello, world!', '')); + } + + public function testBefore(): void + { + $this->assertSame('Hello', Str::before('Hello, world!', ',')); + $this->assertSame('Hello, world!', Str::before('Hello, world!', 'planet')); + $this->assertSame('Hello, world!', Str::before('Hello, world!', '')); + } + + public function testBeforeLast(): void + { + $this->assertSame('Hello, world', Str::beforeLast('Hello, world, again!', ',')); + $this->assertSame('Hello, world, again!', Str::beforeLast('Hello, world, again!', 'planet')); + $this->assertSame('Hello, world, again!', Str::beforeLast('Hello, world, again!', '')); + } + + public function testAfter(): void + { + $this->assertSame(' world!', Str::after('Hello, world!', ',')); + $this->assertSame('Hello, world!', Str::after('Hello, world!', 'planet')); + $this->assertSame('Hello, world!', Str::after('Hello, world!', '')); + } + + public function testAfterLast(): void + { + $this->assertSame(' again!', Str::afterLast('Hello, world, again!', ',')); + $this->assertSame('Hello, world, again!', Str::afterLast('Hello, world, again!', 'planet')); + $this->assertSame('Hello, world, again!', Str::afterLast('Hello, world, again!', '')); + } + + public function testEscape(): void + { + $this->assertSame('Hello & welcome to <Formwork>!', Str::escape('Hello & welcome to !')); + } + + public function testEscapeAttr(): void + { + $this->assertSame('Hello & welcome to "Framework"!', Str::escapeAttr('Hello & welcome to "Framework"!')); + } + + public function testRemoveTags(): void + { + $this->assertSame('Hello, welcome to Formwork!', Str::removeHtml('Hello, welcome to Formwork!')); + } + + public function testSlug(): void + { + $this->assertSame('hello-world', Str::slug('Hello World!')); + $this->assertSame('hello-world', Str::slug(' Hello ~ World !')); + $this->assertSame('de-etna-erklaerung', Str::slug('De Ætna Erklärung')); + } + + public function testAppend(): void + { + $this->assertSame('Hello, world!!!', Str::append('Hello, world', '!!!')); + } + + public function testPrepend(): void + { + $this->assertSame('!!!Hello, world', Str::prepend('Hello, world', '!!!')); + } + + public function testWrap(): void + { + $this->assertSame('**Hello, world**', Str::wrap('Hello, world', '**')); + } + + public function testRemoveStart(): void + { + $this->assertSame('world!', Str::removeStart('Hello, world!', 'Hello, ')); + $this->assertSame('Hello, world!', Str::removeStart('Hello, world!', 'world')); + } + + public function testRemoveEnd(): void + { + $this->assertSame('Hello, world', Str::removeEnd('Hello, world!', '!')); + $this->assertSame('Hello, world!', Str::removeEnd('Hello, world!', 'Hello')); + } + + public function testDotToBrackets(): void + { + $this->assertSame('array[key1][key2]', Str::dotNotationToBrackets('array.key1.key2')); + $this->assertSame('array', Str::dotNotationToBrackets('array')); + } + + public function testInterpolate(): void + { + $this->assertSame('Hello, John Doe! Welcome to Formwork.', Str::interpolate('Hello, {{name}}! Welcome to {{platform}}.', ['name' => 'John Doe', 'platform' => 'Formwork'])); + $this->assertSame('Hello, {{name}}! Welcome to {{platform}}.', Str::interpolate('Hello, \{{name}}! Welcome to \{{platform}}.', [])); + } + + public function testInterpolateWithClosure(): void + { + $this->assertSame('Hello, John Doe! Welcome to platform.', Str::interpolate('Hello, {{name}}! Welcome to {{platform}}.', function ($key) { + return match ($key) { + 'name' => 'John Doe', + default => $key, + }; + })); + } + + public function testChunk(): void + { + $this->assertSame('Hel lo, wo rld !', Str::chunk('Hello, world!', 3, ' ')); + $this->assertSame('Hello, world!', Str::chunk('Hello, world!', 20, ' ')); + } + + public function testChunkThrowsOnNonPositiveLength(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('$length must be greater than 0'); + Str::chunk('Hello, world!', -1, ' '); + } + + public function testDashCase(): void + { + $this->assertSame('hello-world-string', Str::toDashCase('HelloWorldString')); + } + + public function testSnakeCase(): void + { + $this->assertSame('hello_world_string', Str::toSnakeCase('HelloWorldString')); + } + + public function testCamelCase(): void + { + $this->assertSame('helloWorldString', Str::toCamelCase('hello_world_string')); + $this->assertSame('helloWorldString', Str::toCamelCase('hello-world-string')); + } +} diff --git a/tests/Utils/TextTest.php b/tests/Utils/TextTest.php new file mode 100644 index 000000000..79a817632 --- /dev/null +++ b/tests/Utils/TextTest.php @@ -0,0 +1,76 @@ +assertSame('Hello World!', Text::normalizeWhitespace(" Hello World! \n")); + $this->assertSame('Hello World!', Text::normalizeWhitespace("Hello\tWorld!")); + $this->assertSame('', Text::normalizeWhitespace(" \n\t ")); + } + + public function testWords(): void + { + $this->assertSame(['Hello', 'World!'], Text::splitWords('Hello World!')); + $this->assertSame(['Hello', 'World!'], Text::splitWords(" Hello World! \n")); + $this->assertSame(['Hello', 'World!'], Text::splitWords("Hello\tWorld!")); + $this->assertSame([], Text::splitWords(" \n\t ")); + } + + public function testWordsWithLimit(): void + { + $this->assertSame(['Hello', 'World! This is a test.'], Text::splitWords('Hello World! This is a test.', 2)); + $this->assertSame(['Hello', 'World!', 'This is a test.'], Text::splitWords('Hello World! This is a test.', 3)); + } + + public function testCountWords(): void + { + $this->assertSame(6, Text::countWords('Hello World! This is a test.')); + $this->assertSame(6, Text::countWords(" Hello World! \nThis is a test. ")); + $this->assertSame(6, Text::countWords("Hello\tWorld! This is a test.")); + $this->assertSame(0, Text::countWords(" \n\t ")); + } + + public function testTruncate(): void + { + $this->assertSame('Hello…', Text::truncate('Hello World!', 5)); + $this->assertSame('Hello…', Text::truncate('Hello World!', 8)); + $this->assertSame('Hello World!', Text::truncate('Hello World!', 20)); + } + + public function testTruncateThrowsOnDisabledMbstring(): void + { + Environment::disableExtension('mbstring'); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('requires the extension "mbstring" to be enabled'); + Text::truncate('Hello World!', 5); + } + + public function testTruncateByWords(): void + { + $this->assertSame('Hello…', Text::truncateWords('Hello World! This is a test.', 1)); + $this->assertSame('Hello World! This…', Text::truncateWords('Hello World! This is a test.', 3)); + $this->assertSame('Hello World! This is a test.', Text::truncateWords('Hello World! This is a test.', 10)); + } + + public function testEstimateReadingTime(): void + { + $this->assertSame(1, Text::readingTime('')); + $this->assertSame(1, Text::readingTime('This is a short text.')); + $this->assertSame(3, Text::readingTime(str_repeat('This is a short text. ', 100))); + } +} diff --git a/tests/Utils/UriTest.php b/tests/Utils/UriTest.php new file mode 100644 index 000000000..5280c5873 --- /dev/null +++ b/tests/Utils/UriTest.php @@ -0,0 +1,165 @@ +assertSame('http', Uri::scheme('http://example.com')); + $this->assertSame('https', Uri::scheme('https://example.com')); + $this->assertSame('ftp', Uri::scheme('ftp://example.com')); + $this->assertNull(Uri::scheme('example.com')); + } + + public function testHost(): void + { + $this->assertSame('example.com', Uri::host('http://example.com')); + $this->assertSame('example.com', Uri::host('https://example.com/path')); + $this->assertSame('example.com', Uri::host('ftp://example.com/resource')); + $this->assertNull(Uri::host('no-scheme-host')); + } + + public function testPort(): void + { + $this->assertNull(Uri::port('http://example.com')); + $this->assertNull(Uri::port('https://example.com')); + $this->assertNull(Uri::port('ftp://example.com')); + $this->assertSame(8080, Uri::port('http://example.com:8080')); + $this->assertSame(21, Uri::port('ftp://example.com:21')); + } + + public function testDefaultPort(): void + { + $this->assertSame(80, Uri::getDefaultPort('http')); + $this->assertSame(443, Uri::getDefaultPort('https')); + } + + public function testDefaultPortThrowsOnUnknownScheme(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown scheme "sch"'); + Uri::getDefaultPort('sch'); + } + + public function testIsDefaultPort(): void + { + $this->assertTrue(Uri::isDefaultPort(80, 'http')); + $this->assertTrue(Uri::isDefaultPort(443, 'https')); + $this->assertFalse(Uri::isDefaultPort(8080, 'http')); + $this->assertFalse(Uri::isDefaultPort(80, 'https')); + } + + public function testIsDefaultPortThrowsOnUnknownScheme(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown scheme "sch"'); + Uri::isDefaultPort(80, 'sch'); + } + + public function testPath(): void + { + $this->assertSame('/path/to/resource', Uri::path('http://example.com/path/to/resource')); + $this->assertSame('/another/path', Uri::path('https://example.com/another/path?query=string')); + $this->assertSame('/', Uri::path('ftp://example.com/')); + $this->assertSame('/path/../test/', Uri::path('/path/../test/?query=string')); + } + + public function testAbsolutePath(): void + { + $this->assertSame('http://example.com/path/to/resource', Uri::absolutePath('http://example.com/path/to/resource')); + $this->assertSame('https://example.com/another/path', Uri::absolutePath('https://example.com/another/path?query=string')); + $this->assertSame('ftp://example.com/', Uri::absolutePath('ftp://example.com/')); + } + + public function testQuery(): void + { + $this->assertSame('key=value&foo=bar', Uri::query('http://example.com/path?key=value&foo=bar')); + $this->assertNull(Uri::query('https://example.com/path')); + } + + public function testFragment(): void + { + $this->assertSame('section1', Uri::fragment('http://example.com/path#section1')); + $this->assertNull(Uri::fragment('https://example.com/path')); + } + + public function testQueryToArray(): void + { + $this->assertSame(['key' => 'value', 'foo' => 'bar'], Uri::queryToArray('http://example.com/index?key=value&foo=bar')); + $this->assertSame([], Uri::queryToArray('')); + } + + public function testParse(): void + { + $expected = [ + 'scheme' => 'https', + 'host' => 'example.com', + 'port' => 443, + 'path' => '/path/to/resource', + 'query' => 'key=value&foo=bar', + 'fragment' => 'section1', + ]; + + $this->assertSame($expected, Uri::parse('https://example.com:443/path/to/resource?key=value&foo=bar#section1')); + } + + public function testParseThrowsOnMalformedUri(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid URI "/;c:354/"'); + Uri::parse('/;c:354/'); + } + + public function testMake(): void + { + $this->assertSame('http://localhost:8080/?q=test#top', Uri::make(['port' => 8080, 'query' => 'q=test', 'fragment' => 'top'], 'http://localhost')); + $this->assertSame('http://localhost:8080/path/?foo=bar&baz=qux#frag', Uri::make(['port' => 8080, 'path' => '/path', 'query' => ['foo' => 'bar', 'baz' => 'qux'], 'fragment' => 'frag'], 'http://localhost')); + $this->assertSame('http://localhost:80/', Uri::make(['port' => 80], 'http://localhost', true)); + $this->assertSame('https://example.com:443/?x=1', Uri::make(['scheme' => 'https', 'host' => 'example.com', 'port' => 443, 'path' => '/', 'query' => ['x' => '1']], 'http://localhost', true)); + $this->assertSame('https://example.com/search/?q=test+value&lang=en', Uri::make(['scheme' => 'https', 'host' => 'example.com', 'path' => '/search', 'query' => ['q' => 'test value', 'lang' => 'en']], 'http://localhost')); + } + + public function testNormalize(): void + { + $this->assertSame('http://example.com/path/to/resource.jpg', Uri::normalize('http://example.com/path/to/resource.jpg')); + $this->assertSame('https://example.com/another/path/', Uri::normalize('https://example.com/another/path')); + $this->assertSame('http://example.com/test/?query=string', Uri::normalize('http://example.com/path/../test/?query=string')); + } + + public function testRemoveQuery(): void + { + $this->assertSame('http://example.com/path/to/resource.jpg', Uri::removeQuery('http://example.com/path/to/resource.jpg?key=value&foo=bar')); + $this->assertSame('https://example.com/another/path/', Uri::removeQuery('https://example.com/another/path?query=string')); + } + + public function testRemoveFragment(): void + { + $this->assertSame('http://example.com/path/to/resource.jpg', Uri::removeFragment('http://example.com/path/to/resource.jpg#section1')); + $this->assertSame('https://example.com/another/path/', Uri::removeFragment('https://example.com/another/path#top')); + } + + public function testResolve(): void + { + $this->assertSame('http://example.com/path/to/resource.jpg', Uri::resolveRelative('resource.jpg', 'http://example.com/path/to/')); + $this->assertSame('https://example.com/path/index.html', Uri::resolveRelative('path/index.html', 'https://example.com/another')); + $this->assertSame('http://example.com/assets/css/style.css', Uri::resolveRelative('assets/css/style.css', 'http://example.com/')); + } + + public function testResolveWithFragmentOnly(): void + { + $this->assertSame('http://example.com/path/to/resource.jpg#section1', Uri::resolveRelative('#section1', 'http://example.com/path/to/resource.jpg')); + $this->assertSame('https://example.com/another/path/index.html#top', Uri::resolveRelative('#top', 'https://example.com/another/path/index.html')); + } + + public function testEncode(): void + { + $this->assertSame('/path/to/resource?key=%7B%7D&foo=%C3%89%C6%92#%C3%A5n%C2%A9', Uri::encode('/path/to/resource?key={}&foo=Ƀ#ån©')); + } +} From a6030d4e63fecb60773ba947086ef1e28dac858d Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:24:18 +0100 Subject: [PATCH 03/51] Add tests for the `Data` namespace --- tests/Data/CollectionDataProxyTest.php | 73 ++ tests/Data/CollectionTest.php | 1011 +++++++++++++++++ .../Exceptions/InvalidValueExceptionTest.php | 24 + tests/Data/Fixtures/DataArrayableFixture.php | 16 + tests/Data/Fixtures/DataCountableFixture.php | 16 + tests/Data/Fixtures/DataGetterFixture.php | 15 + tests/Data/Fixtures/DataIteratorFixture.php | 16 + .../Fixtures/DataMultipleGetterFixture.php | 15 + .../Fixtures/DataMultipleSetterFixture.php | 20 + tests/Data/Fixtures/DataSetterFixture.php | 20 + tests/Data/PaginationTest.php | 54 + tests/Data/Traits/DataArrayableTest.php | 19 + tests/Data/Traits/DataCountableTest.php | 19 + tests/Data/Traits/DataGetterTest.php | 35 + tests/Data/Traits/DataIteratorTest.php | 23 + tests/Data/Traits/DataMultipleGetterTest.php | 42 + tests/Data/Traits/DataMultipleSetterTest.php | 38 + tests/Data/Traits/DataSetterTest.php | 40 + 18 files changed, 1496 insertions(+) create mode 100644 tests/Data/CollectionDataProxyTest.php create mode 100644 tests/Data/CollectionTest.php create mode 100644 tests/Data/Exceptions/InvalidValueExceptionTest.php create mode 100644 tests/Data/Fixtures/DataArrayableFixture.php create mode 100644 tests/Data/Fixtures/DataCountableFixture.php create mode 100644 tests/Data/Fixtures/DataGetterFixture.php create mode 100644 tests/Data/Fixtures/DataIteratorFixture.php create mode 100644 tests/Data/Fixtures/DataMultipleGetterFixture.php create mode 100644 tests/Data/Fixtures/DataMultipleSetterFixture.php create mode 100644 tests/Data/Fixtures/DataSetterFixture.php create mode 100644 tests/Data/PaginationTest.php create mode 100644 tests/Data/Traits/DataArrayableTest.php create mode 100644 tests/Data/Traits/DataCountableTest.php create mode 100644 tests/Data/Traits/DataGetterTest.php create mode 100644 tests/Data/Traits/DataIteratorTest.php create mode 100644 tests/Data/Traits/DataMultipleGetterTest.php create mode 100644 tests/Data/Traits/DataMultipleSetterTest.php create mode 100644 tests/Data/Traits/DataSetterTest.php diff --git a/tests/Data/CollectionDataProxyTest.php b/tests/Data/CollectionDataProxyTest.php new file mode 100644 index 000000000..a9002adc1 --- /dev/null +++ b/tests/Data/CollectionDataProxyTest.php @@ -0,0 +1,73 @@ + (object) ['key' => 'value1'], + 'item2' => (object) ['key' => 'value2'], + ]); + + $this->assertInstanceOf(CollectionDataProxy::class, $collection->everyItem()); + } + + public function testPropertyGet(): void + { + $collection = Collection::from([ + 'item1' => (object) ['key' => 'value1'], + 'item2' => (object) ['key' => 'value2'], + ]); + + $this->assertSame([ + 'item1' => 'value1', + 'item2' => 'value2', + ], $collection->everyItem()->key->toArray()); + } + + public function testPropertySet(): void + { + $collection = Collection::from([ + 'item1' => (object) ['key' => 'value1'], + 'item2' => (object) ['key' => 'value2'], + ]); + + $collection->everyItem()->key = 'newValue'; + + $this->assertSame([ + 'item1' => 'newValue', + 'item2' => 'newValue', + ], $collection->everyItem()->key->toArray()); + } + + public function testMethodCall(): void + { + $collection = Collection::from([ + 'item1' => new class { + public function greet(): string + { + return 'Hello from item1'; + } + }, + 'item2' => new class { + public function greet(): string + { + return 'Hello from item2'; + } + }, + ], typed: false); + + $this->assertSame([ + 'item1' => 'Hello from item1', + 'item2' => 'Hello from item2', + ], $collection->everyItem()->greet()->toArray()); + } +} diff --git a/tests/Data/CollectionTest.php b/tests/Data/CollectionTest.php new file mode 100644 index 000000000..630403a63 --- /dev/null +++ b/tests/Data/CollectionTest.php @@ -0,0 +1,1011 @@ +assertInstanceOf(Collection::class, $collection); + $this->assertInstanceOf(AbstractCollection::class, $collection); + $this->assertCount(3, $collection); + } + + public function testToMutable(): void + { + $immutableCollection = Collection::from(['itemA', 'itemB'], mutable: false); + $mutableCollection = $immutableCollection->toMutable(); + + $this->assertInstanceOf(Collection::class, $mutableCollection); + $this->assertNotSame($immutableCollection, $mutableCollection); + $this->assertTrue($mutableCollection->isMutable()); + $this->assertSame(['itemA', 'itemB'], $mutableCollection->values()); + } + + public function testToMutableThrowsOnAlreadyMutable(): void + { + $mutableCollection = Collection::from(['itemA', 'itemB'], mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot convert an already mutable collection to mutable'); + $mutableCollection->toMutable(); + } + + public function testToImmutable(): void + { + $mutableCollection = Collection::from(['itemA', 'itemB'], mutable: true); + $immutableCollection = $mutableCollection->toImmutable(); + + $this->assertInstanceOf(Collection::class, $immutableCollection); + $this->assertNotSame($mutableCollection, $immutableCollection); + $this->assertFalse($immutableCollection->isMutable()); + $this->assertSame(['itemA', 'itemB'], $immutableCollection->values()); + } + + public function testToImmutableThrowsOnAlreadyImmutable(): void + { + $immutableCollection = Collection::from(['itemA', 'itemB'], mutable: false); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot convert an already immutable collection to immutable'); + $immutableCollection->toImmutable(); + } + + public function testOfThrowsOnAssociativityMismatch(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Associative collections cannot be created from non-associative data'); + Collection::of('string', ['item1', 'item2'], associative: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Non-associative collections cannot be created from associative data'); + Collection::of('string', ['key1' => 'item1', 'key2' => 'item2'], associative: false); + } + + public function testOfThrowsOnTypeMismatch(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Typed collections cannot be created from data of different types'); + Collection::of('string', ['item1', 2, 'item3']); + } + + public function testFromWithMatchingTypes(): void + { + $collection = Collection::from(['item1', 'item2', 'item3']); + + $this->assertInstanceOf(Collection::class, $collection); + $this->assertTrue($collection->isTyped()); + $this->assertSame('string', $collection->dataType()); + } + + public function testFromWithMismatchedTypes(): void + { + $collection = Collection::from(['item1', 2, 'item3']); + + $this->assertInstanceOf(Collection::class, $collection); + $this->assertFalse($collection->isTyped()); + } + + public function testFromThrowsOnStrictTypeMismatch(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot create a typed collection with data of different types'); + Collection::from(['item1', 2, 'item3'], typed: true); + } + + public function testIsAssociative(): void + { + $associativeCollection = Collection::from(['key1' => 'value1', 'key2' => 'value2']); + $indexedCollection = Collection::from(['value1', 'value2', 'value3']); + + $this->assertTrue($associativeCollection->isAssociative()); + $this->assertFalse($indexedCollection->isAssociative()); + } + + public function testIsMutable(): void + { + $mutableCollection = Collection::from([], mutable: true); + $immutableCollection = Collection::from([], mutable: false); + + $this->assertTrue($mutableCollection->isMutable()); + $this->assertFalse($immutableCollection->isMutable()); + } + + public function testIsTyped(): void + { + $typedCollection = Collection::of('string'); + $untypedCollection = Collection::from([]); + + $this->assertTrue($typedCollection->isTyped()); + $this->assertFalse($untypedCollection->isTyped()); + } + + public function testDataType(): void + { + $arrayCollection = Collection::of('array'); + $intCollection = Collection::of('int'); + + $this->assertSame('array', $arrayCollection->dataType()); + $this->assertSame('int', $intCollection->dataType()); + } + + public function testIsEmpty(): void + { + $emptyCollection = Collection::from([]); + $nonEmptyCollection = Collection::from(['item1']); + + $this->assertTrue($emptyCollection->isEmpty()); + $this->assertFalse($nonEmptyCollection->isEmpty()); + } + + public function testNth(): void + { + $collection = Collection::from(['first', 'second', 'third', 'fourth']); + + $this->assertSame('first', $collection->nth(0)); + $this->assertSame('second', $collection->nth(1)); + $this->assertSame('third', $collection->nth(2)); + $this->assertSame('fourth', $collection->nth(3)); + } + + public function testAt(): void + { + $collection = Collection::from(['apple', 'banana', 'cherry']); + + // Positive indices + $this->assertSame('apple', $collection->at(0)); + $this->assertSame('banana', $collection->at(1)); + $this->assertSame('cherry', $collection->at(2)); + + // Negative indices + $this->assertSame('cherry', $collection->at(-1)); + $this->assertSame('banana', $collection->at(-2)); + $this->assertSame('apple', $collection->at(-3)); + } + + public function testFirst(): void + { + $collection = Collection::from(['alpha', 'beta', 'gamma']); + + $this->assertSame('alpha', $collection->first()); + } + + public function testLast(): void + { + $collection = Collection::from(['alpha', 'beta', 'gamma']); + + $this->assertSame('gamma', $collection->last()); + } + + public function testRandom(): void + { + $data = ['red', 'green', 'blue']; + $collection = Collection::from($data); + + $randomItem = $collection->random(); + $this->assertContains($randomItem, $data); + } + + public function testIndexOf(): void + { + $collection = Collection::from(['cat', 'dog', 'fish']); + + $this->assertSame(0, $collection->indexOf('cat')); + $this->assertSame(1, $collection->indexOf('dog')); + $this->assertSame(2, $collection->indexOf('fish')); + $this->assertNull($collection->indexOf('bird')); + } + + public function testKeyOf(): void + { + $collection = Collection::from(['a' => 'apple', 'b' => 'banana', 'c' => 'cherry']); + + $this->assertSame('a', $collection->keyOf('apple')); + $this->assertSame('b', $collection->keyOf('banana')); + $this->assertSame('c', $collection->keyOf('cherry')); + $this->assertNull($collection->keyOf('date')); + } + + public function testKeyOfThrowsOnNonAssociative(): void + { + $collection = Collection::from(['apple', 'banana', 'cherry']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Only associative collections support keys'); + $collection->keyOf('banana'); + } + + public function testKeys(): void + { + $collection = Collection::from(['x' => 10, 'y' => 20, 'z' => 30]); + + $this->assertSame(['x', 'y', 'z'], $collection->keys()); + } + + public function testKeysThrowsOnNonAssociative(): void + { + $collection = Collection::from([10, 20, 30]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Only associative collections support keys'); + $collection->keys(); + } + + public function testValues(): void + { + $associativeCollection = Collection::from(['a' => 'apple', 'b' => 'banana', 'c' => 'cherry']); + $indexedCollection = Collection::from(['apple', 'banana', 'cherry']); + + $this->assertSame(['apple', 'banana', 'cherry'], $associativeCollection->values()); + $this->assertSame(['apple', 'banana', 'cherry'], $indexedCollection->values()); + } + + public function testContains(): void + { + $associativeCollection = Collection::from(['key1' => 'value1', 'key2' => 'value2']); + $indexedCollection = Collection::from(['value1', 'value2', 'value3']); + + $this->assertTrue($associativeCollection->contains('value1')); + $this->assertFalse($associativeCollection->contains('value3')); + + $this->assertTrue($indexedCollection->contains('value2')); + $this->assertFalse($indexedCollection->contains('value4')); + } + + public function testEvery(): void + { + $data = [2, 4, 6, 8]; + $collection = Collection::from($data); + + $this->assertTrue($collection->every(fn($item) => $item % 2 === 0)); + $this->assertFalse($collection->every(fn($item) => $item > 4)); + } + + public function testSome(): void + { + $data = [1, 3, 5, 7]; + $collection = Collection::from($data); + + $this->assertTrue($collection->some(fn($item) => $item === 3)); + $this->assertFalse($collection->some(fn($item) => $item % 2 === 0)); + } + + public function testFind(): void + { + $associativeCollection = Collection::from(['a' => 10, 'b' => 15, 'c' => 20]); + $indexedCollection = Collection::from([10, 15, 20]); + + $this->assertSame(15, $associativeCollection->find(fn($item) => $item > 12)); + $this->assertNull($associativeCollection->find(fn($item) => $item > 25)); + + $this->assertSame(15, $indexedCollection->find(fn($item) => $item > 12)); + $this->assertNull($indexedCollection->find(fn($item) => $item > 25)); + } + + public function testClone(): void + { + $collection = Collection::from(['itemA', 'itemB', 'itemC']); + $clonedCollection = $collection->clone(); + + $this->assertInstanceOf(Collection::class, $clonedCollection); + $this->assertNotSame($collection, $clonedCollection); + $this->assertEquals($collection, $clonedCollection); + } + + public function testDeepClone(): void + { + $data = [ + new Collection(['a', 'b']), + new Collection(['c', 'd']), + ]; + + $collection = Collection::from($data); + $deepClonedCollection = $collection->deepClone(); + + $this->assertInstanceOf(Collection::class, $deepClonedCollection); + $this->assertNotSame($collection, $deepClonedCollection); + $this->assertEquals($collection, $deepClonedCollection); + + foreach ($deepClonedCollection->values() as $index => $item) { + $this->assertNotSame($collection->at($index), $item); + $this->assertEquals($collection->at($index), $item); + } + } + + public function testReverse(): void + { + $collection = Collection::from(['first', 'second', 'third']); + $reversedCollection = $collection->reverse(); + + $this->assertInstanceOf(Collection::class, $reversedCollection); + $this->assertNotSame($collection, $reversedCollection); + $this->assertSame(['third', 'second', 'first'], $reversedCollection->values()); + } + + public function testShuffle(): void + { + $collection = Collection::from(['one', 'two', 'three', 'four', 'five']); + $shuffledCollection = $collection->shuffle(); + + $this->assertInstanceOf(Collection::class, $shuffledCollection); + $this->assertNotSame($collection, $shuffledCollection); + $this->assertCount(5, $shuffledCollection); + $this->assertNotSame($collection->values(), $shuffledCollection->values()); + + foreach (['one', 'two', 'three', 'four', 'five'] as $item) { + $this->assertTrue($shuffledCollection->contains($item)); + } + } + + public function testUnique(): void + { + $collection = Collection::from(['apple', 'banana', 'apple', 'orange', 'banana']); + $uniqueCollection = $collection->unique(); + + $this->assertInstanceOf(Collection::class, $uniqueCollection); + $this->assertNotSame($collection, $uniqueCollection); + $this->assertSame(['apple', 'banana', 'orange'], $uniqueCollection->values()); + } + + public function testDuplicates(): void + { + $collection = Collection::from(['apple', 'banana', 'apple', 'orange', 'banana', 'banana']); + $duplicatesCollection = $collection->duplicates(); + + $this->assertInstanceOf(Collection::class, $duplicatesCollection); + $this->assertNotSame($collection, $duplicatesCollection); + $this->assertSame(['apple', 'banana', 'banana'], $duplicatesCollection->values()); + } + + public function testSlice(): void + { + $collection = Collection::from(['a', 'b', 'c', 'd', 'e']); + + $slicedCollection = $collection->slice(1, 3); + $this->assertInstanceOf(Collection::class, $slicedCollection); + $this->assertNotSame($collection, $slicedCollection); + $this->assertSame(['b', 'c', 'd'], $slicedCollection->values()); + } + + public function testLimit(): void + { + $collection = Collection::from(['x', 'y', 'z', 'w']); + + $limitedCollection = $collection->limit(2); + $this->assertInstanceOf(Collection::class, $limitedCollection); + $this->assertNotSame($collection, $limitedCollection); + $this->assertSame(['x', 'y'], $limitedCollection->values()); + } + + public function testEach(): void + { + $collection = Collection::from(['red', 'green', 'blue']); + + $collectedItems = []; + $collection->each(function ($item) use (&$collectedItems) { + $collectedItems[] = $item; + }); + + $this->assertSame(['red', 'green', 'blue'], $collectedItems); + } + + public function testEachWithEarlyReturn(): void + { + $collection = Collection::from([1, 2, 3, 4, 5]); + + $collectedItems = []; + $collection->each(function ($item) use (&$collectedItems) { + $collectedItems[] = $item; + if ($item >= 3) { + return false; + } + return true; + }); + + $this->assertSame([1, 2, 3], $collectedItems); + } + + public function testMap(): void + { + $collection = Collection::from([1, 2, 3]); + + $mappedCollection = $collection->map(fn($item) => $item * 2); + + $this->assertInstanceOf(Collection::class, $mappedCollection); + $this->assertNotSame($collection, $mappedCollection); + $this->assertSame([2, 4, 6], $mappedCollection->values()); + } + + public function testFilter(): void + { + $data = [1, 2, 3, 4, 5]; + $collection = Collection::from($data); + + $filteredCollection = $collection->filter(fn($item) => $item % 2 === 0); + + $this->assertInstanceOf(Collection::class, $filteredCollection); + $this->assertNotSame($collection, $filteredCollection); + $this->assertSame([2, 4], $filteredCollection->values()); + } + + public function testReject(): void + { + $data = [1, 2, 3, 4, 5]; + $collection = Collection::from($data); + + $rejectedCollection = $collection->reject(fn($item) => $item % 2 === 0); + + $this->assertInstanceOf(Collection::class, $rejectedCollection); + $this->assertNotSame($collection, $rejectedCollection); + $this->assertSame([1, 3, 5], $rejectedCollection->values()); + } + + public function testSort(): void + { + $collection = Collection::from([3, 1, 4, 2]); + + $sortedCollection = $collection->sort(); + + $this->assertInstanceOf(Collection::class, $sortedCollection); + $this->assertNotSame($collection, $sortedCollection); + $this->assertSame([1, 2, 3, 4], $sortedCollection->values()); + } + + public function testGroup(): void + { + $collection = Collection::from(['apple', 'apricot', 'banana', 'blueberry', 'cherry']); + + $grouped = $collection->group(fn($item) => $item[0]); + + $this->assertIsArray($grouped); + $this->assertCount(3, $grouped); + $this->assertSame(['apple', 'apricot'], $grouped['a']); + $this->assertSame(['banana', 'blueberry'], $grouped['b']); + $this->assertSame(['cherry'], $grouped['c']); + } + + public function testExtract(): void + { + $collection = Collection::from([ + 'alice' => ['name' => 'Alice', 'age' => 30], + 'bob' => ['name' => 'Bob', 'age' => 25], + 'charlie' => ['name' => 'Charlie', 'age' => 35], + ]); + + $names = $collection->extract('name'); + $ages = $collection->extract('age'); + + $this->assertSame(['alice' => 'Alice', 'bob' => 'Bob', 'charlie' => 'Charlie'], $names); + $this->assertSame(['alice' => 30, 'bob' => 25, 'charlie' => 35], $ages); + } + + public function testPluck(): void + { + $collection = Collection::from([ + 'alice' => ['name' => 'Alice', 'age' => 30], + 'bob' => ['name' => 'Bob', 'age' => 25], + 'charlie' => ['name' => 'Charlie', 'age' => 35], + ]); + + $names = $collection->pluck('name'); + $ages = $collection->pluck('age'); + + $this->assertSame(['Alice', 'Bob', 'Charlie'], $names); + $this->assertSame([30, 25, 35], $ages); + } + + public function testFlatten(): void + { + $collection = Collection::from([ + ['a', 'b'], + ['c', 'd'], + ['e', 'f'], + ]); + + $flattenedCollection = $collection->flatten(); + + $this->assertInstanceOf(Collection::class, $flattenedCollection); + $this->assertNotSame($collection, $flattenedCollection); + $this->assertSame(['a', 'b', 'c', 'd', 'e', 'f'], $flattenedCollection->values()); + } + + public function testFilterByWithCallback(): void + { + $collection = Collection::from([ + ['name' => 'Alice', 'age' => 30], + ['name' => 'Bob', 'age' => 25], + ['name' => 'Charlie', 'age' => 35], + ]); + + $filteredCollection = $collection->filterBy('age', fn($age) => $age > 30); + + $this->assertInstanceOf(Collection::class, $filteredCollection); + $this->assertNotSame($collection, $filteredCollection); + $this->assertSame([['name' => 'Charlie', 'age' => 35]], $filteredCollection->values()); + } + + public function testFilterByWithDefaultFilters(): void + { + $filters = [ + '==', + 'equalTo', + '!=', + 'notEqualTo', + '===', + 'strictlyEqualTo', + '!==', + 'strictlyNotEqualTo', + '>', + 'greaterThan', + '>=', + 'greaterThanOrEqualTo', + '<', + 'lessThan', + '<=', + 'lessThanOrEqualTo', + ]; + + $data = [ + ['value' => 10], + ['value' => 20], + ['value' => 30], + ]; + + $collection = Collection::from($data); + + foreach ($filters as $filter) { + $expected = match ($filter) { + '==', 'equalTo' => [['value' => 20]], + '!=', 'notEqualTo' => [['value' => 10], ['value' => 30]], + '===', 'strictlyEqualTo' => [['value' => 20]], + '!==', 'strictlyNotEqualTo' => [['value' => 10], ['value' => 30]], + '>', 'greaterThan' => [['value' => 30]], + '>=', 'greaterThanOrEqualTo' => [['value' => 20], ['value' => 30]], + '<', 'lessThan' => [['value' => 10]], + '<=', 'lessThanOrEqualTo' => [['value' => 10], ['value' => 20]], + }; + + $filteredCollection = $collection->filterBy('value', $filter, 20); + + $this->assertInstanceOf(Collection::class, $filteredCollection); + $this->assertNotSame($collection, $filteredCollection); + $this->assertSame($expected, $filteredCollection->values()); + } + } + + public function testFilterByThrowsOnInvalidFilter(): void + { + $collection = Collection::from([ + ['value' => 10], + ['value' => 20], + ['value' => 30], + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unknown filter "invalidComparison"'); + $collection->filterBy('value', 'invalidComparison', 20); + } + + public function testFilterByThrowsOnCallbackWithThirdArgument(): void + { + $collection = Collection::from([ + ['value' => 10], + ['value' => 20], + ['value' => 30], + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unexpected third argument passed'); + $collection->filterBy('value', fn($value) => $value > 15, 20); + } + + public function testSortBy(): void + { + $collection = Collection::from([ + ['name' => 'Charlie', 'age' => 35], + ['name' => 'Alice', 'age' => 30], + ['name' => 'Bob', 'age' => 25], + ]); + + $sortedByName = $collection->sortBy('name'); + $sortedByAge = $collection->sortBy('age'); + + $this->assertInstanceOf(Collection::class, $sortedByName); + $this->assertNotSame($collection, $sortedByName); + $this->assertSame([ + ['name' => 'Alice', 'age' => 30], + ['name' => 'Bob', 'age' => 25], + ['name' => 'Charlie', 'age' => 35], + ], $sortedByName->values()); + + $this->assertInstanceOf(Collection::class, $sortedByAge); + $this->assertNotSame($collection, $sortedByAge); + $this->assertSame([ + ['name' => 'Bob', 'age' => 25], + ['name' => 'Alice', 'age' => 30], + ['name' => 'Charlie', 'age' => 35], + ], $sortedByAge->values()); + } + + public function testGroupBy(): void + { + $collection = Collection::from([ + ['name' => 'Alice', 'department' => 'HR'], + ['name' => 'Bob', 'department' => 'IT'], + ['name' => 'Charlie', 'department' => 'HR'], + ['name' => 'David', 'department' => 'IT'], + ['name' => 'Eve', 'department' => 'Finance'], + ]); + + $grouped = $collection->groupBy('department'); + + $this->assertIsArray($grouped); + $this->assertCount(3, $grouped); + $this->assertSame([ + ['name' => 'Alice', 'department' => 'HR'], + ['name' => 'Charlie', 'department' => 'HR'], + ], $grouped['HR']); + $this->assertSame([ + ['name' => 'Bob', 'department' => 'IT'], + ['name' => 'David', 'department' => 'IT'], + ], $grouped['IT']); + $this->assertSame([ + ['name' => 'Eve', 'department' => 'Finance'], + ], $grouped['Finance']); + } + + public function testKeyBy(): void + { + $collection = Collection::from([ + ['id' => 'a1', 'name' => 'Alice'], + ['id' => 'b2', 'name' => 'Bob'], + ['id' => 'c3', 'name' => 'Charlie'], + ]); + + $keyedCollection = $collection->keyBy('id'); + + $this->assertInstanceOf(Collection::class, $keyedCollection); + $this->assertNotSame($collection, $keyedCollection); + $this->assertSame([ + 'a1' => ['id' => 'a1', 'name' => 'Alice'], + 'b2' => ['id' => 'b2', 'name' => 'Bob'], + 'c3' => ['id' => 'c3', 'name' => 'Charlie'], + ], $keyedCollection->toArray()); + } + + public function testWith(): void + { + $data = ['item1', 'item2']; + $collection = Collection::from($data); + + $newCollection = $collection->with('item3'); + + $this->assertInstanceOf(Collection::class, $newCollection); + $this->assertNotSame($collection, $newCollection); + $this->assertSame(['item1', 'item2', 'item3'], $newCollection->values()); + } + + public function testWithSkipsExistingItems(): void + { + $collection = Collection::from(['item1', 'item2']); + + $newCollection = $collection->with('item2'); + + $this->assertInstanceOf(Collection::class, $newCollection); + $this->assertNotSame($collection, $newCollection); + $this->assertSame(['item1', 'item2'], $newCollection->values()); + } + + public function testWithout(): void + { + $collection = Collection::from(['item1', 'item2', 'item3']); + + $newCollection = $collection->without('item2'); + + $this->assertInstanceOf(Collection::class, $newCollection); + $this->assertNotSame($collection, $newCollection); + $this->assertSame(['item1', 'item3'], $newCollection->values()); + } + + public function testEveryItem(): void + { + $collection = Collection::from(['itemA', 'itemB', 'itemC']); + + $dataProxy = $collection->everyItem(); + + $this->assertInstanceOf(CollectionDataProxy::class, $dataProxy); + } + + public function testUnion(): void + { + $collection1 = Collection::from(['a' => 'item1', 'b' => 'item2']); + $collection2 = Collection::from(['b' => 'item2', 'c' => 'item3']); + + $unionCollection = $collection1->union($collection2); + + $this->assertInstanceOf(Collection::class, $unionCollection); + $this->assertNotSame($collection1, $unionCollection); + $this->assertSame(['a' => 'item1', 'b' => 'item2', 'c' => 'item3'], $unionCollection->toArray()); + } + + public function testIntersection(): void + { + $collection1 = Collection::from(['a' => 'item1', 'b' => 'item2', 'c' => 'item3']); + $collection2 = Collection::from(['b' => 'item2', 'c' => 'item4', 'd' => 'item3']); + + $intersectionCollection = $collection1->intersection($collection2); + + $this->assertInstanceOf(Collection::class, $intersectionCollection); + $this->assertNotSame($collection1, $intersectionCollection); + $this->assertSame(['b' => 'item2', 'c' => 'item3'], $intersectionCollection->toArray()); + } + + public function testDifference(): void + { + $collection1 = Collection::from(['a' => 'item1', 'b' => 'item2', 'c' => 'item3']); + $collection2 = Collection::from(['b' => 'item2', 'c' => 'item4']); + + $differenceCollection = $collection1->difference($collection2); + + $this->assertInstanceOf(Collection::class, $differenceCollection); + $this->assertNotSame($collection1, $differenceCollection); + $this->assertSame(['a' => 'item1', 'c' => 'item3'], $differenceCollection->toArray()); + } + + public function testAdd(): void + { + $collection = Collection::from(['item1', 'item2'], mutable: true); + + $collection->add('item3'); + + $this->assertCount(3, $collection); + $this->assertSame(['item1', 'item2', 'item3'], $collection->values()); + } + + public function testAddThrowsOnImmutable(): void + { + $collection = Collection::from(['item1', 'item2'], mutable: false); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be added only to mutable and non-associative collections'); + $collection->add('item3'); + } + + public function testAddThrowsOnAssociative(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2'], mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be added only to mutable and non-associative collections'); + $collection->add('value3'); + } + + public function testAddThrowsOnTypeMismatch(): void + { + $collection = Collection::of('int', mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Value must be of type int to be added, string given'); + $collection->add('notAnInteger'); + } + + public function testAddMultiple(): void + { + $collection = Collection::from(['item1'], mutable: true); + + $collection->addMultiple(['item2', 'item3']); + + $this->assertCount(3, $collection); + $this->assertSame(['item1', 'item2', 'item3'], $collection->values()); + } + + public function testPull(): void + { + $collection = Collection::from(['item1', 'item2', 'item3'], mutable: true); + + $collection->pull('item2'); + + $this->assertSame(['item1', 'item3'], $collection->values()); + } + + public function testPullThrowsOnImmutable(): void + { + $collection = Collection::from(['item1', 'item2', 'item3'], mutable: false); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be pulled only from mutable and non-associative collections'); + $collection->pull('item2'); + } + + public function testPullThrowsOnAssociative(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2'], mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be pulled only from mutable and non-associative collections'); + $collection->pull('value1'); + } + + public function testPullMultiple(): void + { + $collection = Collection::from(['item1', 'item2', 'item3', 'item4'], mutable: true); + + $collection->pullMultiple(['item2', 'item4']); + + $this->assertSame(['item1', 'item3'], $collection->values()); + } + + public function testMoveItem(): void + { + $collection = Collection::from(['item1', 'item2', 'item3'], mutable: true); + + $collection->moveItem(0, 2); + + $this->assertSame(['item2', 'item3', 'item1'], $collection->values()); + } + + public function testMoveItemInAssociative(): void + { + $collection = Collection::from(['a' => 'item1', 'b' => 'item2', 'c' => 'item3'], mutable: true); + + $collection->moveItem(0, 2); + + $this->assertSame(['b' => 'item2', 'c' => 'item3', 'a' => 'item1'], $collection->toArray()); + } + + public function testHas(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2']); + + $this->assertTrue($collection->has('key1')); + $this->assertFalse($collection->has('key3')); + } + + public function testHasThrowsOnNonAssociative(): void + { + $collection = Collection::from(['value1', 'value2', 'value3']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Value presence can be checked only in associative collections'); + $collection->has('key1'); + } + + public function testGet(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2']); + + $this->assertSame('value1', $collection->get('key1')); + $this->assertSame('value2', $collection->get('key2')); + $this->assertNull($collection->get('key3')); + $this->assertSame('default', $collection->get('key3', 'default')); + } + + public function testGetThrowsOnNonAssociative(): void + { + $collection = Collection::from(['value1', 'value2', 'value3']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be get only from associative collections'); + $collection->get('key1'); + } + + public function testSet(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2'], mutable: true); + + $collection->set('key2', 'newValue2'); + $collection->set('key3', 'value3'); + + $this->assertSame(['key1' => 'value1', 'key2' => 'newValue2', 'key3' => 'value3'], $collection->toArray()); + } + + public function testSetThrowsOnNonAssociative(): void + { + $collection = Collection::from(['value1', 'value2', 'value3'], mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be set only to associative and mutable collections'); + $collection->set('key1', 'newValue1'); + } + + public function testSetThrowsOnImmutable(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2'], mutable: false); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be set only to associative and mutable collections'); + $collection->set('key2', 'newValue2'); + } + + public function testSetThrowsOnTypeMismatch(): void + { + $collection = Collection::of('int', associative: true, mutable: true); + $collection->set('key1', 10); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Value must be of type int, string given'); + $collection->set('key2', 'notAnInteger'); + } + + public function testRemove(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3'], mutable: true); + + $collection->remove('key2'); + + $this->assertSame(['key1' => 'value1', 'key3' => 'value3'], $collection->toArray()); + } + + public function testRemoveThrowsOnNonAssociative(): void + { + $collection = Collection::from(['value1', 'value2', 'value3'], mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be removed only from associative and mutable collections'); + $collection->remove('key1'); + } + + public function testRemoveThrowsOnImmutable(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2'], mutable: false); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be removed only from associative and mutable collections'); + $collection->remove('key2'); + } + + public function testMerge(): void + { + $collection1 = Collection::from(['a' => 'item1', 'b' => 'item2'], mutable: true); + $collection2 = Collection::from(['b' => 'item3', 'c' => 'item4']); + + $collection1->merge($collection2); + + $this->assertSame(['a' => 'item1', 'b' => 'item3', 'c' => 'item4'], $collection1->toArray()); + } + + public function testMergeThrowsOnImmutable(): void + { + $collection1 = Collection::from(['a' => 'item1', 'b' => 'item2'], mutable: false); + $collection2 = Collection::from(['b' => 'item3', 'c' => 'item4']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be merged only into mutable collections'); + $collection1->merge($collection2); + } + + public function testMergeThrowsOnAssociativityMismatch(): void + { + $collection1 = Collection::from(['item1', 'item2'], mutable: true); + $collection2 = Collection::from(['b' => 'item3', 'c' => 'item4']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Collections cannot be merged if their associativeness is different'); + $collection1->merge($collection2); + } + + public function testMergeThrowsOnTypeMismatch(): void + { + $collection1 = Collection::of('int', mutable: true); + $collection1->add(1); + $collection1->add(2); + + $collection2 = Collection::of('string', mutable: true); + $collection2->add('three'); + $collection2->add('four'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Collections with data of different types cannot be merged'); + $collection1->merge($collection2); + } +} diff --git a/tests/Data/Exceptions/InvalidValueExceptionTest.php b/tests/Data/Exceptions/InvalidValueExceptionTest.php new file mode 100644 index 000000000..ac7cefcc3 --- /dev/null +++ b/tests/Data/Exceptions/InvalidValueExceptionTest.php @@ -0,0 +1,24 @@ +assertSame('Invalid value provided', $exception->getMessage()); + } + + public function testConstructorWithIdentifierAndContext(): void + { + $exception = new InvalidValueException('Invalid age', 'age.invalid', ['age' => -5]); + $this->assertSame('age.invalid', $exception->getIdentifier()); + $this->assertSame(['age' => -5], $exception->getContext()); + } +} diff --git a/tests/Data/Fixtures/DataArrayableFixture.php b/tests/Data/Fixtures/DataArrayableFixture.php new file mode 100644 index 000000000..5832e7a0a --- /dev/null +++ b/tests/Data/Fixtures/DataArrayableFixture.php @@ -0,0 +1,16 @@ +data = $data; + } +} diff --git a/tests/Data/Fixtures/DataCountableFixture.php b/tests/Data/Fixtures/DataCountableFixture.php new file mode 100644 index 000000000..5a902d3cf --- /dev/null +++ b/tests/Data/Fixtures/DataCountableFixture.php @@ -0,0 +1,16 @@ +data = $data; + } +} diff --git a/tests/Data/Fixtures/DataGetterFixture.php b/tests/Data/Fixtures/DataGetterFixture.php new file mode 100644 index 000000000..9b41dc14e --- /dev/null +++ b/tests/Data/Fixtures/DataGetterFixture.php @@ -0,0 +1,15 @@ +data = $data; + } +} diff --git a/tests/Data/Fixtures/DataIteratorFixture.php b/tests/Data/Fixtures/DataIteratorFixture.php new file mode 100644 index 000000000..08bab3d2b --- /dev/null +++ b/tests/Data/Fixtures/DataIteratorFixture.php @@ -0,0 +1,16 @@ +data = $data; + } +} diff --git a/tests/Data/Fixtures/DataMultipleGetterFixture.php b/tests/Data/Fixtures/DataMultipleGetterFixture.php new file mode 100644 index 000000000..a7f6e3cdb --- /dev/null +++ b/tests/Data/Fixtures/DataMultipleGetterFixture.php @@ -0,0 +1,15 @@ +data = $data; + } +} diff --git a/tests/Data/Fixtures/DataMultipleSetterFixture.php b/tests/Data/Fixtures/DataMultipleSetterFixture.php new file mode 100644 index 000000000..334467cac --- /dev/null +++ b/tests/Data/Fixtures/DataMultipleSetterFixture.php @@ -0,0 +1,20 @@ +data = $data; + } + + public function data(): array + { + return $this->data; + } +} diff --git a/tests/Data/Fixtures/DataSetterFixture.php b/tests/Data/Fixtures/DataSetterFixture.php new file mode 100644 index 000000000..9c44a20ed --- /dev/null +++ b/tests/Data/Fixtures/DataSetterFixture.php @@ -0,0 +1,20 @@ +data = $data; + } + + public function data(): array + { + return $this->data; + } +} diff --git a/tests/Data/PaginationTest.php b/tests/Data/PaginationTest.php new file mode 100644 index 000000000..e1a37f773 --- /dev/null +++ b/tests/Data/PaginationTest.php @@ -0,0 +1,54 @@ +assertSame(10, $pagination->length()); + $this->assertSame(5, $pagination->pages()); + $this->assertSame($pagination->pages(), $pagination->lastPage()); + $this->assertSame(1, $pagination->currentPage()); + $this->assertSame(0, $pagination->offset()); + $this->assertTrue($pagination->hasPages()); + + $this->assertTrue($pagination->has(1)); + $this->assertTrue($pagination->has(5)); + $this->assertFalse($pagination->has(6)); + $this->assertFalse($pagination->has(0)); + + $this->assertTrue($pagination->isFirstPage()); + $this->assertTrue($pagination->hasNextPage()); + $this->assertFalse($pagination->isLastPage()); + $this->assertFalse($pagination->hasPreviousPage()); + $this->assertSame(1, $pagination->previousPage()); + $this->assertSame(2, $pagination->nextPage()); + + $pagination->setCurrentPage(3); + $this->assertSame(3, $pagination->currentPage()); + $this->assertSame(20, $pagination->offset()); + $this->assertTrue($pagination->hasNextPage()); + $this->assertTrue($pagination->hasPreviousPage()); + $this->assertSame(2, $pagination->previousPage()); + $this->assertSame(4, $pagination->nextPage()); + + $pagination->setCurrentPage(5); + $this->assertSame(5, $pagination->currentPage()); + $this->assertSame(40, $pagination->offset()); + $this->assertFalse($pagination->hasNextPage()); + $this->assertTrue($pagination->isLastPage()); + $this->assertTrue($pagination->hasPreviousPage()); + $this->assertSame(4, $pagination->previousPage()); + $this->assertSame(5, $pagination->nextPage()); + } +} diff --git a/tests/Data/Traits/DataArrayableTest.php b/tests/Data/Traits/DataArrayableTest.php new file mode 100644 index 000000000..701357aed --- /dev/null +++ b/tests/Data/Traits/DataArrayableTest.php @@ -0,0 +1,19 @@ + 'value1', 'key2' => 'value2']; + $fixture = new DataArrayableFixture($data); + $this->assertSame($data, $fixture->toArray()); + } +} diff --git a/tests/Data/Traits/DataCountableTest.php b/tests/Data/Traits/DataCountableTest.php new file mode 100644 index 000000000..57370405c --- /dev/null +++ b/tests/Data/Traits/DataCountableTest.php @@ -0,0 +1,19 @@ + 'value1', 'key2' => 'value2', 'key3' => 'value3']; + $fixture = new DataCountableFixture($data); + $this->assertCount(3, $fixture); + } +} diff --git a/tests/Data/Traits/DataGetterTest.php b/tests/Data/Traits/DataGetterTest.php new file mode 100644 index 000000000..ad654b15e --- /dev/null +++ b/tests/Data/Traits/DataGetterTest.php @@ -0,0 +1,35 @@ + 'value1', 'key2' => 'value2']; + $fixture = new DataGetterFixture($data); + $this->assertSame('value1', $fixture->get('key1')); + $this->assertSame('value2', $fixture->get('key2')); + } + + public function testGetReturnsDefaultValueForNonExistingKey(): void + { + $data = ['key1' => 'value1']; + $fixture = new DataGetterFixture($data); + $this->assertSame('default', $fixture->get('key2', 'default')); + } + + public function testHasReturnsKeyExistence(): void + { + $data = ['key1' => 'value1']; + $fixture = new DataGetterFixture($data); + $this->assertTrue($fixture->has('key1')); + $this->assertFalse($fixture->has('key2')); + } +} diff --git a/tests/Data/Traits/DataIteratorTest.php b/tests/Data/Traits/DataIteratorTest.php new file mode 100644 index 000000000..532fa26d2 --- /dev/null +++ b/tests/Data/Traits/DataIteratorTest.php @@ -0,0 +1,23 @@ + 'value1', 'key2' => 'value2', 'key3' => 'value3']; + $fixture = new DataIteratorFixture($data); + $iteratedData = []; + foreach ($fixture as $key => $value) { + $iteratedData[$key] = $value; + } + $this->assertSame($data, $iteratedData); + } +} diff --git a/tests/Data/Traits/DataMultipleGetterTest.php b/tests/Data/Traits/DataMultipleGetterTest.php new file mode 100644 index 000000000..8984ca438 --- /dev/null +++ b/tests/Data/Traits/DataMultipleGetterTest.php @@ -0,0 +1,42 @@ + 'value1', 'key2' => 'value2', 'key3' => 'value3']; + $fixture = new DataMultipleGetterFixture($data); + $values = $fixture->getMultiple(['key1', 'key3']); + $this->assertSame(['key1' => 'value1', 'key3' => 'value3'], $values); + } + + public function testGetMultipleWithNonExistentKeysReturnsDefault(): void + { + $data = ['key1' => 'value1', 'key2' => 'value2']; + $fixture = new DataMultipleGetterFixture($data); + $values = $fixture->getMultiple(['key1', 'key3'], 'default'); + $this->assertSame(['key1' => 'value1', 'key3' => 'default'], $values); + } + + public function testHasMultipleReturnsTrueWhenAllKeysExist(): void + { + $data = ['key1' => 'value1', 'key2' => 'value2']; + $fixture = new DataMultipleGetterFixture($data); + $this->assertTrue($fixture->hasMultiple(['key1', 'key2'])); + } + + public function testHasMultipleReturnsFalseWhenAnyKeyDoesNotExist(): void + { + $data = ['key1' => 'value1', 'key2' => 'value2']; + $fixture = new DataMultipleGetterFixture($data); + $this->assertFalse($fixture->hasMultiple(['key1', 'key3'])); + } +} diff --git a/tests/Data/Traits/DataMultipleSetterTest.php b/tests/Data/Traits/DataMultipleSetterTest.php new file mode 100644 index 000000000..7df6296a8 --- /dev/null +++ b/tests/Data/Traits/DataMultipleSetterTest.php @@ -0,0 +1,38 @@ + 'value1', 'key2' => 'value2']); + + $fixture->setMultiple(['key1' => 'newValue1', 'key3' => 'value3']); + + $data = $fixture->data(); + + $this->assertSame('newValue1', $data['key1']); + $this->assertSame('value2', $data['key2']); + $this->assertSame('value3', $data['key3']); + } + + public function testRemoveMultiple(): void + { + $fixture = new DataMultipleSetterFixture(['key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3']); + + $fixture->removeMultiple(['key1', 'key3']); + + $data = $fixture->data(); + + $this->assertArrayNotHasKey('key1', $data); + $this->assertSame('value2', $data['key2']); + $this->assertArrayNotHasKey('key3', $data); + } +} diff --git a/tests/Data/Traits/DataSetterTest.php b/tests/Data/Traits/DataSetterTest.php new file mode 100644 index 000000000..ad33ef905 --- /dev/null +++ b/tests/Data/Traits/DataSetterTest.php @@ -0,0 +1,40 @@ +set('key1', 'value1'); + $this->assertSame(['key1' => 'value1'], $fixture->data()); + + $fixture->set('key2', 'value2'); + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $fixture->data()); + + $fixture->set('key1', 'newValue1'); + $this->assertSame(['key1' => 'newValue1', 'key2' => 'value2'], $fixture->data()); + } + + public function testRemove(): void + { + $fixture = new DataSetterFixture(['key1' => 'value1', 'key2' => 'value2']); + + $fixture->remove('key1'); + $this->assertSame(['key2' => 'value2'], $fixture->data()); + + $fixture->remove('key3'); // Removing non-existent key + $this->assertSame(['key2' => 'value2'], $fixture->data()); + + $fixture->remove('key2'); + $this->assertSame([], $fixture->data()); + } +} From e7cfd4f7a14f111315b2192803ec538e22f9dc82 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:24:57 +0100 Subject: [PATCH 04/51] Add tests for the `Parsers` namespace --- .../Fixtures/CommonMarkExtensionFixture.php | 11 ++ tests/Parsers/Fixtures/EnumFixture.php | 10 ++ .../Parsers/Fixtures/SerializableFixture.php | 23 +++ .../SetStateImplementingClassFixture.php | 13 ++ tests/Parsers/Fixtures/files/json/test.json | 5 + tests/Parsers/Fixtures/files/php/test.php | 7 + tests/Parsers/Fixtures/files/yaml/test.yaml | 6 + tests/Parsers/JsonTest.php | 97 ++++++++++++ tests/Parsers/MarkdownTest.php | 88 +++++++++++ tests/Parsers/PhpTest.php | 147 ++++++++++++++++++ tests/Parsers/YamlTest.php | 97 ++++++++++++ 11 files changed, 504 insertions(+) create mode 100644 tests/Parsers/Fixtures/CommonMarkExtensionFixture.php create mode 100644 tests/Parsers/Fixtures/EnumFixture.php create mode 100644 tests/Parsers/Fixtures/SerializableFixture.php create mode 100644 tests/Parsers/Fixtures/SetStateImplementingClassFixture.php create mode 100644 tests/Parsers/Fixtures/files/json/test.json create mode 100644 tests/Parsers/Fixtures/files/php/test.php create mode 100644 tests/Parsers/Fixtures/files/yaml/test.yaml create mode 100644 tests/Parsers/JsonTest.php create mode 100644 tests/Parsers/MarkdownTest.php create mode 100644 tests/Parsers/PhpTest.php create mode 100644 tests/Parsers/YamlTest.php diff --git a/tests/Parsers/Fixtures/CommonMarkExtensionFixture.php b/tests/Parsers/Fixtures/CommonMarkExtensionFixture.php new file mode 100644 index 000000000..545a832ce --- /dev/null +++ b/tests/Parsers/Fixtures/CommonMarkExtensionFixture.php @@ -0,0 +1,11 @@ +data = $data; + } + + public function __serialize(): array + { + return ['data' => $this->data]; + } + + public function __unserialize(array $data): void + { + $this->data = $data['data']; + } +} diff --git a/tests/Parsers/Fixtures/SetStateImplementingClassFixture.php b/tests/Parsers/Fixtures/SetStateImplementingClassFixture.php new file mode 100644 index 000000000..718510a65 --- /dev/null +++ b/tests/Parsers/Fixtures/SetStateImplementingClassFixture.php @@ -0,0 +1,13 @@ + 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'testing', 'formwork'], +]; diff --git a/tests/Parsers/Fixtures/files/yaml/test.yaml b/tests/Parsers/Fixtures/files/yaml/test.yaml new file mode 100644 index 000000000..39b0a3bff --- /dev/null +++ b/tests/Parsers/Fixtures/files/yaml/test.yaml @@ -0,0 +1,6 @@ +title: Test +description: This is a test. +tags: + - php + - yaml + - parser diff --git a/tests/Parsers/JsonTest.php b/tests/Parsers/JsonTest.php new file mode 100644 index 000000000..64df695cc --- /dev/null +++ b/tests/Parsers/JsonTest.php @@ -0,0 +1,97 @@ +tearDownTempDirectory(); + } + + public function testParse(): void + { + $json = << 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'json', 'parser'], + ]; + + $this->assertSame($expected, Json::parse($json)); + } + + public function testParseFile(): void + { + $jsonFilePath = TESTS_TMP_PATH . '/test.json'; + + $expected = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'json', 'parser'], + ]; + + $this->assertSame($expected, Json::parseFile($jsonFilePath)); + } + + public function testEncode(): void + { + $data = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'json', 'parser'], + ]; + + $expected = '{"title":"Test","description":"This is a test.","tags":["php","json","parser"]}'; + + $this->assertJsonStringEqualsJsonString($expected, Json::encode($data)); + } + + public function testEncodeWithPrettyPrint(): void + { + $data = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'json', 'parser'], + ]; + + $expected = <<assertJsonStringEqualsJsonString($expected, Json::encode($data, ['prettyPrint' => true])); + } + + public function testEncodeEmptyArrayWithForceObjectOption(): void + { + $data = []; + + $this->assertJsonStringEqualsJsonString('{}', Json::encode($data, ['forceObject' => true])); + } +} diff --git a/tests/Parsers/MarkdownTest.php b/tests/Parsers/MarkdownTest.php new file mode 100644 index 000000000..bf7ddfa60 --- /dev/null +++ b/tests/Parsers/MarkdownTest.php @@ -0,0 +1,88 @@ +Hello, World!\nThis is a bold statement.\n"; + + $this->assertSame($expectedHtml, Markdown::parse($markdown)); + } + + public function testParseWithSiteUri(): void + { + $site = $this->createStub(Site::class); + + $site->method('uri') + ->willReturnArgument(0); + + $markdown = "\n\n[Link text](https://example.com)"; + $expectedHtml = "\nLink text\n"; + + $this->assertSame($expectedHtml, Markdown::parse($markdown, ['site' => $site])); + } + + public function testParseReturnsHeadingsIdsWhenOptionEnabled(): void + { + $markdown = "## Section One\n\n## Section Two"; + $expectedHtml = "Section One\nSection Two\n"; + + $this->assertSame($expectedHtml, Markdown::parse($markdown, ['addHeadingIds' => true])); + } + + public function testParseWithCommonMarkExtensions(): void + { + $options = [ + 'commonmarkExtensions' => [ + CommonMarkExtensionFixture::class => [ + 'enabled' => true, + ], + ], + ]; + + $this->expectNotToPerformAssertions(); + Markdown::parse('', $options); + } + + public function testParseWithCommonMarkExtensionsDoesNotAddEnvironmentExtensions(): void + { + $options = [ + 'commonmarkExtensions' => [ + CommonMarkCoreExtension::class => [ + 'enabled' => true, + ], + ], + ]; + + $this->expectNotToPerformAssertions(); + Markdown::parse('', $options); + } + + public function testParseThrowsUnexpectedValueExceptionOnInvalidCommonMarkExtension(): void + { + $options = [ + 'commonmarkExtensions' => [ + stdClass::class => [ + 'enabled' => true, + ], + ], + ]; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid CommonMark extension "stdClass"'); + Markdown::parse('', $options); + } +} diff --git a/tests/Parsers/PhpTest.php b/tests/Parsers/PhpTest.php new file mode 100644 index 000000000..8f431b2e7 --- /dev/null +++ b/tests/Parsers/PhpTest.php @@ -0,0 +1,147 @@ +tearDownTempDirectory(); + } + + public function testParseAlwaysThrowsException(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Parsing a string of Php code is not allowed'); + Php::parse('assertSame([ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'testing', 'formwork'], + ], Php::parseFile($filePath)); + } + + public function testEncodeExportsDataAsPhpString(): void + { + $enumFixtureClass = EnumFixture::class; + + $arraySerializable = $this->createStub(ArraySerializable::class); + $arraySerializable->method('toArray')->willReturn([ + 'key' => 'value', + ]); + $arraySerializableClass = $arraySerializable::class; + + $setStateImplementingClass = SetStateImplementingClassFixture::class; + + $serializable = new SerializableFixture('value'); + $serialized = addcslashes(serialize($serializable), '\\'); + + $data = [ + 'string' => 'Test', + 'int' => 3, + 'float' => 3.14, + 'boolean' => false, + 'null' => null, + 'array' => ['test', 29, 2.71, true, null, []], + 'object' => (object) ['key' => 'value'], + 'enum' => EnumFixture::Alpha, + 'arraySerializable' => $arraySerializable, + 'setStateImplementing' => new SetStateImplementingClassFixture('value'), + 'serializable' => $serializable, + ]; + + $expected = << 'Test', + 'int' => 3, + 'float' => 3.14, + 'boolean' => false, + 'null' => null, + 'array' => [ + 'test', + 29, + 2.71, + true, + null, + [] + ], + 'object' => (object) [ + 'key' => 'value' + ], + 'enum' => \\$enumFixtureClass::Alpha, + 'arraySerializable' => \\$arraySerializableClass::fromArray([ + 'key' => 'value' + ]), + 'setStateImplementing' => \\$setStateImplementingClass::__set_state([ + 'key' => 'value' + ]), + 'serializable' => unserialize('$serialized') + ] + PHP; + + $this->assertSame($expected, Php::encode($data)); + } + + public function testEncodeThrowsExceptionWithUnencodableClasses(): void + { + $data = [ + 'closure' => fn() => 'Unencodable', + ]; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Objects of class "Closure" cannot be encoded'); + Php::encode($data); + } + + public function testEncodeThrowsExceptionWithResources(): void + { + $stream = fopen('php://temp', 'r'); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Data of type "resource" cannot be encoded'); + + try { + Php::encode(['resource' => $stream]); + } finally { + fclose($stream); + } + } + + public function testEncodeToFile(): void + { + $data = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'testing', 'formwork'], + ]; + + $filePath = TESTS_TMP_PATH . '/output.php'; + + Php::encodeToFile($data, $filePath); + + $this->assertSame($data, include $filePath); + } +} diff --git a/tests/Parsers/YamlTest.php b/tests/Parsers/YamlTest.php new file mode 100644 index 000000000..fbaf3d7d2 --- /dev/null +++ b/tests/Parsers/YamlTest.php @@ -0,0 +1,97 @@ +tearDownTempDirectory(); + } + + public function testParse(): void + { + $yamlString = << 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'yaml', 'parser'], + ]; + + $this->assertSame($expected, Yaml::parse($yamlString)); + } + + public function testParseFile(): void + { + $yamlFilePath = TESTS_TMP_PATH . '/test.yaml'; + + $expected = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'yaml', 'parser'], + ]; + + $this->assertSame($expected, Yaml::parseFile($yamlFilePath)); + } + + public function testEncode(): void + { + $data = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'yaml', 'parser'], + ]; + + $expectedYamlString = <<assertSame($expectedYamlString, Yaml::encode($data)); + } + + public function testEncodeToFile(): void + { + $data = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'yaml', 'parser'], + ]; + + $yamlFilePath = TESTS_TMP_PATH . '/output.yaml'; + + Yaml::encodeToFile($data, $yamlFilePath); + + $this->assertFileExists($yamlFilePath); + $this->assertSame(Yaml::encode($data), FileSystem::read($yamlFilePath)); + } + + public function testEncodeReturnsEmptyStringForEmptyData(): void + { + $this->assertSame('', Yaml::encode([])); + } +} From f96e9f6a46fb5c4476d671136e49877d1130269f Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:31:00 +0100 Subject: [PATCH 05/51] Update tests --- tests/Utils/FileSystemTest.php | 1 + tests/Utils/MimeTypeTest.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Utils/FileSystemTest.php b/tests/Utils/FileSystemTest.php index 22c41181a..9d19c9989 100644 --- a/tests/Utils/FileSystemTest.php +++ b/tests/Utils/FileSystemTest.php @@ -796,6 +796,7 @@ public function testListDirectories(): void public function testListDirectoriesIncludingHidden(): void { + FileSystem::createDirectory(TESTS_TMP_PATH . '/dir/.hiddendir'); $dirs = iterator_to_array(FileSystem::listDirectories(TESTS_TMP_PATH . '/dir', includeHidden: true)); $this->assertContains('.hiddendir', $dirs); } diff --git a/tests/Utils/MimeTypeTest.php b/tests/Utils/MimeTypeTest.php index 527f81bf9..86a5dcb08 100644 --- a/tests/Utils/MimeTypeTest.php +++ b/tests/Utils/MimeTypeTest.php @@ -41,7 +41,7 @@ public function testFromFileThrowsOnDisabledFileinfo(): void Environment::disableExtension('fileinfo'); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('requires the extension "fileinfo" to be enabled'); - MimeType::fromFile(__DIR__ . '/fixtures/MimeType/sample.html'); + MimeType::fromFile(__DIR__ . '/Fixtures/files/mimetype/sample.html'); } public function testExtensions(): void From 29fa08ab175d0bd67457de5f874ea97d991f38ae Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:08:36 +0200 Subject: [PATCH 06/51] Update tests --- .gitattributes | 2 + .gitignore | 1 + composer.json | 10 +- composer.lock | 677 +++++++++++++++++- formwork/.php-cs-fixer.php | 2 +- phpunit.xml.dist | 8 +- tests/TestCase.php | 14 - .../Data/CollectionDataProxyTest.php | 2 +- tests/{ => Unit}/Data/CollectionTest.php | 16 +- .../Exceptions/InvalidValueExceptionTest.php | 2 +- .../Data/Fixtures/DataArrayableFixture.php | 2 +- .../Data/Fixtures/DataCountableFixture.php | 2 +- .../Data/Fixtures/DataGetterFixture.php | 2 +- .../Data/Fixtures/DataIteratorFixture.php | 2 +- .../Fixtures/DataMultipleGetterFixture.php | 2 +- .../Fixtures/DataMultipleSetterFixture.php | 2 +- .../Data/Fixtures/DataSetterFixture.php | 2 +- tests/{ => Unit}/Data/PaginationTest.php | 2 +- .../Data/Traits/DataArrayableTest.php | 4 +- .../Data/Traits/DataCountableTest.php | 4 +- .../{ => Unit}/Data/Traits/DataGetterTest.php | 4 +- .../Data/Traits/DataIteratorTest.php | 4 +- .../Data/Traits/DataMultipleGetterTest.php | 4 +- .../Data/Traits/DataMultipleSetterTest.php | 4 +- .../{ => Unit}/Data/Traits/DataSetterTest.php | 4 +- .../Fixtures/CommonMarkExtensionFixture.php | 2 +- .../Parsers/Fixtures/EnumFixture.php | 2 +- .../Parsers/Fixtures/SerializableFixture.php | 2 +- .../SetStateImplementingClassFixture.php | 2 +- .../Parsers/Fixtures/files/json/test.json | 0 .../Parsers/Fixtures/files/php/test.php | 0 .../Parsers/Fixtures/files/yaml/test.yaml | 0 tests/{ => Unit}/Parsers/JsonTest.php | 2 +- tests/{ => Unit}/Parsers/MarkdownTest.php | 4 +- tests/{ => Unit}/Parsers/PhpTest.php | 8 +- tests/{ => Unit}/Parsers/YamlTest.php | 2 +- tests/{ => Unit}/Utils/ArrTest.php | 28 +- tests/{ => Unit}/Utils/ConstraintTest.php | 2 +- tests/{ => Unit}/Utils/DateTest.php | 2 +- tests/{ => Unit}/Utils/FileSystemTest.php | 4 +- .../Utils/Fixtures/ArrayableFixture.php | 2 +- .../Utils/Fixtures/FileSystemFixture.php | 2 +- .../Utils/Fixtures/StringableFixture.php | 2 +- .../Utils/Fixtures/TraversableFixture.php | 2 +- .../Utils/Fixtures/files/mimetype/invalid.svg | 0 .../Utils/Fixtures/files/mimetype/sample.html | 0 .../Utils/Fixtures/files/mimetype/sample.yaml | 0 .../Utils/Fixtures/files/mimetype/valid.svg | 0 .../Utils/Fixtures/files/tmp/dir/.hidden | 0 .../Utils/Fixtures/files/tmp/dir/b.txt | 0 .../Utils/Fixtures/files/tmp/dir/sample.txt | 0 .../Utils/Fixtures/files/tmp/dir/subdir/a.txt | 0 .../Utils/Fixtures/files/tmp/sample.txt | 0 .../Utils/Fixtures/files/tmp/symlink | 0 tests/{ => Unit}/Utils/Fixtures/functions.php | 2 +- tests/{ => Unit}/Utils/HtmlTest.php | 2 +- tests/{ => Unit}/Utils/MimeTypeTest.php | 2 +- tests/{ => Unit}/Utils/PathTest.php | 2 +- tests/{ => Unit}/Utils/StrTest.php | 26 +- tests/{ => Unit}/Utils/TextTest.php | 2 +- tests/{ => Unit}/Utils/UriTest.php | 2 +- tests/bootstrap.php | 10 +- 62 files changed, 761 insertions(+), 133 deletions(-) create mode 100644 .gitattributes rename tests/{ => Unit}/Data/CollectionDataProxyTest.php (98%) rename tests/{ => Unit}/Data/CollectionTest.php (98%) rename tests/{ => Unit}/Data/Exceptions/InvalidValueExceptionTest.php (94%) rename tests/{ => Unit}/Data/Fixtures/DataArrayableFixture.php (85%) rename tests/{ => Unit}/Data/Fixtures/DataCountableFixture.php (83%) rename tests/{ => Unit}/Data/Fixtures/DataGetterFixture.php (80%) rename tests/{ => Unit}/Data/Fixtures/DataIteratorFixture.php (83%) rename tests/{ => Unit}/Data/Fixtures/DataMultipleGetterFixture.php (82%) rename tests/{ => Unit}/Data/Fixtures/DataMultipleSetterFixture.php (86%) rename tests/{ => Unit}/Data/Fixtures/DataSetterFixture.php (85%) rename tests/{ => Unit}/Data/PaginationTest.php (98%) rename tests/{ => Unit}/Data/Traits/DataArrayableTest.php (80%) rename tests/{ => Unit}/Data/Traits/DataCountableTest.php (80%) rename tests/{ => Unit}/Data/Traits/DataGetterTest.php (90%) rename tests/{ => Unit}/Data/Traits/DataIteratorTest.php (84%) rename tests/{ => Unit}/Data/Traits/DataMultipleGetterTest.php (93%) rename tests/{ => Unit}/Data/Traits/DataMultipleSetterTest.php (90%) rename tests/{ => Unit}/Data/Traits/DataSetterTest.php (91%) rename tests/{ => Unit}/Parsers/Fixtures/CommonMarkExtensionFixture.php (84%) rename tests/{ => Unit}/Parsers/Fixtures/EnumFixture.php (70%) rename tests/{ => Unit}/Parsers/Fixtures/SerializableFixture.php (87%) rename tests/{ => Unit}/Parsers/Fixtures/SetStateImplementingClassFixture.php (83%) rename tests/{ => Unit}/Parsers/Fixtures/files/json/test.json (100%) rename tests/{ => Unit}/Parsers/Fixtures/files/php/test.php (100%) rename tests/{ => Unit}/Parsers/Fixtures/files/yaml/test.yaml (100%) rename tests/{ => Unit}/Parsers/JsonTest.php (98%) rename tests/{ => Unit}/Parsers/MarkdownTest.php (96%) rename tests/{ => Unit}/Parsers/PhpTest.php (95%) rename tests/{ => Unit}/Parsers/YamlTest.php (98%) rename tests/{ => Unit}/Utils/ArrTest.php (98%) rename tests/{ => Unit}/Utils/ConstraintTest.php (99%) rename tests/{ => Unit}/Utils/DateTest.php (99%) rename tests/{ => Unit}/Utils/FileSystemTest.php (99%) rename tests/{ => Unit}/Utils/Fixtures/ArrayableFixture.php (83%) rename tests/{ => Unit}/Utils/Fixtures/FileSystemFixture.php (99%) rename tests/{ => Unit}/Utils/Fixtures/StringableFixture.php (82%) rename tests/{ => Unit}/Utils/Fixtures/TraversableFixture.php (83%) rename tests/{ => Unit}/Utils/Fixtures/files/mimetype/invalid.svg (100%) rename tests/{ => Unit}/Utils/Fixtures/files/mimetype/sample.html (100%) rename tests/{ => Unit}/Utils/Fixtures/files/mimetype/sample.yaml (100%) rename tests/{ => Unit}/Utils/Fixtures/files/mimetype/valid.svg (100%) rename tests/{ => Unit}/Utils/Fixtures/files/tmp/dir/.hidden (100%) rename tests/{ => Unit}/Utils/Fixtures/files/tmp/dir/b.txt (100%) rename tests/{ => Unit}/Utils/Fixtures/files/tmp/dir/sample.txt (100%) rename tests/{ => Unit}/Utils/Fixtures/files/tmp/dir/subdir/a.txt (100%) rename tests/{ => Unit}/Utils/Fixtures/files/tmp/sample.txt (100%) rename tests/{ => Unit}/Utils/Fixtures/files/tmp/symlink (100%) rename tests/{ => Unit}/Utils/Fixtures/functions.php (97%) rename tests/{ => Unit}/Utils/HtmlTest.php (98%) rename tests/{ => Unit}/Utils/MimeTypeTest.php (98%) rename tests/{ => Unit}/Utils/PathTest.php (99%) rename tests/{ => Unit}/Utils/StrTest.php (86%) rename tests/{ => Unit}/Utils/TextTest.php (98%) rename tests/{ => Unit}/Utils/UriTest.php (99%) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..3a3ce6e7c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/.gitignore b/.gitignore index 0fc4607dd..6880d4484 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store .php-cs-fixer.cache .phpunit.cache +.phpunit.result.cache phpunit.xml diff --git a/composer.json b/composer.json index f4b123d18..33c780586 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,11 @@ "Formwork\\": "formwork/src/" } }, + "autoload-dev": { + "psr-4": { + "Formwork\\Tests\\": "tests/" + } + }, "require": { "php": ">=8.3", "ext-dom": "*", @@ -46,7 +51,7 @@ "friendsofphp/php-cs-fixer": "^3.0", "phpstan/phpstan": "^2.0.3", "rector/rector": "^2.0.3", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5" }, "scripts": { "fix": "php-cs-fixer fix --config=formwork/.php-cs-fixer.php --verbose", @@ -56,6 +61,7 @@ "phpstan:baseline": "phpstan analyse --configuration=formwork/phpstan.neon --generate-baseline=formwork/phpstan-baseline.neon", "rector": "rector --config=formwork/.rector.php", "rector:check": "rector --dry-run --config=formwork/.rector.php", + "tests": "phpunit", "serve": [ "Composer\\Config::disableProcessTimeout", "php bin/serve" @@ -63,4 +69,4 @@ "test": "phpunit", "test:coverage": "phpunit -d pcov.enabled=1 --coverage-html tests/coverage" } -} +} \ No newline at end of file diff --git a/composer.lock b/composer.lock index d9ac1327e..6523db6e4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "644d621594aef5a1c0adcddaa672a1fc", + "content-hash": "9ddc8e67d47e9d65ff3da28db191cc1e", "packages": [ { "name": "dflydev/dot-access-data", @@ -2608,6 +2608,681 @@ ], "time": "2026-08-25T15:35:54+00:00" }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, { "name": "symfony/console", "version": "v7.4.20", diff --git a/formwork/.php-cs-fixer.php b/formwork/.php-cs-fixer.php index d91f92eed..5ce9c3a44 100644 --- a/formwork/.php-cs-fixer.php +++ b/formwork/.php-cs-fixer.php @@ -4,7 +4,7 @@ $finder = Finder::create() ->in(dirname(__DIR__)) - ->exclude(['cache', 'formwork/views', 'panel/node_modules', 'panel/views', 'site/plugins', 'site/templates', 'tests']); + ->exclude(['cache', 'formwork/views', 'panel/node_modules', 'panel/views', 'site/plugins', 'site/templates']); $config = new Config(); diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 80decdf40..483be94d0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,8 +1,8 @@ - + - - ./tests + + tests/Unit @@ -15,4 +15,4 @@ ./formwork/src/Utils/scripts - + \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index aeedd69f2..7fcc32528 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,24 +3,10 @@ namespace Formwork\Tests; use Formwork\Utils\FileSystem; -use PHPUnit\Framework\Attributes\BeforeClass; use PHPUnit\Framework\TestCase as BaseTestCase; -use ReflectionClass; class TestCase extends BaseTestCase { - #[BeforeClass(100)] - public static function setUpEnvironment(): void - { - require_once TESTS_PATH . '/Environment.php'; - - $dir = dirname((new ReflectionClass(static::class))->getFileName()); - - if (FileSystem::exists($dir . '/fixtures/functions.php')) { - require_once $dir . '/fixtures/functions.php'; - } - } - protected function setUpTempDirectory(): void { if (!FileSystem::isDirectory(TESTS_TMP_PATH, assertExists: false)) { diff --git a/tests/Data/CollectionDataProxyTest.php b/tests/Unit/Data/CollectionDataProxyTest.php similarity index 98% rename from tests/Data/CollectionDataProxyTest.php rename to tests/Unit/Data/CollectionDataProxyTest.php index a9002adc1..21b590989 100644 --- a/tests/Data/CollectionDataProxyTest.php +++ b/tests/Unit/Data/CollectionDataProxyTest.php @@ -1,6 +1,6 @@ [['value' => 20]], - '!=', 'notEqualTo' => [['value' => 10], ['value' => 30]], - '===', 'strictlyEqualTo' => [['value' => 20]], - '!==', 'strictlyNotEqualTo' => [['value' => 10], ['value' => 30]], - '>', 'greaterThan' => [['value' => 30]], + '==', 'equalTo' => [['value' => 20]], + '!=', 'notEqualTo' => [['value' => 10], ['value' => 30]], + '===', 'strictlyEqualTo' => [['value' => 20]], + '!==', 'strictlyNotEqualTo' => [['value' => 10], ['value' => 30]], + '>', 'greaterThan' => [['value' => 30]], '>=', 'greaterThanOrEqualTo' => [['value' => 20], ['value' => 30]], - '<', 'lessThan' => [['value' => 10]], - '<=', 'lessThanOrEqualTo' => [['value' => 10], ['value' => 20]], + '<', 'lessThan' => [['value' => 10]], + '<=', 'lessThanOrEqualTo' => [['value' => 10], ['value' => 20]], }; $filteredCollection = $collection->filterBy('value', $filter, 20); diff --git a/tests/Data/Exceptions/InvalidValueExceptionTest.php b/tests/Unit/Data/Exceptions/InvalidValueExceptionTest.php similarity index 94% rename from tests/Data/Exceptions/InvalidValueExceptionTest.php rename to tests/Unit/Data/Exceptions/InvalidValueExceptionTest.php index ac7cefcc3..0a5d7e860 100644 --- a/tests/Data/Exceptions/InvalidValueExceptionTest.php +++ b/tests/Unit/Data/Exceptions/InvalidValueExceptionTest.php @@ -1,6 +1,6 @@ 'Sempronius', - 'email' => 'sempronius@example.com', - 'country' => 'Italy', - ]; - - $fruits = [ - 'apple', - 'banana', - 'cherry', - 'banana', - ]; - - $this->assertTrue(Arr::isAssociative($user)); - $this->assertFalse(Arr::isAssociative($fruits)); - $this->assertFalse(Arr::isAssociative([])); - } - public function testMap(): void { $data = [ diff --git a/tests/Utils/ConstraintTest.php b/tests/Unit/Utils/ConstraintTest.php similarity index 99% rename from tests/Utils/ConstraintTest.php rename to tests/Unit/Utils/ConstraintTest.php index 66e8769ee..f0a577521 100644 --- a/tests/Utils/ConstraintTest.php +++ b/tests/Unit/Utils/ConstraintTest.php @@ -1,6 +1,6 @@ assertTrue(Str::startsWith('Hello, world!', 'Hello')); - $this->assertFalse(Str::startsWith('Hello, world!', 'world')); - - $this->assertTrue(Str::startsWith('Hello, world!', '')); - } - - public function testEndsWith(): void - { - $this->assertTrue(Str::endsWith('Hello, world!', 'world!')); - $this->assertFalse(Str::endsWith('Hello, world!', 'Hello')); - - $this->assertTrue(Str::endsWith('Hello, world!', '')); - } - - public function testContains(): void - { - $this->assertTrue(Str::contains('Hello, world!', 'lo, wo')); - $this->assertFalse(Str::contains('Hello, world!', 'planet')); - - $this->assertTrue(Str::contains('Hello, world!', '')); - } - public function testBefore(): void { $this->assertSame('Hello', Str::before('Hello, world!', ',')); diff --git a/tests/Utils/TextTest.php b/tests/Unit/Utils/TextTest.php similarity index 98% rename from tests/Utils/TextTest.php rename to tests/Unit/Utils/TextTest.php index 79a817632..7454d57d6 100644 --- a/tests/Utils/TextTest.php +++ b/tests/Unit/Utils/TextTest.php @@ -1,6 +1,6 @@ addPsr4('Formwork\Tests\\', TESTS_PATH); -$autoloader->register(); +require TESTS_PATH . '/Environment.php'; + +require TESTS_PATH . '/Unit/Utils/Fixtures/functions.php'; + +(new App())->load(); From cd77583a774a8f01bf9da0c816e9f07d84faee1a Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:07:58 +0200 Subject: [PATCH 07/51] Add `FormTest` --- tests/Unit/Forms/FormTest.php | 791 ++++++++++++++++++++++++++++++++++ 1 file changed, 791 insertions(+) create mode 100644 tests/Unit/Forms/FormTest.php diff --git a/tests/Unit/Forms/FormTest.php b/tests/Unit/Forms/FormTest.php new file mode 100644 index 000000000..8a1e2ee1f --- /dev/null +++ b/tests/Unit/Forms/FormTest.php @@ -0,0 +1,791 @@ +app = App::instance(); + } + + public function testNameAndFieldsAreAvailable(): void + { + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $this->assertSame('contact', $form->name()); + $this->assertSame($fields, $form->fields()->toArray()); + } + + public function testNewFormIsNotSubmittedAndIsValid(): void + { + $form = $this->createForm('contact'); + + $this->assertFalse($form->isSubmitted()); + $this->assertFalse($form->isValid()); + $this->assertSame(ResponseStatus::OK, $form->getResponseStatus()); + } + + public function testDataCannotBeReadBeforeSubmission(): void + { + $form = $this->createForm('contact'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Form "contact" has not been submitted yet.'); + + $form->data(); + } + + public function testUploadedFilesCannotBeReadBeforeSubmission(): void + { + $form = $this->createForm('contact'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Form "contact" has not been submitted yet.'); + + $form->uploadedFiles(); + } + + public function testNonPostRequestDoesNotSubmitTheForm(): void + { + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + 'value' => 'original', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $request = $this->createRequest(RequestMethod::GET, input: ['name' => 'changed']); + + $this->assertSame($form, $form->processRequest($request)); + + $this->assertFalse($form->isSubmitted()); + $this->assertFalse($form->isValid()); + $this->assertSame(ResponseStatus::OK, $form->getResponseStatus()); + $this->assertSame(['name' => 'original'], (new FieldCollection($fields))->extract('value')); + } + + public function testPostRequestSubmitsTheForm(): void + { + $form = $this->createForm('contact'); + + $form->processRequest($this->createRequest(RequestMethod::POST)); + + $this->assertTrue($form->isSubmitted()); + } + + public function testProcessRequestCannotBeCalledTwice(): void + { + $form = $this->createForm('contact'); + + $request = $this->createRequest(RequestMethod::POST); + + $form->processRequest($request); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Form "contact" has already been processed.'); + + $form->processRequest($request); + } + + public function testProcessRequestReturnsTheSameForm(): void + { + $form = $this->createForm('contact'); + + $this->assertSame($form, $form->processRequest($this->createRequest(RequestMethod::POST))); + } + + public function testRequestInputOverridesQueryData(): void + { + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $form->processRequest( + $this->createRequest( + RequestMethod::POST, + input: ['name' => 'input'], + query: ['name' => 'query'], + ), + ); + + $this->assertSame(['name' => 'input'], $form->data()->toArray()); + } + + public function testMissingTextFieldSetsEmptyValue(): void + { + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + 'default' => 'Default name', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST)); + + $this->assertSame(['name' => ''], $form->data()->toArray()); + } + + public function testSubmittedValueOverridesFieldDefault(): void + { + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + 'default' => 'Default name', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST, input: ['name' => 'Submitted name'])); + + $this->assertSame(['name' => 'Submitted name'], $form->data()->toArray()); + } + + public function testValidSubmissionHasOkResponseStatus(): void + { + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + 'required' => true, + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST, input: ['name' => 'Sempronius'])); + + $this->assertTrue($form->isValid()); + $this->assertSame(ResponseStatus::OK, $form->getResponseStatus()); + } + + public function testInvalidSubmissionHasUnprocessableEntityResponseStatus(): void + { + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + 'required' => true, + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST)); + + $this->assertFalse($form->isValid()); + $this->assertSame(ResponseStatus::UnprocessableEntity, $form->getResponseStatus()); + } + + public function testFormDataContainsNonUploadFields(): void + { + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + ]), + 'email' => $this->createField('email', [ + 'type' => 'email', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST, input: [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + ])); + + $this->assertSame([ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + ], $form->data()->toArray()); + } + + public function testUploadFieldsAreExcludedFromFormData(): void + { + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + ]), + 'attachment' => $this->createField('attachment', [ + 'type' => 'upload', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST, input: [ + 'name' => 'Sempronius', + ], files: [ + 'attachment' => $this->uploadData(), + ]), uploadFiles: false); + + $this->assertSame(['name' => 'Sempronius'], $form->data()->toArray()); + } + + public function testPreserveEmptyKeepsEmptyFields(): void + { + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST), preserveEmpty: true); + + $this->assertSame(['name' => ''], $form->data()->toArray()); + } + + public function testPreserveEmptyFalseRemovesEmptyFields(): void + { + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST), preserveEmpty: false); + + $this->assertSame([], $form->data()->toArray()); + } + + public function testSetDefaultUploadsDestinationReturnsTheSameForm(): void + { + $form = $this->createForm('contact'); + + $this->assertSame($form, $form->setDefaultUploadsDestination('/uploads')); + } + + public function testUploadsAreNotProcessedWhenUploadProcessingIsDisabled(): void + { + $uploader = $this->createMock(FileUploader::class); + + $uploader + ->expects($this->never()) + ->method('upload'); + + $form = $this->createForm( + 'contact', + [ + 'attachment' => $this->createField('attachment', [ + 'type' => 'upload', + ]), + ], + ); + + $form->processRequest($this->createRequest(RequestMethod::POST, files: [ + 'attachment' => $this->uploadData(), + ]), uploadFiles: false,); + + $this->assertSame([], $form->uploadedFiles()); + } + + public function testUploadsAreNotProcessedWhenValidationFails(): void + { + $uploader = $this->createMock(FileUploader::class); + + $uploader + ->expects($this->never()) + ->method('upload'); + + $fields = [ + 'name' => $this->createField('name', [ + 'type' => 'text', + 'required' => true, + ]), + 'attachment' => $this->createField('attachment', [ + 'type' => 'upload', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST, files: [ + 'attachment' => $this->uploadData(), + ])); + + $this->assertFalse($form->isValid()); + $this->assertSame([], $form->uploadedFiles()); + } + + public function testEmptyUploadIsIgnored(): void + { + $uploader = $this->createMock(FileUploader::class); + $uploader + ->expects($this->never()) + ->method('upload'); + + $fields = [ + 'attachment' => $this->createField('attachment', [ + 'type' => 'upload', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST)); + + $this->assertSame([], $form->uploadedFiles()); + } + + public function testSingleUploadIsProcessed(): void + { + $uploadedFile = new File('/uploads/photo.jpg'); + + $uploader = $this->createMock(FileUploader::class); + + $uploader + ->expects($this->once()) + ->method('upload') + ->with( + $this->isInstanceOf(UploadedFile::class), + '/uploads', + 'photo', + ['image/jpeg'], + true, + ) + ->willReturn($uploadedFile); + + $fields = [ + 'attachment' => $this->createField('attachment', [ + 'type' => 'upload', + 'destination' => '/uploads', + 'filename' => 'photo', + 'accept' => ['jpg'], + 'overwrite' => true, + ]), + ]; + + $form = $this->createForm('contact', $fields, $uploader); + + $form->processRequest($this->createRequest(RequestMethod::POST, files: [ + 'attachment' => $this->uploadData(), + ])); + + $this->assertSame([$uploadedFile], $form->uploadedFiles()); + } + + public function testDefaultUploadDestinationIsUsedWhenFieldHasNoDestination(): void + { + $uploadedFile = new File('/uploads/photo.jpg'); + $uploader = $this->createMock(FileUploader::class); + + $uploader + ->expects($this->once()) + ->method('upload') + ->with( + $this->isInstanceOf(UploadedFile::class), + '/uploads', + null, + ['image/jpeg'], + false, + ) + ->willReturn($uploadedFile); + + $fields = [ + 'attachment' => $this->createField('attachment', [ + 'type' => 'upload', + 'accept' => ['jpg'], + ]), + ]; + + $form = $this->createForm('contact', $fields, $uploader); + + $form->setDefaultUploadsDestination('/uploads'); + + $form->processRequest($this->createRequest(RequestMethod::POST, files: [ + 'attachment' => $this->uploadData(), + ])); + + $this->assertSame([$uploadedFile], $form->uploadedFiles()); + } + + public function testFieldUploadDestinationTakesPrecedenceOverDefaultDestination(): void + { + $uploadedFile = new File('/field-uploads/photo.jpg'); + $uploader = $this->createMock(FileUploader::class); + + $uploader + ->expects($this->once()) + ->method('upload') + ->with( + $this->isInstanceOf(UploadedFile::class), + '/field-uploads', + null, + ['image/jpeg'], + false, + ) + ->willReturn($uploadedFile); + + $fields = [ + 'attachment' => $this->createField('attachment', [ + 'type' => 'upload', + 'destination' => '/field-uploads', + 'accept' => ['jpg'], + ]), + ]; + + $form = $this->createForm('contact', $fields, $uploader); + + $form->setDefaultUploadsDestination('/default-uploads'); + + $form->processRequest($this->createRequest(RequestMethod::POST, files: [ + 'attachment' => $this->uploadData(), + ])); + + $this->assertSame([$uploadedFile], $form->uploadedFiles()); + } + + public function testMissingUploadDestinationThrows(): void + { + $fields = [ + 'attachment' => $this->createField('attachment', [ + 'type' => 'upload', + ]), + ]; + + $form = $this->createForm('contact', $fields); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('No destination specified for file upload.'); + + $form->processRequest($this->createRequest(RequestMethod::POST, files: [ + 'attachment' => $this->uploadData(), + ])); + } + + public function testMultipleUploadsAreProcessed(): void + { + $firstResult = new File('/uploads/first.jpg'); + $secondResult = new File('/uploads/second.jpg'); + + $uploader = $this->createMock(FileUploader::class); + + $uploader + ->expects($this->exactly(2)) + ->method('upload') + ->with( + $this->isInstanceOf(UploadedFile::class), + '/uploads', + null, + ['image/jpeg'], + false, + ) + ->willReturnCallback(static fn(UploadedFile $file): File => match ($file->clientName()) { + 'first.jpg' => $firstResult, + 'second.jpg' => $secondResult, + default => throw new LogicException('Unexpected uploaded file.'), + }); + + $fields = [ + 'attachments' => $this->createField('attachments', [ + 'type' => 'upload', + 'accept' => ['jpg'], + 'multiple' => true, + 'destination' => '/uploads', + ]), + ]; + + $form = $this->createForm('contact', $fields, $uploader); + + $form->processRequest($this->createRequest(RequestMethod::POST, files: [ + 'attachments' => [ + 'name' => ['first.jpg', 'second.jpg'], + 'full_path' => ['first.jpg', 'second.jpg'], + 'type' => ['image/jpeg', 'image/jpeg'], + 'tmp_name' => ['/tmp/first.jpg', '/tmp/second.jpg'], + 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_OK], + 'size' => ['123', '456'], + ], + ])); + + $this->assertSame([$firstResult, $secondResult], $form->uploadedFiles()); + } + + public static function fieldTypesProvider(): iterable + { + yield 'array' => [ + 'array', + [], + ['first', 'second'], + ['first', 'second'], + [], + ]; + + yield 'checkbox' => [ + 'checkbox', + [], + true, + true, + false, + ]; + + yield 'color' => [ + 'color', + [], + '#FF0000', + '#ff0000', + null, + ]; + + yield 'date' => [ + 'date', + [], + '2026-01-15', + '2026-01-15 00:00:00', + null, + ]; + + yield 'duration' => [ + 'duration', + [], + '3600', + 3600, + null, + ]; + + yield 'email' => [ + 'email', + [], + 'sempronius@example.com', + 'sempronius@example.com', + '', + ]; + + yield 'file' => [ + 'file', + [], + 'document.pdf', + 'document.pdf', + null, + ]; + + yield 'files' => [ + 'files', + [], + ['document.pdf', 'image.jpg'], + ['document.pdf', 'image.jpg'], + [], + ]; + + yield 'image' => [ + 'image', + [], + 'photo.jpg', + 'photo.jpg', + null, + ]; + + yield 'images' => [ + 'images', + [], + ['photo.jpg', 'another.jpg'], + ['photo.jpg', 'another.jpg'], + [], + ]; + + yield 'markdown' => [ + 'markdown', + [], + '**Hello**', + '**Hello**', + '', + ]; + + yield 'number' => [ + 'number', + [], + '42', + 42, + null, + ]; + + yield 'page' => [ + 'page', + [], + '/about', + '/about', + null, + ]; + + yield 'password' => [ + 'password', + [], + 'secret', + 'secret', + '', + ]; + + yield 'range' => [ + 'range', + [], + '50', + 50, + null, + ]; + + yield 'select' => [ + 'select', + [ + 'options' => [ + 'first' => 'First', + 'second' => 'Second', + ], + ], + 'second', + 'second', + '', + ]; + + yield 'slug' => [ + 'slug', + [], + 'my-page', + 'my-page', + '', + ]; + + yield 'tags' => [ + 'tags', + [], + ['first', 'second'], + ['first', 'second'], + [], + ]; + + yield 'template' => [ + 'template', + [], + 'default', + 'default', + null, + ]; + + yield 'text' => [ + 'text', + [], + 'Sempronius', + 'Sempronius', + '', + ]; + + yield 'textarea' => [ + 'textarea', + [], + 'A description', + 'A description', + '', + ]; + + yield 'togglegroup' => [ + 'togglegroup', + [], + 'first', + 'first', + null, + ]; + } + + #[DataProvider('fieldTypesProvider')] + public function testFieldTypeValues( + string $type, + array $config, + mixed $submittedValue, + mixed $expectedSubmittedValue, + mixed $expectedMissingValue, + ): void { + $fieldName = $type; + $fields = [ + $fieldName => $this->createField($fieldName, [ + 'type' => $type, + ...$config, + ]), + ]; + + $form = $this->createForm('test', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST, input: [ + $fieldName => $submittedValue, + ])); + + $this->assertSame( + [$fieldName => $expectedSubmittedValue], + $form->data()->toArray(), + ); + + $form = $this->createForm('test', $fields); + + $form->processRequest($this->createRequest(RequestMethod::POST)); + + $this->assertSame( + [$fieldName => $expectedMissingValue], + $form->data()->toArray(), + ); + } + + private function createField(string $name, array $data): Field + { + return $this->app->getService(FieldFactory::class)->make($name, $data); + } + + private function createForm(string $name, array $fields = [], ?FileUploader $uploader = null): Form + { + return new Form($name, new FieldCollection($fields), $uploader ?? $this->createStub(FileUploader::class)); + } + + private function createRequest(RequestMethod $method, array $input = [], array $query = [], array $files = []): Request + { + return new Request($input, $query, [], $files, ['REQUEST_METHOD' => $method->value]); + } + + /** + * @return array + */ + private function uploadData(string $name = 'photo.jpg', int $error = UPLOAD_ERR_OK): array + { + return [ + 'name' => $name, + 'full_path' => $name, + 'type' => 'image/jpeg', + 'tmp_name' => '/tmp/' . $name, + 'error' => $error, + 'size' => '123', + ]; + } +} From 8b6748970882c0fcca37d21fbf3c2e97b07d1381 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:08:09 +0200 Subject: [PATCH 08/51] Add `FieldCollectionTest` --- tests/Unit/Fields/FieldCollectionTest.php | 425 ++++++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 tests/Unit/Fields/FieldCollectionTest.php diff --git a/tests/Unit/Fields/FieldCollectionTest.php b/tests/Unit/Fields/FieldCollectionTest.php new file mode 100644 index 000000000..7cfce86cc --- /dev/null +++ b/tests/Unit/Fields/FieldCollectionTest.php @@ -0,0 +1,425 @@ +app = App::instance(); + } + + public function testSetLayoutAndGetLayout(): void + { + $fields = new FieldCollection(); + $layout = $this->createStub(Layout::class); + + $fields->setLayout($layout); + + $this->assertSame($layout, $fields->layout()); + } + + public function testSetModelAndGetModel(): void + { + $fields = new FieldCollection(); + $model = $this->createStub(Model::class); + + $fields->setModel($model); + + $this->assertSame($model, $fields->model()); + } + + public function testModelIsNullByDefault(): void + { + $fields = new FieldCollection(); + + $this->assertNull($fields->model()); + } + + public function testExtractReturnsFieldValuesWithKeys(): void + { + $fields = new FieldCollection([ + 'name' => $this->createField('name', [ + 'type' => 'text', + 'value' => 'Sempronius', + ]), + 'email' => $this->createField('email', [ + 'type' => 'email', + 'value' => 'sempronius@example.com', + ]), + ]); + + $this->assertSame( + [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + ], + $fields->extract('value'), + ); + } + + public function testExtractUsesDefaultForMissingKeys(): void + { + $fields = new FieldCollection([ + 'name' => $this->createField('name', ['type' => 'text']), + 'email' => $this->createField('email', ['type' => 'email']), + ]); + + $this->assertSame( + [ + 'name' => 'fallback', + 'email' => 'fallback', + ], + $fields->extract('missing', 'fallback'), + ); + } + + public function testSetValuesReturnsSameCollection(): void + { + $fields = new FieldCollection([ + 'name' => $this->createField('name', ['type' => 'text']), + ]); + + $this->assertSame($fields, $fields->setValues(['name' => 'Sempronius'])); + } + + public function testSetValuesSetsValuesForFieldsPresentInData(): void + { + $fields = new FieldCollection([ + 'name' => $this->createField('name', ['type' => 'text']), + 'email' => $this->createField('email', ['type' => 'email']), + ]); + + $fields->setValues([ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + ]); + + $this->assertSame( + [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + ], + $fields->extract('value'), + ); + } + + public function testSetValuesDoesNotChangeFieldsMissingFromData(): void + { + $fields = new FieldCollection([ + 'name' => $this->createField('name', [ + 'type' => 'text', + 'value' => 'Existing name', + ]), + 'email' => $this->createField('email', [ + 'type' => 'email', + 'value' => 'existing@example.com', + ]), + ]); + + $fields->setValues(['name' => 'Sempronius']); + + $this->assertSame( + [ + 'name' => 'Sempronius', + 'email' => 'existing@example.com', + ], + $fields->extract('value'), + ); + } + + public function testSetValuesUsesExplicitDefaultForMissingFields(): void + { + $fields = new FieldCollection([ + 'name' => $this->createField('name', [ + 'type' => 'text', + 'value' => 'Existing name', + ]), + 'email' => $this->createField('email', [ + 'type' => 'email', + 'value' => 'existing@example.com', + ]), + ]); + + $fields->setValues(['name' => 'Sempronius'], null); + + $this->assertSame( + [ + 'name' => 'Sempronius', + 'email' => null, + ], + $fields->extract('value'), + ); + } + + #[TestWith(['enabled', false], name: "'enabled' => false")] + #[TestWith(['nullable', null], name: "'nullable' => null")] + #[TestWith(['empty', ''], name: "'empty' => ''")] + public function testSetValuesPreservesValuesThatArePresent(string $name, mixed $value): void + { + $fields = new FieldCollection([ + $name => $this->createField($name, [ + 'type' => 'text', + 'value' => 'original', + ]), + ]); + + $fields->setValues([$name => $value]); + + $this->assertSame([$name => $value], $fields->extract('value')); + } + + public function testSetValuesCanReplaceAnExistingValueWithNull(): void + { + $fields = new FieldCollection([ + 'value' => $this->createField('value', [ + 'type' => 'text', + 'value' => 'original', + ]), + ]); + + $fields->setValues(['value' => null]); + + $this->assertSame(['value' => null], $fields->extract('value')); + } + + public function testSetValuesFromRequestSetsRequestValues(): void + { + $fields = new FieldCollection([ + 'name' => $this->createField('name', ['type' => 'text']), + 'email' => $this->createField('email', ['type' => 'email']), + ]); + + $request = $this->createRequest(RequestMethod::POST, input: [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + ]); + + $this->assertSame($fields, $fields->setValuesFromRequest($request)); + + $this->assertSame( + [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + ], + $fields->extract('value'), + ); + } + + public function testSetValuesFromRequestMergesQueryAndInputWithInputTakingPrecedence(): void + { + $fields = new FieldCollection([ + 'name' => $this->createField('name', ['type' => 'text']), + 'email' => $this->createField('email', ['type' => 'email']), + ]); + + $request = $this->createRequest( + RequestMethod::POST, + input: [ + 'name' => 'input', + ], + query: [ + 'name' => 'query', + 'email' => 'query@example.com', + ] + ); + + $fields->setValuesFromRequest($request); + + $this->assertSame( + [ + 'name' => 'input', + 'email' => 'query@example.com', + ], + $fields->extract('value'), + ); + } + + public function testValidateReturnsSameCollection(): void + { + $fields = new FieldCollection([ + 'name' => $this->createField('name', [ + 'type' => 'text', + 'value' => 'Sempronius', + ]), + ]); + + $this->assertSame($fields, $fields->validate()); + + $this->assertTrue($fields->isValidated()); + } + + public function testValidateValidatesAllFields(): void + { + $validated = []; + + $first = $this->createField('first', [ + 'type' => 'text', + 'value' => 'first', + ]); + + $first->setMethods([ + 'validate' => function (Field $field, mixed $value) use (&$validated): mixed { + $validated[] = $field->name(); + return $value; + }, + ]); + + $second = $this->createField('second', [ + 'type' => 'text', + 'value' => 'second', + ]); + + $second->setMethods([ + 'validate' => function (Field $field, mixed $value) use (&$validated): mixed { + $validated[] = $field->name(); + + return $value; + }, + ]); + + $fields = new FieldCollection([ + 'first' => $first, + 'second' => $second, + ]); + + $fields->validate(); + + $this->assertSame(['first', 'second'], $validated); + $this->assertTrue($fields->isValidated()); + } + + public function testIsValidReturnsTrueWhenAllFieldsAreValid(): void + { + $fields = new FieldCollection([ + 'name' => $this->createField('name', [ + 'type' => 'text', + 'value' => 'Sempronius', + ]), + 'email' => $this->createField('email', [ + 'type' => 'text', + 'value' => 'sempronius@example.com', + ]), + ]); + + $this->assertTrue($fields->isValid()); + $this->assertTrue($fields->isValidated()); + } + + public function testIsValidReturnsFalseWhenOneFieldIsInvalid(): void + { + $invalid = $this->createField('invalid', [ + 'type' => 'text', + 'value' => '', + ]); + + $invalid->setMethods([ + 'validate' => static function (): never { + throw new ValidationException('Invalid value'); + }, + ]); + + $valid = $this->createField('valid', [ + 'type' => 'text', + 'value' => 'valid', + ]); + + $fields = new FieldCollection([ + 'invalid' => $invalid, + 'valid' => $valid, + ]); + + $this->assertFalse($fields->isValid()); + $this->assertTrue($invalid->isValidated()); + $this->assertTrue($valid->isValidated()); + } + + public function testIsValidValidatesEveryFieldEvenAfterAnInvalidField(): void + { + $validated = []; + + $invalid = $this->createField('invalid', [ + 'type' => 'text', + 'value' => '', + ]); + + $invalid->setMethods([ + 'validate' => function (Field $field, mixed $value) use (&$validated): never { + $validated[] = $field->name(); + + throw new ValidationException('Invalid value'); + }, + ]); + + $valid = $this->createField('valid', [ + 'type' => 'text', + 'value' => 'valid', + ]); + + $valid->setMethods([ + 'validate' => function (Field $field, mixed $value) use (&$validated): mixed { + $validated[] = $field->name(); + + return $value; + }, + ]); + + $fields = new FieldCollection([ + 'invalid' => $invalid, + 'valid' => $valid, + ]); + + $this->assertFalse($fields->isValid()); + $this->assertSame(['invalid', 'valid'], $validated); + } + + public function testIsValidatedReturnsFalseUntilAllFieldsHaveBeenValidated(): void + { + $fields = new FieldCollection([ + 'first' => $this->createField('first', [ + 'type' => 'text', + 'value' => 'first', + ]), + 'second' => $this->createField('second', [ + 'type' => 'text', + 'value' => 'second', + ]), + ]); + + $this->assertFalse($fields->isValidated()); + + $fields->first()->validate(); + + $this->assertFalse($fields->isValidated()); + + $fields->last()->validate(); + + $this->assertTrue($fields->isValidated()); + } + + private function createField(string $name, array $data): Field + { + return $this->app->getService(FieldFactory::class)->make($name, $data); + } + + private function createRequest(RequestMethod $method, array $input = [], array $query = [], array $files = []): Request + { + return new Request($input, $query, [], $files, ['REQUEST_METHOD' => $method->value]); + } +} From 26a1397c01923f1f4eaa66d21e709bde658184b8 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:35:53 +0200 Subject: [PATCH 09/51] Update tests --- tests/Unit/Forms/FormTest.php | 2 +- tests/Unit/Http/RequestTest.php | 352 ++++++++++++++++++++ tests/Unit/Pages/PageTest.php | 572 ++++++++++++++++++++++++++++++++ 3 files changed, 925 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Http/RequestTest.php create mode 100644 tests/Unit/Pages/PageTest.php diff --git a/tests/Unit/Forms/FormTest.php b/tests/Unit/Forms/FormTest.php index 8a1e2ee1f..210ff1183 100644 --- a/tests/Unit/Forms/FormTest.php +++ b/tests/Unit/Forms/FormTest.php @@ -311,7 +311,7 @@ public function testUploadsAreNotProcessedWhenUploadProcessingIsDisabled(): void $form->processRequest($this->createRequest(RequestMethod::POST, files: [ 'attachment' => $this->uploadData(), - ]), uploadFiles: false,); + ]), uploadFiles: false, ); $this->assertSame([], $form->uploadedFiles()); } diff --git a/tests/Unit/Http/RequestTest.php b/tests/Unit/Http/RequestTest.php new file mode 100644 index 000000000..d53294597 --- /dev/null +++ b/tests/Unit/Http/RequestTest.php @@ -0,0 +1,352 @@ +request(['SCRIPT_NAME' => $scriptName]); + + $this->assertSame($expectedRoot, $request->root()); + } + + /** + * @return array + */ + public static function rootProvider(): array + { + return [ + 'document root' => [ + 'scriptName' => '/index.php', + 'expectedRoot' => '/', + ], + 'nested application' => [ + 'scriptName' => '/formwork/index.php', + 'expectedRoot' => '/formwork/', + ], + 'absolute cli script path' => [ + 'scriptName' => '/project/vendor/bin/phpunit', + 'expectedRoot' => '/project/vendor/bin/', + ], + 'missing script name' => [ + 'scriptName' => '', + 'expectedRoot' => '/', + ], + ]; + } + + public function testUriRemovesTheScriptDirectoryFromTheRequestUri(): void + { + $request = $this->request([ + 'SCRIPT_NAME' => '/formwork/index.php', + 'REQUEST_URI' => '/formwork/about', + ]); + + $this->assertSame('/about', $request->uri()); + } + + public function testUriIsPreservedWhenItDoesNotStartWithTheRequestRoot(): void + { + $request = $this->request([ + 'SCRIPT_NAME' => '/formwork/index.php', + 'REQUEST_URI' => '/about', + ]); + + $this->assertSame('/about', $request->uri()); + } + + public function testAbsoluteUriCombinesTheBaseUriAndTheNormalizedRequestUri(): void + { + $request = $this->request([ + 'SCRIPT_NAME' => '/formwork/index.php', + 'REQUEST_URI' => '/formwork/about', + 'SERVER_NAME' => 'example.test', + 'SERVER_PORT' => '8080', + ]); + + $this->assertSame('http://example.test:8080/formwork/about', $request->absoluteUri()); + } + + public function testBaseUriIncludesTheApplicationRoot(): void + { + $request = $this->request([ + 'SCRIPT_NAME' => '/formwork/index.php', + 'SERVER_NAME' => 'Example.test', + 'SERVER_PORT' => '80', + ]); + + $this->assertSame('http://example.test/formwork/', $request->baseUri()); + } + + public function testBasicAccessorsExposeMethodServerAndRequestHeaders(): void + { + $request = $this->request([ + 'REQUEST_METHOD' => 'GET', + 'SERVER_PROTOCOL' => 'HTTP/2', + 'REMOTE_ADDR' => '192.0.2.10', + 'CONTENT_LENGTH' => '42', + 'QUERY_STRING' => 'page=2', + 'HTTP_REFERER' => 'https://example.test/source/', + 'HTTP_USER_AGENT' => 'RequestTest/1.0', + 'HTTP_ACCEPT' => 'text/html, application/json;q=0.8', + 'HTTP_ACCEPT_ENCODING' => 'gzip, br;q=0.5', + 'HTTP_ACCEPT_LANGUAGE' => 'it-IT, en;q=0.7', + ]); + + $this->assertSame(RequestMethod::GET, $request->method()); + $this->assertSame('HTTP/2', $request->protocol()); + $this->assertSame('192.0.2.10', $request->ip()); + $this->assertSame(42, $request->contentLength()); + $this->assertSame('https://example.test/source/', $request->referer()); + $this->assertSame('RequestTest/1.0', $request->userAgent()); + $this->assertSame('page=2', $request->content()); + $this->assertSame('text/html', array_key_first($request->mimeTypes())); + $this->assertSame(0.8, $request->mimeTypes()['application/json']); + $this->assertSame(['gzip' => 1.0, 'br' => 0.5], $request->encodings()); + $this->assertSame(['it-IT' => 1.0, 'en' => 0.7], $request->languages()); + $this->assertSame('GET', $request->server()->get('REQUEST_METHOD')); + $this->assertSame('RequestTest/1.0', $request->headers()->get('User-Agent')); + } + + public function testInputQueryCookiesAndHeaderKeysAreDecodedAndNormalized(): void + { + $request = $this->request( + [ + 'HTTP_X_CUSTOM_HEADER' => 'value', + ], + ['user%5Bname%5D' => 'Giuseppe'], + ['page%5Bnumber%5D' => '2'], + ['cookie%5Bname%5D' => 'session'], + ); + + $this->assertSame(['user[name]' => 'Giuseppe'], $request->input()->toArray()); + $this->assertSame(['page[number]' => '2'], $request->query()->toArray()); + $this->assertSame(['cookie%5Bname%5D' => 'session'], $request->cookies()->toArray()); + $this->assertSame('value', $request->headers()->get('X-Custom-Header')); + $this->assertNull($request->headers()->get('x_custom_header')); + } + + public function testHostIsNormalizedAndInvalidHostsAreRejected(): void + { + $request = $this->request(['SERVER_NAME' => ' Example.TEST:8080 ']); + + $this->assertSame('example.test', $request->host()); + $this->assertSame(80, $request->port()); + + $ipv6Request = $this->request(['SERVER_NAME' => '[2001:db8::1]:8080']); + + $this->assertSame('[2001:db8::1]', $ipv6Request->host()); + + $invalidRequest = $this->request(['SERVER_NAME' => 'not a valid host']); + + $this->expectException(UnexpectedValueException::class); + $invalidRequest->host(); + } + + public function testTrustedForwardedHeadersOverrideConnectionMetadata(): void + { + $request = $this->request([ + 'REMOTE_ADDR' => '10.0.0.10', + 'HTTP_FORWARDED' => 'for=203.0.113.9;host=Example.org:8443;proto=https;port=8443', + 'SERVER_NAME' => 'internal.test', + 'SERVER_PORT' => '80', + 'HTTPS' => 'off', + ]); + $request->setTrustedProxies(['10.0.0.10']); + + $this->assertTrue($request->isFromTrustedProxy()); + $this->assertSame('203.0.113.9', $request->ip()); + $this->assertSame('example.org', $request->host()); + $this->assertSame(8443, $request->port()); + $this->assertTrue($request->isSecure()); + } + + public function testUntrustedForwardedHeadersAreIgnored(): void + { + $request = $this->request([ + 'REMOTE_ADDR' => '192.0.2.10', + 'HTTP_FORWARDED' => 'for=203.0.113.9;host=public.test;proto=https;port=8443', + 'SERVER_NAME' => 'internal.test', + 'SERVER_PORT' => '8080', + 'HTTPS' => 'off', + ]); + + $this->assertFalse($request->isFromTrustedProxy()); + $this->assertSame('192.0.2.10', $request->ip()); + $this->assertSame('internal.test', $request->host()); + $this->assertSame(8080, $request->port()); + $this->assertFalse($request->isSecure()); + } + + public function testXForwardedHeadersAreUsedForTrustedProxies(): void + { + $request = $this->request([ + 'REMOTE_ADDR' => '10.0.0.10', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.9', + 'HTTP_X_FORWARDED_HOST' => 'public.test:9443', + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'HTTP_X_FORWARDED_PORT' => '9443', + 'SERVER_NAME' => 'internal.test', + 'SERVER_PORT' => '80', + ]); + $request->setTrustedProxies(['10.0.0.10']); + + $this->assertSame('203.0.113.9', $request->ip()); + $this->assertSame('public.test', $request->host()); + $this->assertSame(9443, $request->port()); + $this->assertTrue($request->isSecure()); + } + + public function testSecurityLocalhostAndRequestTypeAreDetected(): void + { + $secure = $this->request([ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTPS' => 'on', + 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', + ]); + + $this->assertTrue($secure->isSecure()); + $this->assertTrue($secure->isLocalhost()); + $this->assertTrue($secure->isXmlHttpRequest()); + $this->assertSame(RequestType::XmlHttpRequest, $secure->type()); + + $regular = $this->request(['REMOTE_ADDR' => '192.0.2.10', 'HTTPS' => 'off']); + + $this->assertFalse($regular->isSecure()); + $this->assertFalse($regular->isLocalhost()); + $this->assertFalse($regular->isXmlHttpRequest()); + $this->assertSame(RequestType::Http, $regular->type()); + } + + public function testNonGetContentReadsTheRequestBodyAndReturnsNullWhenItIsEmpty(): void + { + $request = $this->request(['REQUEST_METHOD' => 'POST']); + + $this->assertNull($request->content()); + } + + public function testRefererCanBeValidatedAgainstTheCurrentOriginAndPath(): void + { + $request = $this->request([ + 'SCRIPT_NAME' => '/index.php', + 'REQUEST_URI' => '/current', + 'SERVER_NAME' => 'example.test', + 'HTTP_REFERER' => 'http://example.test/admin/users?tab=active', + ]); + + $this->assertTrue($request->validateReferer('/admin')); + $this->assertFalse($request->validateReferer('/panel')); + } + + public function testUploadedFilesAreNormalizedIntoUploadedFileObjects(): void + { + $request = $this->request( + [], + [], + [], + [], + [ + 'avatar%5Bimage%5D' => [ + 'name' => 'avatar.png', + 'full_path' => 'avatar.png', + 'type' => 'image/png', + 'tmp_name' => '/tmp/php-avatar', + 'error' => UPLOAD_ERR_OK, + 'size' => '12', + ], + 'documents' => [ + 'name' => ['first.txt', 'second.txt'], + 'full_path' => ['first.txt', 'second.txt'], + 'type' => ['text/plain', 'text/plain'], + 'tmp_name' => ['/tmp/php-first', '/tmp/php-second'], + 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_NO_FILE], + 'size' => ['10', '0'], + ], + ], + ); + + $avatar = $request->files()->get('avatar[image]'); + $documents = $request->files()->get('documents'); + + $this->assertInstanceOf(UploadedFile::class, $avatar); + $this->assertSame('avatar[image]', $avatar->fieldName()); + $this->assertSame('avatar.png', $avatar->clientName()); + $this->assertSame(12, $avatar->size()); + $this->assertTrue($avatar->isUploaded()); + $this->assertIsArray($documents); + $this->assertCount(2, $documents); + $this->assertSame('second.txt', $documents[1]->clientName()); + $this->assertTrue($documents[1]->isEmpty()); + $this->assertCount(3, $request->files()->getAll()); + } + + public function testSessionIsMemoizedAndPreviousSessionIsAbsentWithoutCookie(): void + { + $request = $this->request(); + $session = $request->session(); + + $this->assertInstanceOf(Session::class, $session); + $this->assertSame($session, $request->session()); + $this->assertFalse($request->hasPreviousSession()); + + $session->save(); + } + + public function testFromGlobalsUsesTheScriptNameExposedByThePhpRuntime(): void + { + $request = Request::fromGlobals(); + $scriptName = (string) ($_SERVER['SCRIPT_NAME'] ?? ''); + $expectedRoot = '/' . ltrim((string) preg_replace('~[^/]+$~', '', $scriptName), '/'); + + $this->assertSame( + $expectedRoot, + $request->root(), + sprintf('Request::root() is derived from SCRIPT_NAME=%s', var_export($scriptName, true)), + ); + } + + /** + * @param array $server + */ + /** + * @param array $server + * @param array $input + * @param array $query + * @param array $cookies + * @param array $files + */ + private function request( + array $server = [], + array $input = [], + array $query = [], + array $cookies = [], + array $files = [], + ): Request { + return new Request( + $input, + $query, + $cookies, + $files, + $server + [ + 'REQUEST_METHOD' => 'GET', + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => '80', + ], + ); + } +} diff --git a/tests/Unit/Pages/PageTest.php b/tests/Unit/Pages/PageTest.php new file mode 100644 index 000000000..8b81b2e92 --- /dev/null +++ b/tests/Unit/Pages/PageTest.php @@ -0,0 +1,572 @@ +setUpTempDirectory(); + $this->app = App::instance(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testLoadsFixturePageAndExposesCoreProperties(): void + { + $page = $this->fixturePage('/about'); + + $this->assertTrue($page->hasLoaded()); + $this->assertSame('page', $page->getModelIdentifier()); + $this->assertSame('about', $page->slug()); + $this->assertSame('/about/', $page->route()); + $this->assertStringEndsWith('/about/', $page->uri('', false)); + $this->assertNotNull($page->path()); + $this->assertNotNull($page->relativePath()); + $this->assertNotNull($page->contentFile()); + $this->assertSame($page->title(), (string) $page); + $this->assertSame($page, $page->site()->findPage('/about')); + } + + public function testDefaultsContainStablePageDefaults(): void + { + $defaults = $this->fixturePage('/about')->defaults(); + + $this->assertTrue($defaults['published']); + $this->assertTrue($defaults['routable']); + $this->assertTrue($defaults['searchable']); + $this->assertTrue($defaults['cacheable']); + $this->assertTrue($defaults['allowChildren']); + $this->assertSame(200, $defaults['responseStatus']); + $this->assertSame([], $defaults['metadata']); + $this->assertSame([], $defaults['taxonomy']); + $this->assertIsString($defaults['content']); + } + + public function testPageWithoutAPathGetsUnroutableDefaults(): void + { + $page = new Page(['site' => $this->temporarySite()], $this->app); + $defaults = $page->defaults(); + + $this->assertNull($page->path()); + $this->assertNull($page->route()); + $this->assertFalse($defaults['routable']); + $this->assertFalse($defaults['cacheable']); + $this->assertFalse($defaults['listed']); + $this->assertFalse($defaults['orderable']); + $this->assertTrue($page->isEmpty()); + } + + public function testGetHasAndSetWorkForFrontmatterAndRuntimeFields(): void + { + $page = $this->fixturePage('/about'); + + $this->assertTrue($page->has('title')); + $this->assertSame($page->title(), $page->get('title')); + $this->assertSame('fallback', $page->get('missing', 'fallback')); + + $page->set('title', 'Changed title'); + $page->set('customRuntimeValue', 'value'); + + $this->assertSame('Changed title', $page->title()); + $this->assertSame('value', $page->get('customRuntimeValue')); + } + + public function testNumberPrefixAndSlugAreSeparated(): void + { + $page = $this->fixturePage('/about'); + + $this->assertSame(1, $page->num()); + $this->assertSame('about', $page->slug()); + $this->assertSame('/about/', $page->route()); + } + + public function testTemplateLanguageAndContentFileAreResolvedFromTheFilename(): void + { + $page = $this->temporaryPage('localized', ['title' => 'Localized']); + + $this->assertSame('page', $page->template()->name()); + $this->assertNull($page->language()); + $this->assertSame([], $page->languages()->available()->toArray()); + } + + public function testMultilingualContentSelectsTheRequestedLanguageAndTracksAvailableVersions(): void + { + $site = $this->temporarySite(); + $path = rtrim((string) $site->contentPath(), '/') . '/localized/'; + mkdir($path, 0o777, true); + file_put_contents($path . 'page.en.md', "---\ntitle: English\n---\nEnglish content"); + file_put_contents($path . 'page.it.md', "---\ntitle: Italiano\n---\nContenuto italiano"); + + $page = new Page(['site' => $site, 'path' => $path, 'language' => 'it'], $this->app); + + $this->assertSame('it', $page->language()?->code()); + $this->assertSame('Italiano', $page->title()); + $this->assertSame('Contenuto italiano', trim(strip_tags($page->content()))); + $this->assertTrue($page->languages()->available()->has('en')); + $this->assertTrue($page->languages()->available()->has('it')); + } + + public function testInvalidLanguageIsRejectedAfterLoading(): void + { + $site = $this->temporarySite(); + $path = rtrim((string) $site->contentPath(), '/') . '/localized/'; + mkdir($path, 0o777, true); + file_put_contents($path . 'page.en.md', "---\ntitle: English\n---\nEnglish content"); + file_put_contents($path . 'page.it.md', "---\ntitle: Italiano\n---\nContenuto italiano"); + $page = new Page(['site' => $site, 'path' => $path, 'language' => 'it'], $this->app); + + $this->expectException(InvalidValueException::class); + $page->set('language', 'fr'); + } + + public function testIconFallsBackToTheSchemeAndCanBeOverridden(): void + { + $page = $this->temporaryPage('icon'); + $defaultIcon = $page->icon(); + + $this->assertIsString($defaultIcon); + $this->assertSame($defaultIcon, $page->icon()); + } + + public function testStatusHonoursPublishedAndDateWindows(): void + { + $page = $this->temporaryPage('status', ['published' => false]); + $this->assertSame(Page::PAGE_STATUS_NOT_PUBLISHED, $page->status()); + + $page = $this->temporaryPage('future', [ + 'published' => true, + 'publishDate' => "'" . date('Y-m-d', time() + 86400) . "'", + ]); + $this->assertFalse($page->isPublished()); + + $page = $this->temporaryPage('expired', [ + 'published' => true, + 'unpublishDate' => "'" . date('Y-m-d', time() - 86400) . "'", + ]); + $this->assertFalse($page->isPublished()); + } + + public function testMetadataTaxonomyAndResponseStatusAreNormalized(): void + { + $page = $this->temporaryPage('data'); + + $page->set('metadata', ['description' => 'A description']); + $page->set('taxonomy', ['tags' => ['php', 'cms']]); + $page->set('responseStatus', 404); + + $this->assertSame('A description', $page->metadata()->get('description')->content()); + $this->assertSame(['tags' => ['php', 'cms']], $page->taxonomy()); + $this->assertSame(ResponseStatus::fromCode(404), $page->responseStatus()); + } + + public function testCanonicalRouteIsNormalizedAndNullWhenEmpty(): void + { + $page = $this->temporaryPage('canonical'); + + $this->assertNull($page->canonicalRoute()); + $page->set('canonicalRoute', 'docs//page/'); + $this->assertSame('docs/page/', $page->canonicalRoute()); + $page->set('canonicalRoute', null); + $this->assertNull($page->canonicalRoute()); + } + + public function testInvalidSlugTemplateAndParentAreRejected(): void + { + $page = $this->temporaryPage('validation'); + + $this->expectException(InvalidValueException::class); + $page->set('slug', 'not a valid slug'); + } + + public function testInvalidTemplateIsRejected(): void + { + $page = $this->temporaryPage('template-validation'); + + $this->expectException(InvalidValueException::class); + $page->set('template', 'does-not-exist'); + } + + public function testParentCanBeResolvedByRouteAndInvalidParentIsRejected(): void + { + $site = $this->temporarySite(); + $parent = $this->temporaryPage('parent', site: $site); + $child = $this->temporaryPage('child', site: $site); + + $child->set('parent', '.'); + $this->assertSame($site, $child->parent()); + $child->set('parent', $parent); + $this->assertSame($parent, $child->parent()); + + $this->expectException(InvalidValueException::class); + $child->set('parent', '/missing-parent'); + } + + #[DataProvider('invalidTaxonomyProvider')] + public function testRejectsInvalidTaxonomy(mixed $taxonomy, string $exception): void + { + $page = $this->temporaryPage('invalid-taxonomy'); + + $this->expectException($exception); + $page->set('taxonomy', $taxonomy); + } + + public function testTraversalMethodsExposeThePageTree(): void + { + $about = $this->fixturePage('/about'); + $blog = $this->fixturePage('/blog'); + $index = $this->fixturePage('/'); + + $this->assertSame($this->app->site(), $about->parent()); + $this->assertTrue($about->isChildOf($this->app->site())); + $this->assertTrue($about->isSiblingOf($blog)); + $this->assertTrue($about->isSiblingOf($index)); + $this->assertTrue($about->hasAncestors()); + $this->assertFalse($about->isAncestorOf($this->app->site())); + $this->assertSame(0, $about->index()); + $this->assertSame(1, $about->level()); + $this->assertSame($blog, $about->nextSibling()); + $this->assertSame($about, $blog->previousSibling()); + $this->assertTrue($about->siblings()->contains($blog)); + $this->assertTrue($about->inclusiveSiblings()->contains($about)); + } + + public function testSiblingPredicateIsSymmetricAndExposesCurrentRegression(): void + { + $about = $this->fixturePage('/about'); + $blog = $this->fixturePage('/blog'); + + $this->assertTrue($about->isSiblingOf($blog)); + $this->assertTrue($blog->isSiblingOf($about)); + $this->assertTrue($about->isSiblingOf($this->fixturePage('/'))); + $this->assertFalse($about->isSiblingOf($this->fixturePage('/blog/hello-world'))); + } + + public function testIsEmptyIsTrueOnlyWhenThereIsNoFrontmatter(): void + { + $empty = $this->temporaryPage('empty', frontmatter: []); + $nonEmpty = $this->temporaryPage('non-empty', ['title' => 'Not empty']); + $withoutContentFile = $this->temporaryPage('directory-only', createContentFile: false); + + $this->assertTrue($empty->isEmpty()); + $this->assertFalse($nonEmpty->isEmpty()); + $this->assertTrue($withoutContentFile->isEmpty()); + } + + public function testUidIsStableAndDerivedFromTheRelativePath(): void + { + $page = $this->fixturePage('/about'); + + $this->assertSame($page->uid(), $page->uid()); + $this->assertMatchesRegularExpression('/^[a-f0-9]{8}(?:-[a-f0-9]{8}){3}$/', $page->uid()); + $this->assertNotSame($page->uid(), $this->fixturePage('/blog')->uid()); + } + + public function testUriUsesCanonicalRouteAndAbsoluteRequestUri(): void + { + $page = $this->fixturePage('/about'); + + $this->assertStringEndsWith('/about/team/', $page->uri('team', false)); + $page->set('canonicalRoute', '/company/about'); + $this->assertStringEndsWith('/company/about/team/', $page->uri('team', false)); + } + + public function testSaveMovesAndReloadsThePageFromDisk(): void + { + $page = $this->temporaryPage('editable', ['title' => 'Before'], content: 'Before'); + + $page->set('slug', 'renamed'); + $page->set('title', 'After'); + $page->set('content', 'After content'); + $page->save(); + + $this->assertSame('renamed', $page->slug()); + $this->assertSame('After', $page->title()); + $this->assertSame('After content', trim($page->contentFile()?->content())); + $this->assertDirectoryExists((string) $page->contentPath()); + $this->assertFileExists((string) $page->contentFile()?->path()); + } + + public function testSaveOmitsRuntimeFieldsFromFrontmatter(): void + { + $page = $this->temporaryPage('frontmatter', ['title' => 'Before']); + $page->set('title', 'After'); + $page->set('slug', 'renamed'); + $page->set('template', 'page'); + $page->set('parent', '.'); + $page->save(); + + $frontmatter = $page->contentFile()?->frontmatter(); + $this->assertIsArray($frontmatter); + $this->assertSame('After', $frontmatter['title']); + $this->assertArrayNotHasKey('content', $frontmatter); + $this->assertArrayNotHasKey('slug', $frontmatter); + $this->assertArrayNotHasKey('template', $frontmatter); + $this->assertArrayNotHasKey('parent', $frontmatter); + } + + public function testSaveRejectsAPageWithoutAParent(): void + { + $site = $this->temporarySite(); + $page = new Page(['site' => $site], $this->app); + + $this->expectException(\UnexpectedValueException::class); + $page->save(); + } + + public function testReloadRejectsAnUnloadedPage(): void + { + $page = $this->temporaryPage('reloadable'); + $page->reload(); + $this->assertTrue($page->hasLoaded()); + + $reflection = new \ReflectionClass($page); + $loaded = $reflection->getProperty('loaded'); + $loaded->setValue($page, false); + + $this->expectException(RuntimeException::class); + $page->reload(); + } + + public function testDuplicateCreatesAnIndependentCopyAndDeleteRemovesIt(): void + { + $page = $this->temporaryPage('original', ['title' => 'Original']); + $duplicate = $page->duplicate(['title' => 'Copy']); + + $this->assertNotSame($page, $duplicate); + $this->assertSame('original-copy', $duplicate->slug()); + $this->assertSame('Copy', $duplicate->title()); + $this->assertDirectoryExists((string) $duplicate->contentPath()); + $this->assertTrue($duplicate->isDeletable()); + + $path = $duplicate->contentPath(); + $duplicate->delete(); + $this->assertDirectoryDoesNotExist((string) $path); + } + + public function testDuplicateGeneratesTheNextAvailableCopySlug(): void + { + $page = $this->temporaryPage('original', ['title' => 'Original']); + $first = $page->duplicate(); + $second = $page->duplicate(); + + $this->assertSame('original-copy', $first->slug()); + $this->assertSame('original-copy-2', $second->slug()); + } + + public function testDeleteCanRemoveOnlyTheCurrentLanguageOrAllLanguages(): void + { + $site = $this->temporarySite(); + $path = rtrim((string) $site->contentPath(), '/') . '/localized/'; + mkdir($path, 0o777, true); + file_put_contents($path . 'page.en.md', "---\ntitle: English\n---\nEnglish content"); + file_put_contents($path . 'page.it.md', "---\ntitle: Italiano\n---\nContenuto italiano"); + $page = new Page(['site' => $site, 'path' => $path, 'language' => 'it'], $this->app); + + $page->delete(); + $this->assertFileExists($path . 'page.en.md'); + $this->assertFileDoesNotExist($path . 'page.it.md'); + + $page->delete(allLanguages: true); + $this->assertDirectoryDoesNotExist($path); + } + + public function testPageWithChildrenCannotBeDuplicatedOrDeleted(): void + { + $site = $this->temporarySite(); + $parent = $this->temporaryPage('parent', site: $site); + $this->temporaryPage('parent/child', site: $site); + + $this->assertFalse($parent->isDuplicable()); + $this->assertFalse($parent->isDeletable()); + $this->expectException(RuntimeException::class); + $parent->delete(); + } + + public function testFilesAndMediaCollectionsAreConsistent(): void + { + $page = $this->temporaryPage('assets'); + $directory = (string) $page->contentPath(); + copy(ROOT_PATH . '/site/pages/index/formwork.png', $directory . '/photo.png'); + copy(ROOT_PATH . '/site/files/friday.mp4', $directory . '/clip.mp4'); + $this->writeSilentWav($directory . '/sound.mp3'); + + $config = $this->app->config(); + $pagesPath = $config->getString('system.pages.path'); + $config->set('system.pages.path', TESTS_TMP_PATH); + try { + $page->reload(); + + $this->assertCount(3, $page->files()); + $this->assertCount(1, $page->images()); + $this->assertCount(1, $page->videos()); + $this->assertCount(1, $page->audios()); + $this->assertCount(3, $page->media()); + } finally { + $config->set('system.pages.path', $pagesPath); + } + } + + public function testIndexAndErrorPagesAreProtected(): void + { + $index = $this->fixturePage('/'); + $error = $this->fixturePage('/error'); + + $this->assertTrue($index->isIndexPage()); + $this->assertTrue($error->isErrorPage()); + $this->assertTrue($index->isIndexOrErrorPage()); + $this->assertFalse($index->isDeletable()); + $this->assertFalse($error->isDeletable()); + } + + public function testContentAndFilePredicatesRemainCoherentAfterReload(): void + { + $page = $this->temporaryPage('predicates'); + + $this->assertTrue($page->hasContentFile()); + $this->assertFalse($page->isSite()); + $this->assertTrue($page->hasParent()); + $this->assertFalse($page->hasChildren()); + $this->assertFalse($page->hasDescendants()); + $this->assertTrue($page->hasSiblings()); + $this->assertNotNull($page->lastModifiedTime()); + $this->assertSame($page->files(), $page->files()); + } + + public function testLifecycleEventsAreDispatchedWithTheExpectedNames(): void + { + $names = []; + foreach ( + [ + 'pageLoaded', + 'pageBeforeSave', + 'pageAfterSave', + 'pageBeforeDuplicate', + 'pageAfterDuplicate', + 'pageBeforeDelete', + 'pageAfterDelete', + ] as $name + ) { + $this->app->events()->on($name, function (object $event) use (&$names): void { + $names[] = $event->name(); + }); + } + + $page = $this->temporaryPage('events'); + $page->save(); + $duplicate = $page->duplicate(); + $duplicate->delete(); + + foreach (['pageLoaded', 'pageBeforeSave', 'pageAfterSave', 'pageBeforeDuplicate', 'pageAfterDuplicate', 'pageBeforeDelete', 'pageAfterDelete'] as $name) { + $this->assertContains($name, $names); + } + } + + public function testRenderDispatchesPageRenderEventAndReturnsMarkup(): void + { + $page = $this->fixturePage('/about'); + $renderedPage = null; + $this->app->events()->on('pageRender', function (object $event) use (&$renderedPage): void { + $renderedPage = $event->page(); + }); + + $output = $page->render(); + + $this->assertSame($page, $renderedPage); + $this->assertStringContainsString('', $output); + $this->assertStringContainsString('About', $output); + } + + private function fixturePage(string $route): Page + { + $page = $this->app->site()->findPage($route); + + return $page ?? $this->fail(sprintf('Fixture page %s was not found', $route)); + } + + /** + * @param array $frontmatter + */ + private function temporaryPage( + string $relativePath, + array $frontmatter = ['title' => 'Temporary page'], + string $content = 'Temporary content', + bool $createContentFile = true, + ?Site $site = null, + ): Page { + $site ??= $this->temporarySite(); + $path = rtrim((string) $site->contentPath(), '/') . '/' . trim($relativePath, '/') . '/'; + if (!is_dir($path)) { + mkdir($path, 0o777, true); + } + + if ($createContentFile) { + $yaml = "---\n"; + foreach ($frontmatter as $key => $value) { + $yaml .= sprintf("%s: %s\n", $key, is_bool($value) ? ($value ? 'true' : 'false') : $value); + } + file_put_contents($path . 'page.md', $yaml . "---\n" . $content); + } + + return new Page(['site' => $site, 'path' => $path], $this->app); + } + + private function temporarySite(): Site + { + $path = TESTS_TMP_PATH . '/pages-' . ++$this->temporarySiteCounter; + mkdir($path, 0o777, true); + mkdir($path . '/index', 0o777, true); + file_put_contents($path . '/index/index.md', "---\ntitle: Temporary index\n---\n"); + mkdir($path . '/error', 0o777, true); + file_put_contents($path . '/error/error.md', "---\ntitle: Temporary error\n---\n"); + + return new Site( + ['path' => TESTS_TMP_PATH, 'contentPath' => $path, 'metadata' => []], + $this->app->config(), + $this->app->getService(PageFactory::class), + $this->app->getService(PageCollectionFactory::class), + ); + } + + private function writeSilentWav(string $path): void + { + $sampleRate = 8000; + $channels = 1; + $bitsPerSample = 16; + $data = str_repeat("\0", (int) ($sampleRate * $channels * ($bitsPerSample / 8) / 10)); + $header = 'RIFF' . pack('V', 36 + strlen($data)) . 'WAVE'; + $header .= 'fmt ' . pack('VvvVVvv', 16, 1, $channels, $sampleRate, $sampleRate * $channels * 2, 2, $bitsPerSample); + $header .= 'data' . pack('V', strlen($data)); + file_put_contents($path, $header . $data); + } + + public static function invalidTaxonomyProvider(): iterable + { + yield 'scalar' => ['not-an-array', \TypeError::class]; + yield 'non-string taxonomy name' => [[1 => ['term']], InvalidValueException::class]; + yield 'non-list terms' => [['tags' => 'php'], InvalidValueException::class]; + yield 'non-string term' => [['tags' => [1]], InvalidValueException::class]; + } +} From 3f9abad1efec76329dc5c2dd2321b9d76f5d0f51 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:36:32 +0200 Subject: [PATCH 10/51] Update test config --- composer.json | 3 +-- phpunit.xml.dist | 12 ++++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 33c780586..55ba31eb4 100644 --- a/composer.json +++ b/composer.json @@ -61,12 +61,11 @@ "phpstan:baseline": "phpstan analyse --configuration=formwork/phpstan.neon --generate-baseline=formwork/phpstan-baseline.neon", "rector": "rector --config=formwork/.rector.php", "rector:check": "rector --dry-run --config=formwork/.rector.php", - "tests": "phpunit", "serve": [ "Composer\\Config::disableProcessTimeout", "php bin/serve" ], "test": "phpunit", - "test:coverage": "phpunit -d pcov.enabled=1 --coverage-html tests/coverage" + "test:coverage": "@php -d pcov.enabled=1 vendor/bin/phpunit --coverage-html tests/coverage" } } \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 483be94d0..a15b809bb 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,11 @@ - + tests/Unit @@ -15,4 +21,6 @@ ./formwork/src/Utils/scripts - \ No newline at end of file + + + From bdb450a9ac354d38acaab3d59d92cd48bf69ff12 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:53:25 +0200 Subject: [PATCH 11/51] Update tests --- .gitignore | 1 - phpunit.xml.dist | 10 +- tests/Unit/Data/CollectionTest.php | 11 +- tests/Unit/Data/Traits/DataIteratorTest.php | 4 +- .../Data/Traits/DataMultipleGetterTest.php | 2 +- .../Data/Traits/DataMultipleSetterTest.php | 2 +- tests/Unit/Data/Traits/DataSetterTest.php | 2 +- tests/Unit/Fields/FieldCollectionTest.php | 25 +-- tests/Unit/Forms/FormTest.php | 16 +- tests/Unit/Http/ClientTest.php | 126 ++++++++++++++ tests/Unit/Http/CookieTest.php | 27 +++ tests/Unit/Http/FileResponseTest.php | 132 ++++++++++++++ tests/Unit/Http/FileSessionHandlerTest.php | 41 +++++ tests/Unit/Http/HeaderTest.php | 32 ++++ tests/Unit/Http/HeadersDataTest.php | 20 +++ tests/Unit/Http/IpAnonymizerTest.php | 24 +++ tests/Unit/Http/JsonResponseTest.php | 24 +++ tests/Unit/Http/MessagesTest.php | 25 +++ tests/Unit/Http/RedirectResponseTest.php | 21 +++ tests/Unit/Http/RequestDataTest.php | 22 +++ tests/Unit/Http/RequestTest.php | 88 +++++----- tests/Unit/Http/ResponseHeaderUtilsTest.php | 33 ++++ tests/Unit/Http/ResponseHeadersTest.php | 21 +++ tests/Unit/Http/ResponseStatusTest.php | 33 ++++ tests/Unit/Http/ResponseTest.php | 75 ++++++++ tests/Unit/Http/ServerDataTest.php | 23 +++ tests/Unit/Http/SessionTest.php | 113 ++++++++++++ tests/Unit/Http/UploadedFileTest.php | 44 +++++ tests/Unit/Http/VisitorTest.php | 42 +++++ tests/Unit/Http/fixtures/files/sample.txt | 1 + .../Unit/Http/fixtures/sessions/sess_existing | 1 + tests/Unit/Pages/ContentFileTest.php | 44 +++++ tests/Unit/Pages/PageCollectionTest.php | 138 +++++++++++++++ tests/Unit/Pages/PageEventsTest.php | 162 ++++++++++++++++++ tests/Unit/Pages/PageFactoryTest.php | 57 ++++++ tests/Unit/Pages/PageTest.php | 161 +++++++---------- tests/Unit/Pages/PaginationTest.php | 80 +++++++++ .../fixtures/numbered-site/1-numbered/page.md | 4 + .../Unit/Pages/fixtures/site/about/audio.mp3 | Bin 0 -> 417 bytes .../Unit/Pages/fixtures/site/about/image.svg | 3 + tests/Unit/Pages/fixtures/site/about/page.md | 4 + .../Unit/Pages/fixtures/site/about/video.mp4 | Bin 0 -> 28 bytes tests/Unit/Pages/fixtures/site/blog/blog.md | 4 + .../fixtures/site/blog/hello-world/page.md | 4 + .../fixtures/site/directory-only/.gitkeep | 0 tests/Unit/Pages/fixtures/site/empty/page.md | 3 + tests/Unit/Pages/fixtures/site/error/error.md | 4 + tests/Unit/Pages/fixtures/site/index/index.md | 4 + .../Pages/fixtures/site/localized/page.en.md | 4 + .../Pages/fixtures/site/localized/page.it.md | 4 + .../Unit/Pages/fixtures/site/original/page.md | 4 + .../Pages/fixtures/site/parent/child/page.md | 4 + tests/Unit/Pages/fixtures/site/parent/page.md | 4 + 53 files changed, 1545 insertions(+), 188 deletions(-) create mode 100644 tests/Unit/Http/ClientTest.php create mode 100644 tests/Unit/Http/CookieTest.php create mode 100644 tests/Unit/Http/FileResponseTest.php create mode 100644 tests/Unit/Http/FileSessionHandlerTest.php create mode 100644 tests/Unit/Http/HeaderTest.php create mode 100644 tests/Unit/Http/HeadersDataTest.php create mode 100644 tests/Unit/Http/IpAnonymizerTest.php create mode 100644 tests/Unit/Http/JsonResponseTest.php create mode 100644 tests/Unit/Http/MessagesTest.php create mode 100644 tests/Unit/Http/RedirectResponseTest.php create mode 100644 tests/Unit/Http/RequestDataTest.php create mode 100644 tests/Unit/Http/ResponseHeaderUtilsTest.php create mode 100644 tests/Unit/Http/ResponseHeadersTest.php create mode 100644 tests/Unit/Http/ResponseStatusTest.php create mode 100644 tests/Unit/Http/ResponseTest.php create mode 100644 tests/Unit/Http/ServerDataTest.php create mode 100644 tests/Unit/Http/SessionTest.php create mode 100644 tests/Unit/Http/UploadedFileTest.php create mode 100644 tests/Unit/Http/VisitorTest.php create mode 100644 tests/Unit/Http/fixtures/files/sample.txt create mode 100644 tests/Unit/Http/fixtures/sessions/sess_existing create mode 100644 tests/Unit/Pages/ContentFileTest.php create mode 100644 tests/Unit/Pages/PageCollectionTest.php create mode 100644 tests/Unit/Pages/PageEventsTest.php create mode 100644 tests/Unit/Pages/PageFactoryTest.php create mode 100644 tests/Unit/Pages/PaginationTest.php create mode 100644 tests/Unit/Pages/fixtures/numbered-site/1-numbered/page.md create mode 100644 tests/Unit/Pages/fixtures/site/about/audio.mp3 create mode 100644 tests/Unit/Pages/fixtures/site/about/image.svg create mode 100644 tests/Unit/Pages/fixtures/site/about/page.md create mode 100644 tests/Unit/Pages/fixtures/site/about/video.mp4 create mode 100644 tests/Unit/Pages/fixtures/site/blog/blog.md create mode 100644 tests/Unit/Pages/fixtures/site/blog/hello-world/page.md create mode 100644 tests/Unit/Pages/fixtures/site/directory-only/.gitkeep create mode 100644 tests/Unit/Pages/fixtures/site/empty/page.md create mode 100644 tests/Unit/Pages/fixtures/site/error/error.md create mode 100644 tests/Unit/Pages/fixtures/site/index/index.md create mode 100644 tests/Unit/Pages/fixtures/site/localized/page.en.md create mode 100644 tests/Unit/Pages/fixtures/site/localized/page.it.md create mode 100644 tests/Unit/Pages/fixtures/site/original/page.md create mode 100644 tests/Unit/Pages/fixtures/site/parent/child/page.md create mode 100644 tests/Unit/Pages/fixtures/site/parent/page.md diff --git a/.gitignore b/.gitignore index 6880d4484..0fc4607dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .DS_Store .php-cs-fixer.cache .phpunit.cache -.phpunit.result.cache phpunit.xml diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a15b809bb..270b1bf7f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,10 +1,18 @@ diff --git a/tests/Unit/Data/CollectionTest.php b/tests/Unit/Data/CollectionTest.php index e27601db9..87f07e7b9 100644 --- a/tests/Unit/Data/CollectionTest.php +++ b/tests/Unit/Data/CollectionTest.php @@ -61,12 +61,15 @@ public function testToImmutableThrowsOnAlreadyImmutable(): void $immutableCollection->toImmutable(); } - public function testOfThrowsOnAssociativityMismatch(): void + public function testOfThrowsOnAssociativeCollectionFromList(): void { $this->expectException(LogicException::class); $this->expectExceptionMessage('Associative collections cannot be created from non-associative data'); Collection::of('string', ['item1', 'item2'], associative: true); + } + public function testOfThrowsWhenOnNonAssociativeCollectionFromAssociativeData(): void + { $this->expectException(LogicException::class); $this->expectExceptionMessage('Non-associative collections cannot be created from associative data'); Collection::of('string', ['key1' => 'item1', 'key2' => 'item2'], associative: false); @@ -192,6 +195,8 @@ public function testRandom(): void $data = ['red', 'green', 'blue']; $collection = Collection::from($data); + $this->assertSameSize($data, $collection); + $randomItem = $collection->random(); $this->assertContains($randomItem, $data); } @@ -338,9 +343,7 @@ public function testShuffle(): void $shuffledCollection = $collection->shuffle(); $this->assertInstanceOf(Collection::class, $shuffledCollection); - $this->assertNotSame($collection, $shuffledCollection); - $this->assertCount(5, $shuffledCollection); - $this->assertNotSame($collection->values(), $shuffledCollection->values()); + $this->assertSameSize($collection, $shuffledCollection); foreach (['one', 'two', 'three', 'four', 'five'] as $item) { $this->assertTrue($shuffledCollection->contains($item)); diff --git a/tests/Unit/Data/Traits/DataIteratorTest.php b/tests/Unit/Data/Traits/DataIteratorTest.php index 680d67c6e..61e370aba 100644 --- a/tests/Unit/Data/Traits/DataIteratorTest.php +++ b/tests/Unit/Data/Traits/DataIteratorTest.php @@ -3,9 +3,9 @@ namespace Formwork\Tests\Unit\Data\Traits; use Formwork\Data\Traits\DataIterator; +use Formwork\Tests\TestCase; use Formwork\Tests\Unit\Data\Fixtures\DataIteratorFixture; -use PHPUNit\Framework\Attributes\CoversTrait; -use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversTrait; #[CoversTrait(DataIterator::class)] final class DataIteratorTest extends TestCase diff --git a/tests/Unit/Data/Traits/DataMultipleGetterTest.php b/tests/Unit/Data/Traits/DataMultipleGetterTest.php index e891d3493..9956b0baa 100644 --- a/tests/Unit/Data/Traits/DataMultipleGetterTest.php +++ b/tests/Unit/Data/Traits/DataMultipleGetterTest.php @@ -3,9 +3,9 @@ namespace Formwork\Tests\Unit\Data\Traits; use Formwork\Data\Traits\DataMultipleGetter; +use Formwork\Tests\TestCase; use Formwork\Tests\Unit\Data\Fixtures\DataMultipleGetterFixture; use PHPUnit\Framework\Attributes\CoversTrait; -use PHPUnit\Framework\TestCase; #[CoversTrait(DataMultipleGetter::class)] class DataMultipleGetterTest extends TestCase diff --git a/tests/Unit/Data/Traits/DataMultipleSetterTest.php b/tests/Unit/Data/Traits/DataMultipleSetterTest.php index 9da93b8c6..17e3c1801 100644 --- a/tests/Unit/Data/Traits/DataMultipleSetterTest.php +++ b/tests/Unit/Data/Traits/DataMultipleSetterTest.php @@ -3,9 +3,9 @@ namespace Formwork\Tests\Unit\Data\Traits; use Formwork\Data\Traits\DataMultipleSetter; +use Formwork\Tests\TestCase; use Formwork\Tests\Unit\Data\Fixtures\DataMultipleSetterFixture; use PHPUnit\Framework\Attributes\CoversTrait; -use PHPUnit\Framework\TestCase; #[CoversTrait(DataMultipleSetter::class)] class DataMultipleSetterTest extends TestCase diff --git a/tests/Unit/Data/Traits/DataSetterTest.php b/tests/Unit/Data/Traits/DataSetterTest.php index 82353f556..f998f6b91 100644 --- a/tests/Unit/Data/Traits/DataSetterTest.php +++ b/tests/Unit/Data/Traits/DataSetterTest.php @@ -3,9 +3,9 @@ namespace Formwork\Tests\Unit\Data\Traits; use Formwork\Data\Traits\DataSetter; +use Formwork\Tests\TestCase; use Formwork\Tests\Unit\Data\Fixtures\DataSetterFixture; use PHPUnit\Framework\Attributes\CoversTrait; -use PHPUnit\Framework\TestCase; #[CoversTrait(DataSetter::class)] final class DataSetterTest extends TestCase diff --git a/tests/Unit/Fields/FieldCollectionTest.php b/tests/Unit/Fields/FieldCollectionTest.php index 7cfce86cc..063985d09 100644 --- a/tests/Unit/Fields/FieldCollectionTest.php +++ b/tests/Unit/Fields/FieldCollectionTest.php @@ -2,11 +2,9 @@ namespace Formwork\Tests\Unit\Fields; -use Formwork\Cms\App; use Formwork\Fields\Exceptions\ValidationException; use Formwork\Fields\Field; use Formwork\Fields\FieldCollection; -use Formwork\Fields\FieldFactory; use Formwork\Fields\Layout\Layout; use Formwork\Http\Request; use Formwork\Http\RequestMethod; @@ -18,13 +16,6 @@ #[CoversClass(FieldCollection::class)] class FieldCollectionTest extends TestCase { - private App $app; - - public function setUp(): void - { - $this->app = App::instance(); - } - public function testSetLayoutAndGetLayout(): void { $fields = new FieldCollection(); @@ -185,20 +176,6 @@ public function testSetValuesPreservesValuesThatArePresent(string $name, mixed $ $this->assertSame([$name => $value], $fields->extract('value')); } - public function testSetValuesCanReplaceAnExistingValueWithNull(): void - { - $fields = new FieldCollection([ - 'value' => $this->createField('value', [ - 'type' => 'text', - 'value' => 'original', - ]), - ]); - - $fields->setValues(['value' => null]); - - $this->assertSame(['value' => null], $fields->extract('value')); - } - public function testSetValuesFromRequestSetsRequestValues(): void { $fields = new FieldCollection([ @@ -415,7 +392,7 @@ public function testIsValidatedReturnsFalseUntilAllFieldsHaveBeenValidated(): vo private function createField(string $name, array $data): Field { - return $this->app->getService(FieldFactory::class)->make($name, $data); + return new Field($name, $data); } private function createRequest(RequestMethod $method, array $input = [], array $query = [], array $files = []): Request diff --git a/tests/Unit/Forms/FormTest.php b/tests/Unit/Forms/FormTest.php index 210ff1183..527ccf8a7 100644 --- a/tests/Unit/Forms/FormTest.php +++ b/tests/Unit/Forms/FormTest.php @@ -42,7 +42,7 @@ public function testNameAndFieldsAreAvailable(): void $this->assertSame($fields, $form->fields()->toArray()); } - public function testNewFormIsNotSubmittedAndIsValid(): void + public function testNewFormIsNotSubmittedAndIsInvalid(): void { $form = $this->createForm('contact'); @@ -731,14 +731,13 @@ public function testFieldTypeValues( mixed $expectedMissingValue, ): void { $fieldName = $type; - $fields = [ + + $form = $this->createForm('test', [ $fieldName => $this->createField($fieldName, [ 'type' => $type, ...$config, ]), - ]; - - $form = $this->createForm('test', $fields); + ]); $form->processRequest($this->createRequest(RequestMethod::POST, input: [ $fieldName => $submittedValue, @@ -749,7 +748,12 @@ public function testFieldTypeValues( $form->data()->toArray(), ); - $form = $this->createForm('test', $fields); + $form = $this->createForm('test', [ + $fieldName => $this->createField($fieldName, [ + 'type' => $type, + ...$config, + ]), + ]); $form->processRequest($this->createRequest(RequestMethod::POST)); diff --git a/tests/Unit/Http/ClientTest.php b/tests/Unit/Http/ClientTest.php new file mode 100644 index 000000000..f957316bb --- /dev/null +++ b/tests/Unit/Http/ClientTest.php @@ -0,0 +1,126 @@ + 3]); + + $this->assertSame(-1, $client->defaults()['timeout']); + $this->assertSame(['X-Test' => 'value'], $client->normalize(['x-test' => 'value'])); + $this->assertSame(['X-Test: one', 'X-Test: two'], $client->compact(['X-Test' => ['one', 'two']])); + $this->assertIsResource($client->context([ + 'version' => 1.1, + 'method' => 'POST', + 'headers' => ['X-Test' => 'value'], + 'content' => 'body', + 'redirects' => ['follow' => false, 'limit' => 0], + 'timeout' => 3, + 'ssl' => ['verify' => true, 'cabundle' => null], + ])); + } + + public function testResponseHeaderParserHandlesRedirectChains(): void + { + $responses = (new TestClient())->split([ + 'HTTP/1.1 301 Moved Permanently', + 'Location: /next', + 'HTTP/1.1 200 OK', + 'Content-Length: 4', + 'Content-Type: text/plain', + ]); + + $this->assertSame(301, $responses[0]['statusCode']); + $this->assertSame('/next', $responses[0]['headers']['Location']); + $this->assertSame(200, $responses[1]['statusCode']); + } + + public function testFetchHeadersReturnsResponseHeaders(): void + { + $headers = (new HeadersClient())->fetchHeaders('https://example.test/resource'); + + $this->assertInstanceOf(ResponseHeaders::class, $headers); + $this->assertSame('yes', $headers->get('X-Fetched')); + } + + public function testInvalidUrisAndMalformedResponseHeadersAreRejected(): void + { + $client = new TestClient(); + try { + $client->fetch('not a uri'); + $this->fail('Expected invalid URI exception'); + } catch (InvalidArgumentException) { + $this->addToAssertionCount(1); + } + + $this->expectException(UnexpectedValueException::class); + $client->split(['Content-Type: text/plain']); + } + + public function testConnectionFailuresExposeTheUri(): void + { + $client = new TestClient(['timeout' => 1]); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('Cannot connect to "http://127.0.0.1:1"'); + $client->fetch('http://127.0.0.1:1'); + } + + public function testUnreadableCaBundleIsRejected(): void + { + $this->expectException(RuntimeException::class); + (new TestClient())->context([ + 'version' => 1.1, + 'method' => 'GET', + 'headers' => [], + 'content' => '', + 'redirects' => ['follow' => true, 'limit' => 5], + 'timeout' => -1, + 'ssl' => ['verify' => true, 'cabundle' => __DIR__ . '/fixtures/files/missing-ca.pem'], + ]); + } +} + +class TestClient extends Client +{ + public function context(array $options): mixed + { + return $this->createContext($options); + } + + public function split(array $headers): array + { + return $this->splitHTTPResponseHeader($headers); + } + + public function normalize(array $headers): array + { + return $this->normalizeHeaders($headers); + } + + public function compact(array $headers): array + { + return $this->compactHeaders($headers); + } +} + +final class HeadersClient extends TestClient +{ + public function fetch(string $uri, array $options = []): Response + { + return new Response('fixture', headers: ['X-Fetched' => 'yes']); + } +} diff --git a/tests/Unit/Http/CookieTest.php b/tests/Unit/Http/CookieTest.php new file mode 100644 index 000000000..ef592fe8b --- /dev/null +++ b/tests/Unit/Http/CookieTest.php @@ -0,0 +1,27 @@ +assertTrue(Cookie::send('formwork_test', 'value', [ + 'path' => '/', 'httpOnly' => true, 'sameSite' => Cookie::SAMESITE_STRICT, + ])); + $this->assertTrue(Cookie::remove('formwork_test', ['path' => '/'], forceSend: true)); + $this->assertFalse(Cookie::remove('missing_cookie')); + } + + public function testInvalidCookieNamesAreRejected(): void + { + $this->expectException(InvalidArgumentException::class); + Cookie::send('invalid name', 'value'); + } +} diff --git a/tests/Unit/Http/FileResponseTest.php b/tests/Unit/Http/FileResponseTest.php new file mode 100644 index 000000000..0fc9decf8 --- /dev/null +++ b/tests/Unit/Http/FileResponseTest.php @@ -0,0 +1,132 @@ +setUpTempDirectory(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testFileResponsePreparesRangesAndDownloadNames(): void + { + $path = $this->fixturePath(); + $response = new FileResponse($path, download: true, autoEtag: true, autoLastModified: true); + $response->prepare($this->request(['HTTP_RANGE' => 'bytes=0-4'])); + + $this->assertSame(ResponseStatus::PartialContent, $response->status()); + $this->assertSame('bytes 0-4/' . FileSystem::fileSize($path), $response->headers()->get('Content-Range')); + $this->assertSame('5', $response->headers()->get('Content-Length')); + $this->assertTrue($response->headers()->has('ETag')); + $this->assertTrue($response->headers()->has('Last-Modified')); + $this->assertSame($response, $response->setFilename('download.txt')); + $this->assertSame('attachment; filename=download.txt', $response->headers()->get('Content-Disposition')); + } + + public function testFileResponseHandlesSuffixOpenEndedAndUnsatisfiableRanges(): void + { + $path = $this->fixturePath(); + $size = FileSystem::fileSize($path); + + $suffix = new FileResponse($path); + $suffix->prepare($this->request(['HTTP_RANGE' => 'bytes=-5'])); + $this->assertSame('bytes ' . ($size - 5) . '-' . ($size - 1) . '/' . $size, $suffix->headers()->get('Content-Range')); + + $openEnded = new FileResponse($path); + $openEnded->prepare($this->request(['HTTP_RANGE' => 'bytes=3-'])); + $this->assertSame('bytes 3-' . ($size - 1) . '/' . $size, $openEnded->headers()->get('Content-Range')); + + $invalid = new FileResponse($path); + $invalid->prepare($this->request(['HTTP_RANGE' => 'bytes=999-1000'])); + $this->assertSame(ResponseStatus::RangeNotSatisfiable, $invalid->status()); + $this->assertSame('bytes */' . $size, $invalid->headers()->get('Content-Range')); + $this->assertSame('0', $invalid->headers()->get('Content-Length')); + } + + public function testFileResponseAddsAcceptRangesForHeadAndSkipsRangesForEmptyResponses(): void + { + $path = $this->fixturePath(); + $head = new FileResponse($path); + $head->prepare($this->request(['REQUEST_METHOD' => 'HEAD'])); + $empty = new FileResponse($path, ResponseStatus::NoContent); + $empty->prepare($this->request()); + + $this->assertSame('bytes', $head->headers()->get('Accept-Ranges')); + $this->assertFalse($empty->headers()->has('Accept-Ranges')); + } + + public function testFileResponseSendStreamsFullContent(): void + { + $response = new FileResponse($this->fixturePath()); + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame("Formwork HTTP fixture content.\n", $output); + } + + public function testFileResponseSendStreamsPartialContent(): void + { + $response = new FileResponse($this->fixturePath()); + $response->prepare($this->request(['HTTP_RANGE' => 'bytes=0-4'])); + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame('Formw', $output); + } + + public function testHeadResponseDoesNotStreamOrDeleteTheFile(): void + { + $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'head.txt'); + FileSystem::copyFile($this->fixturePath(), $path); + $response = new FileResponse($path, deleteAfterSend: true); + $response->prepare($this->request(['REQUEST_METHOD' => 'HEAD'])); + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame('', $output); + $this->assertTrue(FileSystem::exists($path)); + } + + public function testInlineFileResponseRejectsDownloadFilenameChanges(): void + { + $response = new FileResponse($this->fixturePath()); + + $this->expectException(LogicException::class); + $response->setFilename('download.txt'); + } + + private function fixturePath(): string + { + return __DIR__ . '/fixtures/files/sample.txt'; + } + + /** + * @param array $server + */ + private function request(array $server = []): Request + { + return new Request([], [], [], [], $server + [ + 'REQUEST_METHOD' => 'GET', 'SERVER_NAME' => 'example.test', 'SERVER_PORT' => '80', + ]); + } +} diff --git a/tests/Unit/Http/FileSessionHandlerTest.php b/tests/Unit/Http/FileSessionHandlerTest.php new file mode 100644 index 000000000..cc5cdc7f7 --- /dev/null +++ b/tests/Unit/Http/FileSessionHandlerTest.php @@ -0,0 +1,41 @@ +setUpTempDirectory(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testHandlerReadsWritesValidatesAndDestroysSessions(): void + { + $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'sessions'); + FileSystem::copyDirectory(__DIR__ . '/fixtures/sessions', $path); + $handler = new FileSessionHandler($path); + + $this->assertTrue($handler->open('', 'formwork')); + $this->assertTrue($handler->validateId('existing')); + $this->assertSame("user|s:8:\"Giuseppe\";\n", $handler->read('existing')); + $this->assertTrue($handler->write('created', 'user|s:3:"Bob";')); + $this->assertSame('user|s:3:"Bob";', $handler->read('created')); + $this->assertTrue($handler->updateTimestamp('created', 'ignored')); + $this->assertTrue($handler->destroy('created')); + $this->assertFalse($handler->validateId('created')); + $this->assertTrue($handler->close()); + } +} diff --git a/tests/Unit/Http/HeaderTest.php b/tests/Unit/Http/HeaderTest.php new file mode 100644 index 000000000..3228ac700 --- /dev/null +++ b/tests/Unit/Http/HeaderTest.php @@ -0,0 +1,32 @@ +assertSame([['for', '1'], ['proto', 'https']], $tokens); + $this->assertSame(['for' => '1', 'proto' => 'https'], Header::combine($tokens)); + $this->assertSame(['application/json' => 1.0, 'text/html' => 0.8], Header::parseQualityValues('text/html;q=.8, application/json')); + $this->assertSame('X-Custom-Header', Header::fixHeaderName('x_custom_header')); + $this->assertSame(['X-Test' => 'value'], Header::fixHeaderNames(['x_test' => 'value'])); + + $this->expectException(UnexpectedValueException::class); + Header::combine([[]]); + } + + public function testResponseHeaderFormatting(): void + { + $this->assertSame('a=b; c', ResponseHeader::make(['a' => 'b', 'c'])); + } +} diff --git a/tests/Unit/Http/HeadersDataTest.php b/tests/Unit/Http/HeadersDataTest.php new file mode 100644 index 000000000..d9835d41c --- /dev/null +++ b/tests/Unit/Http/HeadersDataTest.php @@ -0,0 +1,20 @@ + 'value', 'content-type' => 'text/plain']); + + $this->assertSame('value', $headers->get('X-Custom-Header')); + $this->assertNull($headers->get('x_custom_header')); + $this->assertSame(['Content-Type' => 'text/plain', 'X-Custom-Header' => 'value'], $headers->toArray()); + } +} diff --git a/tests/Unit/Http/IpAnonymizerTest.php b/tests/Unit/Http/IpAnonymizerTest.php new file mode 100644 index 000000000..32961912e --- /dev/null +++ b/tests/Unit/Http/IpAnonymizerTest.php @@ -0,0 +1,24 @@ +assertSame('192.0.2.0', IpAnonymizer::anonymize('192.0.2.123')); + $this->assertSame('2001:db8:abcd:12::', IpAnonymizer::anonymize('2001:db8:abcd:12:1234:5678:9abc:def0')); + } + + public function testInvalidIpAddressesAreRejected(): void + { + $this->expectException(InvalidArgumentException::class); + IpAnonymizer::anonymize('invalid'); + } +} diff --git a/tests/Unit/Http/JsonResponseTest.php b/tests/Unit/Http/JsonResponseTest.php new file mode 100644 index 000000000..bce36c128 --- /dev/null +++ b/tests/Unit/Http/JsonResponseTest.php @@ -0,0 +1,24 @@ + 7]); + $error = JsonResponse::error('Invalid', ResponseStatus::UnprocessableEntity, ['field' => 'name']); + + $this->assertStringContainsString('"status":"success"', $success->content()); + $this->assertStringContainsString('"status":"error"', $error->content()); + $this->assertSame(ResponseStatus::Created, $success->status()); + $this->assertSame(ResponseStatus::UnprocessableEntity, $error->status()); + $this->assertSame('application/json; charset=utf-8', $success->headers()->get('Content-Type')); + } +} diff --git a/tests/Unit/Http/MessagesTest.php b/tests/Unit/Http/MessagesTest.php new file mode 100644 index 000000000..46f56eee4 --- /dev/null +++ b/tests/Unit/Http/MessagesTest.php @@ -0,0 +1,25 @@ +add(MessageType::Info, 'hello'); + $messages->set(MessageType::Error, ['bad']); + + $this->assertTrue($messages->has(MessageType::Info)); + $this->assertSame(['hello'], $messages->get(MessageType::Info)); + $this->assertSame(['error' => ['bad']], $messages->getAll()); + $this->assertSame([], $data); + } +} diff --git a/tests/Unit/Http/RedirectResponseTest.php b/tests/Unit/Http/RedirectResponseTest.php new file mode 100644 index 000000000..1444150ef --- /dev/null +++ b/tests/Unit/Http/RedirectResponseTest.php @@ -0,0 +1,21 @@ + 'yes']); + + $this->assertSame('/next', $response->headers()->get('Location')); + $this->assertSame('yes', $response->headers()->get('X-Redirect')); + $this->assertSame(ResponseStatus::SeeOther, $response->status()); + } +} diff --git a/tests/Unit/Http/RequestDataTest.php b/tests/Unit/Http/RequestDataTest.php new file mode 100644 index 000000000..e6cf75e73 --- /dev/null +++ b/tests/Unit/Http/RequestDataTest.php @@ -0,0 +1,22 @@ + 'one', 'second' => 'two']); + + $this->assertFalse($data->isEmpty()); + $this->assertCount(2, $data); + $this->assertSame('one', $data->get('first')); + $this->assertSame('fallback', $data->get('missing', 'fallback')); + $this->assertSame(['first' => 'one', 'second' => 'two'], $data->toArray()); + } +} diff --git a/tests/Unit/Http/RequestTest.php b/tests/Unit/Http/RequestTest.php index d53294597..6d6b473a5 100644 --- a/tests/Unit/Http/RequestTest.php +++ b/tests/Unit/Http/RequestTest.php @@ -2,10 +2,10 @@ namespace Formwork\Tests\Unit\Http; +use Formwork\Http\Files\UploadedFile; use Formwork\Http\Request; use Formwork\Http\RequestMethod; use Formwork\Http\RequestType; -use Formwork\Http\Files\UploadedFile; use Formwork\Http\Session\Session; use Formwork\Tests\TestCase; use PHPUnit\Framework\Attributes\CoversClass; @@ -32,19 +32,19 @@ public static function rootProvider(): array { return [ 'document root' => [ - 'scriptName' => '/index.php', + 'scriptName' => '/index.php', 'expectedRoot' => '/', ], 'nested application' => [ - 'scriptName' => '/formwork/index.php', + 'scriptName' => '/formwork/index.php', 'expectedRoot' => '/formwork/', ], 'absolute cli script path' => [ - 'scriptName' => '/project/vendor/bin/phpunit', + 'scriptName' => '/project/vendor/bin/phpunit', 'expectedRoot' => '/project/vendor/bin/', ], 'missing script name' => [ - 'scriptName' => '', + 'scriptName' => '', 'expectedRoot' => '/', ], ]; @@ -96,14 +96,14 @@ public function testBaseUriIncludesTheApplicationRoot(): void public function testBasicAccessorsExposeMethodServerAndRequestHeaders(): void { $request = $this->request([ - 'REQUEST_METHOD' => 'GET', - 'SERVER_PROTOCOL' => 'HTTP/2', - 'REMOTE_ADDR' => '192.0.2.10', - 'CONTENT_LENGTH' => '42', - 'QUERY_STRING' => 'page=2', - 'HTTP_REFERER' => 'https://example.test/source/', - 'HTTP_USER_AGENT' => 'RequestTest/1.0', - 'HTTP_ACCEPT' => 'text/html, application/json;q=0.8', + 'REQUEST_METHOD' => 'GET', + 'SERVER_PROTOCOL' => 'HTTP/2', + 'REMOTE_ADDR' => '192.0.2.10', + 'CONTENT_LENGTH' => '42', + 'QUERY_STRING' => 'page=2', + 'HTTP_REFERER' => 'https://example.test/source/', + 'HTTP_USER_AGENT' => 'RequestTest/1.0', + 'HTTP_ACCEPT' => 'text/html, application/json;q=0.8', 'HTTP_ACCEPT_ENCODING' => 'gzip, br;q=0.5', 'HTTP_ACCEPT_LANGUAGE' => 'it-IT, en;q=0.7', ]); @@ -161,11 +161,11 @@ public function testHostIsNormalizedAndInvalidHostsAreRejected(): void public function testTrustedForwardedHeadersOverrideConnectionMetadata(): void { $request = $this->request([ - 'REMOTE_ADDR' => '10.0.0.10', + 'REMOTE_ADDR' => '10.0.0.10', 'HTTP_FORWARDED' => 'for=203.0.113.9;host=Example.org:8443;proto=https;port=8443', - 'SERVER_NAME' => 'internal.test', - 'SERVER_PORT' => '80', - 'HTTPS' => 'off', + 'SERVER_NAME' => 'internal.test', + 'SERVER_PORT' => '80', + 'HTTPS' => 'off', ]); $request->setTrustedProxies(['10.0.0.10']); @@ -179,11 +179,11 @@ public function testTrustedForwardedHeadersOverrideConnectionMetadata(): void public function testUntrustedForwardedHeadersAreIgnored(): void { $request = $this->request([ - 'REMOTE_ADDR' => '192.0.2.10', + 'REMOTE_ADDR' => '192.0.2.10', 'HTTP_FORWARDED' => 'for=203.0.113.9;host=public.test;proto=https;port=8443', - 'SERVER_NAME' => 'internal.test', - 'SERVER_PORT' => '8080', - 'HTTPS' => 'off', + 'SERVER_NAME' => 'internal.test', + 'SERVER_PORT' => '8080', + 'HTTPS' => 'off', ]); $this->assertFalse($request->isFromTrustedProxy()); @@ -196,13 +196,13 @@ public function testUntrustedForwardedHeadersAreIgnored(): void public function testXForwardedHeadersAreUsedForTrustedProxies(): void { $request = $this->request([ - 'REMOTE_ADDR' => '10.0.0.10', - 'HTTP_X_FORWARDED_FOR' => '203.0.113.9', - 'HTTP_X_FORWARDED_HOST' => 'public.test:9443', + 'REMOTE_ADDR' => '10.0.0.10', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.9', + 'HTTP_X_FORWARDED_HOST' => 'public.test:9443', 'HTTP_X_FORWARDED_PROTO' => 'https', - 'HTTP_X_FORWARDED_PORT' => '9443', - 'SERVER_NAME' => 'internal.test', - 'SERVER_PORT' => '80', + 'HTTP_X_FORWARDED_PORT' => '9443', + 'SERVER_NAME' => 'internal.test', + 'SERVER_PORT' => '80', ]); $request->setTrustedProxies(['10.0.0.10']); @@ -215,8 +215,8 @@ public function testXForwardedHeadersAreUsedForTrustedProxies(): void public function testSecurityLocalhostAndRequestTypeAreDetected(): void { $secure = $this->request([ - 'REMOTE_ADDR' => '127.0.0.1', - 'HTTPS' => 'on', + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTPS' => 'on', 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', ]); @@ -243,9 +243,9 @@ public function testNonGetContentReadsTheRequestBodyAndReturnsNullWhenItIsEmpty( public function testRefererCanBeValidatedAgainstTheCurrentOriginAndPath(): void { $request = $this->request([ - 'SCRIPT_NAME' => '/index.php', - 'REQUEST_URI' => '/current', - 'SERVER_NAME' => 'example.test', + 'SCRIPT_NAME' => '/index.php', + 'REQUEST_URI' => '/current', + 'SERVER_NAME' => 'example.test', 'HTTP_REFERER' => 'http://example.test/admin/users?tab=active', ]); @@ -262,20 +262,20 @@ public function testUploadedFilesAreNormalizedIntoUploadedFileObjects(): void [], [ 'avatar%5Bimage%5D' => [ - 'name' => 'avatar.png', + 'name' => 'avatar.png', 'full_path' => 'avatar.png', - 'type' => 'image/png', - 'tmp_name' => '/tmp/php-avatar', - 'error' => UPLOAD_ERR_OK, - 'size' => '12', + 'type' => 'image/png', + 'tmp_name' => '/tmp/php-avatar', + 'error' => UPLOAD_ERR_OK, + 'size' => '12', ], 'documents' => [ - 'name' => ['first.txt', 'second.txt'], + 'name' => ['first.txt', 'second.txt'], 'full_path' => ['first.txt', 'second.txt'], - 'type' => ['text/plain', 'text/plain'], - 'tmp_name' => ['/tmp/php-first', '/tmp/php-second'], - 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_NO_FILE], - 'size' => ['10', '0'], + 'type' => ['text/plain', 'text/plain'], + 'tmp_name' => ['/tmp/php-first', '/tmp/php-second'], + 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_NO_FILE], + 'size' => ['10', '0'], ], ], ); @@ -344,8 +344,8 @@ private function request( $files, $server + [ 'REQUEST_METHOD' => 'GET', - 'SERVER_NAME' => 'localhost', - 'SERVER_PORT' => '80', + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => '80', ], ); } diff --git a/tests/Unit/Http/ResponseHeaderUtilsTest.php b/tests/Unit/Http/ResponseHeaderUtilsTest.php new file mode 100644 index 000000000..5e58e7bba --- /dev/null +++ b/tests/Unit/Http/ResponseHeaderUtilsTest.php @@ -0,0 +1,33 @@ +assertSame('attachment; filename=sample.txt', Header::make(['attachment', 'filename' => 'sample.txt'])); + Header::send('X-Formwork-Test', ' value '); + Header::contentType('text/plain'); + Header::sendStatus(ResponseStatus::OK); + $this->addToAssertionCount(1); + } + + public function testNotFoundSendsTheNotFoundStatus(): void + { + Header::notFound(); + $this->addToAssertionCount(1); + } + + public function testRedirectRejectsNonRedirectionStatuses(): void + { + $this->expectException(\InvalidArgumentException::class); + Header::redirect('/next', ResponseStatus::OK); + } +} diff --git a/tests/Unit/Http/ResponseHeadersTest.php b/tests/Unit/Http/ResponseHeadersTest.php new file mode 100644 index 000000000..58b2303b6 --- /dev/null +++ b/tests/Unit/Http/ResponseHeadersTest.php @@ -0,0 +1,21 @@ + 'last', 'a-header' => 'first']); + $headers->set('cache_control', 'no-cache'); + $headers->remove('Z-HEADER'); + + $this->assertSame(['A-Header' => 'first', 'Cache-Control' => 'no-cache'], $headers->toArray()); + $this->assertTrue($headers->has('cache-control')); + } +} diff --git a/tests/Unit/Http/ResponseStatusTest.php b/tests/Unit/Http/ResponseStatusTest.php new file mode 100644 index 000000000..7c18196d5 --- /dev/null +++ b/tests/Unit/Http/ResponseStatusTest.php @@ -0,0 +1,33 @@ +assertSame($code, $status->code()); + $this->assertSame(explode(' ', $status->value, 2)[1], $status->message()); + $this->assertSame($type, $status->type()); + $this->assertSame($status, ResponseStatus::fromCode($code)); + } + + public static function statusProvider(): array + { + return [ + 'informational' => [ResponseStatus::Continue, 100, ResponseStatusType::Informational], + 'successful' => [ResponseStatus::OK, 200, ResponseStatusType::Successful], + 'redirect' => [ResponseStatus::Found, 302, ResponseStatusType::Redirection], + 'client error' => [ResponseStatus::NotFound, 404, ResponseStatusType::ClientError], + 'server error' => [ResponseStatus::InternalServerError, 500, ResponseStatusType::ServerError], + ]; + } +} diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php new file mode 100644 index 000000000..10dc733fc --- /dev/null +++ b/tests/Unit/Http/ResponseTest.php @@ -0,0 +1,75 @@ + 'yes']); + $restored = Response::fromArray($response->toArray()); + $setState = Response::__set_state($response->toArray()); + + $this->assertSame('body', $restored->content()); + $this->assertSame(ResponseStatus::Created, $restored->status()); + $this->assertSame('yes', $restored->headers()->get('X-Test')); + $this->assertSame($restored->toArray(), $setState->toArray()); + } + + public function testResponsePreparationHandlesHeadAndConditionalRequests(): void + { + $request = $this->request(['REQUEST_METHOD' => 'HEAD']); + $response = new Response('body', ResponseStatus::OK, ['ETag' => 'tag', 'Content-Length' => '4']); + $requestWithMatch = $this->request(['HTTP_IF_NONE_MATCH' => 'tag']); + + $response->prepare($request); + $this->assertSame('', $response->content()); + $response->prepare($requestWithMatch); + $this->assertSame(ResponseStatus::NotModified, $response->status()); + $this->assertFalse($response->headers()->has('Content-Length')); + } + + public function testEmptyResponsesClearContentTypeAndContentLength(): void + { + foreach ([ResponseStatus::NoContent, ResponseStatus::NotModified] as $status) { + $response = new Response('body', $status, ['Content-Length' => '4', 'Content-Type' => 'text/plain']); + $response->prepare($this->request()); + + $this->assertSame('', $response->content()); + $this->assertFalse($response->headers()->has('Content-Length')); + $this->assertFalse($response->headers()->has('Content-Type')); + } + } + + public function testResponseSendWritesContentAndAddsDefaultHeaders(): void + { + $response = new Response('body'); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame('body', $output); + $this->assertSame('text/html; charset=utf-8', $response->headers()->get('Content-Type')); + $this->assertSame('no-cache, private', $response->headers()->get('Cache-Control')); + } + + /** + * @param array $server + */ + private function request(array $server = []): Request + { + return new Request([], [], [], [], $server + [ + 'REQUEST_METHOD' => 'GET', + 'SERVER_NAME' => 'example.test', + 'SERVER_PORT' => '80', + ]); + } +} diff --git a/tests/Unit/Http/ServerDataTest.php b/tests/Unit/Http/ServerDataTest.php new file mode 100644 index 000000000..d3f3faa72 --- /dev/null +++ b/tests/Unit/Http/ServerDataTest.php @@ -0,0 +1,23 @@ + 'example.test', + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => '12', + 'REQUEST_METHOD' => 'POST', + ]); + + $this->assertSame(['HOST' => 'example.test', 'CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => '12'], $server->getHeaders()); + } +} diff --git a/tests/Unit/Http/SessionTest.php b/tests/Unit/Http/SessionTest.php new file mode 100644 index 000000000..e209b848d --- /dev/null +++ b/tests/Unit/Http/SessionTest.php @@ -0,0 +1,113 @@ +setUpTempDirectory(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testSessionDataAndMessagesAreMemoized(): void + { + $session = $this->session(); + $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'session-data')); + $session->set('value', 42); + + $this->assertTrue($session->has('value')); + $this->assertSame(42, $session->get('value')); + $this->assertSame($session->messages(), $session->messages()); + $session->remove('value'); + $this->assertFalse($session->has('value')); + $session->save(); + } + + public function testReservedMessageKeysAreRejected(): void + { + $session = $this->session(); + $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'session-data')); + + try { + $session->get('_formwork_messages.foo'); + $this->fail('Expected reserved key exception'); + } catch (InvalidArgumentException) { + $this->addToAssertionCount(1); + } finally { + $session->save(); + } + } + + public function testSessionCanBeConfiguredStartedAndSaved(): void + { + $session = $this->session(); + $session->setName('custom_session'); + $session->setDuration(60); + $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'session-data')); + + $this->assertFalse($session->isStarted()); + $this->assertSame('custom_session', $session->name()); + $session->start(); + $this->assertTrue($session->isStarted()); + $session->save(); + $this->assertFalse($session->isStarted()); + } + + public function testSessionCanRegenerateAndCheckItsCurrentId(): void + { + $session = $this->session(); + $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'session-data')); + $session->start(); + $session->set('value', 42); + $session->regenerate(); + $id = session_id(); + + $this->assertTrue($session->exists($id)); + $this->assertSame(42, $session->get('value')); + $session->save(); + } + + public function testSessionRejectsChangingConfigurationAfterStart(): void + { + $session = $this->session(); + $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'session-data')); + $session->start(); + + try { + $session->setName('another'); + $this->fail('Expected setName exception'); + } catch (\RuntimeException) { + $this->addToAssertionCount(1); + } + try { + $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'another-path')); + $this->fail('Expected setPath exception'); + } catch (\RuntimeException) { + $this->addToAssertionCount(1); + } finally { + $session->save(); + } + } + + private function session(): Session + { + return new Session(new Request([], [], [], [], [ + 'REQUEST_METHOD' => 'GET', 'SERVER_NAME' => 'localhost', 'SERVER_PORT' => '80', + ])); + } +} diff --git a/tests/Unit/Http/UploadedFileTest.php b/tests/Unit/Http/UploadedFileTest.php new file mode 100644 index 000000000..701beefa4 --- /dev/null +++ b/tests/Unit/Http/UploadedFileTest.php @@ -0,0 +1,44 @@ + 'avatar.png', 'full_path' => 'pictures/avatar.png', 'type' => 'image/png', + 'tmp_name' => '/tmp/php-avatar', 'error' => (string) UPLOAD_ERR_OK, 'size' => '12', + ]); + + $this->assertSame('avatar', $file->fieldName()); + $this->assertSame('avatar.png', $file->clientName()); + $this->assertSame('pictures/avatar.png', $file->clientFullPath()); + $this->assertSame('image/png', $file->clientMimeType()); + $this->assertSame('/tmp/php-avatar', $file->tempPath()); + $this->assertSame(UPLOAD_ERR_OK, $file->error()); + $this->assertSame(12, $file->size()); + $this->assertTrue($file->isUploaded()); + $this->assertFalse($file->isEmpty()); + } + + public function testUploadErrorsExposeTranslatedMessagesAndBlockMove(): void + { + $file = new UploadedFile('document', [ + 'name' => 'document.txt', 'full_path' => 'document.txt', 'type' => 'text/plain', + 'tmp_name' => '/tmp/php-document', 'error' => (string) UPLOAD_ERR_NO_FILE, 'size' => '0', + ]); + + $this->assertTrue($file->isEmpty()); + $this->assertSame('No file was uploaded', $file->getErrorMessage()); + $this->assertSame('upload.error.noFile', $file->getErrorTranslationString()); + $this->expectException(TranslatedException::class); + $file->move(__DIR__ . '/fixtures/files', 'document.txt'); + } +} diff --git a/tests/Unit/Http/VisitorTest.php b/tests/Unit/Http/VisitorTest.php new file mode 100644 index 000000000..c3b1e1417 --- /dev/null +++ b/tests/Unit/Http/VisitorTest.php @@ -0,0 +1,42 @@ +request('Googlebot/2.1', 'https://search.example/path'); + $mobile = $this->request('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148'); + $desktop = $this->request('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36'); + + $this->assertTrue(Visitor::isBot($bot)); + $this->assertFalse(Visitor::isBrowser($bot)); + $this->assertSame('search.example', Visitor::getSource($bot)); + $this->assertSame(DeviceType::Mobile, Visitor::getDeviceType($mobile)); + $this->assertTrue(Visitor::isMobile($mobile)); + $this->assertSame(DeviceType::Desktop, Visitor::getDeviceType($desktop)); + $this->assertTrue(Visitor::isDesktop($desktop)); + } + + public function testVisitorHandlesDirectAndSameOriginVisits(): void + { + $this->assertSame('', Visitor::getSource($this->request('Browser/1.0'))); + $this->assertNull(Visitor::getSource($this->request('Browser/1.0', 'https://example.test/previous'))); + } + + private function request(string $agent, ?string $referer = null): Request + { + return new Request([], [], [], [], array_filter([ + 'REQUEST_METHOD' => 'GET', 'SERVER_NAME' => 'example.test', 'SERVER_PORT' => '80', + 'HTTP_USER_AGENT' => $agent, 'HTTP_REFERER' => $referer, + ], static fn($value): bool => $value !== null)); + } +} diff --git a/tests/Unit/Http/fixtures/files/sample.txt b/tests/Unit/Http/fixtures/files/sample.txt new file mode 100644 index 000000000..0236ebb6e --- /dev/null +++ b/tests/Unit/Http/fixtures/files/sample.txt @@ -0,0 +1 @@ +Formwork HTTP fixture content. diff --git a/tests/Unit/Http/fixtures/sessions/sess_existing b/tests/Unit/Http/fixtures/sessions/sess_existing new file mode 100644 index 000000000..b6797c461 --- /dev/null +++ b/tests/Unit/Http/fixtures/sessions/sess_existing @@ -0,0 +1 @@ +user|s:8:"Giuseppe"; diff --git a/tests/Unit/Pages/ContentFileTest.php b/tests/Unit/Pages/ContentFileTest.php new file mode 100644 index 000000000..b60c77190 --- /dev/null +++ b/tests/Unit/Pages/ContentFileTest.php @@ -0,0 +1,44 @@ +assertSame(['title' => 'About'], $file->frontmatter()); + $this->assertSame('This page introduces the project and explains where to find the main sections of the site.', $file->content()); + $this->assertFalse($file->isEmpty()); + } + + public function testEmptyFrontmatterIsReportedAsEmpty(): void + { + $file = new ContentFile(TESTS_PATH . '/Unit/Pages/fixtures/site/empty/page.md'); + + $this->assertSame([], $file->frontmatter()); + $this->assertTrue($file->isEmpty()); + } + + public function testInvalidPageFormatIsRejected(): void + { + $path = TESTS_TMP_PATH . '/invalid-page.md'; + $this->setUpTempDirectory(); + file_put_contents($path, 'not a page'); + + try { + $this->expectException(UnexpectedValueException::class); + new ContentFile($path); + } finally { + $this->tearDownTempDirectory(); + } + } +} diff --git a/tests/Unit/Pages/PageCollectionTest.php b/tests/Unit/Pages/PageCollectionTest.php new file mode 100644 index 000000000..747afa77c --- /dev/null +++ b/tests/Unit/Pages/PageCollectionTest.php @@ -0,0 +1,138 @@ +setUpTempDirectory(); + $this->app = App::instance(); + $this->fixtureSite = $this->temporarySite(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testFiltersExtractAndPaginationPreservePageCollectionType(): void + { + $about = $this->page('/about'); + $blog = $this->page('/blog'); + $about->set('listed', true); + $about->set('taxonomy', ['tags' => ['PHP', 'CMS']]); + $blog->set('published', false); + $blog->set('routable', false); + $blog->set('listed', false); + + $collection = $this->collection($about, $blog); + + $this->assertSame([$about->title(), $blog->title()], array_values($collection->extract('title'))); + $this->assertSame([$about], $collection->listed()->values()); + $this->assertSame([$about], $collection->published()->values()); + $this->assertSame([$about], $collection->routable()->values()); + $this->assertSame([$about, $blog], $collection->allowingChildren()->values()); + $this->assertSame([$about], $collection->havingTaxonomy(['tags' => ['php']], slug: true)->values()); + + $paginated = $collection->paginate(1, 2); + $this->assertSame([$blog], $paginated->values()); + $this->assertSame(2, $paginated->pagination()->pages()); + $this->assertSame(2, $paginated->pagination()->currentPage()); + } + + public function testSearchScoresMatchesAndReturnsResultsInDescendingOrder(): void + { + $about = $this->page('/about'); + $blog = $this->page('/blog'); + $about->set('title', 'PHP guide'); + $blog->set('title', 'A PHP handbook for teams'); + + $results = $this->collection($about, $blog)->search('PHP guide', weights: [ + 'title' => 100, + 'summary' => 0, + 'content' => 0, + 'author' => 0, + 'uri' => 0, + ]); + + $this->assertNotEmpty($results->values()); + $this->assertContains($about, $results->values()); + $this->assertGreaterThan(0, $about->get('score')); + $scores = array_map(static fn(Page $page): int => $page->get('score'), $results->values()); + $sorted = $scores; + rsort($sorted); + $this->assertSame($sorted, $scores); + } + + public function testSearchReturnsAnEmptyCollectionForShortQueries(): void + { + $collection = $this->collection($this->page('/about')); + + $this->assertTrue($collection->search('abc')->isEmpty()); + $this->assertTrue($collection->search('one two', minimumLength: 4)->isEmpty()); + } + + public function testPageRelationshipExclusionMethodsRemoveExpectedPages(): void + { + $parent = $this->page('/blog'); + $child = $this->page('/blog/hello-world'); + $about = $this->page('/about'); + $collection = $this->collection($parent, $child, $about); + + $this->assertSame([$parent, $about], $collection->withoutChildren($parent)->values()); + $this->assertSame([$about], $collection->withoutPageAndChildren($parent)->values()); + $this->assertSame([$parent, $about], $collection->withoutDescendants($parent)->values()); + $this->assertSame([$about], $collection->withoutPageAndDescendants($parent)->values()); + $this->assertSame([$child, $about], $collection->withoutParent($child)->values()); + $this->assertSame([$about], $collection->withoutPageAndParent($child)->values()); + $this->assertSame([$child, $about], $collection->withoutSiblings($about)->values()); + } + + private function page(string $route): Page + { + return $this->fixtureSite->findPage($route) ?? $this->fail("Missing page {$route}"); + } + + private function collection(Page ...$pages): PageCollection + { + $data = []; + foreach ($pages as $page) { + $data[$page->route()] = $page; + } + + return $this->app->getService(PageCollectionFactory::class)->make($data); + } + + private function temporarySite(): Site + { + $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'page-collection-' . ++$this->temporarySiteCounter); + FileSystem::copyDirectory(__DIR__ . '/fixtures/site', $path); + + return new Site( + ['path' => TESTS_TMP_PATH, 'contentPath' => $path, 'metadata' => []], + $this->app->config(), + $this->app->getService(PageFactory::class), + $this->app->getService(PageCollectionFactory::class), + ); + } +} diff --git a/tests/Unit/Pages/PageEventsTest.php b/tests/Unit/Pages/PageEventsTest.php new file mode 100644 index 000000000..829d88e51 --- /dev/null +++ b/tests/Unit/Pages/PageEventsTest.php @@ -0,0 +1,162 @@ +setUpTempDirectory(); + $this->app = App::instance(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testPageAfterDeleteEvent(): void + { + $event = new PageAfterDeleteEvent($page = $this->page('/about')); + + $this->assertSame('pageAfterDelete', $event->name()); + $this->assertSame($page, $event->page()); + } + + public function testPageAfterDuplicateEvent(): void + { + $event = new PageAfterDuplicateEvent( + $page = $this->page('/about'), + $duplicate = $this->page('/blog'), + ); + + $this->assertSame('pageAfterDuplicate', $event->name()); + $this->assertSame($page, $event->page()); + $this->assertSame($duplicate, $event->duplicatePage()); + } + + public function testPageAfterSaveEvent(): void + { + $event = new PageAfterSaveEvent($page = $this->page('/about')); + + $this->assertSame('pageAfterSave', $event->name()); + $this->assertSame($page, $event->page()); + } + + public function testPageBeforeDeleteEvent(): void + { + $event = new PageBeforeDeleteEvent($page = $this->page('/about')); + + $this->assertSame('pageBeforeDelete', $event->name()); + $this->assertSame($page, $event->page()); + } + + public function testPageBeforeDuplicateEvent(): void + { + $with = ['title' => 'Copy']; + $event = new PageBeforeDuplicateEvent($page = $this->page('/about'), $with); + + $this->assertSame('pageBeforeDuplicate', $event->name()); + $this->assertSame($page, $event->page()); + + $eventWith = &$event->with(); + $eventWith['title'] = 'Changed'; + $this->assertSame('Changed', $with['title']); + } + + public function testPageBeforeSaveEvent(): void + { + $event = new PageBeforeSaveEvent($page = $this->page('/about')); + + $this->assertSame('pageBeforeSave', $event->name()); + $this->assertSame($page, $event->page()); + } + + public function testPageLoadedEvent(): void + { + $event = new PageLoadedEvent($page = $this->page('/about')); + + $this->assertSame('pageLoaded', $event->name()); + $this->assertSame($page, $event->page()); + } + + public function testPageOutputEvent(): void + { + $output = 'Before'; + $event = new PageOutputEvent($page = $this->page('/about'), $output); + + $this->assertSame('pageOutput', $event->name()); + $this->assertSame($page, $event->page()); + + $eventOutput = &$event->output(); + $eventOutput .= ' after'; + $this->assertSame('Before after', $output); + } + + public function testPageRenderEvent(): void + { + $vars = ['page' => $this->page('/about')]; + $event = new PageRenderEvent($vars['page'], $vars); + + $this->assertSame('pageRender', $event->name()); + $this->assertSame($vars['page'], $event->page()); + + $eventVars = &$event->vars(); + $eventVars['extra'] = true; + $this->assertTrue($vars['extra']); + } + + private function page(string $route): Page + { + $site = $this->temporarySite(); + $path = FileSystem::joinPaths((string) $site->contentPath(), trim($route, '/')) . '/'; + + return new Page(['site' => $site, 'path' => $path], $this->app); + } + + private function temporarySite(): Site + { + $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'page-events-' . ++$this->temporarySiteCounter); + FileSystem::copyDirectory(__DIR__ . '/fixtures/site', $path); + + return new Site( + ['path' => TESTS_TMP_PATH, 'contentPath' => $path, 'metadata' => []], + $this->app->config(), + $this->app->getService(PageFactory::class), + $this->app->getService(PageCollectionFactory::class), + ); + } +} diff --git a/tests/Unit/Pages/PageFactoryTest.php b/tests/Unit/Pages/PageFactoryTest.php new file mode 100644 index 000000000..c5f01aac5 --- /dev/null +++ b/tests/Unit/Pages/PageFactoryTest.php @@ -0,0 +1,57 @@ +setUpTempDirectory(); + $this->app = App::instance(); + $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'page-factory-site'); + FileSystem::copyDirectory(__DIR__ . '/fixtures/site', $path); + $this->fixtureSite = new Site( + ['path' => TESTS_TMP_PATH, 'contentPath' => $path, 'metadata' => []], + $this->app->config(), + $this->app->getService(PageFactory::class), + $this->app->getService(PageCollectionFactory::class), + ); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testFactoriesBuildTheExpectedPageObjects(): void + { + $pageFactory = $this->app->getService(PageFactory::class); + $collectionFactory = $this->app->getService(PageCollectionFactory::class); + + $page = $pageFactory->make(['site' => $this->fixtureSite]); + $collection = $collectionFactory->make([]); + + $this->assertInstanceOf(Page::class, $page); + $this->assertTrue($page->isEmpty()); + $this->assertInstanceOf(PageCollection::class, $collection); + $this->assertTrue($collection->isEmpty()); + } +} diff --git a/tests/Unit/Pages/PageTest.php b/tests/Unit/Pages/PageTest.php index 8b81b2e92..61518f222 100644 --- a/tests/Unit/Pages/PageTest.php +++ b/tests/Unit/Pages/PageTest.php @@ -10,6 +10,7 @@ use Formwork\Pages\PageCollectionFactory; use Formwork\Pages\PageFactory; use Formwork\Tests\TestCase; +use Formwork\Utils\FileSystem; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; @@ -19,6 +20,8 @@ final class PageTest extends TestCase { private App $app; + private Site $fixtureSite; + private int $temporarySiteCounter = 0; protected function setUp(): void @@ -26,6 +29,7 @@ protected function setUp(): void parent::setUp(); $this->setUpTempDirectory(); $this->app = App::instance(); + $this->fixtureSite = $this->temporarySite(); } protected function tearDown(): void @@ -96,16 +100,16 @@ public function testGetHasAndSetWorkForFrontmatterAndRuntimeFields(): void public function testNumberPrefixAndSlugAreSeparated(): void { - $page = $this->fixturePage('/about'); + $page = $this->temporaryPage('1-numbered', $this->temporarySite(__DIR__ . '/fixtures/numbered-site')); $this->assertSame(1, $page->num()); - $this->assertSame('about', $page->slug()); - $this->assertSame('/about/', $page->route()); + $this->assertSame('numbered', $page->slug()); + $this->assertSame('/numbered/', $page->route()); } public function testTemplateLanguageAndContentFileAreResolvedFromTheFilename(): void { - $page = $this->temporaryPage('localized', ['title' => 'Localized']); + $page = $this->temporaryPage('about'); $this->assertSame('page', $page->template()->name()); $this->assertNull($page->language()); @@ -115,16 +119,13 @@ public function testTemplateLanguageAndContentFileAreResolvedFromTheFilename(): public function testMultilingualContentSelectsTheRequestedLanguageAndTracksAvailableVersions(): void { $site = $this->temporarySite(); - $path = rtrim((string) $site->contentPath(), '/') . '/localized/'; - mkdir($path, 0o777, true); - file_put_contents($path . 'page.en.md', "---\ntitle: English\n---\nEnglish content"); - file_put_contents($path . 'page.it.md', "---\ntitle: Italiano\n---\nContenuto italiano"); + $path = FileSystem::joinPaths((string) $site->contentPath(), 'localized') . '/'; $page = new Page(['site' => $site, 'path' => $path, 'language' => 'it'], $this->app); $this->assertSame('it', $page->language()?->code()); $this->assertSame('Italiano', $page->title()); - $this->assertSame('Contenuto italiano', trim(strip_tags($page->content()))); + $this->assertSame('Questa pagina spiega come è organizzato il sito e dove trovare le sezioni principali.', trim(strip_tags($page->content()))); $this->assertTrue($page->languages()->available()->has('en')); $this->assertTrue($page->languages()->available()->has('it')); } @@ -132,10 +133,7 @@ public function testMultilingualContentSelectsTheRequestedLanguageAndTracksAvail public function testInvalidLanguageIsRejectedAfterLoading(): void { $site = $this->temporarySite(); - $path = rtrim((string) $site->contentPath(), '/') . '/localized/'; - mkdir($path, 0o777, true); - file_put_contents($path . 'page.en.md', "---\ntitle: English\n---\nEnglish content"); - file_put_contents($path . 'page.it.md', "---\ntitle: Italiano\n---\nContenuto italiano"); + $path = FileSystem::joinPaths((string) $site->contentPath(), 'localized') . '/'; $page = new Page(['site' => $site, 'path' => $path, 'language' => 'it'], $this->app); $this->expectException(InvalidValueException::class); @@ -144,7 +142,7 @@ public function testInvalidLanguageIsRejectedAfterLoading(): void public function testIconFallsBackToTheSchemeAndCanBeOverridden(): void { - $page = $this->temporaryPage('icon'); + $page = $this->temporaryPage('about'); $defaultIcon = $page->icon(); $this->assertIsString($defaultIcon); @@ -153,25 +151,24 @@ public function testIconFallsBackToTheSchemeAndCanBeOverridden(): void public function testStatusHonoursPublishedAndDateWindows(): void { - $page = $this->temporaryPage('status', ['published' => false]); + $page = $this->temporaryPage('about'); + $page->set('published', false); $this->assertSame(Page::PAGE_STATUS_NOT_PUBLISHED, $page->status()); - $page = $this->temporaryPage('future', [ - 'published' => true, - 'publishDate' => "'" . date('Y-m-d', time() + 86400) . "'", - ]); + $page = $this->temporaryPage('about'); + $page->set('published', true); + $page->set('publishDate', '2099-01-01'); $this->assertFalse($page->isPublished()); - $page = $this->temporaryPage('expired', [ - 'published' => true, - 'unpublishDate' => "'" . date('Y-m-d', time() - 86400) . "'", - ]); + $page = $this->temporaryPage('about'); + $page->set('published', true); + $page->set('unpublishDate', '2000-01-01'); $this->assertFalse($page->isPublished()); } public function testMetadataTaxonomyAndResponseStatusAreNormalized(): void { - $page = $this->temporaryPage('data'); + $page = $this->temporaryPage('about'); $page->set('metadata', ['description' => 'A description']); $page->set('taxonomy', ['tags' => ['php', 'cms']]); @@ -184,7 +181,7 @@ public function testMetadataTaxonomyAndResponseStatusAreNormalized(): void public function testCanonicalRouteIsNormalizedAndNullWhenEmpty(): void { - $page = $this->temporaryPage('canonical'); + $page = $this->temporaryPage('about'); $this->assertNull($page->canonicalRoute()); $page->set('canonicalRoute', 'docs//page/'); @@ -195,7 +192,7 @@ public function testCanonicalRouteIsNormalizedAndNullWhenEmpty(): void public function testInvalidSlugTemplateAndParentAreRejected(): void { - $page = $this->temporaryPage('validation'); + $page = $this->temporaryPage('about'); $this->expectException(InvalidValueException::class); $page->set('slug', 'not a valid slug'); @@ -203,7 +200,7 @@ public function testInvalidSlugTemplateAndParentAreRejected(): void public function testInvalidTemplateIsRejected(): void { - $page = $this->temporaryPage('template-validation'); + $page = $this->temporaryPage('about'); $this->expectException(InvalidValueException::class); $page->set('template', 'does-not-exist'); @@ -213,7 +210,7 @@ public function testParentCanBeResolvedByRouteAndInvalidParentIsRejected(): void { $site = $this->temporarySite(); $parent = $this->temporaryPage('parent', site: $site); - $child = $this->temporaryPage('child', site: $site); + $child = $this->temporaryPage('about', site: $site); $child->set('parent', '.'); $this->assertSame($site, $child->parent()); @@ -227,7 +224,7 @@ public function testParentCanBeResolvedByRouteAndInvalidParentIsRejected(): void #[DataProvider('invalidTaxonomyProvider')] public function testRejectsInvalidTaxonomy(mixed $taxonomy, string $exception): void { - $page = $this->temporaryPage('invalid-taxonomy'); + $page = $this->temporaryPage('about'); $this->expectException($exception); $page->set('taxonomy', $taxonomy); @@ -239,12 +236,12 @@ public function testTraversalMethodsExposeThePageTree(): void $blog = $this->fixturePage('/blog'); $index = $this->fixturePage('/'); - $this->assertSame($this->app->site(), $about->parent()); - $this->assertTrue($about->isChildOf($this->app->site())); + $this->assertSame($this->fixtureSite, $about->parent()); + $this->assertTrue($about->isChildOf($this->fixtureSite)); $this->assertTrue($about->isSiblingOf($blog)); $this->assertTrue($about->isSiblingOf($index)); $this->assertTrue($about->hasAncestors()); - $this->assertFalse($about->isAncestorOf($this->app->site())); + $this->assertFalse($about->isAncestorOf($this->fixtureSite)); $this->assertSame(0, $about->index()); $this->assertSame(1, $about->level()); $this->assertSame($blog, $about->nextSibling()); @@ -253,7 +250,7 @@ public function testTraversalMethodsExposeThePageTree(): void $this->assertTrue($about->inclusiveSiblings()->contains($about)); } - public function testSiblingPredicateIsSymmetricAndExposesCurrentRegression(): void + public function testSiblingPredicateIsSymmetric(): void { $about = $this->fixturePage('/about'); $blog = $this->fixturePage('/blog'); @@ -266,9 +263,9 @@ public function testSiblingPredicateIsSymmetricAndExposesCurrentRegression(): vo public function testIsEmptyIsTrueOnlyWhenThereIsNoFrontmatter(): void { - $empty = $this->temporaryPage('empty', frontmatter: []); - $nonEmpty = $this->temporaryPage('non-empty', ['title' => 'Not empty']); - $withoutContentFile = $this->temporaryPage('directory-only', createContentFile: false); + $empty = $this->temporaryPage('empty'); + $nonEmpty = $this->temporaryPage('about'); + $withoutContentFile = $this->temporaryPage('directory-only'); $this->assertTrue($empty->isEmpty()); $this->assertFalse($nonEmpty->isEmpty()); @@ -295,7 +292,7 @@ public function testUriUsesCanonicalRouteAndAbsoluteRequestUri(): void public function testSaveMovesAndReloadsThePageFromDisk(): void { - $page = $this->temporaryPage('editable', ['title' => 'Before'], content: 'Before'); + $page = $this->temporaryPage('about'); $page->set('slug', 'renamed'); $page->set('title', 'After'); @@ -311,7 +308,7 @@ public function testSaveMovesAndReloadsThePageFromDisk(): void public function testSaveOmitsRuntimeFieldsFromFrontmatter(): void { - $page = $this->temporaryPage('frontmatter', ['title' => 'Before']); + $page = $this->temporaryPage('about'); $page->set('title', 'After'); $page->set('slug', 'renamed'); $page->set('template', 'page'); @@ -338,7 +335,7 @@ public function testSaveRejectsAPageWithoutAParent(): void public function testReloadRejectsAnUnloadedPage(): void { - $page = $this->temporaryPage('reloadable'); + $page = $this->temporaryPage('about'); $page->reload(); $this->assertTrue($page->hasLoaded()); @@ -352,7 +349,7 @@ public function testReloadRejectsAnUnloadedPage(): void public function testDuplicateCreatesAnIndependentCopyAndDeleteRemovesIt(): void { - $page = $this->temporaryPage('original', ['title' => 'Original']); + $page = $this->temporaryPage('original'); $duplicate = $page->duplicate(['title' => 'Copy']); $this->assertNotSame($page, $duplicate); @@ -368,7 +365,7 @@ public function testDuplicateCreatesAnIndependentCopyAndDeleteRemovesIt(): void public function testDuplicateGeneratesTheNextAvailableCopySlug(): void { - $page = $this->temporaryPage('original', ['title' => 'Original']); + $page = $this->temporaryPage('original'); $first = $page->duplicate(); $second = $page->duplicate(); @@ -379,10 +376,7 @@ public function testDuplicateGeneratesTheNextAvailableCopySlug(): void public function testDeleteCanRemoveOnlyTheCurrentLanguageOrAllLanguages(): void { $site = $this->temporarySite(); - $path = rtrim((string) $site->contentPath(), '/') . '/localized/'; - mkdir($path, 0o777, true); - file_put_contents($path . 'page.en.md', "---\ntitle: English\n---\nEnglish content"); - file_put_contents($path . 'page.it.md', "---\ntitle: Italiano\n---\nContenuto italiano"); + $path = FileSystem::joinPaths((string) $site->contentPath(), 'localized') . '/'; $page = new Page(['site' => $site, 'path' => $path, 'language' => 'it'], $this->app); $page->delete(); @@ -407,12 +401,7 @@ public function testPageWithChildrenCannotBeDuplicatedOrDeleted(): void public function testFilesAndMediaCollectionsAreConsistent(): void { - $page = $this->temporaryPage('assets'); - $directory = (string) $page->contentPath(); - copy(ROOT_PATH . '/site/pages/index/formwork.png', $directory . '/photo.png'); - copy(ROOT_PATH . '/site/files/friday.mp4', $directory . '/clip.mp4'); - $this->writeSilentWav($directory . '/sound.mp3'); - + $page = $this->temporaryPage('about'); $config = $this->app->config(); $pagesPath = $config->getString('system.pages.path'); $config->set('system.pages.path', TESTS_TMP_PATH); @@ -443,7 +432,7 @@ public function testIndexAndErrorPagesAreProtected(): void public function testContentAndFilePredicatesRemainCoherentAfterReload(): void { - $page = $this->temporaryPage('predicates'); + $page = $this->temporaryPage('about'); $this->assertTrue($page->hasContentFile()); $this->assertFalse($page->isSite()); @@ -474,7 +463,7 @@ public function testLifecycleEventsAreDispatchedWithTheExpectedNames(): void }); } - $page = $this->temporaryPage('events'); + $page = $this->temporaryPage('about'); $page->save(); $duplicate = $page->duplicate(); $duplicate->delete(); @@ -499,48 +488,34 @@ public function testRenderDispatchesPageRenderEventAndReturnsMarkup(): void $this->assertStringContainsString('About', $output); } + public static function invalidTaxonomyProvider(): iterable + { + yield 'scalar' => ['not-an-array', \TypeError::class]; + yield 'non-string taxonomy name' => [[1 => ['term']], InvalidValueException::class]; + yield 'non-list terms' => [['tags' => 'php'], InvalidValueException::class]; + yield 'non-string term' => [['tags' => [1]], InvalidValueException::class]; + } + private function fixturePage(string $route): Page { - $page = $this->app->site()->findPage($route); + $page = $this->fixtureSite->findPage($route); return $page ?? $this->fail(sprintf('Fixture page %s was not found', $route)); } - /** - * @param array $frontmatter - */ - private function temporaryPage( - string $relativePath, - array $frontmatter = ['title' => 'Temporary page'], - string $content = 'Temporary content', - bool $createContentFile = true, - ?Site $site = null, - ): Page { + private function temporaryPage(string $relativePath, ?Site $site = null): Page + { $site ??= $this->temporarySite(); - $path = rtrim((string) $site->contentPath(), '/') . '/' . trim($relativePath, '/') . '/'; - if (!is_dir($path)) { - mkdir($path, 0o777, true); - } - - if ($createContentFile) { - $yaml = "---\n"; - foreach ($frontmatter as $key => $value) { - $yaml .= sprintf("%s: %s\n", $key, is_bool($value) ? ($value ? 'true' : 'false') : $value); - } - file_put_contents($path . 'page.md', $yaml . "---\n" . $content); - } + $path = FileSystem::joinPaths((string) $site->contentPath(), trim($relativePath, '/')) . '/'; + FileSystem::assertExists($path); return new Page(['site' => $site, 'path' => $path], $this->app); } - private function temporarySite(): Site + private function temporarySite(string $fixturePath = __DIR__ . '/fixtures/site'): Site { - $path = TESTS_TMP_PATH . '/pages-' . ++$this->temporarySiteCounter; - mkdir($path, 0o777, true); - mkdir($path . '/index', 0o777, true); - file_put_contents($path . '/index/index.md', "---\ntitle: Temporary index\n---\n"); - mkdir($path . '/error', 0o777, true); - file_put_contents($path . '/error/error.md', "---\ntitle: Temporary error\n---\n"); + $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'pages-' . ++$this->temporarySiteCounter); + FileSystem::copyDirectory($fixturePath, $path); return new Site( ['path' => TESTS_TMP_PATH, 'contentPath' => $path, 'metadata' => []], @@ -549,24 +524,4 @@ private function temporarySite(): Site $this->app->getService(PageCollectionFactory::class), ); } - - private function writeSilentWav(string $path): void - { - $sampleRate = 8000; - $channels = 1; - $bitsPerSample = 16; - $data = str_repeat("\0", (int) ($sampleRate * $channels * ($bitsPerSample / 8) / 10)); - $header = 'RIFF' . pack('V', 36 + strlen($data)) . 'WAVE'; - $header .= 'fmt ' . pack('VvvVVvv', 16, 1, $channels, $sampleRate, $sampleRate * $channels * 2, 2, $bitsPerSample); - $header .= 'data' . pack('V', strlen($data)); - file_put_contents($path, $header . $data); - } - - public static function invalidTaxonomyProvider(): iterable - { - yield 'scalar' => ['not-an-array', \TypeError::class]; - yield 'non-string taxonomy name' => [[1 => ['term']], InvalidValueException::class]; - yield 'non-list terms' => [['tags' => 'php'], InvalidValueException::class]; - yield 'non-string term' => [['tags' => [1]], InvalidValueException::class]; - } } diff --git a/tests/Unit/Pages/PaginationTest.php b/tests/Unit/Pages/PaginationTest.php new file mode 100644 index 000000000..46a9dbbf7 --- /dev/null +++ b/tests/Unit/Pages/PaginationTest.php @@ -0,0 +1,80 @@ +setUpTempDirectory(); + $this->app = App::instance(); + $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'pagination-site'); + FileSystem::copyDirectory(__DIR__ . '/fixtures/site', $path); + $this->fixtureSite = new Site( + ['path' => TESTS_TMP_PATH, 'contentPath' => $path, 'metadata' => []], + $this->app->config(), + $this->app->getService(PageFactory::class), + $this->app->getService(PageCollectionFactory::class), + ); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testPaginationRoutesAndUrisAreGeneratedForAllNavigationLinks(): void + { + $pages = $this->fixtureSite->pages(); + $router = $this->createStub(Router::class); + $base = new Route('page', '/{page:all}/'); + $pagination = new Route('page.pagination', '/{page:all}/page/{paginationPage:number}/'); + $routes = new RouteCollection([$base->getName() => $base, $pagination->getName() => $pagination]); + + $router->method('current')->willReturn($pagination); + $router->method('routes')->willReturn($routes); + $router->method('generateWith')->willReturnCallback(static function (string $name, array $parameters): string { + return $name === 'page' ? '/about/' : '/about/page/' . $parameters['paginationPage'] . '/'; + }); + + $result = new Pagination($pages, 1, $this->fixtureSite, $router); + $this->assertSame('/about/', $result->route(1)); + $this->assertSame('/about/page/2/', $result->route(2)); + $this->assertStringEndsWith('/about/page/2/', $result->uri(2)); + $this->assertSame($result->route(1), $result->firstPageRoute()); + $this->assertSame($result->route($result->lastPage()), $result->lastPageRoute()); + $this->assertSame($result->route($result->previousPage()), $result->previousPageRoute()); + $this->assertSame($result->route($result->nextPage()), $result->nextPageRoute()); + } + + public function testRouteRejectsPageNumbersOutsideThePagination(): void + { + $router = $this->createStub(Router::class); + $router->method('current')->willReturn(new Route('page', '/')); + + $pagination = new Pagination($this->fixtureSite->pages(), 100, $this->fixtureSite, $router); + + $this->expectException(UnexpectedValueException::class); + $pagination->route(2); + } +} diff --git a/tests/Unit/Pages/fixtures/numbered-site/1-numbered/page.md b/tests/Unit/Pages/fixtures/numbered-site/1-numbered/page.md new file mode 100644 index 000000000..800276dd7 --- /dev/null +++ b/tests/Unit/Pages/fixtures/numbered-site/1-numbered/page.md @@ -0,0 +1,4 @@ +--- +title: Numbered +--- +This page has a numeric directory prefix. diff --git a/tests/Unit/Pages/fixtures/site/about/audio.mp3 b/tests/Unit/Pages/fixtures/site/about/audio.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3b3909e2d0c4e50e75bff8ba0a5b329f4e5c91e1 GIT binary patch literal 417 RcmezWdqN7sC>XpU006{90`C9- literal 0 HcmV?d00001 diff --git a/tests/Unit/Pages/fixtures/site/about/image.svg b/tests/Unit/Pages/fixtures/site/about/image.svg new file mode 100644 index 000000000..8c4e47a3e --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/about/image.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/Unit/Pages/fixtures/site/about/page.md b/tests/Unit/Pages/fixtures/site/about/page.md new file mode 100644 index 000000000..fab96ffa5 --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/about/page.md @@ -0,0 +1,4 @@ +--- +title: About +--- +This page introduces the project and explains where to find the main sections of the site. diff --git a/tests/Unit/Pages/fixtures/site/about/video.mp4 b/tests/Unit/Pages/fixtures/site/about/video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..4092bf84d9557db006f2b40fc352b1aa7105fff0 GIT binary patch literal 28 fcmZQzV30^FsVvAW&d+6FU}6B#Kx~v-U}6XWX^;pF literal 0 HcmV?d00001 diff --git a/tests/Unit/Pages/fixtures/site/blog/blog.md b/tests/Unit/Pages/fixtures/site/blog/blog.md new file mode 100644 index 000000000..6d94ff81a --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/blog/blog.md @@ -0,0 +1,4 @@ +--- +title: Blog +--- +Notes, experiments, and short updates from the team. diff --git a/tests/Unit/Pages/fixtures/site/blog/hello-world/page.md b/tests/Unit/Pages/fixtures/site/blog/hello-world/page.md new file mode 100644 index 000000000..b8a4bed76 --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/blog/hello-world/page.md @@ -0,0 +1,4 @@ +--- +title: Hello World +--- +The first post shares a few ideas for building a small, readable website. diff --git a/tests/Unit/Pages/fixtures/site/directory-only/.gitkeep b/tests/Unit/Pages/fixtures/site/directory-only/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Unit/Pages/fixtures/site/empty/page.md b/tests/Unit/Pages/fixtures/site/empty/page.md new file mode 100644 index 000000000..b12544270 --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/empty/page.md @@ -0,0 +1,3 @@ +--- +--- +Empty content diff --git a/tests/Unit/Pages/fixtures/site/error/error.md b/tests/Unit/Pages/fixtures/site/error/error.md new file mode 100644 index 000000000..5643e3942 --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/error/error.md @@ -0,0 +1,4 @@ +--- +title: Temporary error +--- + diff --git a/tests/Unit/Pages/fixtures/site/index/index.md b/tests/Unit/Pages/fixtures/site/index/index.md new file mode 100644 index 000000000..c04495dc9 --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/index/index.md @@ -0,0 +1,4 @@ +--- +title: Temporary index +--- + diff --git a/tests/Unit/Pages/fixtures/site/localized/page.en.md b/tests/Unit/Pages/fixtures/site/localized/page.en.md new file mode 100644 index 000000000..cb3102e93 --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/localized/page.en.md @@ -0,0 +1,4 @@ +--- +title: English +--- +This page explains how the site is organized and where to find its main sections. diff --git a/tests/Unit/Pages/fixtures/site/localized/page.it.md b/tests/Unit/Pages/fixtures/site/localized/page.it.md new file mode 100644 index 000000000..41c9e9566 --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/localized/page.it.md @@ -0,0 +1,4 @@ +--- +title: Italiano +--- +Questa pagina spiega come è organizzato il sito e dove trovare le sezioni principali. diff --git a/tests/Unit/Pages/fixtures/site/original/page.md b/tests/Unit/Pages/fixtures/site/original/page.md new file mode 100644 index 000000000..9828406e3 --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/original/page.md @@ -0,0 +1,4 @@ +--- +title: Original +--- +This is the original draft that can be duplicated during the test. diff --git a/tests/Unit/Pages/fixtures/site/parent/child/page.md b/tests/Unit/Pages/fixtures/site/parent/child/page.md new file mode 100644 index 000000000..cd09a7a26 --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/parent/child/page.md @@ -0,0 +1,4 @@ +--- +title: Child +--- +This page is a short entry nested inside the parent section. diff --git a/tests/Unit/Pages/fixtures/site/parent/page.md b/tests/Unit/Pages/fixtures/site/parent/page.md new file mode 100644 index 000000000..015247e63 --- /dev/null +++ b/tests/Unit/Pages/fixtures/site/parent/page.md @@ -0,0 +1,4 @@ +--- +title: Parent +--- +This section groups related pages under one shared topic. From 6a8345ece30a90ab652c99b2247ac1a9b61d222f Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:11:09 +0200 Subject: [PATCH 12/51] Update tests --- tests/Unit/Config/ConfigTest.php | 280 +++++++ tests/Unit/Data/CollectionTest.php | 120 +++ tests/Unit/Fields/FieldTest.php | 86 ++ tests/Unit/Files/FileCollectionTest.php | 130 +++ tests/Unit/Files/FileFactoryTest.php | 116 +++ tests/Unit/Files/FileTest.php | 197 +++++ tests/Unit/Files/FileUriGeneratorTest.php | 97 +++ .../Unit/Files/Services/FileUploaderTest.php | 137 ++++ tests/Unit/Forms/FormTest.php | 93 ++- tests/Unit/Http/FileResponseTest.php | 128 ++- tests/Unit/Http/ResponseTest.php | 82 +- tests/Unit/Pages/ContentFileTest.php | 3 +- tests/Unit/Pages/PageCollectionTest.php | 84 +- tests/Unit/Pages/PageEventsTest.php | 104 +++ tests/Unit/Pages/PageTest.php | 124 +++ tests/Unit/Parsers/JsonTest.php | 22 + tests/Unit/Parsers/PhpTest.php | 27 + tests/Unit/Parsers/YamlTest.php | 19 + tests/Unit/Router/CompiledRouteTest.php | 20 + .../Events/RouteActionResolvedEventTest.php | 35 + tests/Unit/Router/Fixtures/routes.php | 22 + tests/Unit/Router/RouteCollectionTest.php | 48 ++ .../Unit/Router/RouteFilterCollectionTest.php | 44 ++ tests/Unit/Router/RouteFilterTest.php | 54 ++ tests/Unit/Router/RouteParamsTest.php | 32 + tests/Unit/Router/RouteTest.php | 243 ++++++ tests/Unit/Router/RouterTest.php | 340 ++++++++ tests/Unit/Services/ContainerTest.php | 737 ++++++++++++++++++ tests/Unit/Services/ServiceDefinitionTest.php | 94 +++ tests/Unit/Utils/ArrTest.php | 115 +++ 30 files changed, 3609 insertions(+), 24 deletions(-) create mode 100644 tests/Unit/Config/ConfigTest.php create mode 100644 tests/Unit/Fields/FieldTest.php create mode 100644 tests/Unit/Files/FileCollectionTest.php create mode 100644 tests/Unit/Files/FileFactoryTest.php create mode 100644 tests/Unit/Files/FileTest.php create mode 100644 tests/Unit/Files/FileUriGeneratorTest.php create mode 100644 tests/Unit/Files/Services/FileUploaderTest.php create mode 100644 tests/Unit/Router/CompiledRouteTest.php create mode 100644 tests/Unit/Router/Events/RouteActionResolvedEventTest.php create mode 100644 tests/Unit/Router/Fixtures/routes.php create mode 100644 tests/Unit/Router/RouteCollectionTest.php create mode 100644 tests/Unit/Router/RouteFilterCollectionTest.php create mode 100644 tests/Unit/Router/RouteFilterTest.php create mode 100644 tests/Unit/Router/RouteParamsTest.php create mode 100644 tests/Unit/Router/RouteTest.php create mode 100644 tests/Unit/Router/RouterTest.php create mode 100644 tests/Unit/Services/ContainerTest.php create mode 100644 tests/Unit/Services/ServiceDefinitionTest.php diff --git a/tests/Unit/Config/ConfigTest.php b/tests/Unit/Config/ConfigTest.php new file mode 100644 index 000000000..5f53217ea --- /dev/null +++ b/tests/Unit/Config/ConfigTest.php @@ -0,0 +1,280 @@ +setUpTempDirectory(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + } + + public function testEmptyConfigIsMutableButUnresolved(): void + { + $config = new Config(); + + $this->assertFalse($config->has('missing')); + $this->assertTrue($config->hasMultiple([])); + + $this->expectException(UnresolvedConfigException::class); + $config->toArray(); + } + + public function testHasAndHasMultipleDistinguishPresentNullFromMissingKeys(): void + { + $config = new Config(['present' => null, 'nested' => ['value' => false]], resolved: true); + + $this->assertTrue($config->has('present')); + $this->assertTrue($config->has('nested.value')); + $this->assertFalse($config->has('nested.missing')); + $this->assertTrue($config->hasMultiple(['present', 'nested.value'])); + $this->assertFalse($config->hasMultiple(['present', 'nested.missing'])); + } + + public function testGetSupportsNestedPathsDefaultsAndExplicitNullValues(): void + { + $config = new Config([ + 'site' => [ + 'name' => 'Formwork', + 'nullable' => null, + ], + ], resolved: true); + + $this->assertSame('Formwork', $config->get('site.name')); + $this->assertNull($config->get('site.nullable', 'fallback')); + $this->assertSame('fallback', $config->get('site.missing', 'fallback')); + $this->assertSame('fallback', $config->get('missing', 'fallback')); + } + + public function testGetMultipleReturnsRequestedKeysWithTheSharedDefault(): void + { + $config = new Config(['site' => ['name' => 'Formwork']], resolved: true); + + $this->assertSame([ + 'site.name' => 'Formwork', + 'site.missing' => null, + ], $config->getMultiple(['site.name', 'site.missing'])); + $this->assertSame([ + 'site.name' => 'Formwork', + 'site.missing' => 'fallback', + ], $config->getMultiple(['site.name', 'site.missing'], 'fallback')); + } + + #[DataProvider('typedGetterProvider')] + public function testTypedGettersReturnTheirDeclaredTypes(string $getter, string $key, mixed $value): void + { + $config = new Config([$key => $value], resolved: true); + + $this->assertSame($value, $config->{$getter}($key)); + } + + public static function typedGetterProvider(): iterable + { + yield 'string' => ['getString', 'name', 'Formwork']; + yield 'boolean' => ['getBool', 'enabled', true]; + yield 'integer' => ['getInt', 'attempts', 3]; + yield 'float' => ['getFloat', 'ratio', 0.5]; + yield 'array' => ['getArray', 'paths', ['site' => '/site']]; + } + + #[DataProvider('invalidTypedGetterProvider')] + public function testTypedGettersRejectValuesOfTheWrongType(string $getter, string $key, mixed $value, string $type): void + { + $config = new Config([$key => $value], resolved: true); + + $this->expectException(UnexpectedValueException::class); + $article = in_array($type, ['integer', 'array'], true) ? 'an' : 'a'; + $this->expectExceptionMessage(sprintf('Config value for key "%s" is not %s %s', $key, $article, $type)); + $config->{$getter}($key); + } + + public static function invalidTypedGetterProvider(): iterable + { + yield 'string rejects null' => ['getString', 'name', null, 'string']; + yield 'boolean rejects integer' => ['getBool', 'enabled', 1, 'boolean']; + yield 'integer rejects numeric string' => ['getInt', 'attempts', '3', 'integer']; + yield 'float rejects integer' => ['getFloat', 'ratio', 1, 'float']; + yield 'array rejects string' => ['getArray', 'paths', '/site', 'array']; + } + + public function testTypedGettersAcceptTypedDefaultsForMissingValues(): void + { + $config = new Config(resolved: true); + + $this->assertSame('default', $config->getString('name', 'default')); + $this->assertFalse($config->getBool('enabled', false)); + $this->assertSame(7, $config->getInt('attempts', 7)); + $this->assertSame(0.25, $config->getFloat('ratio', 0.25)); + $this->assertSame(['site'], $config->getArray('paths', ['site'])); + } + + public function testSetAndSetMultipleCreateAndOverrideNestedValues(): void + { + $config = new Config(['system' => ['debug' => false]], resolved: true); + + $config->set('system.debug', true); + $config->set('system.paths.cache', '/cache'); + $config->setMultiple(['system.name' => 'Formwork', 'nullable' => null]); + + $this->assertTrue($config->getBool('system.debug')); + $this->assertSame('/cache', $config->getString('system.paths.cache')); + $this->assertSame('Formwork', $config->getString('system.name')); + $this->assertTrue($config->has('nullable')); + $this->assertNull($config->get('nullable')); + } + + public function testToArrayReturnsTheResolvedNestedConfiguration(): void + { + $data = ['system' => ['paths' => ['site' => '/site']], 'enabled' => true]; + $config = new Config($data, resolved: true); + + $this->assertSame($data, $config->toArray()); + } + + public function testGetAndToArrayRejectReadsBeforeResolution(): void + { + $config = new Config(['name' => 'Formwork']); + + $this->expectException(UnresolvedConfigException::class); + $config->get('name'); + } + + public function testResolveSubstitutesVariablesAndReferencesInNestedValues(): void + { + $config = new Config([ + 'paths' => [ + 'root' => '${%ROOT_PATH%}', + 'site' => '${paths.root}/site', + 'cache' => '${CACHE_PATH}/cache', + ], + ]); + + $config->resolve([ + '%ROOT_PATH%' => '/project', + 'CACHE_PATH' => '/tmp', + ]); + + $this->assertTrue($config->has('paths.site')); + $this->assertSame('/project', $config->get('paths.root')); + $this->assertSame('/project/site', $config->get('paths.site')); + $this->assertSame('/tmp/cache', $config->get('paths.cache')); + $this->assertSame('/project/site', $config->toArray()['paths']['site']); + } + + public function testResolveRejectsAnUndefinedReference(): void + { + $config = new Config(['path' => '${missing.path}']); + + $this->expectException(ConfigResolutionException::class); + $this->expectExceptionMessage('undefined key or variable "missing.path"'); + $config->resolve(); + } + + public function testResolveRejectsAReferenceToANonStringValue(): void + { + $config = new Config(['port' => 8080, 'url' => 'http://localhost:${port}']); + + $this->expectException(ConfigResolutionException::class); + $this->expectExceptionMessage('non-string "port"'); + $config->resolve(); + } + + public function testLoadFileLoadsYamlIntoACamelCaseKey(): void + { + $path = TESTS_TMP_PATH . '/site-name.yaml'; + FileSystem::write($path, "name: Formwork\noptions:\n enabled: true\n"); + $config = new Config(resolved: true); + + $config->loadFile($path); + + $this->assertSame('Formwork', $config->getString('siteName.name')); + $this->assertTrue($config->getBool('siteName.options.enabled')); + } + + public function testLoadFileLoadsPhpAndMergesWithExistingConfiguration(): void + { + $path = TESTS_TMP_PATH . '/settings.php'; + FileSystem::write($path, " true, 'nested' => ['loaded' => 'yes']];\n"); + $config = new Config([ + 'settings' => [ + 'existing' => 'kept', + 'nested' => ['original' => 'kept'], + ], + ], resolved: true); + + $config->loadFile($path); + + $this->assertTrue($config->getBool('settings.enabled')); + $this->assertSame('kept', $config->getString('settings.existing')); + $this->assertSame('kept', $config->getString('settings.nested.original')); + $this->assertSame('yes', $config->getString('settings.nested.loaded')); + } + + public function testLoadFileSupportsAPrefixForPluginConfiguration(): void + { + $path = TESTS_TMP_PATH . '/example.yaml'; + FileSystem::write($path, "enabled: true\n"); + $config = new Config(resolved: true); + + $config->loadFile($path, 'plugins'); + + $this->assertTrue($config->getBool('plugins.example.enabled')); + } + + public function testLoadFromPathLoadsEachSupportedConfigurationFile(): void + { + FileSystem::write(TESTS_TMP_PATH . '/first.yaml', "name: First\n"); + FileSystem::write(TESTS_TMP_PATH . '/second.php', " false];\n"); + $config = new Config(resolved: true); + + $config->loadFromPath(TESTS_TMP_PATH); + + $this->assertSame('First', $config->getString('first.name')); + $this->assertFalse($config->getBool('second.enabled')); + } + + public function testLoadFileRejectsMissingFiles(): void + { + $path = TESTS_TMP_PATH . '/missing.yaml'; + + $this->expectException(ConfigLoadingException::class); + $this->expectExceptionMessage(sprintf('Config file "%s" does not exist', $path)); + (new Config())->loadFile($path); + } + + public function testLoadFileRejectsUnsupportedExtensions(): void + { + $path = TESTS_TMP_PATH . '/settings.json'; + FileSystem::write($path, '{}'); + + $this->expectException(ConfigLoadingException::class); + $this->expectExceptionMessage('Unsupported config file type "json"'); + (new Config())->loadFile($path); + } + + public function testFromArrayReconstructsAResolvedConfig(): void + { + $config = Config::fromArray([ + 'config' => ['system' => ['enabled' => true]], + ]); + + $this->assertTrue($config->getBool('system.enabled')); + $this->assertSame(['system' => ['enabled' => true]], $config->toArray()); + } +} diff --git a/tests/Unit/Data/CollectionTest.php b/tests/Unit/Data/CollectionTest.php index 87f07e7b9..094f5033e 100644 --- a/tests/Unit/Data/CollectionTest.php +++ b/tests/Unit/Data/CollectionTest.php @@ -1011,4 +1011,124 @@ public function testMergeThrowsOnTypeMismatch(): void $this->expectExceptionMessage('Collections with data of different types cannot be merged'); $collection1->merge($collection2); } + + public function testImmutableCollectionRejectsEveryMutatingOperation(): void + { + $collection = Collection::from(['a', 'b'], mutable: false); + + foreach ( + [ + static fn() => $collection->add('c'), + static fn() => $collection->pull('a'), + static fn() => $collection->moveItem(0, 1), + ] as $operation + ) { + try { + $operation(); + $this->fail('An immutable collection was mutated.'); + } catch (LogicException) { + // Expected + } + } + + $this->assertSame(['a', 'b'], $collection->values()); + } + + public function testMoveItemPreservesCollectionValuesAndOnlyChangesOrder(): void + { + $collection = Collection::from(['a', 'b', 'c'], mutable: true); + + $collection->moveItem(0, 2); + + $this->assertSame(['b', 'c', 'a'], $collection->values()); + } + + public function testFailedMutationLeavesTheCollectionUnchanged(): void + { + $collection = Collection::of('string', ['first'], mutable: true); + $before = $collection->toArray(); + + try { + $collection->add(123); + $this->fail('The invalid value should have been rejected.'); + } catch (LogicException) { + // Expected + } + + $this->assertSame($before, $collection->toArray()); + } + + public function testNonMutatingTransformationsDoNotChangeTheSource(): void + { + $source = Collection::from([3, 1, 2]); + $original = $source->toArray(); + + $source->reverse(); + $source->shuffle(); + $source->unique(); + $source->duplicates(); + $source->slice(1); + $source->limit(2); + $source->map(fn(int $value): int => $value * 2); + $source->filter(fn(int $value): bool => $value > 1); + $source->reject(fn(int $value): bool => $value > 1); + $source->sort(); + $source->flatten(); + $source->with(4); + $source->without(1); + + $this->assertSame($original, $source->toArray()); + } + + public function testClonePreservesCollectionConfiguration(): void + { + $collection = Collection::of('string', ['a' => 'A', 'b' => 'B'], associative: true, mutable: true); + $clone = $collection->clone(); + + $this->assertNotSame($collection, $clone); + $this->assertSame($collection->toArray(), $clone->toArray()); + $this->assertSame($collection->isAssociative(), $clone->isAssociative()); + $this->assertSame($collection->isMutable(), $clone->isMutable()); + $this->assertSame($collection->dataType(), $clone->dataType()); + } + + public function testMutabilityConversionsPreserveDataAndDoNotAliasCollections(): void + { + $immutable = Collection::from(['a', 'b']); + $mutable = $immutable->toMutable(); + $roundTrip = $mutable->toImmutable(); + + $this->assertFalse($immutable->isMutable()); + $this->assertTrue($mutable->isMutable()); + $this->assertFalse($roundTrip->isMutable()); + $this->assertSame($immutable->toArray(), $mutable->toArray()); + $this->assertSame($immutable->toArray(), $roundTrip->toArray()); + + $mutable->add('c'); + $this->assertSame(['a', 'b'], $immutable->toArray()); + $this->assertSame(['a', 'b'], $roundTrip->toArray()); + } + + public function testReversingTwiceRestoresTheOriginalOrder(): void + { + $collection = Collection::from(['a', 'b', 'c']); + + $this->assertSame($collection->toArray(), $collection->reverse()->reverse()->toArray()); + } + + public function testDeepCloneBreaksObjectIdentityWhileCloneDoesNot(): void + { + $item = new \stdClass(); + $item->value = 'original'; + $collection = Collection::from([$item]); + + $shallow = $collection->clone(); + $deep = $collection->deepClone(); + + $this->assertSame($item, $shallow->first()); + $this->assertNotSame($item, $deep->first()); + + $deep->first()->value = 'changed'; + $this->assertSame('original', $item->value); + } } diff --git a/tests/Unit/Fields/FieldTest.php b/tests/Unit/Fields/FieldTest.php new file mode 100644 index 000000000..d1d51e69e --- /dev/null +++ b/tests/Unit/Fields/FieldTest.php @@ -0,0 +1,86 @@ + true, 'value' => 'Alice']); + $field->validate(); + $this->assertTrue($field->isValidated()); + + $field->set('value', 'Bob'); + + $this->assertFalse($field->isValidated()); + $this->assertTrue($field->isValid()); + } + + public function testRemovingAValueInvalidatesPreviousValidation(): void + { + $field = new Field('name', ['type' => 'text', 'required' => true, 'value' => 'Alice']); + $field->validate(); + $field->remove('value'); + + $this->assertFalse($field->isValidated()); + $this->assertFalse($field->isValid()); + } + + public function testRequiredFieldCanTransitionFromValidToInvalidAndBackToValid(): void + { + $field = new Field('name', ['type' => 'text', 'required' => true, 'value' => 'Alice']); + + $this->assertTrue($field->isValid()); + $field->set('value', ''); + $this->assertFalse($field->isValid()); + $field->set('value', 'Bob'); + $this->assertTrue($field->isValid()); + $this->assertNull($field->getValidationError()); + } + + public function testValidationMethodCanNormalizeTheValue(): void + { + $field = new Field('name', ['type' => 'text', 'value' => ' Alice ']); + $field->setMethods([ + 'validate' => static fn(Field $field, string $value): string => trim($value), + ]); + + $field->validate(); + + $this->assertSame('Alice', $field->value()); + $this->assertTrue($field->isValidated()); + } + + public function testValidationExceptionStillMarksTheFieldAsValidated(): void + { + $field = new Field('name', ['type' => 'text', 'required' => true]); + + try { + $field->validate(); + $this->fail('The required field should have failed validation.'); + } catch (ValidationException) { + // Expected. + } + + $this->assertTrue($field->isValidated()); + $this->assertFalse($field->isValid()); + } + + public function testChangingAValueAfterValidationClearsTheValidationState(): void + { + $field = new Field('name', ['type' => 'text', 'required' => true, 'value' => 'Alice']); + $field->validate(); + $this->assertTrue($field->isValidated()); + + $field->set('value', ''); + + $this->assertFalse($field->isValidated()); + $this->assertFalse($field->isValid()); + } +} diff --git a/tests/Unit/Files/FileCollectionTest.php b/tests/Unit/Files/FileCollectionTest.php new file mode 100644 index 000000000..83f9efb2b --- /dev/null +++ b/tests/Unit/Files/FileCollectionTest.php @@ -0,0 +1,130 @@ +setUpTempDirectory(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testFileCollectionIsTypedAssociativeAndImmutable(): void + { + $path = TESTS_TMP_PATH . '/first.txt'; + FileSystem::write($path, '1'); + $collection = new FileCollection([new File($path)]); + + $this->assertInstanceOf(AbstractCollection::class, $collection); + $this->assertTrue($collection->isAssociative()); + $this->assertSame(File::class, $collection->dataType()); + $this->assertFalse($collection->isMutable()); + } + + public function testListInputIsIndexedByFileName(): void + { + $firstPath = TESTS_TMP_PATH . '/first.txt'; + $secondPath = TESTS_TMP_PATH . '/second.txt'; + FileSystem::write($firstPath, '1'); + FileSystem::write($secondPath, '2'); + $first = new File($firstPath); + $second = new File($secondPath); + + $collection = new FileCollection([$first, $second]); + + $this->assertSame([$first, $second], $collection->values()); + $this->assertSame(['first.txt', 'second.txt'], $collection->keys()); + } + + public function testCollectionKeysCorrespondToEachFileName(): void + { + $firstPath = TESTS_TMP_PATH . '/first.txt'; + $secondPath = TESTS_TMP_PATH . '/second.txt'; + FileSystem::write($firstPath, '1'); + FileSystem::write($secondPath, '2'); + $collection = new FileCollection([new File($firstPath), new File($secondPath)]); + + $this->assertSame( + $collection->keys(), + array_map(static fn(File $file): string => $file->name(), $collection->values()), + ); + } + + public function testListInputIsKeyedByEachFileNameAndPreservesOrder(): void + { + $first = new File('/files/first.txt'); + $second = new File('/files/second.txt'); + $collection = new FileCollection([$first, $second]); + + $this->assertTrue($collection->isAssociative()); + $this->assertTrue($collection->isTyped()); + $this->assertSame(File::class, $collection->dataType()); + $this->assertSame(['first.txt', 'second.txt'], $collection->keys()); + $this->assertSame([$first, $second], $collection->values()); + $this->assertSame($first, $collection->first()); + $this->assertSame($second, $collection->last()); + } + + public function testAssociativeInputUsesItsExplicitKeys(): void + { + $file = new File('/files/original.txt'); + $collection = new FileCollection(['alias' => $file]); + + $this->assertSame($file, $collection->get('alias')); + $this->assertTrue($collection->has('alias')); + $this->assertSame(['alias'], $collection->keys()); + } + + public function testEmptyCollectionHasNoItems(): void + { + $collection = new FileCollection(); + + $this->assertTrue($collection->isEmpty()); + $this->assertSame(0, $collection->count()); + $this->assertNull($collection->first()); + $this->assertSame([], $collection->toArray()); + } + + public function testCollectionRejectsNonFileItems(): void + { + $this->expectException(\TypeError::class); + new FileCollection(['not a File']); + } + + public function testFilesCannotBeAddedOrRemovedBecauseThisCollectionIsImmutable(): void + { + $collection = new FileCollection([new File('/files/one.txt')]); + + $this->expectException(LogicException::class); + $collection->remove('one.txt'); + } + + public function testFilteringReturnsAFileCollectionAndRetainsFileIdentity(): void + { + $first = new File('/files/first.txt'); + $second = new File('/files/second.jpg'); + $collection = new FileCollection([$first, $second]); + + $filtered = $collection->filter(fn(File $file) => $file->extension() === 'jpg'); + + $this->assertInstanceOf(FileCollection::class, $filtered); + $this->assertSame(['second.jpg' => $second], $filtered->toArray()); + $this->assertSame(['first.txt' => $first, 'second.jpg' => $second], $collection->toArray()); + } +} diff --git a/tests/Unit/Files/FileFactoryTest.php b/tests/Unit/Files/FileFactoryTest.php new file mode 100644 index 000000000..0ef36fc2a --- /dev/null +++ b/tests/Unit/Files/FileFactoryTest.php @@ -0,0 +1,116 @@ +setUpTempDirectory(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + } + + public function testMakeBuildsAFileLoadsMetadataAndInjectsTheUriGenerator(): void + { + $path = TESTS_TMP_PATH . '/sample.txt'; + FileSystem::write($path, 'hello'); + FileSystem::write($path . '.meta.yaml', "caption: Example\n"); + $file = new File($path); + $uriGenerator = $this->createMock(FileUriGenerator::class); + $uriGenerator->expects($this->once())->method('generate')->with($file)->willReturn('/files/sample.txt'); + $container = $this->createMock(Container::class); + $container->expects($this->once())->method('build')->with(File::class, ['path' => $path])->willReturn($file); + $container->expects($this->once())->method('get')->with(FileUriGenerator::class)->willReturn($uriGenerator); + $config = new Config(['system' => ['files' => ['metadataExtension' => '.meta.yaml']]], resolved: true); + + $result = (new FileFactory($container, $config))->make($path); + + $this->assertSame($file, $result); + $this->assertSame('Example', $result->get('caption')); + $this->assertSame('/files/sample.txt', $result->uri()); + } + + public function testMakeUsesTheDefaultFileClassWhenMimeTypeHasNoAssociation(): void + { + $path = TESTS_TMP_PATH . '/sample.txt'; + FileSystem::write($path, 'hello'); + $file = new File($path); + $container = $this->createMock(Container::class); + $container->expects($this->once())->method('build')->with(File::class, ['path' => $path])->willReturn($file); + $container->method('get')->willReturn($this->createStub(FileUriGenerator::class)); + $config = new Config(['system' => ['files' => ['metadataExtension' => '.meta.yaml']]], resolved: true); + + $this->assertSame($file, (new FileFactory($container, $config))->make($path)); + } + + public function testMakeBuildsAnAssociatedClass(): void + { + $path = TESTS_TMP_PATH . '/sample.txt'; + FileSystem::write($path, 'hello'); + $file = new File($path); + $container = $this->createMock(Container::class); + $container->expects($this->once())->method('build')->with(File::class, ['path' => $path])->willReturn($file); + $container->method('get')->willReturn($this->createStub(FileUriGenerator::class)); + $config = new Config([ + 'system' => ['files' => ['metadataExtension' => '.meta.yaml']], + ], resolved: true); + + // The association is keyed by the MIME type discovered from the controlled file. + $factory = new FileFactory($container, $config, ['text/plain' => File::class]); + + $this->assertSame($file, $factory->make($path)); + } + + public function testMakeRejectsAContainerResultThatIsNotAFile(): void + { + $path = TESTS_TMP_PATH . '/sample.txt'; + FileSystem::write($path, 'hello'); + $container = $this->createStub(Container::class); + $container->method('build')->willReturn(new \stdClass()); + $config = new Config(['system' => ['files' => ['metadataExtension' => '.meta.yaml']]], resolved: true); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('only instances of Formwork\Files\File are allowed'); + (new FileFactory($container, $config))->make($path); + } + + public function testMakeUsesAnAssociatedFactoryMethod(): void + { + $path = TESTS_TMP_PATH . '/sample.txt'; + FileSystem::write($path, 'hello'); + $result = new File($path); + $built = new FactoryFile($path); + $container = $this->createMock(Container::class); + $container->expects($this->once())->method('build')->with(FactoryFile::class, ['path' => $path])->willReturn($built); + $container->expects($this->once())->method('call')->willReturn($result); + $container->method('get')->with(FileUriGenerator::class)->willReturn($this->createStub(FileUriGenerator::class)); + $config = new Config(['system' => ['files' => ['metadataExtension' => '.meta.yaml']]], resolved: true); + + $this->assertSame($result, (new FileFactory($container, $config, [ + 'text/plain' => [FactoryFile::class, 'fromPath'], + ]))->make($path)); + } +} + +final class FactoryFile extends File +{ + public function fromPath(string $path): File + { + return new File($path); + } +} diff --git a/tests/Unit/Files/FileTest.php b/tests/Unit/Files/FileTest.php new file mode 100644 index 000000000..e5384b558 --- /dev/null +++ b/tests/Unit/Files/FileTest.php @@ -0,0 +1,197 @@ +setUpTempDirectory(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + } + + public function testPathNameExtensionAndStringConversionUseTheGivenPath(): void + { + $file = new File(TESTS_TMP_PATH . '/nested/report.final.txt'); + + $this->assertSame(TESTS_TMP_PATH . '/nested/report.final.txt', $file->path()); + $this->assertSame('report.final.txt', $file->name()); + $this->assertSame('txt', $file->extension()); + $this->assertSame('report.final.txt', (string) $file); + } + + public function testMissingFileMetadataDelegatesToFilesystemAndThrows(): void + { + $file = new File(TESTS_TMP_PATH . '/missing.txt'); + + $this->expectException(FileNotFoundException::class); + $file->size(); + } + + public function testMetadataIsReadLazilyAndContentHashIsStable(): void + { + $path = TESTS_TMP_PATH . '/sample.txt'; + FileSystem::write($path, 'hello'); + $file = new File($path); + + $this->assertSame('text/plain', $file->mimeType()); + $this->assertSame('text', $file->type()); + $this->assertSame('5 B', $file->size()); + $this->assertIsInt($file->lastModifiedTime()); + $this->assertSame(hash_file('sha256', $path), $file->contentHash()); + $this->assertSame($file->contentHash(), $file->contentHash()); + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $file->hash()); + } + + #[DataProvider('typeProvider')] + public function testTypeRecognizesSupportedExtensionsWithTheirMimeTypes(string $extension, string $mime, string $type): void + { + $path = TESTS_TMP_PATH . '/sample.' . $extension; + FileSystem::write($path, 'placeholder'); + $file = new class ($path, $mime) extends File { + public function __construct(string $path, string $mimeType) + { + parent::__construct($path); + $this->mimeType = $mimeType; + } + }; + + // File::type caches the MIME value in the model; seed it through the public accessor. + $this->assertSame($type, $file->type()); + } + + public static function typeProvider(): iterable + { + yield 'pdf' => ['pdf', 'application/pdf', 'pdf']; + yield 'document' => ['docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'document']; + yield 'spreadsheet' => ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'spreadsheet']; + yield 'presentation' => ['pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'presentation']; + yield 'archive' => ['zip', 'application/zip', 'archive']; + } + + public function testUnknownMimeAndExtensionProducesNullType(): void + { + $path = TESTS_TMP_PATH . '/sample.bin'; + FileSystem::write($path, "\0\1\2"); + $file = new File($path); + + $this->assertNull($file->type()); + } + + public function testUriRequiresGeneratorAndDelegatesRelativeAndAbsoluteGeneration(): void + { + $file = new File(TESTS_TMP_PATH . '/sample.txt'); + $this->expectException(FileUriGenerationException::class); + $this->expectExceptionMessage('generator not set'); + $file->uri(); + } + + public function testUriMethodsUseTheConfiguredGenerator(): void + { + $file = new File(TESTS_TMP_PATH . '/sample.txt'); + $generator = $this->createMock(FileUriGenerator::class); + $generator->expects($this->once())->method('generate')->with($file)->willReturn('/files/sample.txt'); + $generator->expects($this->once())->method('generateAbsolute')->with($file)->willReturn('https://example.test/files/sample.txt'); + $file->setUriGenerator($generator); + + $this->assertSame('/files/sample.txt', $file->uri()); + $this->assertSame('https://example.test/files/sample.txt', $file->absoluteUri()); + } + + public function testAnExplicitSchemeIsReturnedForTheFile(): void + { + $file = new File(TESTS_TMP_PATH . '/sample.txt'); + $scheme = $this->createStub(Scheme::class); + + $file->setScheme($scheme); + + $this->assertSame($scheme, $file->scheme()); + } + + public function testToArrayExportsPublicFileMetadataButNotInternalProperties(): void + { + $path = TESTS_TMP_PATH . '/sample.txt'; + FileSystem::write($path, 'hello'); + $file = new File($path); + $file->set('caption', 'A sample'); + + $data = $file->toArray(); + + $this->assertSame('A sample', $data['caption']); + $this->assertSame($path, $data['path']); + $this->assertSame('sample.txt', $data['name']); + $this->assertArrayNotHasKey('hash', $data); + $this->assertArrayNotHasKey('contentHash', $data); + $this->assertArrayNotHasKey('uriGenerator', $data); + } + + public function testSaveRemovesStaleMetadataWhenThereIsNoUserData(): void + { + $path = TESTS_TMP_PATH . '/sample.txt'; + $meta = $path . '.meta.yaml'; + FileSystem::write($path, 'hello'); + FileSystem::write($meta, "title: stale\n"); + + (new File($path))->save(); + + $this->assertFileDoesNotExist($meta); + } + + public function testSaveWritesUserMetadataToTheConfiguredSidecarFile(): void + { + $path = TESTS_TMP_PATH . '/sample.txt'; + $meta = $path . '.meta.yaml'; + FileSystem::write($path, 'hello'); + $file = new File($path); + $file->set('caption', 'A saved caption'); + $file->set('details', ['author' => 'Sempronius']); + + $file->save(); + + $this->assertFileExists($meta); + $this->assertSame( + [ + 'caption' => 'A saved caption', + 'details' => ['author' => 'Sempronius'], + ], + Yaml::parseFile($meta), + ); + } + + public function testStringRepresentationUsesTheFileName(): void + { + $path = TESTS_TMP_PATH . '/document.txt'; + FileSystem::write($path, 'content'); + $file = new File($path); + + $this->assertSame('document.txt', (string) $file); + $this->assertSame('document.txt', $file->name()); + $this->assertSame('txt', $file->extension()); + } + + public function testTypeClassificationIsConsistentWithTheMimeTypeAndExtension(): void + { + $path = TESTS_TMP_PATH . '/document.pdf'; + FileSystem::write($path, '%PDF-1.4'); + $file = new File($path); + + $this->assertSame('pdf', $file->type()); + $this->assertSame($file->type(), $file->type()); + } +} diff --git a/tests/Unit/Files/FileUriGeneratorTest.php b/tests/Unit/Files/FileUriGeneratorTest.php new file mode 100644 index 000000000..136a75c59 --- /dev/null +++ b/tests/Unit/Files/FileUriGeneratorTest.php @@ -0,0 +1,97 @@ +config(); + $uri = $this->createMock(UriGenerator::class); + $uri->expects($this->once())->method('route')->with('files', ['name' => 'logo.png'])->willReturn('/files/logo.png'); + + $this->assertSame('/files/logo.png', $this->generator($config, $uri)->generate(new File('/site/files/logo.png'))); + } + + public function testGeneratesTheProcessedImageRoute(): void + { + $config = $this->config(); + $uri = $this->createMock(UriGenerator::class); + $uri->expects($this->once())->method('route')->with('assets', ['type' => 'images', 'id' => 'abc123', 'name' => 'thumb.jpg'])->willReturn('/assets/images/abc123/thumb.jpg'); + + $this->assertSame('/assets/images/abc123/thumb.jpg', $this->generator($config, $uri)->generate(new File('/assets/images/abc123/thumb.jpg'))); + } + + public function testGeneratesPageContentPathsWithoutNumericDirectoryPrefixes(): void + { + $config = $this->config(); + $uri = $this->createMock(UriGenerator::class); + $uri->expects($this->once())->method('path')->with('/about/team/team.txt')->willReturn('/about/team/team.txt'); + + $this->assertSame('/about/team/team.txt', $this->generator($config, $uri)->generate(new File('/site/pages/1-about/2-team/team.txt'))); + } + + public function testGeneratesUserImageAndPanelAssetPaths(): void + { + $config = $this->config(); + $uri = $this->createMock(UriGenerator::class); + $uri->expects($this->once())->method('path')->with('panel/assets/icons/menu.svg')->willReturn('/panel/assets/icons/menu.svg'); + $uri->expects($this->once())->method('route')->with('panel.users.images', ['image' => 'avatar.png'])->willReturn('/panel/users/images/avatar.png'); + + $generator = $this->generator($config, $uri); + $this->assertSame('/panel/users/images/avatar.png', $generator->generate(new File('/site/users/images/avatar.png'))); + $this->assertSame('/panel/assets/icons/menu.svg', $generator->generate(new File('/panel/assets/icons/menu.svg'))); + } + + public function testUnknownPathRaisesTheDomainException(): void + { + $generator = $this->generator($this->config(), $this->createStub(UriGenerator::class)); + + $this->expectException(FileUriGenerationException::class); + $this->expectExceptionMessage('missing file generator'); + $generator->generate(new File('/outside/file.txt')); + } + + public function testAbsoluteUriResolvesTheGeneratedUriAgainstTheRequestUri(): void + { + $request = new Request([], [], [], [], [ + 'REQUEST_URI' => '/panel/current', + 'HTTP_HOST' => 'example.test', + 'SERVER_NAME' => 'example.test', + 'SERVER_PORT' => '443', + 'HTTPS' => 'on', + ]); + $uri = $this->createStub(UriGenerator::class); + $generator = new FileUriGenerator($this->config(), $request, $uri); + $file = new File('/site/files/logo.png'); + $uri->method('route')->willReturn('/files/logo.png'); + + $this->assertSame('https://example.test/files/logo.png', $generator->generateAbsolute($file)); + } + + private function config(): Config + { + return new Config(['system' => [ + 'files' => ['paths' => ['site' => '/site/files']], + 'images' => ['processPath' => '/assets/images'], + 'pages' => ['path' => '/site/pages'], + 'users' => ['paths' => ['images' => '/site/users/images']], + 'panel' => ['paths' => ['assets' => '/panel/assets']], + ]], resolved: true); + } + + private function generator(Config $config, UriGenerator $uri): FileUriGenerator + { + return new FileUriGenerator($config, new Request([], [], [], [], []), $uri); + } +} diff --git a/tests/Unit/Files/Services/FileUploaderTest.php b/tests/Unit/Files/Services/FileUploaderTest.php new file mode 100644 index 000000000..814413a43 --- /dev/null +++ b/tests/Unit/Files/Services/FileUploaderTest.php @@ -0,0 +1,137 @@ +setUpTempDirectory(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + } + + public function testAllowedMimeTypesAreDerivedFromConfiguredExtensions(): void + { + $uploader = new FileUploader($this->config(['txt', 'jpg']), new FileFactory(new Container(), $this->config(['txt', 'jpg']))); + + $this->assertSame(['text/plain', 'image/jpeg'], $uploader->allowedMimeTypes()); + } + + public function testUploadRejectsAFileThatWasNotUploaded(): void + { + $uploaded = $this->uploadedFile(UPLOAD_ERR_NO_FILE, 'missing.txt'); + $uploader = new FileUploader($this->config(['txt']), new FileFactory(new Container(), $this->config(['txt']))); + + $this->expectException(TranslatedException::class); + $this->expectExceptionMessage('No file uploaded'); + $this->expectExceptionCode(0); + $uploader->upload($uploaded, TESTS_TMP_PATH); + } + + public function testUploadRejectsAMIMETypeOutsideTheAllowedList(): void + { + $temp = TESTS_TMP_PATH . '/payload.bin'; + FileSystem::write($temp, 'binary payload'); + $uploaded = $this->uploadedFile(UPLOAD_ERR_OK, 'payload.bin', $temp); + $uploader = new FileUploader($this->config(['jpg']), new FileFactory(new Container(), $this->config(['jpg']))); + + $this->expectException(TranslatedException::class); + $this->expectExceptionMessage('Invalid mime type text/plain'); + $uploader->upload($uploaded, TESTS_TMP_PATH); + } + + public function testUploadRejectsDestinationsOutsideConfiguredBaseDirectories(): void + { + $temp = TESTS_TMP_PATH . '/payload.txt'; + FileSystem::write($temp, 'payload'); + $uploaded = $this->uploadedFile(UPLOAD_ERR_OK, 'payload.txt', $temp); + $uploader = new FileUploader($this->config(['txt'], ['/allowed']), new FileFactory(new Container(), $this->config(['txt'], ['/allowed']))); + + $this->expectException(TranslatedException::class); + $this->expectExceptionMessage('Invalid destination path'); + $uploader->upload($uploaded, '/outside'); + } + + public function testUploadNormalizesNameChoosesClientExtensionAndReturnsTheFactoryFile(): void + { + $temp = TESTS_TMP_PATH . '/payload.txt'; + FileSystem::write($temp, 'payload'); + $uploaded = $this->createMock(UploadedFile::class); + $uploaded->method('isUploaded')->willReturn(true); + $uploaded->method('tempPath')->willReturn($temp); + $uploaded->method('clientName')->willReturn('My Unsafe Name.TXT'); + $uploaded->expects($this->once())->method('move')->with(TESTS_TMP_PATH, 'renamed-file.txt', true) + ->willReturnCallback(fn(string $destination, string $name) => (bool) FileSystem::write($destination . '/' . $name, 'payload')); + $file = new File(TESTS_TMP_PATH . '/renamed-file.txt'); + $container = $this->fileFactoryContainer($file); + $factory = new FileFactory($container, $this->config(['txt'], [TESTS_TMP_PATH])); + $uploader = new FileUploader($this->config(['txt'], [TESTS_TMP_PATH]), $factory); + + $this->assertSame($file, $uploader->upload($uploaded, TESTS_TMP_PATH, 'Renamed File', overwrite: true)); + } + + public function testUploadUsesTheMimetypeExtensionWhenTheClientExtensionIsNotAssociated(): void + { + $temp = TESTS_TMP_PATH . '/payload.txt'; + FileSystem::write($temp, 'payload'); + $uploaded = $this->createMock(UploadedFile::class); + $uploaded->method('isUploaded')->willReturn(true); + $uploaded->method('tempPath')->willReturn($temp); + $uploaded->method('clientName')->willReturn('photo.exe'); + $uploaded->expects($this->once())->method('move')->with(TESTS_TMP_PATH, 'photo.txt', false) + ->willReturnCallback(fn(string $destination, string $name) => (bool) FileSystem::write($destination . '/' . $name, 'payload')); + $file = new File(TESTS_TMP_PATH . '/photo.txt'); + $factory = new FileFactory($this->fileFactoryContainer($file), $this->config(['txt'], [TESTS_TMP_PATH])); + + $this->assertSame($file, (new FileUploader($this->config(['txt'], [TESTS_TMP_PATH]), $factory))->upload($uploaded, TESTS_TMP_PATH)); + } + + private function config(array $extensions, array $destinations = [TESTS_TMP_PATH]): Config + { + return new Config(['system' => [ + 'files' => [ + 'allowedExtensions' => $extensions, + 'metadataExtension' => '.meta.yaml', + 'uploads' => ['baseDestinations' => $destinations], + ], + 'uploads' => ['processImages' => false], + ]], resolved: true); + } + + private function uploadedFile(int $error, string $name, ?string $tempPath = null): UploadedFile + { + return new UploadedFile('upload', [ + 'name' => $name, + 'full_path' => $name, + 'type' => 'text/plain', + 'tmp_name' => $tempPath ?? TESTS_TMP_PATH . '/missing', + 'error' => (string) $error, + 'size' => '0', + ]); + } + + private function fileFactoryContainer(File $file): Container + { + $container = $this->createMock(Container::class); + $container->method('build')->willReturn($file); + $container->method('get')->with(FileUriGenerator::class)->willReturn($this->createStub(FileUriGenerator::class)); + return $container; + } +} diff --git a/tests/Unit/Forms/FormTest.php b/tests/Unit/Forms/FormTest.php index 527ccf8a7..9965f0588 100644 --- a/tests/Unit/Forms/FormTest.php +++ b/tests/Unit/Forms/FormTest.php @@ -311,7 +311,7 @@ public function testUploadsAreNotProcessedWhenUploadProcessingIsDisabled(): void $form->processRequest($this->createRequest(RequestMethod::POST, files: [ 'attachment' => $this->uploadData(), - ]), uploadFiles: false, ); + ]), uploadFiles: false); $this->assertSame([], $form->uploadedFiles()); } @@ -763,6 +763,97 @@ public function testFieldTypeValues( ); } + public function testPreserveEmptyFalseChangesOnlyTheFormDataProjection(): void + { + $fields = [ + 'name' => $this->createField('name', ['type' => 'text']), + 'message' => $this->createField('message', ['type' => 'text']), + ]; + $form = $this->createForm('contact', $fields); + + $form->processRequest($this->createRequest( + RequestMethod::POST, + input: ['name' => 'Alice'], + ), preserveEmpty: false); + + $this->assertSame(['name' => 'Alice'], $form->data()->toArray()); + $this->assertSame('', $form->fields()->get('message')->value()); + } + + public function testInvalidSubmissionDoesNotInvokeTheUploader(): void + { + $uploader = $this->createMock(FileUploader::class); + $uploader->expects($this->never())->method('upload'); + $form = $this->createForm('upload', [ + 'name' => $this->createField('name', ['type' => 'text', 'required' => true]), + 'photo' => $this->createField('photo', ['type' => 'upload', 'destination' => '/uploads']), + ], $uploader); + + $form->processRequest($this->createRequest( + RequestMethod::POST, + files: ['photo' => $this->uploadData()], + )); + + $this->assertFalse($form->isValid()); + $this->assertSame(ResponseStatus::UnprocessableEntity, $form->getResponseStatus()); + $this->assertSame([], $form->uploadedFiles()); + } + + public function testUploadFailureDoesNotFabricateAnUploadedFile(): void + { + $uploader = $this->createStub(FileUploader::class); + $uploader->method('upload')->willThrowException(new \RuntimeException('upload failed')); + $form = $this->createForm('upload', [ + 'photo' => $this->createField('photo', ['type' => 'upload', 'destination' => '/uploads']), + ], $uploader); + + try { + $form->processRequest($this->createRequest( + RequestMethod::POST, + files: ['photo' => $this->uploadData()], + )); + $this->fail('The upload exception should have propagated.'); + } catch (\RuntimeException) { + // Expected. + } + + $this->assertSame([], $form->uploadedFiles()); + } + + public function testDefaultUploadDestinationIsUsedOnlyWhenFieldDestinationIsMissing(): void + { + $uploaded = new File('/uploads/result.jpg'); + $uploader = $this->createMock(FileUploader::class); + $uploader->expects($this->once()) + ->method('upload') + ->with($this->isInstanceOf(UploadedFile::class), '/default', null, [], false) + ->willReturn($uploaded); + + $form = $this->createForm('upload', [ + 'photo' => $this->createField('photo', ['type' => 'upload', 'accept' => '']), + ], $uploader); + $form->setDefaultUploadsDestination('/default'); + $form->processRequest($this->createRequest(RequestMethod::POST, files: ['photo' => $this->uploadData()])); + + $this->assertSame([$uploaded], $form->uploadedFiles()); + } + + public function testFormDataNeverContainsUploadFieldValues(): void + { + $form = $this->createForm('upload', [ + 'title' => $this->createField('title', ['type' => 'text']), + 'photo' => $this->createField('photo', ['type' => 'upload', 'destination' => '/uploads']), + ]); + + $form->processRequest($this->createRequest( + RequestMethod::POST, + input: ['title' => 'Title'], + files: ['photo' => $this->uploadData()], + ), uploadFiles: false); + + $this->assertSame(['title' => 'Title'], $form->data()->toArray()); + } + private function createField(string $name, array $data): Field { return $this->app->getService(FieldFactory::class)->make($name, $data); diff --git a/tests/Unit/Http/FileResponseTest.php b/tests/Unit/Http/FileResponseTest.php index 0fc9decf8..2eb8c08d6 100644 --- a/tests/Unit/Http/FileResponseTest.php +++ b/tests/Unit/Http/FileResponseTest.php @@ -4,6 +4,7 @@ use Formwork\Http\FileResponse; use Formwork\Http\Request; +use Formwork\Http\RequestMethod; use Formwork\Http\ResponseStatus; use Formwork\Tests\TestCase; use Formwork\Utils\FileSystem; @@ -29,7 +30,7 @@ public function testFileResponsePreparesRangesAndDownloadNames(): void { $path = $this->fixturePath(); $response = new FileResponse($path, download: true, autoEtag: true, autoLastModified: true); - $response->prepare($this->request(['HTTP_RANGE' => 'bytes=0-4'])); + $response->prepare($this->request(server: ['HTTP_RANGE' => 'bytes=0-4'])); $this->assertSame(ResponseStatus::PartialContent, $response->status()); $this->assertSame('bytes 0-4/' . FileSystem::fileSize($path), $response->headers()->get('Content-Range')); @@ -46,15 +47,15 @@ public function testFileResponseHandlesSuffixOpenEndedAndUnsatisfiableRanges(): $size = FileSystem::fileSize($path); $suffix = new FileResponse($path); - $suffix->prepare($this->request(['HTTP_RANGE' => 'bytes=-5'])); + $suffix->prepare($this->request(server: ['HTTP_RANGE' => 'bytes=-5'])); $this->assertSame('bytes ' . ($size - 5) . '-' . ($size - 1) . '/' . $size, $suffix->headers()->get('Content-Range')); $openEnded = new FileResponse($path); - $openEnded->prepare($this->request(['HTTP_RANGE' => 'bytes=3-'])); + $openEnded->prepare($this->request(server: ['HTTP_RANGE' => 'bytes=3-'])); $this->assertSame('bytes 3-' . ($size - 1) . '/' . $size, $openEnded->headers()->get('Content-Range')); $invalid = new FileResponse($path); - $invalid->prepare($this->request(['HTTP_RANGE' => 'bytes=999-1000'])); + $invalid->prepare($this->request(server: ['HTTP_RANGE' => 'bytes=999-1000'])); $this->assertSame(ResponseStatus::RangeNotSatisfiable, $invalid->status()); $this->assertSame('bytes */' . $size, $invalid->headers()->get('Content-Range')); $this->assertSame('0', $invalid->headers()->get('Content-Length')); @@ -64,7 +65,7 @@ public function testFileResponseAddsAcceptRangesForHeadAndSkipsRangesForEmptyRes { $path = $this->fixturePath(); $head = new FileResponse($path); - $head->prepare($this->request(['REQUEST_METHOD' => 'HEAD'])); + $head->prepare($this->request(RequestMethod::HEAD)); $empty = new FileResponse($path, ResponseStatus::NoContent); $empty->prepare($this->request()); @@ -85,7 +86,7 @@ public function testFileResponseSendStreamsFullContent(): void public function testFileResponseSendStreamsPartialContent(): void { $response = new FileResponse($this->fixturePath()); - $response->prepare($this->request(['HTTP_RANGE' => 'bytes=0-4'])); + $response->prepare($this->request(server: ['HTTP_RANGE' => 'bytes=0-4'])); ob_start(); $response->send(); $output = ob_get_clean(); @@ -98,7 +99,7 @@ public function testHeadResponseDoesNotStreamOrDeleteTheFile(): void $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'head.txt'); FileSystem::copyFile($this->fixturePath(), $path); $response = new FileResponse($path, deleteAfterSend: true); - $response->prepare($this->request(['REQUEST_METHOD' => 'HEAD'])); + $response->prepare($this->request(RequestMethod::HEAD)); ob_start(); $response->send(); $output = ob_get_clean(); @@ -115,6 +116,113 @@ public function testInlineFileResponseRejectsDownloadFilenameChanges(): void $response->setFilename('download.txt'); } + public function testPreparedRangeHeadersMatchTheBytesActuallySent(): void + { + $path = TESTS_TMP_PATH . '/range.txt'; + FileSystem::write($path, '0123456789'); + $response = new FileResponse($path); + $response->prepare($this->request(RequestMethod::GET, ['HTTP_RANGE' => 'bytes=2-5'])); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame(ResponseStatus::PartialContent, $response->status()); + $this->assertSame('bytes 2-5/10', $response->headers()->get('Content-Range')); + $this->assertSame('4', $response->headers()->get('Content-Length')); + $this->assertSame('2345', $output); + } + + public function testSuffixRangeHeadersMatchTheSuffixBytes(): void + { + $path = TESTS_TMP_PATH . '/range.txt'; + FileSystem::write($path, '0123456789'); + + $response = new FileResponse($path); + $response->prepare($this->request(RequestMethod::GET, ['HTTP_RANGE' => 'bytes=-3'])); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame('bytes 7-9/10', $response->headers()->get('Content-Range')); + $this->assertSame('3', $response->headers()->get('Content-Length')); + $this->assertSame('789', $output); + } + + public function testUnsatisfiableRangeProducesNoBodyAndConsistentHeaders(): void + { + $path = TESTS_TMP_PATH . '/range.txt'; + FileSystem::write($path, '0123456789'); + $response = new FileResponse($path); + $response->prepare($this->request(RequestMethod::GET, ['HTTP_RANGE' => 'bytes=20-30'])); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame(ResponseStatus::RangeNotSatisfiable, $response->status()); + $this->assertSame('bytes */10', $response->headers()->get('Content-Range')); + $this->assertSame('0', $response->headers()->get('Content-Length')); + $this->assertSame('', $output); + } + + public function testZeroLengthFileIsStillDeletedWhenDeleteAfterSendIsEnabled(): void + { + $path = TESTS_TMP_PATH . '/empty.txt'; + FileSystem::write($path, ''); + $response = new FileResponse($path, deleteAfterSend: true); + $response->prepare($this->request(RequestMethod::GET)); + + $response->send(); + + $this->assertFileDoesNotExist($path); + } + + public function testHeadNeverDeletesTheSourceFile(): void + { + $path = TESTS_TMP_PATH . '/head.txt'; + FileSystem::write($path, 'body'); + $response = new FileResponse($path, deleteAfterSend: true); + $response->prepare($this->request(RequestMethod::HEAD)); + + $response->send(); + + $this->assertFileExists($path); + $this->assertSame('', $response->content()); + } + + public function testAutoValidatorsRemainStableAcrossRepeatedPreparation(): void + { + $path = TESTS_TMP_PATH . '/validators.txt'; + FileSystem::write($path, 'body'); + $response = new FileResponse($path, autoEtag: true, autoLastModified: true); + $request = $this->request(RequestMethod::GET); + + $response->prepare($request); + $etag = $response->headers()->get('ETag'); + $modified = $response->headers()->get('Last-Modified'); + $response->prepare($request); + + $this->assertSame($etag, $response->headers()->get('ETag')); + $this->assertSame($modified, $response->headers()->get('Last-Modified')); + } + + public function testFullResponseStreamsTheEntireFile(): void + { + $path = TESTS_TMP_PATH . '/full.txt'; + FileSystem::write($path, '0123456789'); + $response = new FileResponse($path); + $response->prepare($this->request(RequestMethod::GET)); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame('0123456789', $output); + $this->assertSame('10', $response->headers()->get('Content-Length')); + } + private function fixturePath(): string { return __DIR__ . '/fixtures/files/sample.txt'; @@ -123,10 +231,12 @@ private function fixturePath(): string /** * @param array $server */ - private function request(array $server = []): Request + private function request(RequestMethod $method = RequestMethod::GET, array $server = []): Request { return new Request([], [], [], [], $server + [ - 'REQUEST_METHOD' => 'GET', 'SERVER_NAME' => 'example.test', 'SERVER_PORT' => '80', + 'REQUEST_METHOD' => $method->value, + 'SERVER_NAME' => 'example.test', + 'SERVER_PORT' => '80', ]); } } diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index 10dc733fc..a22b19984 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -3,6 +3,7 @@ namespace Formwork\Tests\Unit\Http; use Formwork\Http\Request; +use Formwork\Http\RequestMethod; use Formwork\Http\Response; use Formwork\Http\ResponseStatus; use Formwork\Tests\TestCase; @@ -25,9 +26,9 @@ public function testResponseCanBeSerializedAndRestored(): void public function testResponsePreparationHandlesHeadAndConditionalRequests(): void { - $request = $this->request(['REQUEST_METHOD' => 'HEAD']); + $request = $this->request(RequestMethod::HEAD); $response = new Response('body', ResponseStatus::OK, ['ETag' => 'tag', 'Content-Length' => '4']); - $requestWithMatch = $this->request(['HTTP_IF_NONE_MATCH' => 'tag']); + $requestWithMatch = $this->request(RequestMethod::GET, ['HTTP_IF_NONE_MATCH' => 'tag']); $response->prepare($request); $this->assertSame('', $response->content()); @@ -61,13 +62,86 @@ public function testResponseSendWritesContentAndAddsDefaultHeaders(): void $this->assertSame('no-cache, private', $response->headers()->get('Cache-Control')); } + public function testPrepareIsStableWhenAppliedTwiceToTheSameRequest(): void + { + $response = new Response('content', ResponseStatus::OK, [ + 'ETag' => '"abc"', + 'Content-Length' => '7', + ]); + $request = $this->request(RequestMethod::GET, ['HTTP_IF_NONE_MATCH' => '"abc"']); + + $response->prepare($request); + $first = $response->toArray(); + $response->prepare($request); + + $this->assertSame($first, $response->toArray()); + } + + public function testConditionalNotModifiedResponsesCannotRetainAResponseBody(): void + { + $response = new Response('body', ResponseStatus::OK, ['ETag' => '"abc"']); + + $response->prepare($this->request(RequestMethod::GET, ['HTTP_IF_NONE_MATCH' => '"abc"'])); + + $this->assertSame(ResponseStatus::NotModified, $response->status()); + $this->assertSame('', $response->content()); + $this->assertFalse($response->headers()->has('Content-Type')); + $this->assertFalse($response->headers()->has('Content-Length')); + } + + public function testNoContentResponsesRemoveEntityHeadersAndBody(): void + { + $response = new Response('body', ResponseStatus::NoContent, [ + 'Content-Type' => 'text/plain', + 'Content-Length' => '4', + ]); + + $response->prepare($this->request(RequestMethod::GET)); + + $this->assertSame('', $response->content()); + $this->assertFalse($response->headers()->has('Content-Type')); + $this->assertFalse($response->headers()->has('Content-Length')); + } + + public function testHeadPreparationSuppressesBodyButPreservesEntityMetadata(): void + { + $response = new Response('body', ResponseStatus::OK, [ + 'Content-Type' => 'text/plain', + 'Content-Length' => '4', + ]); + + $response->prepare($this->request(RequestMethod::HEAD)); + + $this->assertSame('', $response->content()); + $this->assertSame('text/plain', $response->headers()->get('Content-Type')); + $this->assertSame('4', $response->headers()->get('Content-Length')); + } + + public function testSendWritesExactlyThePreparedContent(): void + { + $response = new Response('expected'); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame('expected', $output); + } + + public function testArrayRoundTripPreservesStatusContentAndHeaders(): void + { + $response = new Response('content', ResponseStatus::Created, ['X-Test' => 'value']); + + $this->assertSame($response->toArray(), Response::fromArray($response->toArray())->toArray()); + } + /** * @param array $server */ - private function request(array $server = []): Request + private function request(RequestMethod $method = RequestMethod::GET, array $server = []): Request { return new Request([], [], [], [], $server + [ - 'REQUEST_METHOD' => 'GET', + 'REQUEST_METHOD' => $method->value, 'SERVER_NAME' => 'example.test', 'SERVER_PORT' => '80', ]); diff --git a/tests/Unit/Pages/ContentFileTest.php b/tests/Unit/Pages/ContentFileTest.php index b60c77190..188ebd415 100644 --- a/tests/Unit/Pages/ContentFileTest.php +++ b/tests/Unit/Pages/ContentFileTest.php @@ -4,6 +4,7 @@ use Formwork\Pages\ContentFile; use Formwork\Tests\TestCase; +use Formwork\Utils\FileSystem; use PHPUnit\Framework\Attributes\CoversClass; use UnexpectedValueException; @@ -32,7 +33,7 @@ public function testInvalidPageFormatIsRejected(): void { $path = TESTS_TMP_PATH . '/invalid-page.md'; $this->setUpTempDirectory(); - file_put_contents($path, 'not a page'); + FileSystem::write($path, 'not a page'); try { $this->expectException(UnexpectedValueException::class); diff --git a/tests/Unit/Pages/PageCollectionTest.php b/tests/Unit/Pages/PageCollectionTest.php index 747afa77c..142844f7e 100644 --- a/tests/Unit/Pages/PageCollectionTest.php +++ b/tests/Unit/Pages/PageCollectionTest.php @@ -60,14 +60,14 @@ public function testFiltersExtractAndPaginationPreservePageCollectionType(): voi $this->assertSame(2, $paginated->pagination()->currentPage()); } - public function testSearchScoresMatchesAndReturnsResultsInDescendingOrder(): void + public function testSearchReturnsMatchingResultsInDescendingOrder(): void { $about = $this->page('/about'); $blog = $this->page('/blog'); $about->set('title', 'PHP guide'); $blog->set('title', 'A PHP handbook for teams'); - $results = $this->collection($about, $blog)->search('PHP guide', weights: [ + $results = $this->collection($about, $blog)->search('PHP guide', minimumLength: 3, weights: [ 'title' => 100, 'summary' => 0, 'content' => 0, @@ -75,13 +75,11 @@ public function testSearchScoresMatchesAndReturnsResultsInDescendingOrder(): voi 'uri' => 0, ]); - $this->assertNotEmpty($results->values()); - $this->assertContains($about, $results->values()); - $this->assertGreaterThan(0, $about->get('score')); - $scores = array_map(static fn(Page $page): int => $page->get('score'), $results->values()); - $sorted = $scores; - rsort($sorted); - $this->assertSame($sorted, $scores); + $this->assertNotEmpty($results); + $this->assertContains($about, $results); + $this->assertContains($blog, $results); + + $this->assertSame([$about, $blog], $results->values()); } public function testSearchReturnsAnEmptyCollectionForShortQueries(): void @@ -108,6 +106,74 @@ public function testPageRelationshipExclusionMethodsRemoveExpectedPages(): void $this->assertSame([$child, $about], $collection->withoutSiblings($about)->values()); } + public function testSearchDoesNotAddTransientScoresToTheSourcePages(): void + { + $about = $this->page('/about'); + $about->set('title', 'PHP guide'); + $this->assertFalse($about->has('score')); + + $this->collection($about)->search('PHP guide'); + + $this->assertFalse($about->has('score')); + } + + public function testPaginationDoesNotMutateTheSourceCollection(): void + { + $about = $this->page('/about'); + $blog = $this->page('/blog'); + $collection = $this->collection($about, $blog); + $before = $collection->values(); + + $page = $collection->paginate(1, 2); + + $this->assertSame([$blog], $page->values()); + $this->assertSame($before, $collection->values()); + } + + public function testSortingReturnsANewCollectionAndLeavesOriginalOrderingUntouched(): void + { + $about = $this->page('/about'); + $blog = $this->page('/blog'); + $collection = $this->collection($about, $blog); + $before = $collection->values(); + + $sorted = $collection->sortBy('title'); + + $this->assertNotSame($collection, $sorted); + $this->assertSame($before, $collection->values()); + } + + public function testRelationshipFiltersPreservePageIdentityAndSourceCollection(): void + { + $parent = $this->page('/blog'); + $child = $this->page('/blog/hello-world'); + $about = $this->page('/about'); + $collection = $this->collection($parent, $child, $about); + $before = $collection->values(); + + $filtered = $collection->withoutPageAndDescendants($parent); + + $this->assertSame([$about], $filtered->values()); + $this->assertSame($before, $collection->values()); + $this->assertSame($parent, $collection->at(0)); + $this->assertSame($child, $collection->at(1)); + } + + public function testSearchPreservesResultOrderAfterRepeatedExecution(): void + { + $about = $this->page('/about'); + $blog = $this->page('/blog'); + $about->set('title', 'PHP guide'); + $blog->set('title', 'PHP'); + $collection = $this->collection($about, $blog); + + $first = $collection->search('PHP', minimumLength: 3); + $second = $collection->search('PHP', minimumLength: 3); + + $this->assertSame($first->values(), $second->values()); + $this->assertSame([$about, $blog], $first->values()); + } + private function page(string $route): Page { return $this->fixtureSite->findPage($route) ?? $this->fail("Missing page {$route}"); diff --git a/tests/Unit/Pages/PageEventsTest.php b/tests/Unit/Pages/PageEventsTest.php index 829d88e51..f19091936 100644 --- a/tests/Unit/Pages/PageEventsTest.php +++ b/tests/Unit/Pages/PageEventsTest.php @@ -139,6 +139,110 @@ public function testPageRenderEvent(): void $this->assertTrue($vars['extra']); } + public function testBeforeSaveRunsBeforeAfterSaveAndPersistence(): void + { + $page = $this->page('/about'); + $events = []; + $this->app->events()->on('pageBeforeSave', function (object $event) use (&$events, $page): void { + if ($event->page() === $page) { + $events[] = 'before'; + } + }); + $this->app->events()->on('pageAfterSave', function (object $event) use (&$events, $page): void { + if ($event->page() === $page) { + $events[] = 'after'; + } + }); + + $page->save(); + + $this->assertSame(['before', 'after'], $events); + } + + public function testBeforeSaveFailurePreventsPersistenceAndAfterSave(): void + { + $page = $this->page('/about'); + $page->set('title', 'Not persisted'); + $afterCalled = false; + $this->app->events()->on('pageBeforeSave', function (object $event) use ($page): void { + if ($event->page() === $page) { + throw new \RuntimeException('blocked'); + } + }); + $this->app->events()->on('pageAfterSave', function (object $event) use (&$afterCalled, $page): void { + if ($event->page() === $page) { + $afterCalled = true; + } + }); + + try { + $page->save(); + $this->fail('The before-save exception should have propagated.'); + } catch (\RuntimeException $exception) { + $this->assertSame('blocked', $exception->getMessage()); + } + + $page->reload(); + $this->assertSame('About', $page->title()); + $this->assertFalse($afterCalled); + } + + public function testAfterSaveFailureDoesNotUndoCompletedPersistence(): void + { + $page = $this->page('/about'); + $page->set('title', 'Persisted'); + $this->app->events()->on('pageAfterSave', function (object $event) use ($page): void { + if ($event->page() === $page) { + throw new \RuntimeException('after failure'); + } + }); + + try { + $page->save(); + $this->fail('The after-save exception should have propagated.'); + } catch (\RuntimeException $exception) { + $this->assertSame('after failure', $exception->getMessage()); + } + + $page->reload(); + $this->assertSame('Persisted', $page->title()); + } + + public function testBeforeDeleteFailureLeavesThePageOnDisk(): void + { + $page = $this->page('/about'); + $path = $page->contentPath(); + $this->app->events()->on('pageBeforeDelete', function (object $event) use ($page): void { + if ($event->page() === $page) { + throw new \RuntimeException('blocked'); + } + }); + + try { + $page->delete(); + $this->fail('The before-delete exception should have propagated.'); + } catch (\RuntimeException $exception) { + $this->assertSame('blocked', $exception->getMessage()); + } + + $this->assertDirectoryExists((string) $path); + } + + public function testBeforeDuplicateReceivesTheOverrideDataByReference(): void + { + $page = $this->page('/about'); + $this->app->events()->on('pageBeforeDuplicate', function (object $event) use ($page): void { + if ($event->page() === $page) { + $with = &$event->with(); + $with['title'] = 'From listener'; + } + }); + + $duplicate = $page->duplicate(['title' => 'Original override']); + + $this->assertSame('From listener', $duplicate->title()); + } + private function page(string $route): Page { $site = $this->temporarySite(); diff --git a/tests/Unit/Pages/PageTest.php b/tests/Unit/Pages/PageTest.php index 61518f222..f67729d2d 100644 --- a/tests/Unit/Pages/PageTest.php +++ b/tests/Unit/Pages/PageTest.php @@ -488,6 +488,130 @@ public function testRenderDispatchesPageRenderEventAndReturnsMarkup(): void $this->assertStringContainsString('About', $output); } + public function testChangingSlugKeepsPathIdentityUntilSave(): void + { + $page = $this->temporaryPage('about'); + $path = $page->path(); + + $page->set('slug', 'renamed'); + + $this->assertSame($path, $page->path()); + $this->assertSame('renamed', $page->slug()); + } + + public function testReloadReconstructsAllDerivedAndCachedStateFromDisk(): void + { + $page = $this->temporaryPage('about'); + $page->metadata(); + $path = $page->contentFile()?->path(); + $this->assertNotNull($path); + + FileSystem::write($path, "---\ntitle: Reloaded\nmetadata:\n description: Fresh\n---\nFresh content\n"); + + $page->reload(); + + $this->assertSame('Reloaded', $page->title()); + $this->assertSame('Fresh content', $page->contentFile()?->content()); + $this->assertSame('Fresh', $page->metadata()->get('description')->content()); + } + + public function testReloadAfterExternalPathChangeDoesNotRetainOldLazyProperties(): void + { + $page = $this->temporaryPage('about'); + $page->route(); + $page->num(); + $page->lastModifiedTime(); + + $page->reload(); + + $this->assertSame('/about/', $page->route()); + $this->assertSame('about', $page->slug()); + $this->assertNotNull($page->lastModifiedTime()); + } + + public function testDuplicateDoesNotShareMutableMetadataStateWithTheSource(): void + { + $page = $this->temporaryPage('about'); + $page->set('metadata', ['description' => 'Original']); + $originalMetadata = $page->metadata(); + + $duplicate = $page->duplicate(); + $duplicate->metadata()->set('description', 'Duplicate'); + + $this->assertSame('Original', $originalMetadata->get('description')->content()); + $this->assertSame('Duplicate', $duplicate->metadata()->get('description')->content()); + } + + public function testDuplicateGetsAnIndependentDataSet(): void + { + $page = $this->temporaryPage('original'); + $duplicate = $page->duplicate(); + + $duplicate->set('title', 'Duplicate'); + + $this->assertNotSame($page->title(), $duplicate->title()); + } + + public function testDuplicateFillsTheFirstAvailableCopySlug(): void + { + $page = $this->temporaryPage('original'); + $first = $page->duplicate(); + $second = $page->duplicate(); + $first->delete(); + $third = $page->duplicate(); + + $this->assertSame('original-copy', $third->slug()); + $this->assertSame($third->contentPath(), $first->contentPath()); + $this->assertSame('original-copy-2', $second->slug()); + } + + public function testSaveReloadRoundTripPreservesThePersistedState(): void + { + $page = $this->temporaryPage('about'); + $page->setMultiple([ + 'slug' => 'renamed', + 'title' => 'Persisted title', + 'content' => 'Persisted content', + ]); + $page->save(); + $page->reload(); + + $this->assertSame('renamed', $page->slug()); + $this->assertSame('/renamed/', $page->route()); + $this->assertSame('Persisted title', $page->title()); + $this->assertSame('Persisted content', $page->contentFile()?->content()); + } + + public function testChangingLanguageReloadsTheCorrespondingContentVersion(): void + { + $site = $this->temporarySite(); + $path = FileSystem::joinPaths((string) $site->contentPath(), 'localized') . '/'; + $page = new Page(['site' => $site, 'path' => $path, 'language' => 'it'], $this->app); + + $this->assertSame('it', $page->language()?->code()); + $page->set('language', 'en'); + + $this->assertSame('en', $page->language()?->code()); + $this->assertSame('English', $page->title()); + } + + public function testInvalidLanguageChangeLeavesTheCurrentLanguageUntouched(): void + { + $site = $this->temporarySite(); + $path = FileSystem::joinPaths((string) $site->contentPath(), 'localized') . '/'; + $page = new Page(['site' => $site, 'path' => $path, 'language' => 'it'], $this->app); + + try { + $page->set('language', 'fr'); + $this->fail('The invalid language should have been rejected.'); + } catch (\Throwable) { + // Expected. + } + + $this->assertSame('it', $page->language()?->code()); + $this->assertSame('Italiano', $page->title()); + } + public static function invalidTaxonomyProvider(): iterable { yield 'scalar' => ['not-an-array', \TypeError::class]; diff --git a/tests/Unit/Parsers/JsonTest.php b/tests/Unit/Parsers/JsonTest.php index 4baca1aca..5a5d01b8a 100644 --- a/tests/Unit/Parsers/JsonTest.php +++ b/tests/Unit/Parsers/JsonTest.php @@ -94,4 +94,26 @@ public function testEncodeEmptyArrayWithForceObjectOption(): void $this->assertJsonStringEqualsJsonString('{}', Json::encode($data, ['forceObject' => true])); } + + public function testRoundTripPreservesMeaningfulScalarTypes(): void + { + $data = [ + 'null' => null, + 'false' => false, + 'zero' => 0, + 'fraction' => 1.0, + 'empty' => '', + 'list' => [], + 'nested' => ['value' => 'text'], + ]; + + $this->assertSame($data, Json::parse(Json::encode($data))); + } + + public function testEmptyArrayUsesObjectEncodingByDefault(): void + { + $this->assertSame([], Json::parse(Json::encode([]))); + $this->assertSame([], Json::parse(Json::encode([], ['forceObject' => true]))); + $this->assertSame('{}', Json::encode([], ['forceObject' => true])); + } } diff --git a/tests/Unit/Parsers/PhpTest.php b/tests/Unit/Parsers/PhpTest.php index 1fb691201..cc046ac40 100644 --- a/tests/Unit/Parsers/PhpTest.php +++ b/tests/Unit/Parsers/PhpTest.php @@ -144,4 +144,31 @@ public function testEncodeToFile(): void $this->assertSame($data, include $filePath); } + + public function testRoundTripPreservesArraysAndScalars(): void + { + $data = [ + 'string' => 'text', + 'integer' => 42, + 'float' => 1.5, + 'boolean' => false, + 'null' => null, + 'nested' => ['value' => 'nested'], + ]; + + $path = TESTS_TMP_PATH . '/semantic.php'; + $this->setUpTempDirectory(); + try { + Php::encodeToFile($data, $path); + $this->assertSame($data, Php::parseFile($path)); + } finally { + $this->tearDownTempDirectory(); + } + } + + public function testParserNeverEvaluatesStringInput(): void + { + $this->expectException(LogicException::class); + Php::parse(' true];'); + } } diff --git a/tests/Unit/Parsers/YamlTest.php b/tests/Unit/Parsers/YamlTest.php index 661e88e51..82669a467 100644 --- a/tests/Unit/Parsers/YamlTest.php +++ b/tests/Unit/Parsers/YamlTest.php @@ -94,4 +94,23 @@ public function testEncodeReturnsEmptyStringForEmptyData(): void { $this->assertSame('', Yaml::encode([])); } + + public function testRoundTripPreservesNestedData(): void + { + $data = [ + 'title' => 'Test', + 'enabled' => false, + 'count' => 0, + 'tags' => ['php', 'cms'], + 'nested' => ['value' => 'text'], + ]; + + $this->assertSame($data, Yaml::parse(Yaml::encode($data))); + } + + public function testYamlEmptyDataHasAnExplicitEmptyEncoding(): void + { + $this->assertSame('', Yaml::encode([])); + $this->assertSame([], Yaml::parse(Yaml::encode([]))); + } } diff --git a/tests/Unit/Router/CompiledRouteTest.php b/tests/Unit/Router/CompiledRouteTest.php new file mode 100644 index 000000000..e3784f4f1 --- /dev/null +++ b/tests/Unit/Router/CompiledRouteTest.php @@ -0,0 +1,20 @@ +assertSame('/users/{id}', $compiled->path()); + $this->assertSame('~^/users/([^/]+)$~', $compiled->regex()); + $this->assertSame(['id'], $compiled->params()); + } +} diff --git a/tests/Unit/Router/Events/RouteActionResolvedEventTest.php b/tests/Unit/Router/Events/RouteActionResolvedEventTest.php new file mode 100644 index 000000000..9dbde92bb --- /dev/null +++ b/tests/Unit/Router/Events/RouteActionResolvedEventTest.php @@ -0,0 +1,35 @@ + null; + $event = new RouteActionResolvedEvent($route, $action); + + $this->assertSame('routeActionResolved', $event->name()); + $this->assertSame($route, $event->route()); + $this->assertSame($action, $event->action()); + $this->assertSame(['route' => $route, 'action' => $action], $event->data()); + } + + public function testActionCanBeReplaced(): void + { + $first = static fn(): null => null; + $second = static fn(): null => null; + $event = new RouteActionResolvedEvent(new Route('home', '/'), $first); + + $event->setAction($second); + + $this->assertSame($second, $event->action()); + } +} diff --git a/tests/Unit/Router/Fixtures/routes.php b/tests/Unit/Router/Fixtures/routes.php new file mode 100644 index 000000000..80d8752b9 --- /dev/null +++ b/tests/Unit/Router/Fixtures/routes.php @@ -0,0 +1,22 @@ + [ + 'loaded' => [ + 'path' => '/loaded/{id:digits}', + 'action' => 'Formwork\Tests\Unit\Router\RouteControllerFixture@handle', + 'actionParameters' => ['fromRouteFile' => 'route'], + 'where' => ['id' => ['42']], + 'methods' => ['POST'], + 'prefix' => '/api', + ], + ], + 'filters' => [ + 'loaded-filter' => [ + 'action' => 'Formwork\Tests\Unit\Router\RouteFilterFixture@handle', + 'methods' => ['GET'], + 'types' => ['HTTP'], + 'prefix' => '/api', + ], + ], +]; diff --git a/tests/Unit/Router/RouteCollectionTest.php b/tests/Unit/Router/RouteCollectionTest.php new file mode 100644 index 000000000..aaf125520 --- /dev/null +++ b/tests/Unit/Router/RouteCollectionTest.php @@ -0,0 +1,48 @@ +assertTrue($collection->isAssociative()); + $this->assertTrue($collection->isTyped()); + $this->assertTrue($collection->isMutable()); + $this->assertSame(Route::class, $collection->dataType()); + $this->assertTrue($collection->isEmpty()); + } + + public function testAddIndexesRoutesByNameAndReplacesExistingNames(): void + { + $first = new Route('home', '/'); + $replacement = new Route('home', '/start'); + $other = new Route('about', '/about'); + $collection = new RouteCollection(); + + $collection->add($first); + $collection->add($other); + $collection->add($replacement); + + $this->assertCount(2, $collection); + $this->assertSame($replacement, $collection->get('home')); + $this->assertSame($other, $collection->get('about')); + $this->assertSame(['home', 'about'], $collection->keys()); + } + + public function testConstructorRejectsNonRouteItems(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Typed collections cannot be created from data of different types'); + new RouteCollection(['home' => new \stdClass()]); + } +} diff --git a/tests/Unit/Router/RouteFilterCollectionTest.php b/tests/Unit/Router/RouteFilterCollectionTest.php new file mode 100644 index 000000000..048b9f0f7 --- /dev/null +++ b/tests/Unit/Router/RouteFilterCollectionTest.php @@ -0,0 +1,44 @@ +assertTrue($collection->isAssociative()); + $this->assertTrue($collection->isTyped()); + $this->assertTrue($collection->isMutable()); + $this->assertSame(RouteFilter::class, $collection->dataType()); + } + + public function testAddIndexesFiltersByName(): void + { + $authentication = new RouteFilter('auth', 'Auth@handle'); + $logging = new RouteFilter('logging', 'Log@handle'); + $collection = new RouteFilterCollection(); + + $collection->add($authentication); + $collection->add($logging); + + $this->assertCount(2, $collection); + $this->assertSame($authentication, $collection->get('auth')); + $this->assertSame($logging, $collection->get('logging')); + } + + public function testConstructorRejectsNonFilterItems(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Typed collections cannot be created from data of different types'); + new RouteFilterCollection(['auth' => new \stdClass()]); + } +} diff --git a/tests/Unit/Router/RouteFilterTest.php b/tests/Unit/Router/RouteFilterTest.php new file mode 100644 index 000000000..e543f0e89 --- /dev/null +++ b/tests/Unit/Router/RouteFilterTest.php @@ -0,0 +1,54 @@ + null; + $filter = new RouteFilter('auth', $action); + + $this->assertSame('auth', $filter->getName()); + $this->assertSame($action, $filter->getAction()); + $this->assertSame(['GET'], $filter->getMethods()); + $this->assertSame(['HTTP'], $filter->getTypes()); + $this->assertSame('', $filter->getPrefix()); + } + + public function testMethodsTypesAndPrefixAreFluent(): void + { + $filter = new RouteFilter('api', 'Filter@handle'); + + $this->assertSame($filter, $filter->methods('GET', 'POST')); + $this->assertSame(['GET', 'POST'], $filter->getMethods()); + $this->assertSame($filter, $filter->types('XHR')); + $this->assertSame(['XHR'], $filter->getTypes()); + $this->assertSame($filter, $filter->prefix('/admin')); + $this->assertSame('/admin', $filter->getPrefix()); + } + + public function testNamedArgumentsAreRejectedByMethodsAndTypes(): void + { + $filter = new RouteFilter('api', 'Filter@handle'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Formwork\Router\RouteFilter::methods() accepts only unnamed arguments'); + $filter->methods(method: 'GET'); + } + + public function testNamedArgumentsAreRejectedByTypes(): void + { + $filter = new RouteFilter('api', 'Filter@handle'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Formwork\Router\RouteFilter::types() accepts only unnamed arguments'); + $filter->types(type: 'HTTP'); + } +} diff --git a/tests/Unit/Router/RouteParamsTest.php b/tests/Unit/Router/RouteParamsTest.php new file mode 100644 index 000000000..2bb3a4f2d --- /dev/null +++ b/tests/Unit/Router/RouteParamsTest.php @@ -0,0 +1,32 @@ + '42', 'filters' => ['published' => true]]); + + $this->assertTrue($params->has('id')); + $this->assertFalse($params->has('missing')); + $this->assertSame('42', $params->get('id')); + $this->assertSame('fallback', $params->get('missing', 'fallback')); + $this->assertSame(['published' => true], $params->get('filters')); + $this->assertSame(['id' => '42', 'filters' => ['published' => true]], $params->toArray()); + } + + public function testEmptyRouteParamsHaveNoValues(): void + { + $params = new RouteParams([]); + + $this->assertFalse($params->has('id')); + $this->assertNull($params->get('id')); + $this->assertSame([], $params->toArray()); + } +} diff --git a/tests/Unit/Router/RouteTest.php b/tests/Unit/Router/RouteTest.php new file mode 100644 index 000000000..e868494de --- /dev/null +++ b/tests/Unit/Router/RouteTest.php @@ -0,0 +1,243 @@ +assertSame('home', $route->getName()); + $this->assertSame('/', $route->getPath()); + $this->assertSame(['GET'], $route->getMethods()); + $this->assertSame(['HTTP'], $route->getTypes()); + $this->assertSame('', $route->getPrefix()); + $this->assertSame([], $route->getConstraints()); + $this->assertSame([], $route->getActionParameters()); + } + + #[DataProvider('pathProvider')] + public function testConstructorPreservesNameAndPath(string $name, string $path): void + { + $route = new Route($name, $path); + + $this->assertSame($name, $route->getName()); + $this->assertSame($path, $route->getPath()); + } + + /** + * @return array + */ + public static function pathProvider(): array + { + return [ + 'empty path' => ['empty', ''], + 'root path' => ['root', '/'], + 'trailing slash' => ['docs', '/docs/'], + ]; + } + + public function testActionAndActionParametersAreFluentAndReadable(): void + { + $action = static fn(): Response => new Response('ok'); + $parameters = ['layout' => 'minimal', 'cache' => false]; + $route = (new Route('home', '/'))->action($action)->actionParameters($parameters); + + $this->assertSame($route, $route->action($action)); + $this->assertSame($action, $route->getAction()); + $this->assertSame($parameters, $route->getActionParameters()); + + $route->action('Controller@show'); + $this->assertSame('Controller@show', $route->getAction()); + } + + public function testMethodsAreConfigurableAndReplaceTheDefaults(): void + { + $route = new Route('submit', '/submit'); + + $this->assertSame($route, $route->methods('POST', 'PUT')); + $this->assertSame(['POST', 'PUT'], $route->getMethods()); + + $route->methods(); + $this->assertSame([], $route->getMethods()); + } + + public function testMethodsRejectNamedArguments(): void + { + $route = new Route('submit', '/submit'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Formwork\Router\Route::methods() accepts only unnamed arguments'); + + $route->methods(method: 'POST'); + } + + public function testTypesAreConfigurableAndReplaceTheDefaults(): void + { + $route = new Route('endpoint', '/endpoint'); + + $this->assertSame($route, $route->types('HTTP', 'XHR')); + $this->assertSame(['HTTP', 'XHR'], $route->getTypes()); + + $route->types(); + $this->assertSame([], $route->getTypes()); + } + + public function testTypesRejectNamedArguments(): void + { + $route = new Route('endpoint', '/endpoint'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Formwork\Router\Route::types() accepts only unnamed arguments'); + + $route->types(type: 'XHR'); + } + + public function testPrefixIsFluentAndReadable(): void + { + $route = new Route('admin.home', '/'); + + $this->assertSame($route, $route->prefix('/admin')); + $this->assertSame('/admin', $route->getPrefix()); + } + + public function testParameterConstraintsAcceptArraysAndClosures(): void + { + $constraint = static fn(string $value): bool => $value !== ''; + $route = new Route('user', '/users/{id}'); + + $this->assertSame($route, $route->where('id', ['42', '43'])); + $this->assertSame(['id' => ['42', '43']], $route->getConstraints()); + + $route->where('id', $constraint); + $this->assertSame(['id' => $constraint], $route->getConstraints()); + } + + #[DataProvider('matchingProvider')] + public function testRouteMatchingExposesStaticAndExtractedParameters( + string $routePath, + string $requestPath, + array $expectedParams, + ): void { + $router = $this->router($requestPath); + $route = $router->addRoute('test', $routePath)->action( + static fn(RouteParams $params): Response => new Response((string) json_encode($params->toArray())) + ); + + $response = $router->dispatch(); + + $this->assertSame($route, $router->current()); + $this->assertSame($expectedParams, $router->params()->toArray()); + $this->assertSame($expectedParams, json_decode($response->content(), true)); + } + + /** + * @return array}> + */ + public static function matchingProvider(): array + { + return [ + 'static root' => ['/', '/', []], + 'static path with trailing slash' => ['/docs/', '/docs/', []], + 'dynamic digits parameter' => ['/users/{id:digits}', '/users/42/', ['id' => '42']], + 'optional parameter omitted' => ['/archive/{year:digits}?', '/archive/', []], + 'optional parameter present' => ['/archive/{year:digits}?', '/archive/2025/', ['year' => '2025']], + ]; + } + + public function testRouteConstraintsControlMatching(): void + { + $router = $this->router('/users/42/'); + $route = $router->addRoute('user', '/users/{id}')->action( + static fn(): Response => new Response('matched') + )->where('id', ['42']); + + $this->assertSame('matched', $router->dispatch()->content()); + $this->assertSame($route, $router->current()); + + $router = $this->router('/users/41/'); + $router->addRoute('user', '/users/{id}')->action( + static fn(): Response => new Response('matched') + )->where('id', static fn(string $value): bool => $value === '42'); + + $this->expectException(RouteNotFoundException::class); + $this->expectExceptionMessage('No route matches with "/users/41/"'); + $router->dispatch(); + } + + #[DataProvider('requirementsProvider')] + public function testRouteMethodTypeAndPrefixRequirementsParticipateInMatching( + string $method, + ?string $requestedWith, + string $routePrefix, + array $routeMethods, + array $routeTypes, + bool $matches, + ): void { + $router = $this->router('/admin/dashboard/', $method, $requestedWith); + $router->addRoute('dashboard', '/dashboard/') + ->action(static fn(): Response => new Response('matched')) + ->prefix($routePrefix) + ->methods(...$routeMethods) + ->types(...$routeTypes); + + if (!$matches) { + $this->expectException(RouteNotFoundException::class); + } + + $response = $router->dispatch(); + + if ($matches) { + $this->assertSame('matched', $response->content()); + } + } + + /** + * @return array, list, bool}> + */ + public static function requirementsProvider(): array + { + return [ + 'matching method and prefix' => ['GET', null, '/admin', ['GET'], ['HTTP'], true], + 'HEAD is equivalent to GET' => ['HEAD', null, '/admin', ['GET'], ['HTTP'], true], + 'method mismatch' => ['POST', null, '/admin', ['GET'], ['HTTP'], false], + 'matching XHR type' => ['GET', 'XMLHttpRequest', '/admin', ['GET'], ['XHR'], true], + 'type mismatch' => ['GET', null, '/admin', ['GET'], ['XHR'], false], + 'prefix mismatch' => ['GET', null, '/other', ['GET'], ['HTTP'], false], + ]; + } + + private function router(string $path, string $method = 'GET', ?string $requestedWith = null): Router + { + $server = [ + 'REQUEST_METHOD' => $method, + 'REQUEST_URI' => $path, + 'SERVER_NAME' => 'example.test', + ]; + + if ($requestedWith !== null) { + $server['HTTP_X_REQUESTED_WITH'] = $requestedWith; + } + + return new Router( + new Container(), + new Request([], [], [], [], $server), + new EventDispatcher(), + ); + } +} diff --git a/tests/Unit/Router/RouterTest.php b/tests/Unit/Router/RouterTest.php new file mode 100644 index 000000000..5b4ecf7e7 --- /dev/null +++ b/tests/Unit/Router/RouterTest.php @@ -0,0 +1,340 @@ +router('https://example.test/docs?draft=1#section'); + + $this->assertSame('/docs/', $router->request()); + $this->assertNull($router->current()); + $this->assertSame([], $router->params()->toArray()); + $this->assertTrue($router->routes()->isEmpty()); + $this->assertTrue($router->filters()->isEmpty()); + } + + public function testRequestPrefixAndSetRequestUseNormalizedPaths(): void + { + $router = $this->router('/admin/users'); + + $this->assertTrue($router->requestHasPrefix('/admin')); + $this->assertFalse($router->requestHasPrefix('/administrator')); + + $router->setRequest('/public/index?tab=1'); + + $this->assertSame('/public/index/', $router->request()); + $this->assertTrue($router->requestHasPrefix('public')); + $this->assertFalse($router->requestHasPrefix('/admin')); + } + + public function testSetRequestRejectsAUriWithoutAPath(): void + { + $router = $this->router('/'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid URI "https://"'); + $router->setRequest('https://'); + } + + public function testAddRouteAndFilterReturnConfiguredObjectsAndCollections(): void + { + $router = $this->router('/'); + $route = $router->addRoute('home', '/')->action(static fn(): Response => new Response('home')); + $filter = $router->addFilter('auth', static fn(): null => null); + + $this->assertSame($route, $router->routes()->get('home')); + $this->assertSame($filter, $router->filters()->get('auth')); + $this->assertCount(1, $router->routes()); + $this->assertCount(1, $router->filters()); + } + + public function testDispatchesAClosureActionAndInjectsRouteParams(): void + { + $router = $this->router('/users/42/'); + $route = $router->addRoute('user', '/users/{id:digits}') + ->action(static fn(RouteParams $params): Response => new Response($params->get('id'))); + + $response = $router->dispatch(); + + $this->assertSame('42', $response->content()); + $this->assertSame($route, $router->current()); + $this->assertSame(['id' => '42'], $router->params()->toArray()); + } + + public function testDispatchesTheFirstMatchingRouteAndSkipsNonMatchingRoutes(): void + { + $router = $this->router('/posts/42/'); + $router->addRoute('wrong', '/users/{id}')->action(static fn(): Response => new Response('wrong')); + $matching = $router->addRoute('post', '/posts/{id}') + ->action(static fn(): Response => new Response('post')); + $router->addRoute('unreached', '/posts/{id}')->action(static fn(): Response => new Response('unreached')); + + $this->assertSame('post', $router->dispatch()->content()); + $this->assertSame($matching, $router->current()); + } + + public function testDispatchesFilterResponseBeforeRoutes(): void + { + $router = $this->router('/admin/'); + $router->addFilter('maintenance', static fn(): Response => new Response('filtered')); + $router->addRoute('home', '/admin/')->action(static fn(): Response => new Response('route')); + + $this->assertSame('filtered', $router->dispatch()->content()); + $this->assertNull($router->current()); + } + + public function testAFilterReturningNullAllowsRouteDispatchToContinue(): void + { + $router = $this->router('/'); + $router->addFilter('observer', static fn(): null => null); + $router->addRoute('home', '/')->action(static fn(): Response => new Response('route')); + + $this->assertSame('route', $router->dispatch()->content()); + } + + public function testFilterRequirementsAreAppliedBeforeItsAction(): void + { + $router = $this->router('/admin/', 'POST'); + $router->addFilter('get-only', static fn(): Response => new Response('wrong'))->methods('GET'); + $router->addFilter('other-prefix', static fn(): Response => new Response('wrong'))->prefix('/other'); + $router->addRoute('home', '/admin/')->action(static fn(): Response => new Response('route'))->methods('POST'); + + $this->assertSame('route', $router->dispatch()->content()); + } + + public function testDispatchRejectsInvalidActions(): void + { + $router = $this->router('/'); + $router->addRoute('home', '/')->action('not-callable'); + + $this->expectException(InvalidRouteException::class); + $this->expectExceptionMessage('Invalid callback'); + $router->dispatch(); + } + + public function testDispatchThrowsWhenNoRouteMatches(): void + { + $router = $this->router('/missing'); + + $this->expectException(RouteNotFoundException::class); + $this->expectExceptionMessage('No route matches with "/missing/"'); + $router->dispatch(); + } + + public function testDispatchSupportsControllerActionsAndActionParameters(): void + { + $router = $this->router('/controller/'); + $router->addRoute('controller', '/controller/') + ->action(RouteControllerFixture::class . '@handle') + ->actionParameters(['fromRoute' => 'direct']); + + $this->assertSame('direct', $router->dispatch()->content()); + } + + public function testRouteActionResolvedEventCanReplaceTheAction(): void + { + $events = new EventDispatcher(); + $router = $this->router('/', events: $events); + $router->addRoute('home', '/')->action(static fn(): Response => new Response('original')); + $events->on('routeActionResolved', static function ($event): void { + $event->setAction(static fn(): Response => new Response('replaced')); + }); + + $this->assertSame('replaced', $router->dispatch()->content()); + } + + #[DataProvider('generateProvider')] + public function testGenerateBuildsPathsForSupportedParameterPatterns( + string $path, + array $params, + string $expected, + ): void { + $router = $this->router('/'); + $router->addRoute('generated', $path); + + $this->assertSame($expected, $router->generate('generated', $params)); + } + + /** + * @return array, string}> + */ + public static function generateProvider(): array + { + return [ + 'static path' => ['/docs/', [], '/docs/'], + 'default any pattern' => ['/users/{id}', ['id' => 'abc-123'], '/users/abc-123/'], + 'all pattern' => ['/files/{path:all}', ['path' => 'a/b.txt'], '/files/a/b.txt/'], + 'slug shortcut' => ['/posts/{slug:slug}', ['slug' => 'hello-world'], '/posts/hello-world/'], + 'alnum shortcut' => ['/items/{code:alnum}', ['code' => 'A123'], '/items/A123/'], + 'alpha shortcut' => ['/letters/{value:alpha}', ['value' => 'abc'], '/letters/abc/'], + 'digits shortcut' => ['/page/{number:digits}', ['number' => '12'], '/page/12/'], + 'hex shortcut' => ['/hash/{value:xdigits}', ['value' => 'deadbeef'], '/hash/deadbeef/'], + 'number shortcut' => ['/id/{number:number}', ['number' => '12'], '/id/12/'], + 'base64 shortcut' => ['/token/{value:base64}', ['value' => 'YWJj'], '/token/YWJj/'], + 'optional parameter' => ['/archive/{year:digits}?', [], '/archive/'], + 'comma separator' => ['/search,{term}', ['term' => 'php'], '/search,php/'], + 'colon separator' => ['/version:{version}', ['version' => 'v2'], '/version:v2/'], + ]; + } + + public function testGenerateUsesPrefixAndRejectsUnknownRoutes(): void + { + $router = $this->router('/'); + $router->addRoute('admin', '/dashboard/')->prefix('/admin'); + + $this->assertSame('/admin/dashboard/', $router->generate('admin', [])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Route "missing" does not exist'); + $router->generate('missing', []); + } + + public function testGenerateRejectsMissingAndInvalidRequiredParameters(): void + { + $router = $this->router('/'); + $router->addRoute('user', '/users/{id:digits}'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Non-optional parameter "id" requires a value to generate route'); + $router->generate('user', []); + } + + public function testGenerateRejectsValuesThatDoNotMatchTheirConstraint(): void + { + $router = $this->router('/'); + $router->addRoute('user', '/users/{id:digits}'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid value for param "id"'); + $router->generate('user', ['id' => 'abc']); + } + + public function testGenerateWithUsesCurrentParametersButExplicitValuesWin(): void + { + $router = $this->router('/users/42/overview/'); + $router->addRoute('user', '/users/{id}/{tab}?')->action( + static fn(RouteParams $params): Response => new Response($params->get('id')) + ); + $router->dispatch(); + + $this->assertSame('/users/99/overview/', $router->generateWith('user', ['id' => '99'])); + } + + public function testRewriteRequiresADispatchedCurrentRoute(): void + { + $router = $this->router('/'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot rewrite current route: router has not dispatched the request yet'); + $router->rewrite([]); + } + + public function testRewriteUsesCurrentRouteAndCurrentParameters(): void + { + $router = $this->router('/users/42/'); + $router->addRoute('user', '/users/{id}')->action(static fn(): Response => new Response('ok')); + $router->dispatch(); + + $this->assertSame('/users/84/', $router->rewrite(['id' => '84'])); + } + + public function testInvalidRouteDefinitionsExposeMeaningfulCompilationErrors(): void + { + $router = $this->router('/users/42/'); + $router->addRoute('invalid-separator', '/users{id}')->action(static fn(): Response => new Response('no')); + + $this->expectException(InvalidRouteException::class); + $this->expectExceptionMessage('Parameter "id" must be preceded by a separator'); + $router->dispatch(); + } + + public function testDuplicateRouteParametersAreRejected(): void + { + $router = $this->router('/users/42/42/'); + $router->addRoute('duplicate', '/users/{id}/{id}')->action(static fn(): Response => new Response('no')); + + $this->expectException(InvalidRouteException::class); + $this->expectExceptionMessage('Parameter "id" cannot be used more than once'); + $router->dispatch(); + } + + public function testLoadFromFileAppliesRoutesFiltersPrefixesAndActionParameters(): void + { + $router = $this->router('/api/loaded/42/', 'POST'); + $router->loadFromFile( + __DIR__ . '/Fixtures/routes.php', + prefix: '/ignored', + actionParameters: ['fromGlobal' => 'global'], + ); + + $route = $router->routes()->get('loaded'); + $filter = $router->filters()->get('loaded-filter'); + + $this->assertSame('/api', $route->getPrefix()); + $this->assertSame(['fromRouteFile' => 'route', 'fromGlobal' => 'global'], $route->getActionParameters()); + $this->assertSame(['POST'], $route->getMethods()); + $this->assertSame('/api', $filter->getPrefix()); + $this->assertSame('route', $router->dispatch()->content()); + } + + private function router( + string $path, + string $method = 'GET', + ?string $requestedWith = null, + ?EventDispatcher $events = null, + ): Router { + $server = [ + 'REQUEST_METHOD' => $method, + 'REQUEST_URI' => $path, + 'SERVER_NAME' => 'example.test', + ]; + + if ($requestedWith !== null) { + $server['HTTP_X_REQUESTED_WITH'] = $requestedWith; + } + + return new Router( + new Container(), + new Request([], [], [], [], $server), + $events ?? new EventDispatcher(), + ); + } +} + +final class RouteControllerFixture +{ + public function __construct( + private string $fromRoute = '', + private string $fromRouteFile = '', + ) {} + + public function handle(string $fromRouteFile = ''): Response + { + return new Response($this->fromRoute . $this->fromRouteFile . $fromRouteFile); + } +} + +final class RouteFilterFixture +{ + public function handle(): Response + { + return new Response('filter'); + } +} diff --git a/tests/Unit/Services/ContainerTest.php b/tests/Unit/Services/ContainerTest.php new file mode 100644 index 000000000..f3c350dc9 --- /dev/null +++ b/tests/Unit/Services/ContainerTest.php @@ -0,0 +1,737 @@ +define(SimpleService::class); + + $this->assertSame(SimpleService::class, $definition->getName()); + $this->assertNull($definition->getObject()); + $this->assertFalse($container->isResolved(SimpleService::class)); + $this->assertTrue($container->has(SimpleService::class)); + } + + public function testGetBuildsAndSharesAClassService(): void + { + $container = new Container(); + $container->define(SimpleService::class); + + $first = $container->get(SimpleService::class); + $second = $container->get(SimpleService::class); + + $this->assertInstanceOf(SimpleService::class, $first); + $this->assertSame($first, $second); + $this->assertTrue($container->isResolved(SimpleService::class)); + } + + public function testDirectObjectsAreReturnedAndShared(): void + { + $container = new Container(); + $service = new SimpleService(); + $container->define('configured', $service); + + $this->assertSame($service, $container->get('configured')); + $this->assertSame($service, $container->get('configured')); + } + + public function testBuildInjectsRegisteredDependenciesAndStaticParameters(): void + { + $container = new Container(); + $dependency = new SharedDependency(); + $container->define(SharedDependency::class, $dependency); + $container->define(DependentService::class) + ->parameter('label', 'configured'); + + $service = $container->get(DependentService::class); + + $this->assertSame($dependency, $service->dependency); + $this->assertSame('configured', $service->label); + } + + public function testDependenciesCanBeResolvedThroughAnInterfaceAlias(): void + { + $container = new Container(); + $container->define(SharedDependency::class)->alias(DependencyContract::class); + $container->define(DependentOnContract::class); + + $service = $container->get(DependentOnContract::class); + + $this->assertInstanceOf(SharedDependency::class, $service->dependency); + $this->assertSame($container->get(DependencyContract::class), $service->dependency); + } + + public function testServiceAttributeSelectsAConfiguredIdentifier(): void + { + $container = new Container(); + $selected = new SimpleService(); + $container->define('selected', $selected); + $container->define(AttributeDependentService::class); + + $service = $container->get(AttributeDependentService::class); + + $this->assertSame($selected, $service->service); + } + + public function testClosuresCanBeUsedAsFactoriesAndAreShared(): void + { + $container = new Container(); + $dependency = new SharedDependency(); + $container->define(SharedDependency::class, $dependency); + $container->define('factory', static function (SharedDependency $dependency, string $value): FactoryProduct { + return new FactoryProduct($dependency, $value); + })->parameter('value', 'factory-value'); + + $first = $container->get('factory'); + $second = $container->get('factory'); + + $this->assertInstanceOf(FactoryProduct::class, $first); + $this->assertSame($dependency, $first->dependency); + $this->assertSame('factory-value', $first->value); + $this->assertSame($first, $second); + } + + public function testFactoryExceptionsArePropagated(): void + { + $container = new Container(); + $container->define('factory', static function (): object { + throw new RuntimeException('factory failed'); + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('factory failed'); + $container->get('factory'); + } + + public function testFactoryReturningAnInvalidValueFailsAtTheFactoryBoundary(): void + { + $container = new Container(); + $container->define('factory', static fn(): object => 'invalid'); + + $this->expectException(\TypeError::class); + $container->get('factory'); + } + + public function testBuildAndCallResolveArgumentsAndHonorExplicitParameters(): void + { + $container = new Container(); + $dependency = new SharedDependency(); + $container->define(SharedDependency::class, $dependency); + + $built = $container->build(DependentService::class, ['label' => 'built']); + $called = $container->call(static fn(SharedDependency $dependency, string $label): FactoryProduct => new FactoryProduct($dependency, $label), ['label' => 'called']); + + $this->assertSame($dependency, $built->dependency); + $this->assertSame('built', $built->label); + $this->assertSame($dependency, $called->dependency); + $this->assertSame('called', $called->value); + } + + public function testBuildExplicitArgumentsOverrideRegisteredDependencies(): void + { + $container = new Container(); + $registered = new SharedDependency(); + $explicit = new SharedDependency(); + $container->define(SharedDependency::class, $registered); + + $service = $container->build(DependentService::class, [ + 'dependency' => $explicit, + 'label' => 'explicit', + ]); + + $this->assertSame($explicit, $service->dependency); + $this->assertNotSame($registered, $service->dependency); + } + + public function testCallExplicitArgumentsOverrideRegisteredDependencies(): void + { + $container = new Container(); + $registered = new SharedDependency(); + $explicit = new SharedDependency(); + $container->define(SharedDependency::class, $registered); + + $result = $container->call( + static fn(SharedDependency $dependency): SharedDependency => $dependency, + ['dependency' => $explicit], + ); + + $this->assertSame($explicit, $result); + } + + public function testBuildAndCallSupportVariadicNamedArguments(): void + { + $container = new Container(); + + $built = $container->build(VariadicService::class, ['values' => ['a', 'b']]); + $called = $container->call(static fn(string ...$values): array => $values, ['values' => ['x', 'y']]); + + $this->assertSame(['a', 'b'], $built->values); + $this->assertSame(['x', 'y'], $called); + } + + public function testVariadicArgumentsMustBeArrays(): void + { + $container = new Container(); + + $this->expectException(ContainerException::class); + $container->call(static fn(string ...$values): array => $values, ['values' => 'invalid']); + } + + public function testParameterClosuresAreEvaluatedBeforeFactoryResolution(): void + { + $container = new Container(); + $dependency = new SharedDependency(); + $container->define('factory', static fn(SharedDependency $dependency): FactoryProduct => new FactoryProduct($dependency, 'closure')) + ->parameter('dependency', static fn(): SharedDependency => $dependency); + + $service = $container->get('factory'); + + $this->assertSame($dependency, $service->dependency); + $this->assertSame('closure', $service->value); + } + + public function testLazyFalseResolvesImmediatelyWhileDefaultDefinitionsRemainUnresolved(): void + { + $container = new Container(); + $container->define(SimpleService::class)->lazy(false); + + $this->assertTrue($container->isResolved(SimpleService::class)); + $this->assertInstanceOf(SimpleService::class, $container->get(SimpleService::class)); + } + + public function testAliasesResolveToTheTargetAndPreserveItsIdentity(): void + { + $container = new Container(); + $container->define(SimpleService::class) + ->alias('first') + ->alias('second'); + + $service = $container->get('first'); + + $this->assertSame($service, $container->get('second')); + $this->assertTrue($container->has('first')); + $this->assertFalse($container->has('unknown')); + $this->assertTrue($container->isResolved('second')); + } + + public function testCircularAliasesAreRejected(): void + { + $container = new Container(); + $container->alias('one', 'two'); + + $this->expectException(ContainerException::class); + $container->alias('two', 'one'); + } + + public function testLoaderIsConstructedWithDependenciesAndCalledOnlyOnce(): void + { + $container = new Container(); + $container->define(SharedDependency::class, new SharedDependency()); + $container->define('loaded')->loader(TestServiceLoader::class); + $container->define(TestServiceLoader::class); + + $first = $container->get('loaded'); + $second = $container->get('loaded'); + + $this->assertInstanceOf(LoadedService::class, $first); + $this->assertSame($first, $second); + $this->assertSame(1, TestServiceLoader::$loadCount); + $this->assertSame(1, TestServiceLoader::$resolvedCount); + } + + public function testAliasesResolveLoaderBackedServicesToTheSharedInstance(): void + { + $container = new Container(); + $container->define(SharedDependency::class, new SharedDependency()); + $container->define('loaded')->loader(TestServiceLoader::class)->alias('loaded.alias'); + $container->define(TestServiceLoader::class); + + $this->assertSame($container->get('loaded'), $container->get('loaded.alias')); + $this->assertSame(1, TestServiceLoader::$loadCount); + } + + public function testLoaderCanReturnAServiceThatDependsOnTheContainer(): void + { + $container = new Container(); + $container->define(Container::class, $container); + $container->define('loaded')->loader(ContainerAwareLoader::class); + $container->define(ContainerAwareLoader::class); + + $service = $container->get('loaded'); + + $this->assertSame('container-aware', $service->name); + } + + public function testLoaderReceivesExplicitConstructorParameters(): void + { + $container = new Container(); + $container->define('loaded')->loader(ParameterizedLoader::class)->parameter('label', 'configured'); + + $service = $container->get('loaded'); + + $this->assertSame('configured', $service->name); + } + + public function testLoaderCanResolveAnotherRegisteredService(): void + { + $container = new Container(); + $dependency = new SharedDependency(); + $container->define(SharedDependency::class, $dependency); + $container->define('loaded')->loader(DependencyResolvingLoader::class); + $container->define(DependencyResolvingLoader::class); + + $service = $container->get('loaded'); + + $this->assertSame($dependency, $service->dependency); + } + + public function testRecursiveLoaderResolutionIsRejected(): void + { + $container = new Container(); + $container->define('first')->loader(RecursiveLoader::class)->parameter('other', 'second'); + $container->define('second')->loader(RecursiveLoader::class)->parameter('other', 'first'); + + $this->expectException(ServiceResolutionException::class); + $container->get('first'); + } + + public function testLoaderExceptionsArePropagatedAndTheServiceRemainsUnresolved(): void + { + $container = new Container(); + $container->define('loaded')->loader(ThrowingLoader::class); + + try { + $container->get('loaded'); + $this->fail('The loader should have thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('loader failed', $exception->getMessage()); + } + + $this->assertTrue($container->has('loaded')); + $this->assertFalse($container->isResolved('loaded')); + } + + public function testLoaderReturningAnInvalidValueFailsAtTheLoaderBoundary(): void + { + $container = new Container(); + $container->define('loaded')->loader(InvalidReturnLoader::class); + + $this->expectException(\TypeError::class); + $container->get('loaded'); + } + + public function testObjectDefinitionsCannotAlsoHaveLoaders(): void + { + $definition = (new Container())->define('service', new SimpleService()); + + $this->expectException(\LogicException::class); + $definition->loader(TestServiceLoader::class); + } + + public function testInvalidLoaderIsRejectedDuringResolution(): void + { + $container = new Container(); + $container->define('loaded')->loader(SimpleService::class); + + $this->expectException(ServiceResolutionException::class); + $this->expectExceptionMessage('Invalid loader'); + $container->get('loaded'); + } + + public function testResolutionAwareLoaderFailureDoesNotLeaveAResolvedService(): void + { + $container = new Container(); + $container->define('loaded')->loader(FailingResolutionAwareLoader::class); + $container->define(FailingResolutionAwareLoader::class); + + $this->expectException(RuntimeException::class); + $container->get('loaded'); + } + + public function testResolutionAwareLoaderFailureCanBeRetried(): void + { + $container = new Container(); + $container->define('loaded')->loader(FailingResolutionAwareLoader::class); + $container->define(FailingResolutionAwareLoader::class); + + try { + $container->get('loaded'); + } catch (RuntimeException) { + } + + $this->assertFalse($container->isResolved('loaded')); + $this->assertSame(1, FailingResolutionAwareLoader::$loadCount); + } + + public function testUnknownServicesHaveThePsrNotFoundException(): void + { + $container = new Container(); + + $this->expectException(ServiceNotFoundException::class); + $this->expectExceptionMessage('missing'); + $container->get('missing'); + } + + public function testResolveRejectsUndefinedServices(): void + { + $container = new Container(); + + $this->expectException(ServiceResolutionException::class); + $container->resolve('missing'); + } + + public function testMissingClassDependenciesAreReportedAsNotFound(): void + { + $container = new Container(); + $container->define(DependentOnMissingService::class); + + $this->expectException(ServiceNotFoundException::class); + $container->get(DependentOnMissingService::class); + } + + public function testNestedDependenciesAndSharedDependenciesPreserveIdentity(): void + { + $container = new Container(); + $container->define(SharedDependency::class); + $container->define(DependentOnSharedDependency::class); + $container->define(NestedDependentService::class); + $container->define(AnotherDependentService::class); + + $nested = $container->get(NestedDependentService::class); + $another = $container->get(AnotherDependentService::class); + + $this->assertSame($nested->dependency->dependency, $another->dependency); + } + + public function testOptionalMissingDependenciesUseTheirDefault(): void + { + $container = new Container(); + $container->define(OptionalDependencyService::class); + + $this->assertNull($container->get(OptionalDependencyService::class)->dependency); + } + + public function testFailedResolutionDoesNotMakeAServiceResolved(): void + { + $container = new Container(); + $container->define(DependentOnMissingService::class); + + try { + $container->get(DependentOnMissingService::class); + $this->fail('The dependency should have been missing.'); + } catch (ServiceNotFoundException) { + } + + $this->assertTrue($container->has(DependentOnMissingService::class)); + $this->assertFalse($container->isResolved(DependentOnMissingService::class)); + } + + public function testDefiningTheSameIdentifierReplacesAnUnresolvedDefinition(): void + { + $container = new Container(); + $first = new SimpleService(); + $second = new SimpleService(); + $container->define('service', $first); + $container->define('service', $second); + + $this->assertSame($second, $container->get('service')); + } + + public function testAliasCanBeRepointedAndCanTargetAnotherAlias(): void + { + $container = new Container(); + $container->define('first', new SimpleService()); + $container->define('second', new SimpleService()); + $container->alias('stable', 'first'); + $container->alias('indirect', 'stable'); + + $this->assertSame($container->get('first'), $container->get('indirect')); + + $container->alias('stable', 'second'); + + $this->assertSame($container->get('second'), $container->get('stable')); + $this->assertNotSame($container->get('first'), $container->get('stable')); + } + + public function testMissingScalarArgumentsAreContainerErrors(): void + { + $container = new Container(); + $container->define(ScalarDependentService::class); + + $this->expectException(ContainerException::class); + $container->get(ScalarDependentService::class); + } + + public function testCircularDependenciesAreReportedAsResolutionErrors(): void + { + $container = new Container(); + $container->define(CircularA::class); + $container->define(CircularB::class); + + $this->expectException(ServiceResolutionException::class); + $container->get(CircularA::class); + } + + #[DataProvider('defaultParameterProvider')] + public function testOptionalParametersUseTheirDefaults(string $class, mixed $expected): void + { + $container = new Container(); + $container->define($class); + + $service = $container->get($class); + + $this->assertSame($expected, $service->value); + } + + public static function defaultParameterProvider(): iterable + { + yield 'string' => [OptionalStringService::class, 'default']; + yield 'integer' => [OptionalIntegerService::class, 7]; + } + + public function testRedefiningAResolvedServiceReplacesTheResolvedInstance(): void + { + $container = new Container(); + + $first = new SimpleService(); + $second = new SimpleService(); + + $container->define('service', $first); + + $this->assertSame($first, $container->get('service')); + + $container->define('service', $second); + + $this->assertSame($second, $container->get('service')); + $this->assertNotSame($first, $container->get('service')); + } + + public function testRedefiningAResolvedDefinitionClearsItsPreviousResolution(): void + { + $container = new Container(); + + $container->define(SimpleService::class); + + $first = $container->get(SimpleService::class); + + $container->define(SimpleService::class); + + $this->assertFalse($container->isResolved(SimpleService::class)); + + $second = $container->get(SimpleService::class); + + $this->assertNotSame($first, $second); + } +} + +interface DependencyContract {} + +final class SimpleService {} + +final class SharedDependency implements DependencyContract {} + +final class DependentService +{ + public function __construct(public SharedDependency $dependency, public string $label) {} +} + +final class DependentOnContract +{ + public function __construct(public DependencyContract $dependency) {} +} + +final class AttributeDependentService +{ + public function __construct(#[Service('selected')] public SimpleService $service) {} +} + +final class FactoryProduct +{ + public function __construct(public SharedDependency $dependency, public string $value) {} +} + +final class VariadicService +{ + public array $values; + + public function __construct(string ...$values) + { + $this->values = $values; + } +} + +final class NestedDependentService +{ + public function __construct(public DependentOnSharedDependency $dependency) {} +} + +final class DependentOnSharedDependency +{ + public function __construct(public SharedDependency $dependency) {} +} + +final class AnotherDependentService +{ + public function __construct(public SharedDependency $dependency) {} +} + +final class OptionalDependencyService +{ + public function __construct(public ?SharedDependency $dependency = null) {} +} + +class LoadedService +{ + public function __construct(public string $name = 'loaded') {} +} + +final class ContainerAwareLoader implements ServiceLoaderInterface +{ + public function __construct(private Container $container) {} + + public function load(Container $container): LoadedService + { + return new LoadedService($this->container === $container ? 'container-aware' : 'wrong'); + } +} + +final class ParameterizedLoader implements ServiceLoaderInterface +{ + public function __construct(private string $label) {} + + public function load(Container $container): LoadedService + { + return new LoadedService($this->label); + } +} + +final class DependencyResolvingLoader implements ServiceLoaderInterface +{ + public function load(Container $container): LoadedService + { + return new LoadedServiceWithDependency($container->get(SharedDependency::class)); + } +} + +final class RecursiveLoader implements ServiceLoaderInterface +{ + public function __construct(private string $other) {} + + public function load(Container $container): LoadedService + { + return $container->get($this->other); + } +} + +final class ThrowingLoader implements ServiceLoaderInterface +{ + public function load(Container $container): LoadedService + { + throw new RuntimeException('loader failed'); + } +} + +final class InvalidReturnLoader implements ServiceLoaderInterface +{ + public function load(Container $container): object + { + return 'invalid'; + } +} + +final class TestServiceLoader implements ResolutionAwareServiceLoaderInterface +{ + public static int $loadCount = 0; + + public static int $resolvedCount = 0; + + public function __construct(private SharedDependency $dependency) {} + + public function load(Container $container): LoadedService + { + self::$loadCount++; + return new LoadedService($this->dependency instanceof SharedDependency ? 'loaded' : 'wrong'); + } + + public function onResolved(object $service, Container $container): void + { + self::$resolvedCount++; + } +} + +final class FailingResolutionAwareLoader implements ResolutionAwareServiceLoaderInterface +{ + public static int $loadCount = 0; + + public function load(Container $container): LoadedService + { + self::$loadCount++; + return new LoadedService(); + } + + public function onResolved(object $service, Container $container): void + { + throw new RuntimeException('resolution callback failed'); + } +} + +final class LoadedServiceWithDependency extends LoadedService +{ + public function __construct(public SharedDependency $dependency) + { + parent::__construct('dependency'); + } +} + +final class DependentOnMissingService +{ + public function __construct(UnknownDependency $dependency) {} +} + +final class ScalarDependentService +{ + public function __construct(string $value) {} +} + +final class CircularA +{ + public function __construct(CircularB $dependency) {} +} + +final class CircularB +{ + public function __construct(CircularA $dependency) {} +} + +final class OptionalStringService +{ + public function __construct(public string $value = 'default') {} +} + +final class OptionalIntegerService +{ + public function __construct(public int $value = 7) {} +} diff --git a/tests/Unit/Services/ServiceDefinitionTest.php b/tests/Unit/Services/ServiceDefinitionTest.php new file mode 100644 index 000000000..d833283af --- /dev/null +++ b/tests/Unit/Services/ServiceDefinitionTest.php @@ -0,0 +1,94 @@ +define('service') + ->parameter('options.enabled', true) + ->parameter('options.name', 'example'); + + $this->assertSame([ + 'options' => [ + 'enabled' => true, + 'name' => 'example', + ], + ], $definition->getParameters()); + } + + public function testParameterReplacementUsesTheLatestValue(): void + { + $definition = (new Container())->define('service') + ->parameter('option', 'first') + ->parameter('option', 'second'); + + $this->assertSame(['option' => 'second'], $definition->getParameters()); + } + + public function testLoaderAndAliasConfigurationAreFluent(): void + { + $container = new Container(); + $definition = $container->define('service') + ->loader(TestServiceLoader::class) + ->alias('service.alias'); + + $this->assertSame($definition, $definition->loader(TestServiceLoader::class)); + $this->assertSame(TestServiceLoader::class, $definition->getLoader()); + $this->assertTrue($container->has('service.alias')); + } + + public function testNewDefinitionsStartWithoutAObjectOrLoader(): void + { + $definition = (new Container())->define('service'); + + $this->assertNull($definition->getObject()); + $this->assertNull($definition->getLoader()); + $this->assertSame([], $definition->getParameters()); + } + + public function testDirectObjectDefinitionsExposeTheirObject(): void + { + $object = new SimpleService(); + $definition = (new Container())->define('service', $object); + + $this->assertSame($object, $definition->getObject()); + } + + public function testLazyFalseEagerlyResolvesTheDefinition(): void + { + $container = new Container(); + $definition = $container->define(SimpleService::class); + + $this->assertFalse($container->isResolved(SimpleService::class)); + $this->assertSame($definition, $definition->lazy(false)); + $this->assertTrue($container->isResolved(SimpleService::class)); + } + + public function testLazyTrueLeavesAnUnresolvedDefinitionUnresolved(): void + { + $container = new Container(); + $definition = $container->define(SimpleService::class); + + $this->assertSame($definition, $definition->lazy(true)); + $this->assertFalse($container->isResolved(SimpleService::class)); + } + + public function testCallingLazyFalseAfterResolutionDoesNotReplaceTheInstance(): void + { + $container = new Container(); + $definition = $container->define(SimpleService::class); + $resolved = $container->get(SimpleService::class); + + $definition->lazy(false); + + $this->assertSame($resolved, $container->get(SimpleService::class)); + } +} diff --git a/tests/Unit/Utils/ArrTest.php b/tests/Unit/Utils/ArrTest.php index 4871e3540..cabe9eb63 100644 --- a/tests/Unit/Utils/ArrTest.php +++ b/tests/Unit/Utils/ArrTest.php @@ -1338,4 +1338,119 @@ public function testFromEntries(): void $this->assertSame($expected, Arr::fromEntries($entries)); } + + public function testLiteralDottedKeysTakePrecedenceOverNestedPaths(): void + { + $array = [ + 'user.name' => 'literal', + 'user' => ['name' => 'nested'], + ]; + + $this->assertSame('literal', Arr::get($array, 'user.name')); + $this->assertTrue(Arr::has($array, 'user.name')); + + Arr::set($array, 'user.name', 'changed'); + $this->assertSame('changed', $array['user.name']); + $this->assertSame('nested', $array['user']['name']); + + Arr::remove($array, 'user.name'); + $this->assertSame(['name' => 'nested'], $array['user']); + } + + public function testDotAndUndotRoundTripNestedAssociativeArrays(): void + { + $source = [ + 'user' => [ + 'name' => 'Alice', + 'profile' => ['city' => 'Bologna'], + ], + 'tags' => ['php', 'cms'], + ]; + + $this->assertSame($source, Arr::undot(Arr::dot($source))); + } + + public function testDotDoesNotFlattenLists(): void + { + $source = ['items' => [['id' => 1], ['id' => 2]]]; + + $this->assertSame($source, Arr::undot(Arr::dot($source))); + $this->assertSame(['items' => $source['items']], Arr::dot($source)); + } + + public function testGetDistinguishesMissingValuesFromExistingNullValues(): void + { + $array = ['present' => null]; + + $this->assertTrue(Arr::has($array, 'present')); + $this->assertNull(Arr::get($array, 'present', 'fallback')); + $this->assertSame('fallback', Arr::get($array, 'missing', 'fallback')); + } + + public function testMoveItemPreservesAllValuesAndKeys(): void + { + $array = ['first' => 'A', 'second' => 'B', 'third' => 'C']; + + Arr::moveItem($array, 0, 2); + + $this->assertSame(['second' => 'B', 'third' => 'C', 'first' => 'A'], $array); + } + + public function testSpliceRejectsReplacementKeyCollisionsWithoutChangingTheArray(): void + { + $array = ['a' => 1, 'b' => 2, 'c' => 3]; + $before = $array; + + try { + Arr::splice($array, 1, 1, ['a' => 20]); + $this->fail('The replacement key should have been rejected.'); + } catch (UnexpectedValueException) { + // Expected. + } + + $this->assertSame($before, $array); + } + + public function testAppendMissingAddsOnlyMissingValuesRecursively(): void + { + $first = ['config' => ['enabled' => true]]; + $second = ['config' => ['enabled' => false, 'cache' => true], 'extra' => 1]; + + $this->assertSame([ + 'config' => ['enabled' => true, 'cache' => true], + 'extra' => 1, + ], Arr::appendMissing($first, $second)); + } + + public function testExtendConcatenatesListsAndRecursesIntoAssociativeArrays(): void + { + $first = ['items' => [1], 'config' => ['a' => 1]]; + $second = ['items' => [2], 'config' => ['b' => 2]]; + + $this->assertSame([ + 'items' => [1, 2], + 'config' => ['a' => 1, 'b' => 2], + ], Arr::extend($first, $second)); + $this->assertSame(['items' => [1], 'config' => ['a' => 1]], $first); + } + + public function testOverrideReplacesListsAtomicallyAndRecursesIntoAssociativeArrays(): void + { + $first = ['items' => [1, 2], 'config' => ['a' => 1, 'nested' => ['x' => 1]]]; + $second = ['items' => [3], 'config' => ['nested' => ['y' => 2]]]; + + $this->assertSame([ + 'items' => [3], + 'config' => ['a' => 1, 'nested' => ['x' => 1, 'y' => 2]], + ], Arr::override($first, $second)); + } + + public function testExcludeRemovesEmptyParentsAfterRecursiveExclusion(): void + { + $array = ['config' => ['a' => 1, 'b' => 2], 'keep' => true]; + + $this->assertSame(['keep' => true], Arr::exclude($array, [ + 'config' => ['a' => 1, 'b' => 2], + ])); + } } From b19477437a2330c1ad5e1f4db8ff39b7eb7c4f64 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:53:52 +0000 Subject: [PATCH 13/51] Fix test fixtures paths and strengthen weak tests - Use a consistent `Fixtures` directory name (Http, Pages) and fix the `fixtures` references in FileSystemTest and MimeTypeTest, which only worked on case-insensitive filesystems - Make FormTest independent from the execution order by loading the schemes service (which registers `FieldFactory`) in `setUp()` - Cover previously untested behavior: `Request::isLocalhost()` with IPv6, `Request::validateReferer()` negative cases, forwarded protocols, single-byte and clamped ranges in `FileResponse`, and the destruction of the old session on `Session::regenerate()` - Replace weak or misleading tests (page icon, slug validation, publish date windows, content file resolution, lifecycle events, non-GET request content, `Request::fromGlobals()`) with assertions on actual behavior Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd --- tests/Unit/Forms/FormTest.php | 10 +- tests/Unit/Http/ClientTest.php | 2 +- tests/Unit/Http/FileResponseTest.php | 56 ++++++- tests/Unit/Http/FileSessionHandlerTest.php | 2 +- .../{fixtures => Fixtures}/files/sample.txt | 0 .../sessions/sess_existing | 0 tests/Unit/Http/RequestTest.php | 132 ++++++++++++--- tests/Unit/Http/SessionTest.php | 37 ++++ tests/Unit/Http/UploadedFileTest.php | 2 +- tests/Unit/Pages/ContentFileTest.php | 4 +- .../numbered-site/1-numbered/page.md | 0 .../site/about/audio.mp3 | Bin .../site/about/image.svg | 0 .../{fixtures => Fixtures}/site/about/page.md | 0 .../site/about/video.mp4 | Bin .../{fixtures => Fixtures}/site/blog/blog.md | 0 .../site/blog/hello-world/page.md | 0 .../site/directory-only/.gitkeep | 0 .../{fixtures => Fixtures}/site/empty/page.md | 0 .../site/error/error.md | 0 .../site/index/index.md | 0 .../site/localized/page.en.md | 0 .../site/localized/page.it.md | 0 .../site/original/page.md | 0 .../site/parent/child/page.md | 0 .../site/parent/page.md | 0 tests/Unit/Pages/PageCollectionTest.php | 2 +- tests/Unit/Pages/PageEventsTest.php | 31 +++- tests/Unit/Pages/PageFactoryTest.php | 2 +- tests/Unit/Pages/PageTest.php | 158 ++++++++++++++---- tests/Unit/Pages/PaginationTest.php | 2 +- tests/Unit/Utils/FileSystemTest.php | 2 +- tests/Unit/Utils/MimeTypeTest.php | 8 +- 33 files changed, 383 insertions(+), 67 deletions(-) rename tests/Unit/Http/{fixtures => Fixtures}/files/sample.txt (100%) rename tests/Unit/Http/{fixtures => Fixtures}/sessions/sess_existing (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/numbered-site/1-numbered/page.md (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/about/audio.mp3 (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/about/image.svg (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/about/page.md (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/about/video.mp4 (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/blog/blog.md (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/blog/hello-world/page.md (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/directory-only/.gitkeep (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/empty/page.md (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/error/error.md (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/index/index.md (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/localized/page.en.md (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/localized/page.it.md (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/original/page.md (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/parent/child/page.md (100%) rename tests/Unit/Pages/{fixtures => Fixtures}/site/parent/page.md (100%) diff --git a/tests/Unit/Forms/FormTest.php b/tests/Unit/Forms/FormTest.php index 9965f0588..ce707b320 100644 --- a/tests/Unit/Forms/FormTest.php +++ b/tests/Unit/Forms/FormTest.php @@ -13,19 +13,25 @@ use Formwork\Http\Request; use Formwork\Http\RequestMethod; use Formwork\Http\ResponseStatus; +use Formwork\Schemes\Schemes; use Formwork\Tests\TestCase; use LogicException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; #[CoversClass(Form::class)] -class FormTest extends TestCase +final class FormTest extends TestCase { private App $app; - public function setUp(): void + protected function setUp(): void { + parent::setUp(); $this->app = App::instance(); + + // `FieldFactory` is registered lazily by the schemes service loader, + // so make sure it is available regardless of the test execution order + $this->app->getService(Schemes::class); } public function testNameAndFieldsAreAvailable(): void diff --git a/tests/Unit/Http/ClientTest.php b/tests/Unit/Http/ClientTest.php index f957316bb..5b943b18a 100644 --- a/tests/Unit/Http/ClientTest.php +++ b/tests/Unit/Http/ClientTest.php @@ -89,7 +89,7 @@ public function testUnreadableCaBundleIsRejected(): void 'content' => '', 'redirects' => ['follow' => true, 'limit' => 5], 'timeout' => -1, - 'ssl' => ['verify' => true, 'cabundle' => __DIR__ . '/fixtures/files/missing-ca.pem'], + 'ssl' => ['verify' => true, 'cabundle' => __DIR__ . '/Fixtures/files/missing-ca.pem'], ]); } } diff --git a/tests/Unit/Http/FileResponseTest.php b/tests/Unit/Http/FileResponseTest.php index 2eb8c08d6..b7456d1ed 100644 --- a/tests/Unit/Http/FileResponseTest.php +++ b/tests/Unit/Http/FileResponseTest.php @@ -10,6 +10,7 @@ use Formwork\Utils\FileSystem; use LogicException; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; #[CoversClass(FileResponse::class)] final class FileResponseTest extends TestCase @@ -61,6 +62,59 @@ public function testFileResponseHandlesSuffixOpenEndedAndUnsatisfiableRanges(): $this->assertSame('0', $invalid->headers()->get('Content-Length')); } + #[DataProvider('satisfiableRangeProvider')] + public function testFileResponseServesSatisfiableRangesWithTheExactBoundaries(string $range, int $start, int $end): void + { + $path = $this->fixturePath(); + $size = FileSystem::fileSize($path); + $response = new FileResponse($path); + $response->prepare($this->request(server: ['HTTP_RANGE' => $range])); + + $this->assertSame(ResponseStatus::PartialContent, $response->status()); + $this->assertSame(sprintf('bytes %d-%d/%d', $start, $end, $size), $response->headers()->get('Content-Range')); + $this->assertSame((string) ($end - $start + 1), $response->headers()->get('Content-Length')); + } + + /** + * The fixture is 31 bytes long + * + * @return iterable + */ + public static function satisfiableRangeProvider(): iterable + { + yield 'single first byte' => ['bytes=0-0', 0, 0]; + yield 'single byte in the middle' => ['bytes=3-3', 3, 3]; + yield 'single last byte' => ['bytes=30-30', 30, 30]; + yield 'last byte by suffix' => ['bytes=-1', 30, 30]; + yield 'whole file' => ['bytes=0-30', 0, 30]; + yield 'end beyond the file is clamped' => ['bytes=5-999', 5, 30]; + yield 'suffix longer than the file is clamped' => ['bytes=-999', 0, 30]; + } + + public function testFileResponseRejectsRangesStartingAfterTheirEndOrAfterTheFile(): void + { + $size = FileSystem::fileSize($this->fixturePath()); + + foreach (['bytes=5-4', 'bytes=' . $size . '-' . $size, 'bytes=' . $size . '-'] as $range) { + $response = new FileResponse($this->fixturePath()); + $response->prepare($this->request(server: ['HTTP_RANGE' => $range])); + + $this->assertSame(ResponseStatus::RangeNotSatisfiable, $response->status(), $range); + $this->assertSame('bytes */' . $size, $response->headers()->get('Content-Range'), $range); + } + } + + public function testFileResponseIgnoresMalformedRanges(): void + { + foreach (['bytes=-', 'items=0-4', 'bytes=a-b', 'bytes=0-4,6-8'] as $range) { + $response = new FileResponse($this->fixturePath()); + $response->prepare($this->request(server: ['HTTP_RANGE' => $range])); + + $this->assertSame(ResponseStatus::OK, $response->status(), $range); + $this->assertFalse($response->headers()->has('Content-Range'), $range); + } + } + public function testFileResponseAddsAcceptRangesForHeadAndSkipsRangesForEmptyResponses(): void { $path = $this->fixturePath(); @@ -225,7 +279,7 @@ public function testFullResponseStreamsTheEntireFile(): void private function fixturePath(): string { - return __DIR__ . '/fixtures/files/sample.txt'; + return __DIR__ . '/Fixtures/files/sample.txt'; } /** diff --git a/tests/Unit/Http/FileSessionHandlerTest.php b/tests/Unit/Http/FileSessionHandlerTest.php index cc5cdc7f7..4bf650e85 100644 --- a/tests/Unit/Http/FileSessionHandlerTest.php +++ b/tests/Unit/Http/FileSessionHandlerTest.php @@ -25,7 +25,7 @@ protected function tearDown(): void public function testHandlerReadsWritesValidatesAndDestroysSessions(): void { $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'sessions'); - FileSystem::copyDirectory(__DIR__ . '/fixtures/sessions', $path); + FileSystem::copyDirectory(__DIR__ . '/Fixtures/sessions', $path); $handler = new FileSessionHandler($path); $this->assertTrue($handler->open('', 'formwork')); diff --git a/tests/Unit/Http/fixtures/files/sample.txt b/tests/Unit/Http/Fixtures/files/sample.txt similarity index 100% rename from tests/Unit/Http/fixtures/files/sample.txt rename to tests/Unit/Http/Fixtures/files/sample.txt diff --git a/tests/Unit/Http/fixtures/sessions/sess_existing b/tests/Unit/Http/Fixtures/sessions/sess_existing similarity index 100% rename from tests/Unit/Http/fixtures/sessions/sess_existing rename to tests/Unit/Http/Fixtures/sessions/sess_existing diff --git a/tests/Unit/Http/RequestTest.php b/tests/Unit/Http/RequestTest.php index 6d6b473a5..24e36c7df 100644 --- a/tests/Unit/Http/RequestTest.php +++ b/tests/Unit/Http/RequestTest.php @@ -212,6 +212,66 @@ public function testXForwardedHeadersAreUsedForTrustedProxies(): void $this->assertTrue($request->isSecure()); } + #[DataProvider('forwardedProtoProvider')] + public function testForwardedProtoOverridesTheConnectionSchemeForTrustedProxies(string $proto, bool $expectedSecure): void + { + foreach ([['HTTPS' => 'on'], ['HTTPS' => 'off'], []] as $connection) { + $request = $this->request([ + 'REMOTE_ADDR' => '10.0.0.10', + 'HTTP_X_FORWARDED_PROTO' => $proto, + ] + $connection); + $request->setTrustedProxies(['10.0.0.10']); + + $this->assertSame($expectedSecure, $request->isSecure(), sprintf('Forwarded proto "%s" with %s', $proto, json_encode($connection))); + } + } + + /** + * @return iterable + */ + public static function forwardedProtoProvider(): iterable + { + yield 'https' => ['https', true]; + yield 'uppercase https' => ['HTTPS', true]; + yield 'on' => ['on', true]; + yield 'ssl' => ['ssl', true]; + yield 'one' => ['1', true]; + yield 'http' => ['http', false]; + yield 'off' => ['off', false]; + yield 'zero' => ['0', false]; + } + + public function testForwardedProtoIsIgnoredForUntrustedProxies(): void + { + $request = $this->request([ + 'REMOTE_ADDR' => '192.0.2.10', + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'HTTPS' => 'off', + ]); + $request->setTrustedProxies(['10.0.0.10']); + + $this->assertFalse($request->isSecure()); + } + + #[DataProvider('localhostProvider')] + public function testLocalhostIsDetectedFromTheClientIpAddress(string $ip, bool $expected): void + { + $this->assertSame($expected, $this->request(['REMOTE_ADDR' => $ip])->isLocalhost()); + } + + /** + * @return iterable + */ + public static function localhostProvider(): iterable + { + yield 'IPv4 loopback' => ['127.0.0.1', true]; + yield 'IPv6 loopback' => ['::1', true]; + yield 'other loopback address' => ['127.0.0.2', false]; + yield 'private IPv4 address' => ['192.168.1.10', false]; + yield 'documentation IPv4 address' => ['192.0.2.10', false]; + yield 'documentation IPv6 address' => ['2001:db8::1', false]; + } + public function testSecurityLocalhostAndRequestTypeAreDetected(): void { $secure = $this->request([ @@ -233,24 +293,41 @@ public function testSecurityLocalhostAndRequestTypeAreDetected(): void $this->assertSame(RequestType::Http, $regular->type()); } - public function testNonGetContentReadsTheRequestBodyAndReturnsNullWhenItIsEmpty(): void + public function testNonGetContentIsNullWhenTheRequestBodyIsEmpty(): void { $request = $this->request(['REQUEST_METHOD' => 'POST']); $this->assertNull($request->content()); } - public function testRefererCanBeValidatedAgainstTheCurrentOriginAndPath(): void + #[DataProvider('refererProvider')] + public function testRefererIsValidatedAgainstTheCurrentOriginAndPath(?string $referer, string $path, bool $expected): void { - $request = $this->request([ + $request = $this->request(array_filter([ 'SCRIPT_NAME' => '/index.php', 'REQUEST_URI' => '/current', 'SERVER_NAME' => 'example.test', - 'HTTP_REFERER' => 'http://example.test/admin/users?tab=active', - ]); + 'HTTP_REFERER' => $referer, + ], static fn(?string $value): bool => $value !== null)); - $this->assertTrue($request->validateReferer('/admin')); - $this->assertFalse($request->validateReferer('/panel')); + $this->assertSame($expected, $request->validateReferer($path)); + } + + /** + * @return iterable + */ + public static function refererProvider(): iterable + { + yield 'page under the path' => ['http://example.test/admin/users?tab=active', '/admin', true]; + yield 'path root with trailing slash' => ['http://example.test/admin/', '/admin', true]; + yield 'path without leading slash' => ['http://example.test/admin/users', 'admin', true]; + yield 'default path accepts any page of the site' => ['http://example.test/anything', '/', true]; + yield 'different path' => ['http://example.test/admin/users', '/panel', false]; + yield 'path sharing only a prefix' => ['http://example.test/admin-evil/users', '/admin', false]; + yield 'different host' => ['http://evil.test/admin/users', '/admin', false]; + yield 'host containing the current one' => ['http://example.test.evil.test/admin/users', '/admin', false]; + yield 'different scheme' => ['https://example.test/admin/users', '/admin', false]; + yield 'missing referer' => [null, '/admin', false]; } public function testUploadedFilesAreNormalizedIntoUploadedFileObjects(): void @@ -307,22 +384,37 @@ public function testSessionIsMemoizedAndPreviousSessionIsAbsentWithoutCookie(): $session->save(); } - public function testFromGlobalsUsesTheScriptNameExposedByThePhpRuntime(): void + public function testFromGlobalsBuildsTheRequestFromThePhpSuperglobals(): void { - $request = Request::fromGlobals(); - $scriptName = (string) ($_SERVER['SCRIPT_NAME'] ?? ''); - $expectedRoot = '/' . ltrim((string) preg_replace('~[^/]+$~', '', $scriptName), '/'); - - $this->assertSame( - $expectedRoot, - $request->root(), - sprintf('Request::root() is derived from SCRIPT_NAME=%s', var_export($scriptName, true)), - ); + $superglobals = [$_SERVER, $_POST, $_GET, $_COOKIE, $_FILES]; + + try { + $_SERVER = [ + 'REQUEST_METHOD' => 'POST', + 'SCRIPT_NAME' => '/formwork/index.php', + 'REQUEST_URI' => '/formwork/about', + 'SERVER_NAME' => 'example.test', + 'SERVER_PORT' => '80', + ]; + $_POST = ['title' => 'Hello']; + $_GET = ['page' => '2']; + $_COOKIE = ['theme' => 'dark']; + $_FILES = []; + + $request = Request::fromGlobals(); + } finally { + [$_SERVER, $_POST, $_GET, $_COOKIE, $_FILES] = $superglobals; + } + + $this->assertSame(RequestMethod::POST, $request->method()); + $this->assertSame('/formwork/', $request->root()); + $this->assertSame('/about', $request->uri()); + $this->assertSame(['title' => 'Hello'], $request->input()->toArray()); + $this->assertSame(['page' => '2'], $request->query()->toArray()); + $this->assertSame(['theme' => 'dark'], $request->cookies()->toArray()); + $this->assertSame([], $request->files()->getAll()); } - /** - * @param array $server - */ /** * @param array $server * @param array $input diff --git a/tests/Unit/Http/SessionTest.php b/tests/Unit/Http/SessionTest.php index e209b848d..f4b5ceb5b 100644 --- a/tests/Unit/Http/SessionTest.php +++ b/tests/Unit/Http/SessionTest.php @@ -82,6 +82,43 @@ public function testSessionCanRegenerateAndCheckItsCurrentId(): void $session->save(); } + public function testRegenerateDestroysTheOldSessionAndMovesItsDataToTheNewOne(): void + { + $directory = FileSystem::joinPaths(TESTS_TMP_PATH, 'session-regenerate'); + FileSystem::createDirectory($directory); + + $session = $this->session(); + $session->setPath($directory); + $session->start(); + $oldId = session_id(); + $session->set('value', 42); + $session->save(); + + $this->assertFileExists(FileSystem::joinPaths($directory, 'sess_' . $oldId)); + + $session->start(); + $session->regenerate(); + $newId = session_id(); + $session->save(); + + $this->assertNotSame($oldId, $newId); + $this->assertFileDoesNotExist(FileSystem::joinPaths($directory, 'sess_' . $oldId)); + $this->assertFileExists(FileSystem::joinPaths($directory, 'sess_' . $newId)); + $this->assertStringContainsString('value|i:42;', (string) file_get_contents(FileSystem::joinPaths($directory, 'sess_' . $newId))); + } + + public function testRegenerateCanDiscardTheSessionData(): void + { + $session = $this->session(); + $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'session-data')); + $session->start(); + $session->set('value', 42); + $session->regenerate(preserveData: false); + + $this->assertFalse($session->has('value')); + $session->save(); + } + public function testSessionRejectsChangingConfigurationAfterStart(): void { $session = $this->session(); diff --git a/tests/Unit/Http/UploadedFileTest.php b/tests/Unit/Http/UploadedFileTest.php index 701beefa4..1e35138dd 100644 --- a/tests/Unit/Http/UploadedFileTest.php +++ b/tests/Unit/Http/UploadedFileTest.php @@ -39,6 +39,6 @@ public function testUploadErrorsExposeTranslatedMessagesAndBlockMove(): void $this->assertSame('No file was uploaded', $file->getErrorMessage()); $this->assertSame('upload.error.noFile', $file->getErrorTranslationString()); $this->expectException(TranslatedException::class); - $file->move(__DIR__ . '/fixtures/files', 'document.txt'); + $file->move(__DIR__ . '/Fixtures/files', 'document.txt'); } } diff --git a/tests/Unit/Pages/ContentFileTest.php b/tests/Unit/Pages/ContentFileTest.php index 188ebd415..a7763ea83 100644 --- a/tests/Unit/Pages/ContentFileTest.php +++ b/tests/Unit/Pages/ContentFileTest.php @@ -13,7 +13,7 @@ final class ContentFileTest extends TestCase { public function testParsesFrontmatterAndNormalizesContentLineEndings(): void { - $path = TESTS_PATH . '/Unit/Pages/fixtures/site/about/page.md'; + $path = TESTS_PATH . '/Unit/Pages/Fixtures/site/about/page.md'; $file = new ContentFile($path); $this->assertSame(['title' => 'About'], $file->frontmatter()); @@ -23,7 +23,7 @@ public function testParsesFrontmatterAndNormalizesContentLineEndings(): void public function testEmptyFrontmatterIsReportedAsEmpty(): void { - $file = new ContentFile(TESTS_PATH . '/Unit/Pages/fixtures/site/empty/page.md'); + $file = new ContentFile(TESTS_PATH . '/Unit/Pages/Fixtures/site/empty/page.md'); $this->assertSame([], $file->frontmatter()); $this->assertTrue($file->isEmpty()); diff --git a/tests/Unit/Pages/fixtures/numbered-site/1-numbered/page.md b/tests/Unit/Pages/Fixtures/numbered-site/1-numbered/page.md similarity index 100% rename from tests/Unit/Pages/fixtures/numbered-site/1-numbered/page.md rename to tests/Unit/Pages/Fixtures/numbered-site/1-numbered/page.md diff --git a/tests/Unit/Pages/fixtures/site/about/audio.mp3 b/tests/Unit/Pages/Fixtures/site/about/audio.mp3 similarity index 100% rename from tests/Unit/Pages/fixtures/site/about/audio.mp3 rename to tests/Unit/Pages/Fixtures/site/about/audio.mp3 diff --git a/tests/Unit/Pages/fixtures/site/about/image.svg b/tests/Unit/Pages/Fixtures/site/about/image.svg similarity index 100% rename from tests/Unit/Pages/fixtures/site/about/image.svg rename to tests/Unit/Pages/Fixtures/site/about/image.svg diff --git a/tests/Unit/Pages/fixtures/site/about/page.md b/tests/Unit/Pages/Fixtures/site/about/page.md similarity index 100% rename from tests/Unit/Pages/fixtures/site/about/page.md rename to tests/Unit/Pages/Fixtures/site/about/page.md diff --git a/tests/Unit/Pages/fixtures/site/about/video.mp4 b/tests/Unit/Pages/Fixtures/site/about/video.mp4 similarity index 100% rename from tests/Unit/Pages/fixtures/site/about/video.mp4 rename to tests/Unit/Pages/Fixtures/site/about/video.mp4 diff --git a/tests/Unit/Pages/fixtures/site/blog/blog.md b/tests/Unit/Pages/Fixtures/site/blog/blog.md similarity index 100% rename from tests/Unit/Pages/fixtures/site/blog/blog.md rename to tests/Unit/Pages/Fixtures/site/blog/blog.md diff --git a/tests/Unit/Pages/fixtures/site/blog/hello-world/page.md b/tests/Unit/Pages/Fixtures/site/blog/hello-world/page.md similarity index 100% rename from tests/Unit/Pages/fixtures/site/blog/hello-world/page.md rename to tests/Unit/Pages/Fixtures/site/blog/hello-world/page.md diff --git a/tests/Unit/Pages/fixtures/site/directory-only/.gitkeep b/tests/Unit/Pages/Fixtures/site/directory-only/.gitkeep similarity index 100% rename from tests/Unit/Pages/fixtures/site/directory-only/.gitkeep rename to tests/Unit/Pages/Fixtures/site/directory-only/.gitkeep diff --git a/tests/Unit/Pages/fixtures/site/empty/page.md b/tests/Unit/Pages/Fixtures/site/empty/page.md similarity index 100% rename from tests/Unit/Pages/fixtures/site/empty/page.md rename to tests/Unit/Pages/Fixtures/site/empty/page.md diff --git a/tests/Unit/Pages/fixtures/site/error/error.md b/tests/Unit/Pages/Fixtures/site/error/error.md similarity index 100% rename from tests/Unit/Pages/fixtures/site/error/error.md rename to tests/Unit/Pages/Fixtures/site/error/error.md diff --git a/tests/Unit/Pages/fixtures/site/index/index.md b/tests/Unit/Pages/Fixtures/site/index/index.md similarity index 100% rename from tests/Unit/Pages/fixtures/site/index/index.md rename to tests/Unit/Pages/Fixtures/site/index/index.md diff --git a/tests/Unit/Pages/fixtures/site/localized/page.en.md b/tests/Unit/Pages/Fixtures/site/localized/page.en.md similarity index 100% rename from tests/Unit/Pages/fixtures/site/localized/page.en.md rename to tests/Unit/Pages/Fixtures/site/localized/page.en.md diff --git a/tests/Unit/Pages/fixtures/site/localized/page.it.md b/tests/Unit/Pages/Fixtures/site/localized/page.it.md similarity index 100% rename from tests/Unit/Pages/fixtures/site/localized/page.it.md rename to tests/Unit/Pages/Fixtures/site/localized/page.it.md diff --git a/tests/Unit/Pages/fixtures/site/original/page.md b/tests/Unit/Pages/Fixtures/site/original/page.md similarity index 100% rename from tests/Unit/Pages/fixtures/site/original/page.md rename to tests/Unit/Pages/Fixtures/site/original/page.md diff --git a/tests/Unit/Pages/fixtures/site/parent/child/page.md b/tests/Unit/Pages/Fixtures/site/parent/child/page.md similarity index 100% rename from tests/Unit/Pages/fixtures/site/parent/child/page.md rename to tests/Unit/Pages/Fixtures/site/parent/child/page.md diff --git a/tests/Unit/Pages/fixtures/site/parent/page.md b/tests/Unit/Pages/Fixtures/site/parent/page.md similarity index 100% rename from tests/Unit/Pages/fixtures/site/parent/page.md rename to tests/Unit/Pages/Fixtures/site/parent/page.md diff --git a/tests/Unit/Pages/PageCollectionTest.php b/tests/Unit/Pages/PageCollectionTest.php index 142844f7e..8602d1129 100644 --- a/tests/Unit/Pages/PageCollectionTest.php +++ b/tests/Unit/Pages/PageCollectionTest.php @@ -192,7 +192,7 @@ private function collection(Page ...$pages): PageCollection private function temporarySite(): Site { $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'page-collection-' . ++$this->temporarySiteCounter); - FileSystem::copyDirectory(__DIR__ . '/fixtures/site', $path); + FileSystem::copyDirectory(__DIR__ . '/Fixtures/site', $path); return new Site( ['path' => TESTS_TMP_PATH, 'contentPath' => $path, 'metadata' => []], diff --git a/tests/Unit/Pages/PageEventsTest.php b/tests/Unit/Pages/PageEventsTest.php index f19091936..e1db5e9eb 100644 --- a/tests/Unit/Pages/PageEventsTest.php +++ b/tests/Unit/Pages/PageEventsTest.php @@ -4,6 +4,7 @@ use Formwork\Cms\App; use Formwork\Cms\Site; +use Formwork\Pages\ContentFile; use Formwork\Pages\Events\PageAfterDeleteEvent; use Formwork\Pages\Events\PageAfterDuplicateEvent; use Formwork\Pages\Events\PageAfterSaveEvent; @@ -243,6 +244,34 @@ public function testBeforeDuplicateReceivesTheOverrideDataByReference(): void $this->assertSame('From listener', $duplicate->title()); } + public function testSaveEventsAreDispatchedBeforeAndAfterThePageIsWritten(): void + { + $page = $this->page('/about'); + $file = (string) $page->contentFile()?->path(); + $active = true; + $titles = []; + + $this->app->events()->on('pageBeforeSave', function (PageBeforeSaveEvent $event) use (&$active, &$titles, $page, $file): void { + if ($active && $event->page() === $page) { + $titles['before'] = (new ContentFile($file))->frontmatter()['title']; + $page->set('title', 'Changed by listener'); + } + }); + $this->app->events()->on('pageAfterSave', function (PageAfterSaveEvent $event) use (&$active, &$titles, $page, $file): void { + if ($active && $event->page() === $page) { + $titles['after'] = (new ContentFile($file))->frontmatter()['title']; + } + }); + + try { + $page->save(); + } finally { + $active = false; + } + + $this->assertSame(['before' => 'About', 'after' => 'Changed by listener'], $titles); + } + private function page(string $route): Page { $site = $this->temporarySite(); @@ -254,7 +283,7 @@ private function page(string $route): Page private function temporarySite(): Site { $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'page-events-' . ++$this->temporarySiteCounter); - FileSystem::copyDirectory(__DIR__ . '/fixtures/site', $path); + FileSystem::copyDirectory(__DIR__ . '/Fixtures/site', $path); return new Site( ['path' => TESTS_TMP_PATH, 'contentPath' => $path, 'metadata' => []], diff --git a/tests/Unit/Pages/PageFactoryTest.php b/tests/Unit/Pages/PageFactoryTest.php index c5f01aac5..17c1dd6fd 100644 --- a/tests/Unit/Pages/PageFactoryTest.php +++ b/tests/Unit/Pages/PageFactoryTest.php @@ -26,7 +26,7 @@ protected function setUp(): void $this->setUpTempDirectory(); $this->app = App::instance(); $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'page-factory-site'); - FileSystem::copyDirectory(__DIR__ . '/fixtures/site', $path); + FileSystem::copyDirectory(__DIR__ . '/Fixtures/site', $path); $this->fixtureSite = new Site( ['path' => TESTS_TMP_PATH, 'contentPath' => $path, 'metadata' => []], $this->app->config(), diff --git a/tests/Unit/Pages/PageTest.php b/tests/Unit/Pages/PageTest.php index f67729d2d..abfe7a0b9 100644 --- a/tests/Unit/Pages/PageTest.php +++ b/tests/Unit/Pages/PageTest.php @@ -100,7 +100,7 @@ public function testGetHasAndSetWorkForFrontmatterAndRuntimeFields(): void public function testNumberPrefixAndSlugAreSeparated(): void { - $page = $this->temporaryPage('1-numbered', $this->temporarySite(__DIR__ . '/fixtures/numbered-site')); + $page = $this->temporaryPage('1-numbered', $this->temporarySite(__DIR__ . '/Fixtures/numbered-site')); $this->assertSame(1, $page->num()); $this->assertSame('numbered', $page->slug()); @@ -112,6 +112,7 @@ public function testTemplateLanguageAndContentFileAreResolvedFromTheFilename(): $page = $this->temporaryPage('about'); $this->assertSame('page', $page->template()->name()); + $this->assertSame('page.md', basename((string) $page->contentFile()?->path())); $this->assertNull($page->language()); $this->assertSame([], $page->languages()->available()->toArray()); } @@ -124,6 +125,7 @@ public function testMultilingualContentSelectsTheRequestedLanguageAndTracksAvail $page = new Page(['site' => $site, 'path' => $path, 'language' => 'it'], $this->app); $this->assertSame('it', $page->language()?->code()); + $this->assertSame('page.it.md', basename((string) $page->contentFile()?->path())); $this->assertSame('Italiano', $page->title()); $this->assertSame('Questa pagina spiega come è organizzato il sito e dove trovare le sezioni principali.', trim(strip_tags($page->content()))); $this->assertTrue($page->languages()->available()->has('en')); @@ -140,30 +142,35 @@ public function testInvalidLanguageIsRejectedAfterLoading(): void $page->set('language', 'fr'); } - public function testIconFallsBackToTheSchemeAndCanBeOverridden(): void + public function testIconIsReadFromTheSchemeAndCanBeOverridden(): void { - $page = $this->temporaryPage('about'); - $defaultIcon = $page->icon(); + $page = $this->temporaryPage('blog'); + + $this->assertSame('page-listing', $page->icon()); + $this->assertSame('page', $this->temporaryPage('about')->icon()); + + $site = $this->temporarySite(); + $path = FileSystem::joinPaths((string) $site->contentPath(), 'about') . '/'; + FileSystem::write($path . 'page.md', "---\ntitle: About\nicon: custom-icon\n---\n\nContent\n"); + $custom = new Page(['site' => $site, 'path' => $path], $this->app); - $this->assertIsString($defaultIcon); - $this->assertSame($defaultIcon, $page->icon()); + $this->assertSame('custom-icon', $custom->icon()); } - public function testStatusHonoursPublishedAndDateWindows(): void + /** + * @param array $data + */ + #[DataProvider('statusProvider')] + public function testStatusHonoursPublishedAndDateWindows(array $data, string $expectedStatus): void { $page = $this->temporaryPage('about'); - $page->set('published', false); - $this->assertSame(Page::PAGE_STATUS_NOT_PUBLISHED, $page->status()); - $page = $this->temporaryPage('about'); - $page->set('published', true); - $page->set('publishDate', '2099-01-01'); - $this->assertFalse($page->isPublished()); + foreach ($data as $key => $value) { + $page->set($key, $value); + } - $page = $this->temporaryPage('about'); - $page->set('published', true); - $page->set('unpublishDate', '2000-01-01'); - $this->assertFalse($page->isPublished()); + $this->assertSame($expectedStatus, $page->status()); + $this->assertSame($expectedStatus === Page::PAGE_STATUS_PUBLISHED, $page->isPublished()); } public function testMetadataTaxonomyAndResponseStatusAreNormalized(): void @@ -190,12 +197,27 @@ public function testCanonicalRouteIsNormalizedAndNullWhenEmpty(): void $this->assertNull($page->canonicalRoute()); } - public function testInvalidSlugTemplateAndParentAreRejected(): void + #[DataProvider('invalidSlugProvider')] + public function testInvalidSlugIsRejected(string $slug): void { $page = $this->temporaryPage('about'); - $this->expectException(InvalidValueException::class); - $page->set('slug', 'not a valid slug'); + try { + $page->set('slug', $slug); + $this->fail(sprintf('Slug "%s" should have been rejected', $slug)); + } catch (InvalidValueException) { + $this->assertSame('about', $page->slug()); + } + } + + #[DataProvider('validSlugProvider')] + public function testValidSlugIsAccepted(string $slug): void + { + $page = $this->temporaryPage('about'); + + $page->set('slug', $slug); + + $this->assertSame($slug, $page->slug()); } public function testInvalidTemplateIsRejected(): void @@ -444,12 +466,14 @@ public function testContentAndFilePredicatesRemainCoherentAfterReload(): void $this->assertSame($page->files(), $page->files()); } - public function testLifecycleEventsAreDispatchedWithTheExpectedNames(): void + public function testLifecycleEventsAreDispatchedInOrderWithTheExpectedPages(): void { - $names = []; + $page = $this->temporaryPage('original'); + + /** @var list $events */ + $events = []; foreach ( [ - 'pageLoaded', 'pageBeforeSave', 'pageAfterSave', 'pageBeforeDuplicate', @@ -458,19 +482,37 @@ public function testLifecycleEventsAreDispatchedWithTheExpectedNames(): void 'pageAfterDelete', ] as $name ) { - $this->app->events()->on($name, function (object $event) use (&$names): void { - $names[] = $event->name(); + $this->app->events()->on($name, function (object $event) use (&$events): void { + $events[] = [$event->name(), $event->page()]; }); } - $page = $this->temporaryPage('about'); $page->save(); $duplicate = $page->duplicate(); $duplicate->delete(); - foreach (['pageLoaded', 'pageBeforeSave', 'pageAfterSave', 'pageBeforeDuplicate', 'pageAfterDuplicate', 'pageBeforeDelete', 'pageAfterDelete'] as $name) { - $this->assertContains($name, $names); - } + $this->assertSame( + ['pageBeforeSave', 'pageAfterSave', 'pageBeforeDuplicate', 'pageAfterDuplicate', 'pageBeforeDelete', 'pageAfterDelete'], + array_column($events, 0), + ); + + // Save and duplicate events refer to the original page, delete events to the one being deleted + $this->assertSame( + [$page, $page, $page, $page, $duplicate, $duplicate], + array_column($events, 1), + ); + } + + public function testPageLoadedEventIsDispatchedWhenAPageIsLoaded(): void + { + $loaded = []; + $this->app->events()->on('pageLoaded', function (object $event) use (&$loaded): void { + $loaded[] = $event->page(); + }); + + $page = $this->temporaryPage('about'); + + $this->assertContains($page, $loaded); } public function testRenderDispatchesPageRenderEventAndReturnsMarkup(): void @@ -612,6 +654,62 @@ public function testInvalidLanguageChangeLeavesTheCurrentLanguageUntouched(): vo $this->assertSame('Italiano', $page->title()); } + /** + * @return iterable, string}> + */ + public static function statusProvider(): iterable + { + yield 'published by default' => [[], Page::PAGE_STATUS_PUBLISHED]; + yield 'explicitly published' => [['published' => true], Page::PAGE_STATUS_PUBLISHED]; + yield 'explicitly not published' => [['published' => false], Page::PAGE_STATUS_NOT_PUBLISHED]; + yield 'publish date in the past' => [['publishDate' => '2000-01-01'], Page::PAGE_STATUS_PUBLISHED]; + yield 'publish date in the future' => [['publishDate' => '2099-01-01'], Page::PAGE_STATUS_NOT_PUBLISHED]; + yield 'unpublish date in the future' => [['unpublishDate' => '2099-01-01'], Page::PAGE_STATUS_PUBLISHED]; + yield 'unpublish date in the past' => [['unpublishDate' => '2000-01-01'], Page::PAGE_STATUS_NOT_PUBLISHED]; + yield 'inside the publication window' => [ + ['publishDate' => '2000-01-01', 'unpublishDate' => '2099-01-01'], + Page::PAGE_STATUS_PUBLISHED, + ]; + yield 'window already closed' => [ + ['publishDate' => '2000-01-01', 'unpublishDate' => '2001-01-01'], + Page::PAGE_STATUS_NOT_PUBLISHED, + ]; + yield 'window not opened yet' => [ + ['publishDate' => '2098-01-01', 'unpublishDate' => '2099-01-01'], + Page::PAGE_STATUS_NOT_PUBLISHED, + ]; + yield 'not published overrides a valid window' => [ + ['published' => false, 'publishDate' => '2000-01-01', 'unpublishDate' => '2099-01-01'], + Page::PAGE_STATUS_NOT_PUBLISHED, + ]; + } + + /** + * @return iterable + */ + public static function invalidSlugProvider(): iterable + { + yield 'empty' => ['']; + yield 'spaces' => ['not a valid slug']; + yield 'underscore' => ['under_score']; + yield 'leading hyphen' => ['-slug']; + yield 'trailing hyphen' => ['slug-']; + yield 'consecutive hyphens' => ['a--b']; + yield 'slash' => ['a/b']; + yield 'accented character' => ['perché']; + } + + /** + * @return iterable + */ + public static function validSlugProvider(): iterable + { + yield 'lowercase' => ['renamed']; + yield 'mixed case' => ['Mixed-Case']; + yield 'digits only' => ['2024']; + yield 'hyphenated' => ['my-new-page-2']; + } + public static function invalidTaxonomyProvider(): iterable { yield 'scalar' => ['not-an-array', \TypeError::class]; @@ -636,7 +734,7 @@ private function temporaryPage(string $relativePath, ?Site $site = null): Page return new Page(['site' => $site, 'path' => $path], $this->app); } - private function temporarySite(string $fixturePath = __DIR__ . '/fixtures/site'): Site + private function temporarySite(string $fixturePath = __DIR__ . '/Fixtures/site'): Site { $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'pages-' . ++$this->temporarySiteCounter); FileSystem::copyDirectory($fixturePath, $path); diff --git a/tests/Unit/Pages/PaginationTest.php b/tests/Unit/Pages/PaginationTest.php index 46a9dbbf7..e5ce8fbf5 100644 --- a/tests/Unit/Pages/PaginationTest.php +++ b/tests/Unit/Pages/PaginationTest.php @@ -28,7 +28,7 @@ protected function setUp(): void $this->setUpTempDirectory(); $this->app = App::instance(); $path = FileSystem::joinPaths(TESTS_TMP_PATH, 'pagination-site'); - FileSystem::copyDirectory(__DIR__ . '/fixtures/site', $path); + FileSystem::copyDirectory(__DIR__ . '/Fixtures/site', $path); $this->fixtureSite = new Site( ['path' => TESTS_TMP_PATH, 'contentPath' => $path, 'metadata' => []], $this->app->config(), diff --git a/tests/Unit/Utils/FileSystemTest.php b/tests/Unit/Utils/FileSystemTest.php index d26099e6b..b7de6b0d1 100644 --- a/tests/Unit/Utils/FileSystemTest.php +++ b/tests/Unit/Utils/FileSystemTest.php @@ -17,7 +17,7 @@ final class FileSystemTest extends TestCase protected function setUp(): void { FileSystemFixture::enableAll(); - FileSystem::copyDirectory(__DIR__ . '/fixtures/files/tmp', TESTS_TMP_PATH, overwrite: true); + FileSystem::copyDirectory(__DIR__ . '/Fixtures/files/tmp', TESTS_TMP_PATH, overwrite: true); FileSystem::createDirectory(TESTS_TMP_PATH . '/emptydir'); } diff --git a/tests/Unit/Utils/MimeTypeTest.php b/tests/Unit/Utils/MimeTypeTest.php index 2e6663102..8dcded41f 100644 --- a/tests/Unit/Utils/MimeTypeTest.php +++ b/tests/Unit/Utils/MimeTypeTest.php @@ -26,14 +26,14 @@ public function testFromExtension(): void public function testFromFile(): void { - $this->assertSame('text/html', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/sample.html')); - $this->assertSame('text/yaml', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/sample.yaml')); + $this->assertSame('text/html', MimeType::fromFile(__DIR__ . '/Fixtures/files/mimetype/sample.html')); + $this->assertSame('text/yaml', MimeType::fromFile(__DIR__ . '/Fixtures/files/mimetype/sample.yaml')); } public function testFromFileWithSvg(): void { - $this->assertSame('image/svg+xml', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/valid.svg')); - $this->assertSame('application/octet-stream', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/invalid.svg')); + $this->assertSame('image/svg+xml', MimeType::fromFile(__DIR__ . '/Fixtures/files/mimetype/valid.svg')); + $this->assertSame('application/octet-stream', MimeType::fromFile(__DIR__ . '/Fixtures/files/mimetype/invalid.svg')); } public function testFromFileThrowsOnDisabledFileinfo(): void From c395b282a7a4d912b11729e9b2714101b3cf77b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:03:58 +0000 Subject: [PATCH 14/51] Skip file permission tests when running as root Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd --- tests/Unit/Utils/FileSystemTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Unit/Utils/FileSystemTest.php b/tests/Unit/Utils/FileSystemTest.php index b7de6b0d1..c73827eda 100644 --- a/tests/Unit/Utils/FileSystemTest.php +++ b/tests/Unit/Utils/FileSystemTest.php @@ -108,6 +108,8 @@ public function testAssertNotExists(): void public function testIsReadable(): void { + $this->skipIfFilePermissionsAreNotEnforced(); + $this->assertTrue(FileSystem::isReadable(TESTS_TMP_PATH . '/sample.txt')); $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); @@ -120,6 +122,8 @@ public function testIsReadable(): void public function testIsWritable(): void { + $this->skipIfFilePermissionsAreNotEnforced(); + $this->assertTrue(FileSystem::isWritable(TESTS_TMP_PATH . '/sample.txt')); $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); @@ -689,6 +693,8 @@ public function testReadThrowsOnFileNotFound(): void public function testReadThrowsOnFileUnreadable(): void { + $this->skipIfFilePermissionsAreNotEnforced(); + $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o444); @@ -883,6 +889,8 @@ public function testWriteThrowsOnNotFile(): void public function testWriteThrowsOnFileUnwritable(): void { + $this->skipIfFilePermissionsAreNotEnforced(); + $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o222); @@ -968,4 +976,14 @@ public function testRandomNameWithPrefix(): void $name = FileSystem::randomName('test_'); $this->assertStringStartsWith('test_', $name); } + + /** + * The root user bypasses file permission checks, so tests relying on them cannot work + */ + private function skipIfFilePermissionsAreNotEnforced(): void + { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('File permissions are not enforced for the root user.'); + } + } } From 6fc64a0ad05116c5a50a102c6b6cc9cb1601cc67 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:03:58 +0000 Subject: [PATCH 15/51] Add application booting and deprecation capturing to TestCase Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NfvbyMgLaMmQmiMej42T4C --- tests/TestCase.php | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index 7fcc32528..ffd13b46c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,10 +3,20 @@ namespace Formwork\Tests; use Formwork\Utils\FileSystem; +use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\TestCase as BaseTestCase; +use ReflectionClass; class TestCase extends BaseTestCase { + #[Before] + protected function bootApplication(): void + { + if ((new ReflectionClass($this))->getAttributes(UsesApplication::class) !== []) { + TestApplication::boot(); + } + } + protected function setUpTempDirectory(): void { if (!FileSystem::isDirectory(TESTS_TMP_PATH, assertExists: false)) { @@ -14,6 +24,31 @@ protected function setUpTempDirectory(): void } } + /** + * Run a callback and return the user deprecation messages it triggered + * + * The application installs its own error handler, so PHPUnit cannot observe user deprecations by itself + * + * @return list + */ + protected function captureDeprecations(callable $callback): array + { + $messages = []; + + set_error_handler(static function (int $severity, string $message) use (&$messages): bool { + $messages[] = $message; + return true; + }, E_USER_DEPRECATED); + + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $messages; + } + protected function tearDownTempDirectory(): void { if (FileSystem::isDirectory(TESTS_TMP_PATH, assertExists: false)) { From cd934bccc536acb9f8a775078bf827d9404b2d55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:20:24 +0000 Subject: [PATCH 16/51] Add an HTTP transport seam to Client Client delegates the connection to an HttpTransportInterface. StreamHttpTransport, used by default, keeps the openssl and allow_url_fopen checks, the error collection and the retrieval of the response headers, so requests can be faked without a server. The stream context is now built from plain options that can be inspected. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd --- formwork/src/Http/Client.php | 70 +-- .../Http/Transport/HttpTransportInterface.php | 19 + .../Http/Transport/StreamHttpTransport.php | 57 +++ tests/Unit/Http/ClientTest.php | 446 ++++++++++++++++-- .../Unit/Http/Fixtures/FakeHttpTransport.php | 67 +++ 5 files changed, 566 insertions(+), 93 deletions(-) create mode 100644 formwork/src/Http/Transport/HttpTransportInterface.php create mode 100644 formwork/src/Http/Transport/StreamHttpTransport.php create mode 100644 tests/Unit/Http/Fixtures/FakeHttpTransport.php diff --git a/formwork/src/Http/Client.php b/formwork/src/Http/Client.php index f8ff9055e..d9c362f10 100644 --- a/formwork/src/Http/Client.php +++ b/formwork/src/Http/Client.php @@ -3,7 +3,8 @@ namespace Formwork\Http; use Formwork\Cms\App; -use Formwork\Http\Exceptions\ConnectionException; +use Formwork\Http\Transport\HttpTransportInterface; +use Formwork\Http\Transport\StreamHttpTransport; use Formwork\Utils\Arr; use Formwork\Utils\Constraint; use Formwork\Utils\FileSystem; @@ -33,16 +34,10 @@ class Client /** * @param array $options */ - public function __construct(array $options = []) - { - if (!extension_loaded('openssl')) { - throw new RuntimeException(sprintf('Class %s requires the extension "openssl" to be enabled', static::class)); - } - - if (ini_get('allow_url_fopen') !== '1') { - throw new RuntimeException(sprintf('Class %s requires "allow_url_fopen" to be enabled in PHP configuration', static::class)); - } - + public function __construct( + array $options = [], + protected HttpTransportInterface $transport = new StreamHttpTransport(), + ) { $this->options = Arr::extend($this->defaults(), $options); } @@ -175,44 +170,19 @@ protected function connect(string $uri, array $options = []): array $options['headers']['Connection'] = 'close'; } - $context = $this->createContext($options); - - $errors = []; - - set_error_handler(static function (int $severity, string $message, string $file, int $line) use (&$errors): bool { - $errors[] = compact('severity', 'message', 'file', 'line'); - return true; - }); + $response = $this->transport->open($uri, $this->createContextOptions($options)); try { - if (($handle = @fopen($uri, 'r', false, $context)) === false) { - $messages = implode("\n", array_map( - static fn(int $i, array $error): string => sprintf('#%d %s', $i, str_replace("\n", ' ', $error['message'])), - array_keys($errors), - $errors - )); - - throw new ConnectionException(sprintf("Cannot connect to \"%s\". Error messages:\n%s", $uri, $messages)); - } - } finally { - restore_error_handler(); - } - - // @todo use the function directly when we drop support for PHP < 8.4 - if (function_exists('http_get_last_response_headers')) { - $http_response_header = http_get_last_response_headers(); - } + $splitResponse = $this->splitHTTPResponseHeader($response['headers']); - if (!isset($http_response_header) || $http_response_header === []) { - throw new RuntimeException(sprintf('Cannot get headers for "%s"', $uri)); - } - - $splitResponse = $this->splitHTTPResponseHeader($http_response_header); + $currentResponse = end($splitResponse); - $currentResponse = end($splitResponse); - - if ($currentResponse === false) { - throw new RuntimeException(sprintf('Cannot get current response for "%s"', $uri)); + if ($currentResponse === false) { + throw new RuntimeException(sprintf('Cannot get current response for "%s"', $uri)); + } + } catch (RuntimeException|UnexpectedValueException $exception) { + @fclose($response['handle']); + throw $exception; } $length = $currentResponse['headers']['Content-Length'] ?? null; @@ -225,18 +195,18 @@ protected function connect(string $uri, array $options = []): array 'status' => $currentResponse['statusCode'], 'headers' => $currentResponse['headers'], 'length' => $length !== null ? (int) $length : null, - 'handle' => $handle, + 'handle' => $response['handle'], ]; } /** - * Create stream context + * Create stream context options * * @param array $options * - * @return resource + * @return array> */ - protected function createContext(array $options) + protected function createContextOptions(array $options): array { $contextOptions = [ 'http' => [ @@ -264,7 +234,7 @@ protected function createContext(array $options) $contextOptions['ssl'][$key] = $bundle; } - return stream_context_create($contextOptions); + return $contextOptions; } /** diff --git a/formwork/src/Http/Transport/HttpTransportInterface.php b/formwork/src/Http/Transport/HttpTransportInterface.php new file mode 100644 index 000000000..5220ec963 --- /dev/null +++ b/formwork/src/Http/Transport/HttpTransportInterface.php @@ -0,0 +1,19 @@ +> $contextOptions + * + * @return array{handle: resource, headers: list} + */ + public function open(string $uri, array $contextOptions): array; +} diff --git a/formwork/src/Http/Transport/StreamHttpTransport.php b/formwork/src/Http/Transport/StreamHttpTransport.php new file mode 100644 index 000000000..27c3890e5 --- /dev/null +++ b/formwork/src/Http/Transport/StreamHttpTransport.php @@ -0,0 +1,57 @@ + sprintf('#%d %s', $i, str_replace("\n", ' ', $error['message'])), + array_keys($errors), + $errors + )); + + throw new ConnectionException(sprintf("Cannot connect to \"%s\". Error messages:\n%s", $uri, $messages)); + } + } finally { + restore_error_handler(); + } + + // @todo use the function directly when we drop support for PHP < 8.4 + if (function_exists('http_get_last_response_headers')) { + $http_response_header = http_get_last_response_headers(); + } + + if (!isset($http_response_header) || $http_response_header === []) { + throw new RuntimeException(sprintf('Cannot get headers for "%s"', $uri)); + } + + return ['handle' => $handle, 'headers' => $http_response_header]; + } +} diff --git a/tests/Unit/Http/ClientTest.php b/tests/Unit/Http/ClientTest.php index 5b943b18a..5d11b8767 100644 --- a/tests/Unit/Http/ClientTest.php +++ b/tests/Unit/Http/ClientTest.php @@ -4,9 +4,13 @@ use Formwork\Http\Client; use Formwork\Http\Exceptions\ConnectionException; -use Formwork\Http\Response; use Formwork\Http\ResponseHeaders; +use Formwork\Http\ResponseStatus; +use Formwork\Http\Transport\HttpTransportInterface; +use Formwork\Http\Transport\StreamHttpTransport; use Formwork\Tests\TestCase; +use Formwork\Tests\Unit\Http\Fixtures\FakeHttpTransport; +use Formwork\Utils\FileSystem; use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; use RuntimeException; @@ -15,112 +19,468 @@ #[CoversClass(Client::class)] final class ClientTest extends TestCase { - public function testDefaultsAndHeaderHelpersBuildAnHttpContext(): void + private FakeHttpTransport $transport; + + protected function setUp(): void + { + $this->setUpTempDirectory(); + $this->transport = new FakeHttpTransport(); + } + + protected function tearDown(): void { - $client = new TestClient(['timeout' => 3]); + $this->tearDownTempDirectory(); + } + public function testDefaultsCanBeOverriddenAndMergedRecursively(): void + { + $client = new TestClient(['timeout' => 3, 'headers' => ['X-Test' => 'value'], 'redirects' => ['limit' => 2]]); + + $options = $client->options(); + + $this->assertSame(3, $options['timeout']); + $this->assertSame('value', $options['headers']['X-Test']); + $this->assertArrayHasKey('User-Agent', $options['headers']); + $this->assertSame('gzip', $options['headers']['Accept-Encoding']); + $this->assertSame(['follow' => true, 'limit' => 2], $options['redirects']); + $this->assertSame(['verify' => true, 'cabundle' => null], $options['ssl']); $this->assertSame(-1, $client->defaults()['timeout']); - $this->assertSame(['X-Test' => 'value'], $client->normalize(['x-test' => 'value'])); - $this->assertSame(['X-Test: one', 'X-Test: two'], $client->compact(['X-Test' => ['one', 'two']])); - $this->assertIsResource($client->context([ - 'version' => 1.1, - 'method' => 'POST', - 'headers' => ['X-Test' => 'value'], - 'content' => 'body', - 'redirects' => ['follow' => false, 'limit' => 0], - 'timeout' => 3, - 'ssl' => ['verify' => true, 'cabundle' => null], - ])); } public function testResponseHeaderParserHandlesRedirectChains(): void { $responses = (new TestClient())->split([ 'HTTP/1.1 301 Moved Permanently', - 'Location: /next', + 'location: /next', 'HTTP/1.1 200 OK', 'Content-Length: 4', - 'Content-Type: text/plain', + 'content-type: text/plain', + 'X-Empty:', ]); + $this->assertCount(2, $responses); $this->assertSame(301, $responses[0]['statusCode']); - $this->assertSame('/next', $responses[0]['headers']['Location']); + $this->assertSame('Moved Permanently', $responses[0]['reasonPhrase']); + $this->assertSame(['Location' => '/next'], $responses[0]['headers']); $this->assertSame(200, $responses[1]['statusCode']); + $this->assertSame('HTTP/1.1', $responses[1]['HTTPVersion']); + $this->assertSame(['Content-Length' => '4', 'Content-Type' => 'text/plain', 'X-Empty' => ''], $responses[1]['headers']); + } + + public function testResponseHeadersWithoutAStatusLineAreRejected(): void + { + $this->expectException(UnexpectedValueException::class); + (new TestClient())->split(['Content-Type: text/plain']); } - public function testFetchHeadersReturnsResponseHeaders(): void + public function testFetchHeadersReturnsOnlyTheHeaders(): void { - $headers = (new HeadersClient())->fetchHeaders('https://example.test/resource'); + $this->transport->respond($this->ok(['X-Custom: value', 'Content-Length: 5']), 'hello'); + + $headers = $this->client()->fetchHeaders('http://example.test/ok'); $this->assertInstanceOf(ResponseHeaders::class, $headers); - $this->assertSame('yes', $headers->get('X-Fetched')); + $this->assertSame('value', $headers->get('X-Custom')); + $this->assertSame('5', $headers->get('Content-Length')); + $this->assertSame('HEAD', $this->transport->lastHttpOptions()['method']); } - public function testInvalidUrisAndMalformedResponseHeadersAreRejected(): void + public function testHeadResponsesDoNotReadTheBody(): void + { + $this->transport->respond($this->ok(['Content-Length: 5']), 'hello'); + + $response = $this->client()->fetch('http://example.test/ok', ['method' => 'head']); + + $this->assertSame('', $response->content()); + } + + public function testFetchReturnsTheBodyStatusAndHeaders(): void + { + $this->transport->respond($this->ok(['Content-Type: text/plain', 'x-custom: value', 'Content-Length: 5']), 'hello'); + + $response = $this->client()->fetch('http://example.test/ok'); + + $this->assertSame('hello', $response->content()); + $this->assertSame(ResponseStatus::OK, $response->status()); + $this->assertSame('text/plain', $response->headers()->get('Content-Type')); + $this->assertSame('value', $response->headers()->get('X-Custom')); + $this->assertSame('http://example.test/ok', $this->transport->requests[0]['uri']); + } + + public function testFetchReadsOnlyAsManyBytesAsContentLength(): void + { + $this->transport->respond($this->ok(['Content-Length: 5']), 'hello and trailing garbage'); + + $this->assertSame('hello', $this->client()->fetch('http://example.test/')->content()); + } + + public function testFetchReadsTheWholeBodyWithoutContentLength(): void + { + $this->transport->respond($this->ok(), 'hello world'); + + $this->assertSame('hello world', $this->client()->fetch('http://example.test/')->content()); + } + + public function testFetchSendsTheConfiguredMethodHeadersAndBody(): void + { + $this->transport->respond($this->ok()); + + $this->client(['headers' => ['X-Default' => 'default']])->fetch('http://example.test/echo', [ + 'method' => 'POST', + 'content' => 'payload', + 'headers' => ['x-request' => 'per request', 'Content-Length' => '7'], + ]); + + $http = $this->transport->lastHttpOptions(); + + $this->assertSame('POST', $http['method']); + $this->assertSame('payload', $http['content']); + $this->assertContains('X-Request: per request', $http['header']); + $this->assertContains('X-Default: default', $http['header']); + $this->assertContains('Accept-Encoding: gzip', $http['header']); + $this->assertContains('Content-Length: 7', $http['header']); + $this->assertContains('Connection: close', $http['header']); + } + + public function testFetchDecodesGzippedContent(): void + { + $this->transport->respond($this->ok(['Content-Encoding: gzip']), (string) gzencode('compressed content')); + + $this->assertSame('compressed content', $this->client()->fetch('http://example.test/gzip')->content()); + } + + public function testFetchRejectsInvalidGzippedContent(): void + { + $this->transport->respond($this->ok(['Content-Encoding: gzip']), 'this is not gzip'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot decode gzipped contents'); + $this->client()->fetch('http://example.test/bad-gzip'); + } + + public function testFetchRejectsUnsupportedContentEncodings(): void + { + $this->transport->respond($this->ok(['Content-Encoding: deflate']), (string) gzdeflate('content')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unsupported Content-Encoding "deflate"'); + $this->client()->fetch('http://example.test/deflate'); + } + + public function testTheLastResponseOfARedirectChainIsReturned(): void + { + $this->transport->respond([ + 'HTTP/1.1 302 Found', + 'Location: /ok', + 'Content-Length: 0', + 'HTTP/1.1 200 OK', + 'Content-Length: 5', + ], 'hello'); + + $response = $this->client()->fetch('http://example.test/redirect'); + + $this->assertSame(ResponseStatus::OK, $response->status()); + $this->assertSame('hello', $response->content()); + $this->assertSame('5', $response->headers()->get('Content-Length')); + $this->assertNull($response->headers()->get('Location')); + } + + public function testRedirectsAreFollowedByDefault(): void + { + $this->transport->respond($this->ok()); + + $this->client()->fetch('http://example.test/redirect'); + + $this->assertSame(1, $this->transport->lastHttpOptions()['follow_location']); + $this->assertSame(5, $this->transport->lastHttpOptions()['max_redirects']); + } + + public function testRedirectsCanBeDisabled(): void + { + $this->transport->respond(['HTTP/1.1 302 Found', 'Location: /ok']); + + $response = $this->client(['redirects' => ['follow' => false]])->fetch('http://example.test/redirect'); + + $this->assertSame(0, $this->transport->lastHttpOptions()['follow_location']); + $this->assertSame(ResponseStatus::Found, $response->status()); + $this->assertSame('/ok', $response->headers()->get('Location')); + } + + public function testRedirectLimitCanBeConfiguredPerRequest(): void + { + $this->transport->respond($this->ok()); + + $this->client()->fetch('http://example.test/loop', ['redirects' => ['limit' => 3]]); + + $this->assertSame(3, $this->transport->lastHttpOptions()['max_redirects']); + } + + public function testInvalidUrisAreRejectedBeforeTouchingTheTransport(): void { - $client = new TestClient(); try { - $client->fetch('not a uri'); - $this->fail('Expected invalid URI exception'); - } catch (InvalidArgumentException) { - $this->addToAssertionCount(1); + $this->client()->fetch('not a uri'); + $this->fail('An invalid URI was accepted.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString('invalid URI', $exception->getMessage()); } - $this->expectException(UnexpectedValueException::class); - $client->split(['Content-Type: text/plain']); + $this->assertSame([], $this->transport->requests); } - public function testConnectionFailuresExposeTheUri(): void + public function testTransportFailuresArePropagated(): void { - $client = new TestClient(['timeout' => 1]); + $this->transport->fail(new ConnectionException('Cannot connect to "http://example.test"')); $this->expectException(ConnectionException::class); - $this->expectExceptionMessage('Cannot connect to "http://127.0.0.1:1"'); - $client->fetch('http://127.0.0.1:1'); + $this->expectExceptionMessage('Cannot connect to "http://example.test"'); + $this->client()->fetch('http://example.test'); } - public function testUnreadableCaBundleIsRejected(): void + public function testResponsesWithoutHeadersAreRejected(): void { + $this->transport->respond([]); + $this->expectException(RuntimeException::class); - (new TestClient())->context([ + $this->expectExceptionMessage('Cannot get current response'); + $this->client()->fetch('http://example.test'); + } + + public function testMalformedResponseHeadersAreRejected(): void + { + $this->transport->respond(['Content-Type: text/plain']); + + $this->expectException(UnexpectedValueException::class); + $this->client()->fetch('http://example.test'); + } + + public function testConnectionIsClosedUnlessAnotherConnectionHeaderIsGiven(): void + { + $this->transport->respond($this->ok())->respond($this->ok())->respond($this->ok()); + + $this->client()->fetch('http://example.test/'); + $default = $this->transport->lastHttpOptions()['header']; + $this->client()->fetch('http://example.test/', ['headers' => ['connection' => 'keep-alive']]); + $explicit = $this->transport->lastHttpOptions()['header']; + $this->client(['version' => 1.0])->fetch('http://example.test/'); + $http10 = $this->transport->lastHttpOptions()['header']; + + $this->assertContains('Connection: close', $default); + $this->assertContains('Connection: keep-alive', $explicit); + $this->assertNotContains('Connection: close', $explicit); + $this->assertNotContains('Connection: close', $http10); + } + + public function testHeaderNamesAreNormalizedAndCompactedForTheRequest(): void + { + $client = new TestClient(); + + $this->assertSame(['X-Test' => 'value', 'Content-Type' => 'text/plain'], $client->normalize(['x-test' => 'value', 'CONTENT-TYPE' => 'text/plain'])); + $this->assertSame(['X-Test: one', 'X-Test: two', 'Accept: text/html'], $client->compact(['X-Test' => ['one', 'two'], ' Accept ' => ' text/html '])); + } + + public function testContextOptionsReflectTheRequestOptions(): void + { + $context = (new TestClient())->contextOptions([ + 'version' => 1.0, + 'method' => 'POST', + 'headers' => ['X-Test' => 'value'], + 'content' => 'body', + 'redirects' => ['follow' => false, 'limit' => 3], + 'timeout' => 7, + 'ssl' => ['verify' => false, 'cabundle' => null], + ]); + + $options = $context; + + $this->assertSame('POST', $options['http']['method']); + $this->assertSame(1.0, $options['http']['protocol_version']); + $this->assertSame(['X-Test: value'], $options['http']['header']); + $this->assertSame('body', $options['http']['content']); + $this->assertSame(0, $options['http']['follow_location']); + $this->assertSame(3, $options['http']['max_redirects']); + $this->assertSame(7, $options['http']['timeout']); + $this->assertTrue($options['http']['ignore_errors']); + $this->assertFalse($options['ssl']['verify_peer']); + $this->assertFalse($options['ssl']['verify_peer_name']); + $this->assertFalse($options['ssl']['allow_self_signed']); + } + + public function testContextOptionsUseCaFilesAndCaDirectories(): void + { + FileSystem::write(TESTS_TMP_PATH . '/bundle.pem', 'bundle'); + FileSystem::createDirectory(TESTS_TMP_PATH . '/certificates'); + $client = new TestClient(); + + $file = $client->contextOptions($this->requestOptions(TESTS_TMP_PATH . '/bundle.pem')); + $directory = $client->contextOptions($this->requestOptions(TESTS_TMP_PATH . '/certificates')); + + $this->assertSame(TESTS_TMP_PATH . '/bundle.pem', $file['ssl']['cafile']); + $this->assertArrayNotHasKey('capath', $file['ssl']); + $this->assertSame(TESTS_TMP_PATH . '/certificates', $directory['ssl']['capath']); + $this->assertArrayNotHasKey('cafile', $directory['ssl']); + } + + public function testMissingCaBundleIsRejected(): void + { + $this->expectException(RuntimeException::class); + (new TestClient())->contextOptions($this->requestOptions(TESTS_TMP_PATH . '/missing-ca.pem')); + } + + public function testErrorResponsesAreReturnedInsteadOfThrown(): void + { + $this->transport->respond(['HTTP/1.1 404 Not Found', 'Content-Type: text/plain', 'Content-Length: 4'], 'nope'); + + $response = $this->client()->fetch('http://example.test/missing'); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + $this->assertSame('nope', $response->content()); + $this->assertTrue($this->transport->lastHttpOptions()['ignore_errors']); + } + + public function testDownloadWritesTheBodyToTheDestination(): void + { + $destination = TESTS_TMP_PATH . '/downloaded.txt'; + $this->transport->respond($this->ok(['Content-Length: 5']), 'hello'); + + $this->client()->download('http://example.test/ok', $destination); + + $this->assertSame('hello', FileSystem::read($destination)); + $this->assertSame(['downloaded.txt'], iterator_to_array(FileSystem::listFiles(TESTS_TMP_PATH), false)); + } + + public function testDownloadsWithoutContentLengthAreCopiedUntilTheEnd(): void + { + $destination = TESTS_TMP_PATH . '/downloaded.txt'; + $this->transport->respond($this->ok(), 'hello world'); + + $this->client()->download('http://example.test/ok', $destination); + + $this->assertSame('hello world', FileSystem::read($destination)); + } + + public function testIncompleteDownloadsAreRejectedAndLeaveNoFiles(): void + { + $destination = TESTS_TMP_PATH . '/truncated.txt'; + $this->transport->respond($this->ok(['Content-Length: 10']), 'short'); + + try { + $this->client()->download('http://example.test/truncated', $destination); + $this->fail('An incomplete download was accepted.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('Incomplete download', $exception->getMessage()); + $this->assertStringContainsString('expected 10 bytes, got 5', $exception->getMessage()); + } + + $this->assertSame([], iterator_to_array(FileSystem::listFiles(TESTS_TMP_PATH), false)); + } + + public function testFailedDownloadsDoNotReplaceAnExistingFile(): void + { + $destination = TESTS_TMP_PATH . '/existing.txt'; + FileSystem::write($destination, 'previous'); + $this->transport->respond($this->ok(['Content-Length: 10']), 'short'); + + try { + $this->client()->download('http://example.test/truncated', $destination); + } catch (RuntimeException) { + // Expected + } + + $this->assertSame('previous', FileSystem::read($destination)); + $this->assertSame(['existing.txt'], iterator_to_array(FileSystem::listFiles(TESTS_TMP_PATH), false)); + } + + public function testTheDefaultTransportUsesPhpStreams(): void + { + $client = new TestClient(); + + $this->assertInstanceOf(StreamHttpTransport::class, $client->transport()); + } + + /** + * @param list $headers + * + * @return list + */ + private function ok(array $headers = []): array + { + return ['HTTP/1.1 200 OK', ...$headers]; + } + + /** + * @param array $options + */ + private function client(array $options = []): Client + { + return new Client($options, $this->transport); + } + + private function requestOptions(string $cabundle): array + { + return [ 'version' => 1.1, 'method' => 'GET', 'headers' => [], 'content' => '', 'redirects' => ['follow' => true, 'limit' => 5], 'timeout' => -1, - 'ssl' => ['verify' => true, 'cabundle' => __DIR__ . '/Fixtures/files/missing-ca.pem'], - ]); + 'ssl' => ['verify' => true, 'cabundle' => $cabundle], + ]; } } class TestClient extends Client { - public function context(array $options): mixed + /** + * @param array $options + * + * @return array> + */ + public function contextOptions(array $options): array { - return $this->createContext($options); + return $this->createContextOptions($options); } + public function transport(): HttpTransportInterface + { + return $this->transport; + } + + /** + * @param list $headers + * + * @return array> + */ public function split(array $headers): array { return $this->splitHTTPResponseHeader($headers); } + /** + * @param array $headers + * + * @return array + */ public function normalize(array $headers): array { return $this->normalizeHeaders($headers); } + /** + * @param array $headers + * + * @return list + */ public function compact(array $headers): array { return $this->compactHeaders($headers); } -} -final class HeadersClient extends TestClient -{ - public function fetch(string $uri, array $options = []): Response + /** + * @return array + */ + public function options(): array { - return new Response('fixture', headers: ['X-Fetched' => 'yes']); + return $this->options; } } diff --git a/tests/Unit/Http/Fixtures/FakeHttpTransport.php b/tests/Unit/Http/Fixtures/FakeHttpTransport.php new file mode 100644 index 000000000..73e200392 --- /dev/null +++ b/tests/Unit/Http/Fixtures/FakeHttpTransport.php @@ -0,0 +1,67 @@ +>}> + */ + public array $requests = []; + + /** + * @var list, body: string}|Throwable> + */ + private array $responses = []; + + /** + * Queue a response, the header lines may contain the whole redirect chain + * + * @param list $headers + */ + public function respond(array $headers, string $body = ''): self + { + $this->responses[] = ['headers' => $headers, 'body' => $body]; + + return $this; + } + + /** + * Queue a failure + */ + public function fail(Throwable $exception): self + { + $this->responses[] = $exception; + + return $this; + } + + public function open(string $uri, array $contextOptions): array + { + $this->requests[] = ['uri' => $uri, 'options' => $contextOptions]; + + $response = array_shift($this->responses) ?? throw new LogicException('No response queued for ' . $uri); + + if ($response instanceof Throwable) { + throw $response; + } + + $handle = fopen('php://memory', 'w+'); + fwrite($handle, $response['body']); + rewind($handle); + + return ['handle' => $handle, 'headers' => $response['headers']]; + } + + /** + * @return array + */ + public function lastHttpOptions(): array + { + return $this->requests[array_key_last($this->requests)]['options']['http']; + } +} From b07632930968fe7e1e1a464c19b7949d2d852fe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:20:28 +0000 Subject: [PATCH 17/51] Let the Updater take the HTTP client The client can be passed to the Updater constructor instead of being swapped through reflection by the tests. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd --- formwork/src/Updater/Updater.php | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/formwork/src/Updater/Updater.php b/formwork/src/Updater/Updater.php index 1f916e7c6..fa2bcc180 100644 --- a/formwork/src/Updater/Updater.php +++ b/formwork/src/Updater/Updater.php @@ -47,11 +47,6 @@ final class Updater 'preferDistAssets' => true, ]; - /** - * HTTP Client to make requests - */ - private Client $client; - /** * Array containing release information * @@ -71,6 +66,7 @@ final class Updater */ public function __construct( private array $options, + private Client $client = new Client(['headers' => ['Accept' => 'application/vnd.github.v3+json']]), ) { if (!extension_loaded('zip')) { throw new RuntimeException(sprintf('Class %s requires the extension "zip" to be enabled', self::class)); @@ -81,8 +77,6 @@ public function __construct( if ($this->registry->toArray() === []) { $this->initializeRegistry(); } - - $this->client = new Client(['headers' => ['Accept' => 'application/vnd.github.v3+json']]); } /** @@ -181,7 +175,7 @@ public function latestRelease(): ?array } /** - * Download the release archive and extract it into ROOT_PATH, always cleaning up the + * Download the release archive and extract it into the installation root, always cleaning up the * temporary archive file, even if the download or the extraction fails * * @return list @@ -223,7 +217,7 @@ private function verifyArchiveChecksum(string $archiveFile): void } /** - * Extract a release archive into ROOT_PATH and return the list of installed files + * Extract a release archive into the installation root and return the list of installed files * * @return list */ From 8a7e563dda70b0acea18c7ee716d078031bdef4c Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:50:14 +0000 Subject: [PATCH 18/51] Add App::rootPath() and App::systemPath() to replace the path constants The installation paths are given by the application, like in Laravel's `Application`: the constructor takes an optional root path, which defaults to the `ROOT_PATH` constant, and the system path is the `SYSTEM_PATH` constant or the directory containing Formwork. `Backupper`, `Updater` and `RateLimiter` take the root path as a string parameter, so that they can be tested over any installation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NfvbyMgLaMmQmiMej42T4C --- formwork/config/services/services.php | 7 +++- formwork/src/Authentication/RateLimiter.php | 5 ++- formwork/src/Backup/Backupper.php | 6 ++- formwork/src/Cms/App.php | 39 ++++++++++++++++++- formwork/src/Commands/CacheCommand.php | 4 +- .../src/Panel/Controllers/CacheController.php | 2 +- .../Panel/Controllers/OptionsController.php | 10 ++--- .../Panel/Controllers/PluginsController.php | 8 ++-- .../src/Panel/Controllers/ToolsController.php | 14 +++---- .../Loaders/AuthenticationServiceLoader.php | 4 +- .../Services/Loaders/ConfigServiceLoader.php | 36 ++++++++++------- .../Services/Loaders/PanelServiceLoader.php | 3 +- formwork/src/Updater/Updater.php | 3 +- 13 files changed, 97 insertions(+), 44 deletions(-) diff --git a/formwork/config/services/services.php b/formwork/config/services/services.php index 06f1d5b9c..eeb14f76f 100644 --- a/formwork/config/services/services.php +++ b/formwork/config/services/services.php @@ -4,6 +4,7 @@ use Formwork\Authentication\Authenticator; use Formwork\Backup\Backupper; use Formwork\Cache\CacheManager; +use Formwork\Cms\App; use Formwork\Cms\Site; use Formwork\Cms\UriGenerator; use Formwork\Config\Config; @@ -162,8 +163,10 @@ ->alias('plugins'); $container->define(Backupper::class) - ->parameter('options', fn(Config $config) => $config->getArray('system.backup')); + ->parameter('options', fn(Config $config) => $config->getArray('system.backup')) + ->parameter('rootPath', fn(App $app) => $app->rootPath()); $container->define(Updater::class) - ->parameter('options', fn(Config $config) => $config->getArray('system.updates')); + ->parameter('options', fn(Config $config) => $config->getArray('system.updates')) + ->parameter('rootPath', fn(App $app) => $app->rootPath()); }; diff --git a/formwork/src/Authentication/RateLimiter.php b/formwork/src/Authentication/RateLimiter.php index 8b7c17762..09d3d1de6 100644 --- a/formwork/src/Authentication/RateLimiter.php +++ b/formwork/src/Authentication/RateLimiter.php @@ -30,12 +30,13 @@ public function __construct( private Registry $registry, private int $limit, private int $resetTime, + private string $rootPath, Request $request, ) { - // Hash visitor IP address followed by the ROOT_PATH constant. + // Hash visitor IP address followed by the installation root path. // Do NOT use any client-controlled values as formerly done with // `$request->host()`, as this could be used to bypass the rate limit - $this->attemptHash = hash('sha256', "{$request->ip()}@" . ROOT_PATH); + $this->attemptHash = hash('sha256', "{$request->ip()}@" . $this->rootPath); if ($registry->has($this->attemptHash)) { [$this->attempts, $this->lastAttemptTime] = $registry->get($this->attemptHash); diff --git a/formwork/src/Backup/Backupper.php b/formwork/src/Backup/Backupper.php index f32df11e0..f2e4b3c4e 100644 --- a/formwork/src/Backup/Backupper.php +++ b/formwork/src/Backup/Backupper.php @@ -25,6 +25,8 @@ final class Backupper public function __construct( private Request $request, private array $options, + private string $rootPath, + private App $app, ) { if (!extension_loaded('zip')) { throw new RuntimeException(sprintf('Class %s requires the extension "zip" to be enabled', self::class)); @@ -41,7 +43,7 @@ public function backup(?string $name = null, ?string $hostname = null): string $previousMaxExecutionTime = ini_set('max_execution_time', $this->options['maxExecutionTime']); try { - $source = ROOT_PATH; + $source = $this->rootPath; $path = $this->options['path']; if (!FileSystem::exists($this->options['path'])) { @@ -54,7 +56,7 @@ public function backup(?string $name = null, ?string $hostname = null): string $name = Str::interpolate($name ?? $this->options['name'], [ 'hostname' => preg_replace(['/\.|:+/', '/\[|\]/'], ['-', ''], $hostname ?? $this->options['hostname'] ?? $this->request->host() ?? 'unknown-host'), - 'site' => Str::slug(App::instance()->site()->title() ?? 'unknown-site'), + 'site' => Str::slug($this->app->site()->title() ?? 'unknown-site'), 'context' => PHP_SAPI === 'cli' ? 'cli' : 'web', 'version' => App::VERSION, 'random' => FileSystem::randomName(), diff --git a/formwork/src/Cms/App.php b/formwork/src/Cms/App.php index 0cd79fb76..4d2d851e1 100644 --- a/formwork/src/Cms/App.php +++ b/formwork/src/Cms/App.php @@ -22,6 +22,7 @@ use Formwork\Traits\SingletonClass; use Formwork\Translations\Translations; use Formwork\Utils\Str; +use RuntimeException; use Throwable; final class App @@ -33,6 +34,11 @@ final class App */ public const string VERSION = '2.3.12'; + /** + * Installation root path + */ + private string $rootPath; + /** * App services container */ @@ -43,10 +49,19 @@ final class App */ private bool $loaded = false; - public function __construct() + /** + * @param ?string $rootPath Installation root path, defaults to the `ROOT_PATH` constant if defined + */ + public function __construct(?string $rootPath = null) { $this->initializeSingleton(); + if ($rootPath === null && !defined('ROOT_PATH')) { + throw new RuntimeException('ROOT_PATH constant is not defined and no root path was provided'); + } + + $this->rootPath = rtrim($rootPath ?? ROOT_PATH, '/\\'); + $this->container = new Container(); } @@ -63,6 +78,26 @@ public function __call(string $name, array $arguments): mixed throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', self::class, $name)); } + /** + * Get the installation root path + * + * @since 2.4.0 + */ + public function rootPath(): string + { + return $this->rootPath; + } + + /** + * Get the Formwork system path + * + * @since 2.4.0 + */ + public function systemPath(): string + { + return defined('SYSTEM_PATH') ? SYSTEM_PATH : dirname(__DIR__, 2); + } + /** * Get Config instance */ @@ -244,7 +279,7 @@ private function loadServices(Container $container): void $container->define(self::class, $this) ->lazy(false); - $definitions = require SYSTEM_PATH . '/config/services/services.php'; + $definitions = require $this->systemPath() . '/config/services/services.php'; $definitions($container); $container->resolveEagerServices(); diff --git a/formwork/src/Commands/CacheCommand.php b/formwork/src/Commands/CacheCommand.php index 296a1e3b4..f7b6dcf91 100644 --- a/formwork/src/Commands/CacheCommand.php +++ b/formwork/src/Commands/CacheCommand.php @@ -207,7 +207,7 @@ private function help(?array $argv = null): void */ private function configCacheStats(): void { - $path = ROOT_PATH . '/cache/config/'; + $path = $this->app->rootPath() . '/cache/config/'; $items = iterator_to_array(FileSystem::listContents($path)); $size = FileSystem::directorySize($path); @@ -304,7 +304,7 @@ private function clearPagesCache(): void */ private function clearConfigCache(): void { - ConfigServiceLoader::clearCache(); + ConfigServiceLoader::clearCache($this->app->rootPath()); } /** diff --git a/formwork/src/Panel/Controllers/CacheController.php b/formwork/src/Panel/Controllers/CacheController.php index a9b73a2e7..5b22533e5 100644 --- a/formwork/src/Panel/Controllers/CacheController.php +++ b/formwork/src/Panel/Controllers/CacheController.php @@ -109,6 +109,6 @@ private function clearImagesCache(): void */ private function clearConfigCache(): void { - ConfigServiceLoader::clearCache(); + ConfigServiceLoader::clearCache($this->app->rootPath()); } } diff --git a/formwork/src/Panel/Controllers/OptionsController.php b/formwork/src/Panel/Controllers/OptionsController.php index 9333db78c..d7a4093f7 100644 --- a/formwork/src/Panel/Controllers/OptionsController.php +++ b/formwork/src/Panel/Controllers/OptionsController.php @@ -119,10 +119,10 @@ public function siteOptions(Schemes $schemes): Response private function getConfigDefaults(): Config { $config = new Config(); - $config->loadFromPath(SYSTEM_PATH . '/config/'); + $config->loadFromPath($this->app->systemPath() . '/config/'); $config->resolve([ - '%ROOT_PATH%' => ROOT_PATH, - '%SYSTEM_PATH%' => SYSTEM_PATH, + '%ROOT_PATH%' => $this->app->rootPath(), + '%SYSTEM_PATH%' => $this->app->systemPath(), ]); return $config; } @@ -133,7 +133,7 @@ private function getConfigDefaults(): Config private function getConfigOverrides(): Config { $config = new Config(resolved: true); - $config->loadFromPath(ROOT_PATH . '/site/config/'); + $config->loadFromPath($this->app->rootPath() . '/site/config/'); return $config; } @@ -157,7 +157,7 @@ private function updateOptions(string $type, array $formData, array $options, ar // Update config file if options differ if ($options !== $old) { - Yaml::encodeToFile($options, ROOT_PATH . "/site/config/{$type}.yaml"); + Yaml::encodeToFile($options, $this->app->rootPath() . "/site/config/{$type}.yaml"); return true; } diff --git a/formwork/src/Panel/Controllers/PluginsController.php b/formwork/src/Panel/Controllers/PluginsController.php index 269eea3c3..599f524c7 100644 --- a/formwork/src/Panel/Controllers/PluginsController.php +++ b/formwork/src/Panel/Controllers/PluginsController.php @@ -150,11 +150,13 @@ private function updatePluginsOptions(Plugin $plugin, array $options): void { $options = Arr::override($this->config->getArray("plugins.{$plugin->name()}", []), Arr::undot($options)); - if (!FileSystem::isDirectory(ROOT_PATH . '/site/config/plugins/', assertExists: false)) { - FileSystem::createDirectory(ROOT_PATH . '/site/config/plugins/'); + $configPath = $this->app->rootPath() . '/site/config/plugins'; + + if (!FileSystem::isDirectory($configPath . '/', assertExists: false)) { + FileSystem::createDirectory($configPath . '/'); } - $path = FileSystem::joinPaths(ROOT_PATH . '/site/config/plugins', Path::resolve("{$plugin->id()}.yaml", '/', DIRECTORY_SEPARATOR)); + $path = FileSystem::joinPaths($configPath, Path::resolve("{$plugin->id()}.yaml", '/', DIRECTORY_SEPARATOR)); Yaml::encodeToFile($options, $path); } diff --git a/formwork/src/Panel/Controllers/ToolsController.php b/formwork/src/Panel/Controllers/ToolsController.php index 7933fd0f5..dd9abde32 100644 --- a/formwork/src/Panel/Controllers/ToolsController.php +++ b/formwork/src/Panel/Controllers/ToolsController.php @@ -93,7 +93,7 @@ public function info(): Response $formwork = [ 'Formwork Version' => $this->app::VERSION, 'PHP Version' => PHP_VERSION, - 'Disk Usage' => FileSystem::formatSize(FileSystem::directorySize(ROOT_PATH)), + 'Disk Usage' => FileSystem::formatSize(FileSystem::directorySize($this->app->rootPath())), ]; $warnings = []; @@ -124,8 +124,8 @@ public function info(): Response 'File Creation Mask' => sprintf('0%03o', umask()), ], 'Paths' => [ - 'Root Path' => ROOT_PATH, - 'Formwork Path' => SYSTEM_PATH, + 'Root Path' => $this->app->rootPath(), + 'Formwork Path' => $this->app->systemPath(), ], 'PHP' => [ 'Version' => PHP_VERSION, @@ -229,8 +229,8 @@ private function getHeaders(): array private function getDependencies(): array { $dependencies = []; - if (FileSystem::exists(ROOT_PATH . '/composer.lock')) { - $composerLock = Json::parseFile(ROOT_PATH . '/composer.lock'); + if (FileSystem::exists($this->app->rootPath() . '/composer.lock')) { + $composerLock = Json::parseFile($this->app->rootPath() . '/composer.lock'); foreach ($composerLock['packages'] as $package) { /** * @var string @@ -288,8 +288,8 @@ private function getComposerJson(): array if (isset($this->composerJson)) { return $this->composerJson; } - if (FileSystem::exists(ROOT_PATH . '/composer.json')) { - return $this->composerJson = Json::parseFile(ROOT_PATH . '/composer.json'); + if (FileSystem::exists($this->app->rootPath() . '/composer.json')) { + return $this->composerJson = Json::parseFile($this->app->rootPath() . '/composer.json'); } return $this->composerJson = []; } diff --git a/formwork/src/Services/Loaders/AuthenticationServiceLoader.php b/formwork/src/Services/Loaders/AuthenticationServiceLoader.php index d657d8073..6716ad937 100644 --- a/formwork/src/Services/Loaders/AuthenticationServiceLoader.php +++ b/formwork/src/Services/Loaders/AuthenticationServiceLoader.php @@ -4,6 +4,7 @@ use Formwork\Authentication\Authenticator; use Formwork\Authentication\RateLimiter; +use Formwork\Cms\App; use Formwork\Config\Config; use Formwork\Log\Registry; use Formwork\Services\Container; @@ -24,7 +25,8 @@ public function load(Container $container): Authenticator $container->define(RateLimiter::class) ->parameter('registry', new Registry(FileSystem::joinPaths($this->config->getString('system.authentication.registryPath'), 'accessAttempts.json'))) ->parameter('limit', $this->config->getInt('system.authentication.limits.maxAttempts')) - ->parameter('resetTime', $this->config->getInt('system.authentication.limits.resetTime')); + ->parameter('resetTime', $this->config->getInt('system.authentication.limits.resetTime')) + ->parameter('rootPath', fn(App $app) => $app->rootPath()); return $container->build(Authenticator::class); } diff --git a/formwork/src/Services/Loaders/ConfigServiceLoader.php b/formwork/src/Services/Loaders/ConfigServiceLoader.php index cce68d45d..32d835689 100644 --- a/formwork/src/Services/Loaders/ConfigServiceLoader.php +++ b/formwork/src/Services/Loaders/ConfigServiceLoader.php @@ -2,6 +2,7 @@ namespace Formwork\Services\Loaders; +use Formwork\Cms\App; use Formwork\Config\Config; use Formwork\Http\Request; use Formwork\Parsers\Php; @@ -13,12 +14,13 @@ final class ConfigServiceLoader implements ServiceLoaderInterface { /** - * Config cache path + * Config cache path, relative to the installation root */ - private const string CACHE_PATH = ROOT_PATH . '/cache/config/'; + private const string CACHE_PATH = '/cache/config/'; public function __construct( private Request $request, + private App $app, ) {} public function load(Container $container): Config @@ -30,16 +32,16 @@ public function load(Container $container): Config } else { $config = new Config(); - $config->loadFromPath(SYSTEM_PATH . '/config/'); - $config->loadFromPath(ROOT_PATH . '/site/config/'); + $config->loadFromPath($this->app->systemPath() . '/config/'); + $config->loadFromPath($this->app->rootPath() . '/site/config/'); - if (FileSystem::isDirectory($pluginsConfigPath = ROOT_PATH . '/site/config/plugins/', assertExists: false)) { + if (FileSystem::isDirectory($pluginsConfigPath = $this->app->rootPath() . '/site/config/plugins/', assertExists: false)) { $config->loadFromPath($pluginsConfigPath, 'plugins'); } $config->resolve([ - '%ROOT_PATH%' => ROOT_PATH, - '%SYSTEM_PATH%' => SYSTEM_PATH, + '%ROOT_PATH%' => $this->app->rootPath(), + '%SYSTEM_PATH%' => $this->app->systemPath(), ]); if (PHP_SAPI !== 'cli' && $cacheFile !== null) { @@ -60,10 +62,12 @@ public function load(Container $container): Config * * @internal */ - public static function clearCache(): void + public static function clearCache(string $rootPath): void { - FileSystem::delete(self::CACHE_PATH, recursive: true); - FileSystem::createDirectory(self::CACHE_PATH, recursive: true); + $cachePath = $rootPath . self::CACHE_PATH; + + FileSystem::delete($cachePath, recursive: true); + FileSystem::createDirectory($cachePath, recursive: true); } private function getConfigCacheFile(): ?string @@ -74,10 +78,12 @@ private function getConfigCacheFile(): ?string return null; } - $cacheFile = FileSystem::joinPaths(self::CACHE_PATH, "config.{$host}.php"); + $cachePath = $this->app->rootPath() . self::CACHE_PATH; + + $cacheFile = FileSystem::joinPaths($cachePath, "config.{$host}.php"); - if (!FileSystem::isDirectory(self::CACHE_PATH, assertExists: false)) { - FileSystem::createDirectory(self::CACHE_PATH, recursive: true); + if (!FileSystem::isDirectory($cachePath, assertExists: false)) { + FileSystem::createDirectory($cachePath, recursive: true); } return $cacheFile; @@ -87,7 +93,7 @@ private function validateConfigCacheFile(?string $cacheFile): bool { return $cacheFile !== null && FileSystem::exists($cacheFile) - && !FileSystem::directoryModifiedSince(ROOT_PATH . '/site/config/', FileSystem::lastModifiedTime($cacheFile)) - && !FileSystem::directoryModifiedSince(SYSTEM_PATH . '/config/', FileSystem::lastModifiedTime($cacheFile)); + && !FileSystem::directoryModifiedSince($this->app->rootPath() . '/site/config/', FileSystem::lastModifiedTime($cacheFile)) + && !FileSystem::directoryModifiedSince($this->app->systemPath() . '/config/', FileSystem::lastModifiedTime($cacheFile)); } } diff --git a/formwork/src/Services/Loaders/PanelServiceLoader.php b/formwork/src/Services/Loaders/PanelServiceLoader.php index 29ed7e648..9d4a75400 100644 --- a/formwork/src/Services/Loaders/PanelServiceLoader.php +++ b/formwork/src/Services/Loaders/PanelServiceLoader.php @@ -60,7 +60,8 @@ public function load(Container $container): Panel $container->define(RateLimiter::class) ->parameter('registry', new Registry(FileSystem::joinPaths($this->config->getString('system.authentication.registryPath'), 'accessAttempts.json'))) ->parameter('limit', $this->config->getInt('system.panel.loginAttempts', $this->config->getInt('system.authentication.limits.maxAttempts'))) - ->parameter('resetTime', $this->config->getInt('system.panel.loginResetTime', $this->config->getInt('system.authentication.limits.resetTime'))); + ->parameter('resetTime', $this->config->getInt('system.panel.loginResetTime', $this->config->getInt('system.authentication.limits.resetTime'))) + ->parameter('rootPath', fn(App $app) => $app->rootPath()); $container->resolve(RateLimiter::class); } diff --git a/formwork/src/Updater/Updater.php b/formwork/src/Updater/Updater.php index fa2bcc180..78c194225 100644 --- a/formwork/src/Updater/Updater.php +++ b/formwork/src/Updater/Updater.php @@ -66,6 +66,7 @@ final class Updater */ public function __construct( private array $options, + private string $rootPath, private Client $client = new Client(['headers' => ['Accept' => 'application/vnd.github.v3+json']]), ) { if (!extension_loaded('zip')) { @@ -243,7 +244,7 @@ private function extractRelease(string $archiveFile): array throw new RuntimeException('Cannot get filename from zip archive'); } - $root = ROOT_PATH; + $root = $this->rootPath; $destination = Path::resolve($filename, $root, DIRECTORY_SEPARATOR); if (!Path::isRelativeTo($destination, $root)) { From 0f322fa72236d2ca859acc24b9e72472cee15154 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:02:54 +0200 Subject: [PATCH 19/51] Add App::unload() and install the error handler when running `App::unload()` lets the application be loaded again from a clean state, which is how the tests isolate themselves. The error handler is installed by `App::run()` instead of `App::load()`, so that loading the application never changes the global error handling. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NfvbyMgLaMmQmiMej42T4C --- formwork/src/Cms/App.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/formwork/src/Cms/App.php b/formwork/src/Cms/App.php index 4d2d851e1..c384d3988 100644 --- a/formwork/src/Cms/App.php +++ b/formwork/src/Cms/App.php @@ -231,18 +231,30 @@ public function load(): void return; } - $this->loadErrorHandler(); $this->loadServices($this->container); $this->plugins()->initializeEnabled(); $this->loadRoutes(); $this->loaded = true; } + /** + * Unload Formwork, so that the next load starts from a clean state + * + * @since 2.4.0 + */ + public function unload(): void + { + $this->container = new Container(); + $this->loaded = false; + } + /** * Run Formwork */ public function run(): Response { + $this->loadErrorHandler(); + try { $this->load(); $response = $this->router()->dispatch(); From d17d89703c7625bd48d6da56f9e5fd6dde5b6881 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:56:11 +0200 Subject: [PATCH 20/51] Cache dynamic field variables in their loader The variables are cached by the loader closure created by `SchemesServiceLoader` instead of a static property of `DynamicFieldValue`, so that each load of the application gets its own variables. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NfvbyMgLaMmQmiMej42T4C --- formwork/src/Fields/Dynamic/DynamicFieldValue.php | 14 +++----------- .../src/Services/Loaders/SchemesServiceLoader.php | 7 ++++++- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/formwork/src/Fields/Dynamic/DynamicFieldValue.php b/formwork/src/Fields/Dynamic/DynamicFieldValue.php index 7a45aea10..157a8b222 100644 --- a/formwork/src/Fields/Dynamic/DynamicFieldValue.php +++ b/formwork/src/Fields/Dynamic/DynamicFieldValue.php @@ -17,11 +17,6 @@ class DynamicFieldValue */ public static Closure $varsLoader; - /** - * @var array - */ - protected static array $vars = []; - /** * Dynamic value computation status */ @@ -67,11 +62,8 @@ public static function withComputed(string $value, self $dynamic): self */ public function compute(): void { - if (static::$vars === []) { - if (!isset(static::$varsLoader)) { - throw new UnexpectedValueException(sprintf('%s() must be set to a valid Closure before computing dynamic field values', __METHOD__)); - } - static::$vars = (static::$varsLoader)(); + if (!isset(static::$varsLoader)) { + throw new UnexpectedValueException(sprintf('%s() must be set to a valid Closure before computing dynamic field values', __METHOD__)); } if ($this->computed) { @@ -85,7 +77,7 @@ public function compute(): void $this->computing = true; try { - $vars = [...self::$vars, 'this' => $this->field]; + $vars = [...(static::$varsLoader)(), 'this' => $this->field]; if (($model = $this->field->parent()?->model()) !== null) { $vars['model'] = $vars[$model->getModelIdentifier()] = $model; diff --git a/formwork/src/Services/Loaders/SchemesServiceLoader.php b/formwork/src/Services/Loaders/SchemesServiceLoader.php index c726b437d..31495d6c7 100644 --- a/formwork/src/Services/Loaders/SchemesServiceLoader.php +++ b/formwork/src/Services/Loaders/SchemesServiceLoader.php @@ -22,7 +22,12 @@ public function load(Container $container): object $container->define(FieldFactory::class); - DynamicFieldValue::$varsLoader = fn() => $container->call(require $this->config->getString('system.fields.dynamic.vars.file')); + // Vars are cached by the loader, so that a newly loaded application never gets those of a previous one + $vars = null; + + DynamicFieldValue::$varsLoader = function () use ($container, &$vars): array { + return $vars ??= $container->call(require $this->config->getString('system.fields.dynamic.vars.file')); + }; return $container->build(Schemes::class); } From 8e877cd652db57a3ca9d79edb8c9841b69555a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:22:55 +0000 Subject: [PATCH 21/51] Run the tests over an application on a temporary installation The bootstrap creates the application of the tests over a temporary installation, before any test can call `App::instance()`, and the tests that use it declare `#[UsesApplication]` to have it loaded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NfvbyMgLaMmQmiMej42T4C --- tests/TestApplication.php | 16 ++ tests/TestInstallation.php | 85 ++++++++ .../Unit/Backup/BackupperInstallationTest.php | 100 ++++++++++ .../Unit/Updater/UpdaterInstallationTest.php | 183 ++++++++++++++++++ tests/UsesApplication.php | 8 + tests/bootstrap.php | 11 +- 6 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 tests/TestApplication.php create mode 100644 tests/TestInstallation.php create mode 100644 tests/Unit/Backup/BackupperInstallationTest.php create mode 100644 tests/Unit/Updater/UpdaterInstallationTest.php create mode 100644 tests/UsesApplication.php diff --git a/tests/TestApplication.php b/tests/TestApplication.php new file mode 100644 index 000000000..a075a0457 --- /dev/null +++ b/tests/TestApplication.php @@ -0,0 +1,16 @@ +load(); + } +} diff --git a/tests/TestInstallation.php b/tests/TestInstallation.php new file mode 100644 index 000000000..31309806a --- /dev/null +++ b/tests/TestInstallation.php @@ -0,0 +1,85 @@ + ['title' => 'Home'], + 'about/page.md' => ['title' => 'About'], + 'error/page.md' => ['title' => 'Page Not Found', 'routable' => false, 'cacheable' => false, 'allowChildren' => false, 'responseStatus' => 404], + 'blog/blog.md' => ['title' => 'Blog', 'postsPerPage' => 2], + 'blog/20240101-first/post.md' => ['title' => 'First', 'publishDate' => '2024-01-01 00:00:00', 'taxonomy' => ['tag' => ['ideas']]], + 'blog/20240102-second/post.md' => ['title' => 'Second', 'publishDate' => '2024-01-02 00:00:00', 'taxonomy' => ['tag' => ['ideas', 'news']]], + 'blog/20240103-third/post.md' => ['title' => 'Third', 'publishDate' => '2024-01-03 00:00:00'], + ]; + + foreach ($pages as $file => $data) { + FileSystem::createDirectory(dirname($path . '/' . $file), recursive: true); + FileSystem::write($path . '/' . $file, "---\n" . Yaml::encode($data) . "---\n" . $data['title'] . " content\n"); + } + } +} diff --git a/tests/Unit/Backup/BackupperInstallationTest.php b/tests/Unit/Backup/BackupperInstallationTest.php new file mode 100644 index 000000000..50ec5a1eb --- /dev/null +++ b/tests/Unit/Backup/BackupperInstallationTest.php @@ -0,0 +1,100 @@ +setUpTempDirectory(); + + $this->root = TESTS_TMP_PATH . '/installation'; + FileSystem::createDirectory($this->root . '/site/pages', recursive: true); + FileSystem::createDirectory($this->root . '/cache', recursive: true); + FileSystem::write($this->root . '/index.php', 'root . '/site/pages/page.md', 'content'); + FileSystem::write($this->root . '/cache/data.php', 'cached'); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testTheWholeInstallationIsArchivedExceptIgnoredFiles(): void + { + $destination = $this->backupper(['ignore' => ['cache/*']])->backup('installation', 'example.com'); + + $this->assertEqualsCanonicalizing(['index.php', 'site/pages/page.md'], $this->entries($destination)); + } + + public function testTheArchiveIsWrittenInTheConfiguredPath(): void + { + $destination = $this->backupper()->backup('installation', 'example.com'); + + $this->assertSame(TESTS_TMP_PATH . '/backups', dirname($destination)); + } + + public function testSiteTitleComesFromTheApplication(): void + { + $destination = $this->backupper()->backup('{{site}}', 'example.com'); + + $this->assertStringStartsWith(Str::slug((string) App::instance()->site()->title()) . '-', basename($destination)); + } + + public function testFilesOfOtherInstallationsAreNeverArchived(): void + { + $destination = $this->backupper()->backup('installation', 'example.com'); + + $this->assertNotContains('composer.json', $this->entries($destination)); + $this->assertNotContains('formwork/src/Cms/App.php', $this->entries($destination)); + } + + /** + * @param array $options + */ + private function backupper(array $options = []): Backupper + { + return new Backupper(new Request([], [], [], [], []), [ + 'path' => TESTS_TMP_PATH . '/backups', + 'name' => '{{hostname}}-backup', + 'maxExecutionTime' => '180', + 'maxFiles' => 10, + 'ignore' => [], + ...$options, + ], $this->root, App::instance()); + } + + /** + * @return list + */ + private function entries(string $archive): array + { + $zip = new ZipArchive(); + $this->assertTrue($zip->open($archive)); + + $entries = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $entries[] = (string) $zip->getNameIndex($i); + } + $zip->close(); + + return $entries; + } +} diff --git a/tests/Unit/Updater/UpdaterInstallationTest.php b/tests/Unit/Updater/UpdaterInstallationTest.php new file mode 100644 index 000000000..3ed639682 --- /dev/null +++ b/tests/Unit/Updater/UpdaterInstallationTest.php @@ -0,0 +1,183 @@ +setUpTempDirectory(); + + $this->root = TESTS_TMP_PATH . '/installation'; + FileSystem::createDirectory($this->root . '/formwork/src', recursive: true); + FileSystem::createDirectory($this->root . '/site/pages', recursive: true); + FileSystem::write($this->root . '/index.php', 'old index'); + FileSystem::write($this->root . '/formwork/src/Old.php', 'obsolete file'); + FileSystem::write($this->root . '/formwork/src/Kept.php', 'old version'); + FileSystem::write($this->root . '/site/pages/page.md', 'user content'); + + $this->archive = TESTS_TMP_PATH . '/release.zip'; + $this->buildArchive([ + 'index.php' => 'new index', + 'formwork/src/Kept.php' => 'new version', + 'formwork/src/Added.php' => 'added file', + 'site/pages/page.md' => 'content shipped with the release', + ]); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testReleaseIsInstalledInTheGivenRoot(): void + { + $this->assertTrue($this->updater()->update(force: true)); + + $this->assertSame('new index', FileSystem::read($this->root . '/index.php')); + $this->assertSame('new version', FileSystem::read($this->root . '/formwork/src/Kept.php')); + $this->assertSame('added file', FileSystem::read($this->root . '/formwork/src/Added.php')); + } + + public function testIgnoredPathsAreNotTouched(): void + { + $this->updater()->update(force: true); + + $this->assertSame('user content', FileSystem::read($this->root . '/site/pages/page.md')); + } + + public function testTheRegistryRecordsTheInstalledRelease(): void + { + $this->updater()->update(force: true); + + $registry = Json::parseFile(TESTS_TMP_PATH . '/updates.json'); + $this->assertSame($this->nextMinor(), $registry['currentRelease']); + $this->assertTrue($registry['upToDate']); + $this->assertIsInt($registry['lastUpdate']); + } + + public function testTemporaryFilesAreRemoved(): void + { + $this->updater()->update(force: true); + + $this->assertFileDoesNotExist(TESTS_TMP_PATH . '/update.zip'); + $this->assertFileDoesNotExist(TESTS_TMP_PATH . '/update.zip.lock'); + } + + public function testObsoleteFilesAreKeptUnlessCleanupIsRequested(): void + { + $this->updater()->update(force: true, cleanupAfterInstall: false); + + $this->assertFileExists($this->root . '/formwork/src/Old.php'); + } + + public function testObsoleteFilesOfInstalledDirectoriesAreRemovedWithCleanup(): void + { + $this->updater()->update(force: true, cleanupAfterInstall: true); + + $this->assertFileDoesNotExist($this->root . '/formwork/src/Old.php'); + $this->assertFileExists($this->root . '/formwork/src/Kept.php'); + $this->assertFileExists($this->root . '/site/pages/page.md', 'Ignored paths are never cleaned up'); + } + + public function testArchivesEscapingTheRootAreRefusedAndNothingLeavesTheInstallation(): void + { + $this->buildArchive(['../outside.txt' => 'payload']); + + try { + $this->updater()->update(force: true); + $this->fail('The archive must be refused.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString('invalid destination', $exception->getMessage()); + } + + $this->assertFileDoesNotExist(TESTS_TMP_PATH . '/outside.txt'); + $this->assertFileDoesNotExist(dirname(TestInstallation::rootPath()) . '/outside.txt'); + } + + public function testTheRealInstallationIsNeverTouched(): void + { + $before = FileSystem::read(TestInstallation::rootPath() . '/index.php'); + + $this->updater()->update(force: true); + + $this->assertSame($before, FileSystem::read(TestInstallation::rootPath() . '/index.php')); + $this->assertFileDoesNotExist(TestInstallation::rootPath() . '/formwork/src/Added.php'); + } + + private function updater(): Updater + { + $client = $this->createStub(Client::class); + $client->method('fetch')->willReturn(new Response(Json::encode([ + 'name' => 'Release', + 'tag_name' => $this->nextMinor(), + 'published_at' => '2024-05-01T10:00:00Z', + 'zipball_url' => 'https://api.example.test/zipball', + 'assets' => [], + ]))); + $client->method('download')->willReturnCallback(function (string $uri, string $file): void { + copy($this->archive, $file); + }); + $client->method('fetchHeaders')->willReturn(new ResponseHeaders(['ETag' => '"etag"'])); + + return new Updater([ + 'registryFile' => TESTS_TMP_PATH . '/updates.json', + 'tempFile' => TESTS_TMP_PATH . '/update.zip', + 'time' => 900, + 'force' => false, + 'preferDistAssets' => true, + 'lockTimeout' => 300, + 'cleanupAfterInstall' => false, + 'ignore' => ['site/*'], + ], $this->root, $client); + } + + private function nextMinor(): string + { + $version = SemVer::fromString(App::VERSION); + + return sprintf('%d.%d.0', $version->major(), $version->minor() + 1); + } + + /** + * @param array $files + */ + private function buildArchive(array $files): void + { + if (file_exists($this->archive)) { + unlink($this->archive); + } + + $zip = new ZipArchive(); + $this->assertTrue($zip->open($this->archive, ZipArchive::CREATE)); + foreach ($files as $name => $content) { + // Release archives contain an entry for every directory + $directory = dirname($name); + if ($directory !== '.' && !str_starts_with($directory, '..')) { + $zip->addEmptyDir($directory); + } + $zip->addFromString($name, $content); + } + $zip->close(); + } +} diff --git a/tests/UsesApplication.php b/tests/UsesApplication.php new file mode 100644 index 000000000..760a551c2 --- /dev/null +++ b/tests/UsesApplication.php @@ -0,0 +1,8 @@ +load(); +// The application of the tests, over a temporary installation. It is created here, before +// any test can call `App::instance()`, and loaded by the tests that use it +new App(TestInstallation::create()); From ee7b191a5e01d6fa61a96f8f502a90285fa9b402 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:49:18 +0000 Subject: [PATCH 22/51] Improve HTTP tests and add missing cases - Run the Header, Cookie, Session and Response tests against the built-in PHP server (`PhpServer` helper): the CLI SAPI does not keep track of the headers, so the previous tests asserted nothing about them - Test `Client` against a local HTTP server (fetch, headers, redirects, gzip, download) instead of only its protected helpers - Cover session cookies, strict mode, flash messages and regeneration across real requests - Add cases for the `Request` and `FileResponse` gaps found with mutation testing, `UploadedFile` errors and limits, `Visitor`, `IpAnonymizer`, `Messages`, `JsonResponse` and `ResponseStatus` - Remove tests that asserted nothing or duplicated other tests Some of the new tests fail until the bugs they reveal are fixed: multiple cookies lose all but the last one, empty cookie names raise a `ValueError`, conditional requests are answered with 304 for any method and status, `RedirectResponse` can be overridden by a `Location` header, tablets are detected as mobile devices, invalid gzip content raises an `ErrorException` instead of a `RuntimeException`. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd --- tests/Unit/Http/CookieTest.php | 55 +++- tests/Unit/Http/FileResponseTest.php | 158 ++++------- tests/Unit/Http/HeaderTest.php | 52 +++- tests/Unit/Http/IpAnonymizerTest.php | 51 +++- tests/Unit/Http/JsonResponseTest.php | 49 +++- tests/Unit/Http/MessagesTest.php | 73 +++++- tests/Unit/Http/RedirectResponseTest.php | 15 ++ tests/Unit/Http/ResponseHeaderUtilsTest.php | 33 ++- tests/Unit/Http/ResponseStatusTest.php | 33 +++ tests/Unit/Http/ResponseTest.php | 185 +++++++++++-- tests/Unit/Http/SessionTest.php | 247 ++++++++++++++++-- .../Transport/StreamHttpTransportTest.php | 47 ++++ tests/Unit/Http/UploadedFileTest.php | 107 +++++++- tests/Unit/Http/VisitorTest.php | 101 +++++-- 14 files changed, 997 insertions(+), 209 deletions(-) create mode 100644 tests/Unit/Http/Transport/StreamHttpTransportTest.php diff --git a/tests/Unit/Http/CookieTest.php b/tests/Unit/Http/CookieTest.php index ef592fe8b..3cdf6d2cc 100644 --- a/tests/Unit/Http/CookieTest.php +++ b/tests/Unit/Http/CookieTest.php @@ -6,22 +6,61 @@ use Formwork\Tests\TestCase; use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; #[CoversClass(Cookie::class)] final class CookieTest extends TestCase { - public function testCookieCanBeSentAndRemovedWithOptions(): void + /** + * @var array + */ + private array $originalCookies = []; + + protected function setUp(): void + { + parent::setUp(); + $this->originalCookies = $_COOKIE; + $_COOKIE = []; + } + + protected function tearDown(): void + { + $_COOKIE = $this->originalCookies; + parent::tearDown(); + } + + #[DataProvider('invalidNameProvider')] + public function testInvalidCookieNamesAreRejected(string $name): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid cookie name'); + Cookie::send($name, 'value'); + } + + /** + * @return iterable + */ + public static function invalidNameProvider(): iterable + { + yield 'space' => ['invalid name']; + yield 'empty name' => ['']; + yield 'semicolon' => ['a;b']; + yield 'equal sign' => ['a=b']; + yield 'comma' => ['a,b']; + yield 'slash' => ['a/b']; + yield 'brackets' => ['a[b]']; + yield 'control character' => ["a\nb"]; + yield 'non ASCII character' => ['caffè']; + } + + public function testRemoveDoesNothingForUnknownCookies(): void { - $this->assertTrue(Cookie::send('formwork_test', 'value', [ - 'path' => '/', 'httpOnly' => true, 'sameSite' => Cookie::SAMESITE_STRICT, - ])); - $this->assertTrue(Cookie::remove('formwork_test', ['path' => '/'], forceSend: true)); - $this->assertFalse(Cookie::remove('missing_cookie')); + $this->assertFalse(Cookie::remove('unknown')); } - public function testInvalidCookieNamesAreRejected(): void + public function testRemoveValidatesTheNameToo(): void { $this->expectException(InvalidArgumentException::class); - Cookie::send('invalid name', 'value'); + Cookie::remove('invalid name'); } } diff --git a/tests/Unit/Http/FileResponseTest.php b/tests/Unit/Http/FileResponseTest.php index b7456d1ed..1b659fda1 100644 --- a/tests/Unit/Http/FileResponseTest.php +++ b/tests/Unit/Http/FileResponseTest.php @@ -42,24 +42,16 @@ public function testFileResponsePreparesRangesAndDownloadNames(): void $this->assertSame('attachment; filename=download.txt', $response->headers()->get('Content-Disposition')); } - public function testFileResponseHandlesSuffixOpenEndedAndUnsatisfiableRanges(): void + public function testFileResponseAddsAcceptRangesForHeadAndSkipsRangesForEmptyResponses(): void { $path = $this->fixturePath(); - $size = FileSystem::fileSize($path); - - $suffix = new FileResponse($path); - $suffix->prepare($this->request(server: ['HTTP_RANGE' => 'bytes=-5'])); - $this->assertSame('bytes ' . ($size - 5) . '-' . ($size - 1) . '/' . $size, $suffix->headers()->get('Content-Range')); - - $openEnded = new FileResponse($path); - $openEnded->prepare($this->request(server: ['HTTP_RANGE' => 'bytes=3-'])); - $this->assertSame('bytes 3-' . ($size - 1) . '/' . $size, $openEnded->headers()->get('Content-Range')); + $head = new FileResponse($path); + $head->prepare($this->request(RequestMethod::HEAD)); + $empty = new FileResponse($path, ResponseStatus::NoContent); + $empty->prepare($this->request()); - $invalid = new FileResponse($path); - $invalid->prepare($this->request(server: ['HTTP_RANGE' => 'bytes=999-1000'])); - $this->assertSame(ResponseStatus::RangeNotSatisfiable, $invalid->status()); - $this->assertSame('bytes */' . $size, $invalid->headers()->get('Content-Range')); - $this->assertSame('0', $invalid->headers()->get('Content-Length')); + $this->assertSame('bytes', $head->headers()->get('Accept-Ranges')); + $this->assertFalse($empty->headers()->has('Accept-Ranges')); } #[DataProvider('satisfiableRangeProvider')] @@ -85,6 +77,8 @@ public static function satisfiableRangeProvider(): iterable yield 'single first byte' => ['bytes=0-0', 0, 0]; yield 'single byte in the middle' => ['bytes=3-3', 3, 3]; yield 'single last byte' => ['bytes=30-30', 30, 30]; + yield 'open ended range' => ['bytes=3-', 3, 30]; + yield 'suffix range' => ['bytes=-5', 26, 30]; yield 'last byte by suffix' => ['bytes=-1', 30, 30]; yield 'whole file' => ['bytes=0-30', 0, 30]; yield 'end beyond the file is clamped' => ['bytes=5-999', 5, 30]; @@ -101,6 +95,7 @@ public function testFileResponseRejectsRangesStartingAfterTheirEndOrAfterTheFile $this->assertSame(ResponseStatus::RangeNotSatisfiable, $response->status(), $range); $this->assertSame('bytes */' . $size, $response->headers()->get('Content-Range'), $range); + $this->assertSame('0', $response->headers()->get('Content-Length'), $range); } } @@ -115,37 +110,15 @@ public function testFileResponseIgnoresMalformedRanges(): void } } - public function testFileResponseAddsAcceptRangesForHeadAndSkipsRangesForEmptyResponses(): void + public function testFileResponseAnswersConditionalRequestsWithNotModified(): void { - $path = $this->fixturePath(); - $head = new FileResponse($path); - $head->prepare($this->request(RequestMethod::HEAD)); - $empty = new FileResponse($path, ResponseStatus::NoContent); - $empty->prepare($this->request()); + $response = new FileResponse($this->fixturePath(), autoEtag: true); + $etag = $response->prepare($this->request())->headers()->get('ETag'); - $this->assertSame('bytes', $head->headers()->get('Accept-Ranges')); - $this->assertFalse($empty->headers()->has('Accept-Ranges')); - } + $conditional = (new FileResponse($this->fixturePath(), autoEtag: true))->prepare($this->request(server: ['HTTP_IF_NONE_MATCH' => (string) $etag])); - public function testFileResponseSendStreamsFullContent(): void - { - $response = new FileResponse($this->fixturePath()); - ob_start(); - $response->send(); - $output = ob_get_clean(); - - $this->assertSame("Formwork HTTP fixture content.\n", $output); - } - - public function testFileResponseSendStreamsPartialContent(): void - { - $response = new FileResponse($this->fixturePath()); - $response->prepare($this->request(server: ['HTTP_RANGE' => 'bytes=0-4'])); - ob_start(); - $response->send(); - $output = ob_get_clean(); - - $this->assertSame('Formw', $output); + $this->assertSame(ResponseStatus::NotModified, $conditional->status()); + $this->assertSame('', $conditional->content()); } public function testHeadResponseDoesNotStreamOrDeleteTheFile(): void @@ -159,6 +132,7 @@ public function testHeadResponseDoesNotStreamOrDeleteTheFile(): void $output = ob_get_clean(); $this->assertSame('', $output); + $this->assertSame('', $response->content()); $this->assertTrue(FileSystem::exists($path)); } @@ -170,55 +144,63 @@ public function testInlineFileResponseRejectsDownloadFilenameChanges(): void $response->setFilename('download.txt'); } - public function testPreparedRangeHeadersMatchTheBytesActuallySent(): void + public function testValidatorsAreNotAddedUnlessRequested(): void { - $path = TESTS_TMP_PATH . '/range.txt'; - FileSystem::write($path, '0123456789'); - $response = new FileResponse($path); - $response->prepare($this->request(RequestMethod::GET, ['HTTP_RANGE' => 'bytes=2-5'])); - - ob_start(); - $response->send(); - $output = ob_get_clean(); + $response = new FileResponse($this->fixturePath()); + $response->prepare($this->request()); - $this->assertSame(ResponseStatus::PartialContent, $response->status()); - $this->assertSame('bytes 2-5/10', $response->headers()->get('Content-Range')); - $this->assertSame('4', $response->headers()->get('Content-Length')); - $this->assertSame('2345', $output); + $this->assertFalse($response->headers()->has('ETag')); + $this->assertFalse($response->headers()->has('Last-Modified')); } - public function testSuffixRangeHeadersMatchTheSuffixBytes(): void + public function testRequestedValidatorsDependOnTheFile(): void { - $path = TESTS_TMP_PATH . '/range.txt'; - FileSystem::write($path, '0123456789'); + $first = FileSystem::joinPaths(TESTS_TMP_PATH, 'first.txt'); + $second = FileSystem::joinPaths(TESTS_TMP_PATH, 'second.txt'); + FileSystem::write($first, 'first'); + FileSystem::write($second, 'second'); + touch($first, 1_700_000_000); - $response = new FileResponse($path); - $response->prepare($this->request(RequestMethod::GET, ['HTTP_RANGE' => 'bytes=-3'])); - - ob_start(); - $response->send(); - $output = ob_get_clean(); + $firstResponse = (new FileResponse($first, autoEtag: true, autoLastModified: true))->prepare($this->request()); + $secondResponse = (new FileResponse($second, autoEtag: true, autoLastModified: true))->prepare($this->request()); - $this->assertSame('bytes 7-9/10', $response->headers()->get('Content-Range')); - $this->assertSame('3', $response->headers()->get('Content-Length')); - $this->assertSame('789', $output); + $this->assertSame('Tue, 14 Nov 2023 22:13:20 GMT', $firstResponse->headers()->get('Last-Modified')); + $this->assertNotSame($firstResponse->headers()->get('ETag'), $secondResponse->headers()->get('ETag')); } - public function testUnsatisfiableRangeProducesNoBodyAndConsistentHeaders(): void + /** + * @param array $server + */ + #[DataProvider('sentContentProvider')] + public function testSentBodyMatchesTheAnnouncedStatusAndHeaders(array $server, ResponseStatus $status, string $expectedBody, ?string $expectedRange): void { $path = TESTS_TMP_PATH . '/range.txt'; FileSystem::write($path, '0123456789'); $response = new FileResponse($path); - $response->prepare($this->request(RequestMethod::GET, ['HTTP_RANGE' => 'bytes=20-30'])); + $response->prepare($this->request(RequestMethod::GET, $server)); ob_start(); $response->send(); $output = ob_get_clean(); - $this->assertSame(ResponseStatus::RangeNotSatisfiable, $response->status()); - $this->assertSame('bytes */10', $response->headers()->get('Content-Range')); - $this->assertSame('0', $response->headers()->get('Content-Length')); - $this->assertSame('', $output); + $this->assertSame($status, $response->status()); + $this->assertSame($expectedBody, $output); + $this->assertSame((string) strlen($expectedBody), $response->headers()->get('Content-Length')); + $this->assertSame($expectedRange, $response->headers()->get('Content-Range')); + } + + /** + * @return iterable, ResponseStatus, string, ?string}> + */ + public static function sentContentProvider(): iterable + { + yield 'whole file' => [[], ResponseStatus::OK, '0123456789', null]; + yield 'bounded range' => [['HTTP_RANGE' => 'bytes=2-5'], ResponseStatus::PartialContent, '2345', 'bytes 2-5/10']; + yield 'single byte' => [['HTTP_RANGE' => 'bytes=0-0'], ResponseStatus::PartialContent, '0', 'bytes 0-0/10']; + yield 'open ended range' => [['HTTP_RANGE' => 'bytes=7-'], ResponseStatus::PartialContent, '789', 'bytes 7-9/10']; + yield 'suffix range' => [['HTTP_RANGE' => 'bytes=-3'], ResponseStatus::PartialContent, '789', 'bytes 7-9/10']; + yield 'range clamped to the file size' => [['HTTP_RANGE' => 'bytes=8-99'], ResponseStatus::PartialContent, '89', 'bytes 8-9/10']; + yield 'unsatisfiable range' => [['HTTP_RANGE' => 'bytes=20-30'], ResponseStatus::RangeNotSatisfiable, '', 'bytes */10']; } public function testZeroLengthFileIsStillDeletedWhenDeleteAfterSendIsEnabled(): void @@ -233,19 +215,6 @@ public function testZeroLengthFileIsStillDeletedWhenDeleteAfterSendIsEnabled(): $this->assertFileDoesNotExist($path); } - public function testHeadNeverDeletesTheSourceFile(): void - { - $path = TESTS_TMP_PATH . '/head.txt'; - FileSystem::write($path, 'body'); - $response = new FileResponse($path, deleteAfterSend: true); - $response->prepare($this->request(RequestMethod::HEAD)); - - $response->send(); - - $this->assertFileExists($path); - $this->assertSame('', $response->content()); - } - public function testAutoValidatorsRemainStableAcrossRepeatedPreparation(): void { $path = TESTS_TMP_PATH . '/validators.txt'; @@ -262,21 +231,6 @@ public function testAutoValidatorsRemainStableAcrossRepeatedPreparation(): void $this->assertSame($modified, $response->headers()->get('Last-Modified')); } - public function testFullResponseStreamsTheEntireFile(): void - { - $path = TESTS_TMP_PATH . '/full.txt'; - FileSystem::write($path, '0123456789'); - $response = new FileResponse($path); - $response->prepare($this->request(RequestMethod::GET)); - - ob_start(); - $response->send(); - $output = ob_get_clean(); - - $this->assertSame('0123456789', $output); - $this->assertSame('10', $response->headers()->get('Content-Length')); - } - private function fixturePath(): string { return __DIR__ . '/Fixtures/files/sample.txt'; diff --git a/tests/Unit/Http/HeaderTest.php b/tests/Unit/Http/HeaderTest.php index 3228ac700..1fe6c2e36 100644 --- a/tests/Unit/Http/HeaderTest.php +++ b/tests/Unit/Http/HeaderTest.php @@ -3,9 +3,9 @@ namespace Formwork\Tests\Unit\Http; use Formwork\Http\Header; -use Formwork\Http\Utils\Header as ResponseHeader; use Formwork\Tests\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use UnexpectedValueException; #[CoversClass(Header::class)] @@ -25,8 +25,54 @@ public function testHeaderParsingAndFormatting(): void Header::combine([[]]); } - public function testResponseHeaderFormatting(): void + public function testHeaderNamesAreNormalized(): void { - $this->assertSame('a=b; c', ResponseHeader::make(['a' => 'b', 'c'])); + $this->assertSame('Content-Type', Header::fixHeaderName('CONTENT_TYPE')); + $this->assertSame('X-Custom-Header', Header::fixHeaderName('x-custom_header')); + $this->assertSame('Accept', Header::fixHeaderName('accept')); + } + + public function testCombineTurnsTokensWithoutValueIntoFlags(): void + { + $this->assertSame( + ['secure' => true, 'max-age' => '5'], + Header::combine(Header::split('secure; max-age=5', ';=')), + ); + } + + public function testQualityValuesDefaultToOneAndAreSortedByQuality(): void + { + $this->assertSame( + ['text/html' => 1.0, 'application/json' => 0.9, '*/*' => 0.1], + Header::parseQualityValues('*/*;q=0.1, application/json;q=0.9, text/html'), + ); + } + + /** + * @param list $expected + */ + #[DataProvider('etagListProvider')] + public function testETagsAreParsedFromAHeader(string $header, array $expected): void + { + $this->assertSame($expected, Header::parseETags($header)); + } + + /** + * @return iterable}> + */ + public static function etagListProvider(): iterable + { + yield 'empty header' => ['', []]; + yield 'only whitespace' => [' ', []]; + yield 'single ETag' => ['"abc"', ['"abc"']]; + yield 'weak ETag' => ['W/"abc"', ['W/"abc"']]; + yield 'wildcard' => ['*', ['*']]; + yield 'list' => ['"a", "b", "c"', ['"a"', '"b"', '"c"']]; + yield 'list without spaces' => ['"a","b"', ['"a"', '"b"']]; + yield 'list with extra spaces' => ['"a" , "b"', ['"a"', '"b"']]; + yield 'mixed weak and strong ETags' => ['W/"a", "b"', ['W/"a"', '"b"']]; + yield 'empty items' => ['"a",, "b",', ['"a"', '"b"']]; + yield 'ETag containing a comma' => ['"a,b", "c"', ['"a,b"', '"c"']]; + yield 'weak ETag containing a comma' => ['W/"a,b"', ['W/"a,b"']]; } } diff --git a/tests/Unit/Http/IpAnonymizerTest.php b/tests/Unit/Http/IpAnonymizerTest.php index 32961912e..0c40c9d0a 100644 --- a/tests/Unit/Http/IpAnonymizerTest.php +++ b/tests/Unit/Http/IpAnonymizerTest.php @@ -6,19 +6,58 @@ use Formwork\Tests\TestCase; use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; #[CoversClass(IpAnonymizer::class)] final class IpAnonymizerTest extends TestCase { - public function testIpv4AndIpv6AddressesAreAnonymized(): void + #[DataProvider('invalidAddressProvider')] + public function testInvalidIpAddressesAreRejected(string $address): void { - $this->assertSame('192.0.2.0', IpAnonymizer::anonymize('192.0.2.123')); - $this->assertSame('2001:db8:abcd:12::', IpAnonymizer::anonymize('2001:db8:abcd:12:1234:5678:9abc:def0')); + $this->expectException(InvalidArgumentException::class); + IpAnonymizer::anonymize($address); } - public function testInvalidIpAddressesAreRejected(): void + /** + * @return iterable + */ + public static function invalidAddressProvider(): iterable { - $this->expectException(InvalidArgumentException::class); - IpAnonymizer::anonymize('invalid'); + yield 'text' => ['invalid']; + yield 'empty string' => ['']; + yield 'incomplete IPv4' => ['1.2.3']; + yield 'out of range octet' => ['256.0.0.1']; + yield 'trailing whitespace' => ['192.0.2.123 ']; + yield 'IPv4 with a port' => ['192.0.2.123:80']; + } + + #[DataProvider('addressProvider')] + public function testAddressesAreAnonymizedKeepingOnlyTheNetworkPrefix(string $address, string $expected): void + { + $this->assertSame($expected, IpAnonymizer::anonymize($address)); + } + + /** + * @return iterable + */ + public static function addressProvider(): iterable + { + yield 'IPv4' => ['192.0.2.123', '192.0.2.0']; + yield 'IPv4 with the maximum host part' => ['255.255.255.255', '255.255.255.0']; + yield 'IPv4 already anonymized' => ['192.0.2.0', '192.0.2.0']; + yield 'IPv4 unspecified address' => ['0.0.0.0', '0.0.0.0']; + yield 'full IPv6' => ['2001:db8:abcd:12:1234:5678:9abc:def0', '2001:db8:abcd:12::']; + yield 'abbreviated IPv6' => ['2001:db8::1', '2001:db8::']; + yield 'IPv6 loopback' => ['::1', '::']; + yield 'IPv6 link local' => ['fe80::1ff:fe23:4567:890a', 'fe80::']; + } + + public function testAnonymizationIsIdempotent(): void + { + foreach (['192.0.2.123', '2001:db8:abcd:12:1234:5678:9abc:def0'] as $address) { + $once = IpAnonymizer::anonymize($address); + + $this->assertSame($once, IpAnonymizer::anonymize($once)); + } } } diff --git a/tests/Unit/Http/JsonResponseTest.php b/tests/Unit/Http/JsonResponseTest.php index bce36c128..75559c319 100644 --- a/tests/Unit/Http/JsonResponseTest.php +++ b/tests/Unit/Http/JsonResponseTest.php @@ -10,15 +10,46 @@ #[CoversClass(JsonResponse::class)] final class JsonResponseTest extends TestCase { - public function testSuccessAndErrorFactoriesBuildJsonResponses(): void + public function testSuccessFactoryBuildsASuccessPayload(): void { - $success = JsonResponse::success('Done', ResponseStatus::Created, ['id' => 7]); - $error = JsonResponse::error('Invalid', ResponseStatus::UnprocessableEntity, ['field' => 'name']); - - $this->assertStringContainsString('"status":"success"', $success->content()); - $this->assertStringContainsString('"status":"error"', $error->content()); - $this->assertSame(ResponseStatus::Created, $success->status()); - $this->assertSame(ResponseStatus::UnprocessableEntity, $error->status()); - $this->assertSame('application/json; charset=utf-8', $success->headers()->get('Content-Type')); + $response = JsonResponse::success('Done', ResponseStatus::Created, ['id' => 7]); + $payload = json_decode($response->content(), true); + + $this->assertSame('success', $payload['status']); + $this->assertSame('Done', $payload['message']); + $this->assertSame(201, (int) $payload['code']); + $this->assertSame(['id' => 7], $payload['data']); + $this->assertSame(ResponseStatus::Created, $response->status()); + $this->assertSame('application/json; charset=utf-8', $response->headers()->get('Content-Type')); + } + + public function testErrorFactoryBuildsAnErrorPayload(): void + { + $response = JsonResponse::error('Invalid', ResponseStatus::UnprocessableEntity, ['field' => 'name']); + $payload = json_decode($response->content(), true); + + $this->assertSame('error', $payload['status']); + $this->assertSame('Invalid', $payload['message']); + $this->assertSame(422, (int) $payload['code']); + $this->assertSame(['field' => 'name'], $payload['data']); + $this->assertSame(ResponseStatus::UnprocessableEntity, $response->status()); + } + + public function testFactoriesUseDefaultStatusesAndEmptyData(): void + { + $success = json_decode(JsonResponse::success('Done')->content(), true); + $error = JsonResponse::error('Failed'); + + $this->assertSame(200, (int) $success['code']); + $this->assertSame([], $success['data']); + $this->assertSame(ResponseStatus::BadRequest, $error->status()); + } + + public function testCustomHeadersCanOverrideTheContentType(): void + { + $response = new JsonResponse('{}', headers: ['Content-Type' => 'application/problem+json']); + + $this->assertSame('application/problem+json', $response->headers()->get('Content-Type')); + $this->assertSame('{}', $response->content()); } } diff --git a/tests/Unit/Http/MessagesTest.php b/tests/Unit/Http/MessagesTest.php index 46f56eee4..097935924 100644 --- a/tests/Unit/Http/MessagesTest.php +++ b/tests/Unit/Http/MessagesTest.php @@ -10,16 +10,81 @@ #[CoversClass(Messages::class)] final class MessagesTest extends TestCase { - public function testMessagesCanBeAddedReadAndRemoved(): void + public function testMessagesAreStoredInTheGivenArray(): void { $data = []; $messages = new Messages($data); - $messages->add(MessageType::Info, 'hello'); - $messages->set(MessageType::Error, ['bad']); + $messages->add(MessageType::Info, 'first'); + $messages->add(MessageType::Info, 'second'); + $messages->set(MessageType::Error, 'single'); + + $this->assertSame(['info' => ['first', 'second'], 'error' => ['single']], $data); + $this->assertSame($data, $messages->toArray()); + } + + public function testSetReplacesExistingMessagesOfTheSameType(): void + { + $data = []; + $messages = new Messages($data); + $messages->add(MessageType::Warning, 'old'); + + $messages->set(MessageType::Warning, ['new', 'newer']); + + $this->assertSame(['new', 'newer'], $messages->get(MessageType::Warning)); + } + + public function testHasIsFalseForMissingOrEmptyMessages(): void + { + $data = []; + $messages = new Messages($data); + + $this->assertFalse($messages->has(MessageType::Info)); + + $messages->set(MessageType::Info, []); + $this->assertFalse($messages->has(MessageType::Info)); + + $messages->add(MessageType::Info, 'hello'); $this->assertTrue($messages->has(MessageType::Info)); + $this->assertFalse($messages->has(MessageType::Error)); + } + + public function testGetReturnsAndConsumesOnlyTheRequestedType(): void + { + $data = []; + $messages = new Messages($data); + $messages->add(MessageType::Info, 'hello'); + $messages->add(MessageType::Error, 'bad'); + $this->assertSame(['hello'], $messages->get(MessageType::Info)); - $this->assertSame(['error' => ['bad']], $messages->getAll()); + $this->assertSame([], $messages->get(MessageType::Info)); + $this->assertSame([], $messages->get(MessageType::Warning)); + $this->assertSame(['error' => ['bad']], $data); + } + + public function testGetAllReturnsAndConsumesEveryMessage(): void + { + $data = []; + $messages = new Messages($data); + $messages->add(MessageType::Info, 'hello'); + $messages->add(MessageType::Error, 'bad'); + + $this->assertSame(['info' => ['hello'], 'error' => ['bad']], $messages->getAll()); + $this->assertSame([], $messages->getAll()); + $this->assertSame([], $data); + } + + public function testRemoveAndRemoveAllDiscardMessages(): void + { + $data = []; + $messages = new Messages($data); + $messages->add(MessageType::Info, 'hello'); + $messages->add(MessageType::Error, 'bad'); + + $messages->remove(MessageType::Info); + $this->assertSame(['error' => ['bad']], $data); + + $messages->removeAll(); $this->assertSame([], $data); } } diff --git a/tests/Unit/Http/RedirectResponseTest.php b/tests/Unit/Http/RedirectResponseTest.php index 1444150ef..e0a9bf527 100644 --- a/tests/Unit/Http/RedirectResponseTest.php +++ b/tests/Unit/Http/RedirectResponseTest.php @@ -18,4 +18,19 @@ public function testRedirectResponseSetsLocationAndCustomHeaders(): void $this->assertSame('yes', $response->headers()->get('X-Redirect')); $this->assertSame(ResponseStatus::SeeOther, $response->status()); } + + public function testRedirectUsesFoundStatusAndAnEmptyBodyByDefault(): void + { + $response = new RedirectResponse('/next'); + + $this->assertSame(ResponseStatus::Found, $response->status()); + $this->assertSame('', $response->content()); + } + + public function testTheGivenUriIsNotOverriddenByAnExtraLocationHeader(): void + { + $response = new RedirectResponse('/next', headers: ['Location' => '/elsewhere']); + + $this->assertSame('/next', $response->headers()->get('Location')); + } } diff --git a/tests/Unit/Http/ResponseHeaderUtilsTest.php b/tests/Unit/Http/ResponseHeaderUtilsTest.php index 5e58e7bba..c0de1d758 100644 --- a/tests/Unit/Http/ResponseHeaderUtilsTest.php +++ b/tests/Unit/Http/ResponseHeaderUtilsTest.php @@ -5,29 +5,38 @@ use Formwork\Http\ResponseStatus; use Formwork\Http\Utils\Header; use Formwork\Tests\TestCase; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; #[CoversClass(Header::class)] final class ResponseHeaderUtilsTest extends TestCase { - public function testHeaderUtilityBuildsAndSendsValues(): void + public function testRedirectRejectsNonRedirectionStatuses(): void { - $this->assertSame('attachment; filename=sample.txt', Header::make(['attachment', 'filename' => 'sample.txt'])); - Header::send('X-Formwork-Test', ' value '); - Header::contentType('text/plain'); - Header::sendStatus(ResponseStatus::OK); - $this->addToAssertionCount(1); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('only 3XX statuses are allowed'); + Header::redirect('/next', ResponseStatus::OK); } - public function testNotFoundSendsTheNotFoundStatus(): void + /** + * @param list $data + */ + #[DataProvider('makeProvider')] + public function testMakeJoinsValuesAndParameters(array $data, string $expected): void { - Header::notFound(); - $this->addToAssertionCount(1); + $this->assertSame($expected, Header::make($data)); } - public function testRedirectRejectsNonRedirectionStatuses(): void + /** + * @return iterable, string}> + */ + public static function makeProvider(): iterable { - $this->expectException(\InvalidArgumentException::class); - Header::redirect('/next', ResponseStatus::OK); + yield 'no data' => [[], '']; + yield 'single value' => [['text/html'], 'text/html']; + yield 'value with a parameter' => [['application/json', 'charset' => 'utf-8'], 'application/json; charset=utf-8']; + yield 'several parameters' => [['attachment', 'filename' => 'sample.txt', 'size' => '12'], 'attachment; filename=sample.txt; size=12']; + yield 'parameters before values' => [['a' => 'b', 'c'], 'a=b; c']; } } diff --git a/tests/Unit/Http/ResponseStatusTest.php b/tests/Unit/Http/ResponseStatusTest.php index 7c18196d5..3f0b07f7e 100644 --- a/tests/Unit/Http/ResponseStatusTest.php +++ b/tests/Unit/Http/ResponseStatusTest.php @@ -5,6 +5,7 @@ use Formwork\Http\ResponseStatus; use Formwork\Http\ResponseStatusType; use Formwork\Tests\TestCase; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -30,4 +31,36 @@ public static function statusProvider(): array 'server error' => [ResponseStatus::InternalServerError, 500, ResponseStatusType::ServerError], ]; } + + public function testStatusCodesAreUnique(): void + { + $codes = array_map(static fn(ResponseStatus $status): int => $status->code(), ResponseStatus::cases()); + + $this->assertSame($codes, array_values(array_unique($codes))); + } + + public function testEveryStatusIsConsistentWithItsValueAndType(): void + { + foreach (ResponseStatus::cases() as $status) { + $this->assertMatchesRegularExpression('/^[1-5][0-9]{2} [A-Za-z\' -]+$/', $status->value, $status->name); + $this->assertSame($status, ResponseStatus::fromCode($status->code()), $status->name); + $this->assertSame( + match ((int) ($status->code() / 100)) { + 1 => ResponseStatusType::Informational, + 2 => ResponseStatusType::Successful, + 3 => ResponseStatusType::Redirection, + 4 => ResponseStatusType::ClientError, + 5 => ResponseStatusType::ServerError, + }, + $status->type(), + $status->name, + ); + } + } + + public function testUnknownStatusCodesAreRejected(): void + { + $this->expectException(InvalidArgumentException::class); + ResponseStatus::fromCode(299); + } } diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index a22b19984..985536271 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -8,6 +8,8 @@ use Formwork\Http\ResponseStatus; use Formwork\Tests\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; #[CoversClass(Response::class)] final class ResponseTest extends TestCase @@ -49,19 +51,6 @@ public function testEmptyResponsesClearContentTypeAndContentLength(): void } } - public function testResponseSendWritesContentAndAddsDefaultHeaders(): void - { - $response = new Response('body'); - - ob_start(); - $response->send(); - $output = ob_get_clean(); - - $this->assertSame('body', $output); - $this->assertSame('text/html; charset=utf-8', $response->headers()->get('Content-Type')); - $this->assertSame('no-cache, private', $response->headers()->get('Cache-Control')); - } - public function testPrepareIsStableWhenAppliedTwiceToTheSameRequest(): void { $response = new Response('content', ResponseStatus::OK, [ @@ -89,6 +78,98 @@ public function testConditionalNotModifiedResponsesCannotRetainAResponseBody(): $this->assertFalse($response->headers()->has('Content-Length')); } + #[DataProvider('validatorProvider')] + public function testConditionalGetRequestsAreAnsweredWithNotModifiedOnlyWhenValidatorsMatch(array $requestHeaders, ResponseStatus $expected): void + { + $response = new Response('body', ResponseStatus::OK, ['ETag' => '"abc"', 'Last-Modified' => 'Wed, 01 Jan 2025 00:00:00 GMT']); + $request = $this->request(RequestMethod::GET, $requestHeaders); + + $response->prepare($request); + + $this->assertSame($expected, $response->status()); + } + + /** + * @return iterable, ResponseStatus}> + */ + public static function validatorProvider(): iterable + { + yield 'matching ETag' => [['HTTP_IF_NONE_MATCH' => '"abc"'], ResponseStatus::NotModified]; + yield 'different ETag' => [['HTTP_IF_NONE_MATCH' => '"other"'], ResponseStatus::OK]; + yield 'ETag in a list' => [['HTTP_IF_NONE_MATCH' => '"other", "abc"'], ResponseStatus::NotModified]; + yield 'weak ETag' => [['HTTP_IF_NONE_MATCH' => 'W/"abc"'], ResponseStatus::NotModified]; + yield 'any ETag' => [['HTTP_IF_NONE_MATCH' => '*'], ResponseStatus::NotModified]; + yield 'matching modification date' => [['HTTP_IF_MODIFIED_SINCE' => 'Wed, 01 Jan 2025 00:00:00 GMT'], ResponseStatus::NotModified]; + yield 'different modification date' => [['HTTP_IF_MODIFIED_SINCE' => 'Tue, 31 Dec 2024 00:00:00 GMT'], ResponseStatus::OK]; + yield 'ETag takes precedence over the modification date' => [ + ['HTTP_IF_NONE_MATCH' => '"other"', 'HTTP_IF_MODIFIED_SINCE' => 'Wed, 01 Jan 2025 00:00:00 GMT'], + ResponseStatus::OK, + ]; + yield 'no validators' => [[], ResponseStatus::OK]; + } + + public function testModificationDateIsIgnoredWhenTheRequestHasETagsButTheResponseHasNone(): void + { + // If-None-Match takes precedence over If-Modified-Since even when it cannot be evaluated + $response = new Response('body', ResponseStatus::OK, ['Last-Modified' => 'Wed, 01 Jan 2025 00:00:00 GMT']); + + $response->prepare($this->request(RequestMethod::GET, [ + 'HTTP_IF_NONE_MATCH' => '"abc"', + 'HTTP_IF_MODIFIED_SINCE' => 'Wed, 01 Jan 2025 00:00:00 GMT', + ])); + + $this->assertSame(ResponseStatus::OK, $response->status()); + } + + #[DataProvider('invalidDateProvider')] + public function testUnparseableModificationDatesNeverProduceNotModified(string $lastModified, string $ifModifiedSince): void + { + $response = new Response('body', ResponseStatus::OK, ['Last-Modified' => $lastModified]); + + $response->prepare($this->request(RequestMethod::GET, ['HTTP_IF_MODIFIED_SINCE' => $ifModifiedSince])); + + $this->assertSame(ResponseStatus::OK, $response->status()); + } + + /** + * @return iterable + */ + public static function invalidDateProvider(): iterable + { + yield 'invalid request date' => ['Wed, 01 Jan 2025 00:00:00 GMT', 'not a date']; + yield 'invalid response date' => ['not a date', 'Wed, 01 Jan 2025 00:00:00 GMT']; + yield 'both dates invalid' => ['not a date', 'also not a date']; + yield 'empty request date' => ['Wed, 01 Jan 2025 00:00:00 GMT', '']; + } + + public function testETagsContainingCommasAreMatchedAsAWhole(): void + { + $response = new Response('body', ResponseStatus::OK, ['ETag' => '"a,b"']); + + $response->prepare($this->request(RequestMethod::GET, ['HTTP_IF_NONE_MATCH' => '"a,b"'])); + + $this->assertSame(ResponseStatus::NotModified, $response->status()); + } + + public function testPartsOfAnETagContainingCommasDoNotMatch(): void + { + $response = new Response('body', ResponseStatus::OK, ['ETag' => '"b"']); + + $response->prepare($this->request(RequestMethod::GET, ['HTTP_IF_NONE_MATCH' => '"a,b"'])); + + $this->assertSame(ResponseStatus::OK, $response->status()); + } + + public function testConditionalHeadersDoNotAffectRequestsOtherThanGetAndHead(): void + { + $response = new Response('body', ResponseStatus::OK, ['ETag' => '"abc"']); + + $response->prepare($this->request(RequestMethod::POST, ['HTTP_IF_NONE_MATCH' => '"abc"'])); + + $this->assertSame(ResponseStatus::OK, $response->status()); + $this->assertSame('body', $response->content()); + } + public function testNoContentResponsesRemoveEntityHeadersAndBody(): void { $response = new Response('body', ResponseStatus::NoContent, [ @@ -117,22 +198,82 @@ public function testHeadPreparationSuppressesBodyButPreservesEntityMetadata(): v $this->assertSame('4', $response->headers()->get('Content-Length')); } - public function testSendWritesExactlyThePreparedContent(): void + // Headers cannot be sent once the output of the test runner has started, so the + // tests sending a response run in a separate process and check the headers as data + + #[RunInSeparateProcess] + public function testSendWritesTheContentAndCompletesTheHeaders(): void { - $response = new Response('expected'); + $response = new Response('Hello'); - ob_start(); - $response->send(); - $output = ob_get_clean(); + $this->assertSame('Hello', $this->send($response)); + $this->assertSame('text/html; charset=utf-8', $response->headers()->get('Content-Type')); + $this->assertSame('no-cache, private', $response->headers()->get('Cache-Control')); + } + + #[RunInSeparateProcess] + public function testSendKeepsTheGivenHeaders(): void + { + $response = new Response('{}', ResponseStatus::OK, [ + 'X-Own' => 'own', + 'Cache-Control' => 'max-age=60', + 'Content-Type' => 'application/json', + ]); + + $this->send($response); - $this->assertSame('expected', $output); + $this->assertSame('own', $response->headers()->get('X-Own')); + $this->assertSame('max-age=60', $response->headers()->get('Cache-Control')); + $this->assertSame('application/json', $response->headers()->get('Content-Type')); } - public function testArrayRoundTripPreservesStatusContentAndHeaders(): void + #[DataProvider('contentTypeProvider')] + #[RunInSeparateProcess] + public function testTextContentTypesGetACharsetWhenMissing(string $contentType, string $expected): void { - $response = new Response('content', ResponseStatus::Created, ['X-Test' => 'value']); + $response = new Response('body', ResponseStatus::OK, ['Content-Type' => $contentType]); - $this->assertSame($response->toArray(), Response::fromArray($response->toArray())->toArray()); + $this->send($response); + + $this->assertSame($expected, $response->headers()->get('Content-Type')); + } + + /** + * @return iterable + */ + public static function contentTypeProvider(): iterable + { + yield 'plain text' => ['text/plain', 'text/plain; charset=utf-8']; + yield 'CSS' => ['text/css', 'text/css; charset=utf-8']; + yield 'text with a charset' => ['text/plain; charset=iso-8859-1', 'text/plain; charset=iso-8859-1']; + yield 'JSON' => ['application/json', 'application/json']; + yield 'image' => ['image/png', 'image/png']; + } + + public function testNotModifiedIsOnlyUsedForSuccessfulResponses(): void + { + $response = new Response('missing', ResponseStatus::NotFound, ['ETag' => '"abc"']); + + $response->prepare($this->request(RequestMethod::GET, ['HTTP_IF_NONE_MATCH' => '"abc"'])); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + $this->assertSame('missing', $response->content()); + } + + /** + * Send the response and return its output + */ + private function send(Response $response): string + { + $response->prepare($this->request()); + + ob_start(); + try { + $response->send(); + return (string) ob_get_contents(); + } finally { + ob_end_clean(); + } } /** diff --git a/tests/Unit/Http/SessionTest.php b/tests/Unit/Http/SessionTest.php index f4b5ceb5b..116c67e25 100644 --- a/tests/Unit/Http/SessionTest.php +++ b/tests/Unit/Http/SessionTest.php @@ -2,24 +2,36 @@ namespace Formwork\Tests\Unit\Http; +use Closure; use Formwork\Http\Request; +use Formwork\Http\Session\MessageType; use Formwork\Http\Session\Session; use Formwork\Tests\TestCase; use Formwork\Utils\FileSystem; use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; #[CoversClass(Session::class)] final class SessionTest extends TestCase { + /** + * @var array + */ + private array $originalCookies = []; + protected function setUp(): void { parent::setUp(); $this->setUpTempDirectory(); + $this->originalCookies = $_COOKIE; } protected function tearDown(): void { + $this->endRequest(); + $_COOKIE = $this->originalCookies; $this->tearDownTempDirectory(); parent::tearDown(); } @@ -38,21 +50,51 @@ public function testSessionDataAndMessagesAreMemoized(): void $session->save(); } - public function testReservedMessageKeysAreRejected(): void + public function testSessionCannotBeStartedTwice(): void { $session = $this->session(); $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'session-data')); + $session->start(); try { - $session->get('_formwork_messages.foo'); - $this->fail('Expected reserved key exception'); - } catch (InvalidArgumentException) { - $this->addToAssertionCount(1); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Session already started'); + $session->start(); } finally { $session->save(); } } + /** + * @param Closure(Session): mixed $operation + */ + #[DataProvider('reservedKeyOperationProvider')] + public function testReservedMessageKeysAreRejected(Closure $operation): void + { + $session = $this->session(); + $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'session-data')); + + try { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is reserved'); + $operation($session); + } finally { + $session->save(); + } + } + + /** + * @return iterable + */ + public static function reservedKeyOperationProvider(): iterable + { + yield 'get' => [static fn(Session $session) => $session->get('_formwork_messages.foo')]; + yield 'has' => [static fn(Session $session) => $session->has('_formwork_messages')]; + yield 'set' => [static fn(Session $session) => $session->set('_formwork_messages', ['forged'])]; + yield 'set nested key' => [static fn(Session $session) => $session->set('_formwork_messages.info', ['forged'])]; + yield 'remove' => [static fn(Session $session) => $session->remove('_formwork_messages')]; + } + public function testSessionCanBeConfiguredStartedAndSaved(): void { $session = $this->session(); @@ -82,6 +124,34 @@ public function testSessionCanRegenerateAndCheckItsCurrentId(): void $session->save(); } + /** + * @param Closure(Session): mixed $change + */ + #[DataProvider('configurationChangeProvider')] + public function testSessionRejectsChangingNameAndPathAfterStart(Closure $change, string $message): void + { + $session = $this->session(); + $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'session-data')); + $session->start(); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($message); + $change($session); + } finally { + $session->save(); + } + } + + /** + * @return iterable + */ + public static function configurationChangeProvider(): iterable + { + yield 'name' => [static fn(Session $session) => $session->setName('another'), 'Cannot set session name']; + yield 'save path' => [static fn(Session $session) => $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'another-path')), 'Cannot set session save path']; + } + public function testRegenerateDestroysTheOldSessionAndMovesItsDataToTheNewOne(): void { $directory = FileSystem::joinPaths(TESTS_TMP_PATH, 'session-regenerate'); @@ -119,26 +189,167 @@ public function testRegenerateCanDiscardTheSessionData(): void $session->save(); } - public function testSessionRejectsChangingConfigurationAfterStart(): void + public function testDestroyEndsTheSessionAndRemovesItsData(): void { $session = $this->session(); $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'session-data')); $session->start(); + $session->set('value', 42); - try { - $session->setName('another'); - $this->fail('Expected setName exception'); - } catch (\RuntimeException) { - $this->addToAssertionCount(1); + $session->destroy(); + + $this->assertFalse($session->isStarted()); + $this->assertSame(PHP_SESSION_NONE, session_status()); + } + + public function testDestroyRemovesTheSessionFile(): void + { + $id = $this->sessionId($this->sessionRequest('write')); + $this->assertFileExists(TESTS_TMP_PATH . '/sessions/sess_' . $id); + + $this->sessionRequest('destroy', $id); + + $this->assertFileDoesNotExist(TESTS_TMP_PATH . '/sessions/sess_' . $id); + } + + public function testExistsComparesTheGivenIdWithTheCurrentOne(): void + { + $session = $this->session(); + $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'session-data')); + $session->start(); + $id = (string) session_id(); + + $this->assertTrue($session->exists($id)); + $this->assertTrue($session->isStarted()); + + $this->assertFalse($session->exists('another-session-id-0123456789')); + $this->assertFalse($session->isStarted()); + } + + public function testNewVisitorsGetANewSessionStoredOnDisk(): void + { + $id = $this->sessionId($this->sessionRequest('write')); + + $this->assertMatchesRegularExpression('/^[a-z0-9,-]{22,256}$/i', $id); + $this->assertFileExists(TESTS_TMP_PATH . '/sessions/sess_' . $id); + } + + public function testDataAndFlashMessagesPersistAcrossRequests(): void + { + $id = $this->sessionId($this->sessionRequest('write')); + + $second = $this->sessionRequest('read', $id); + $third = $this->sessionRequest('read', $id); + + $this->assertSame($id, $this->sessionId($second)); + $this->assertSame('Alice', $second['user']); + $this->assertSame(['info' => ['Saved']], $second['messages']); + $this->assertSame($id, $this->sessionId($third)); + $this->assertSame('Alice', $third['user']); + $this->assertSame([], $third['messages']); + } + + #[DataProvider('invalidSessionIdProvider')] + public function testMalformedSessionIdsInTheCookieAreIgnored(string $suppliedId): void + { + $response = $this->sessionRequest('read', $suppliedId); + $id = $this->sessionId($response); + + $this->assertNotSame($suppliedId, $id); + $this->assertMatchesRegularExpression('/^[a-z0-9,-]{22,256}$/i', $id); + } + + /** + * @return iterable + */ + public static function invalidSessionIdProvider(): iterable + { + yield 'too short' => ['abc']; + yield 'path traversal' => ['../../nonexistent/secret-0000000000000']; + yield 'invalid characters' => ['abcdefghijklmnopqrstuvwxyz!@#$%']; + yield 'too long' => [str_repeat('a', 257)]; + } + + public function testUnknownWellFormedSessionIdsAreReplacedByNewOnes(): void + { + $suppliedId = str_repeat('a', 32); + + $response = $this->sessionRequest('read', $suppliedId); + + $this->assertNotSame($suppliedId, $this->sessionId($response)); + $this->assertFileDoesNotExist(TESTS_TMP_PATH . '/sessions/sess_' . $suppliedId); + } + + /** + * @param array{id: string, user: mixed, messages: array>} $response + */ + private function sessionId(array $response): string + { + return $response['id']; + } + + /** + * Run a request against the session, as a separate script would do + * + * Every request starts with no session state and ends by closing the session + * + * @return array{id: string, user: mixed, messages: array>} + */ + private function sessionRequest(string $action, ?string $cookieId = null): array + { + if (!FileSystem::isDirectory(TESTS_TMP_PATH . '/sessions', assertExists: false)) { + FileSystem::createDirectory(TESTS_TMP_PATH . '/sessions'); } - try { - $session->setPath(FileSystem::joinPaths(TESTS_TMP_PATH, 'another-path')); - $this->fail('Expected setPath exception'); - } catch (\RuntimeException) { - $this->addToAssertionCount(1); - } finally { - $session->save(); + + $this->endRequest(); + + $cookies = $cookieId !== null ? ['formwork_session' => $cookieId] : []; + $_COOKIE = $cookies; + + $session = new Session(new Request([], [], $cookies, [], [ + 'REQUEST_METHOD' => 'GET', 'SERVER_NAME' => 'localhost', 'SERVER_PORT' => '80', + ])); + $session->setPath(TESTS_TMP_PATH . '/sessions'); + + switch ($action) { + case 'write': + $session->set('user', 'Alice'); + $session->messages()->add(MessageType::Info, 'Saved'); + break; + + case 'read': + $session->start(); + break; + + case 'destroy': + $session->start(); + $session->destroy(); + break; + } + + $response = ['id' => (string) session_id(), 'user' => null, 'messages' => []]; + + if ($action === 'read') { + $response['user'] = $session->get('user'); + $response['messages'] = $session->messages()->getAll(); + } + + $session->save(); + + return $response; + } + + /** + * Close the session and forget its state, so that the next request starts from scratch + */ + private function endRequest(): void + { + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); } + + session_id(''); + $_SESSION = []; } private function session(): Session diff --git a/tests/Unit/Http/Transport/StreamHttpTransportTest.php b/tests/Unit/Http/Transport/StreamHttpTransportTest.php new file mode 100644 index 000000000..eac203205 --- /dev/null +++ b/tests/Unit/Http/Transport/StreamHttpTransportTest.php @@ -0,0 +1,47 @@ +assertNotFalse($server); + $port = substr((string) strrchr((string) stream_socket_get_name($server, false), ':'), 1); + fclose($server); + + try { + (new StreamHttpTransport())->open('http://127.0.0.1:' . $port, ['http' => ['timeout' => 1, 'ignore_errors' => true]]); + $this->fail('The connection was not refused.'); + } catch (ConnectionException $exception) { + $this->assertStringContainsString(sprintf('Cannot connect to "http://127.0.0.1:%s"', $port), $exception->getMessage()); + $this->assertStringContainsString('#0 ', $exception->getMessage()); + } + } + + public function testTheErrorHandlerIsRestoredAfterAFailedConnection(): void + { + $handler = static fn(): bool => true; + set_error_handler($handler); + + try { + (new StreamHttpTransport())->open('http://127.0.0.1:1', ['http' => ['timeout' => 1]]); + } catch (ConnectionException) { + // Expected + } + + $current = set_error_handler(static fn(): bool => true); + restore_error_handler(); + restore_error_handler(); + + $this->assertSame($handler, $current); + } +} diff --git a/tests/Unit/Http/UploadedFileTest.php b/tests/Unit/Http/UploadedFileTest.php index 1e35138dd..ff3588276 100644 --- a/tests/Unit/Http/UploadedFileTest.php +++ b/tests/Unit/Http/UploadedFileTest.php @@ -5,7 +5,9 @@ use Formwork\Exceptions\TranslatedException; use Formwork\Http\Files\UploadedFile; use Formwork\Tests\TestCase; +use Formwork\Utils\FileSystem; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; #[CoversClass(UploadedFile::class)] final class UploadedFileTest extends TestCase @@ -28,17 +30,104 @@ public function testUploadedFileExposesClientAndUploadMetadata(): void $this->assertFalse($file->isEmpty()); } - public function testUploadErrorsExposeTranslatedMessagesAndBlockMove(): void + #[DataProvider('uploadErrorProvider')] + public function testEveryUploadErrorHasAMessageATranslationAndBlocksTheMove(int $error, string $message, string $translation): void { - $file = new UploadedFile('document', [ - 'name' => 'document.txt', 'full_path' => 'document.txt', 'type' => 'text/plain', - 'tmp_name' => '/tmp/php-document', 'error' => (string) UPLOAD_ERR_NO_FILE, 'size' => '0', - ]); + $file = $this->uploadedFile($error); + + $this->assertSame($message, $file->getErrorMessage()); + $this->assertSame($translation, $file->getErrorTranslationString()); + $this->assertFalse($file->isUploaded()); + $this->assertSame($error === UPLOAD_ERR_NO_FILE, $file->isEmpty()); + + try { + $file->move(TESTS_TMP_PATH, 'file.txt'); + $this->fail('A file with an upload error was moved.'); + } catch (TranslatedException $exception) { + $this->assertSame($translation, $exception->getLanguageString()); + $this->assertStringContainsString($message, $exception->getMessage()); + } + } + + /** + * @return iterable + */ + public static function uploadErrorProvider(): iterable + { + yield 'ini size' => [UPLOAD_ERR_INI_SIZE, 'The uploaded file exceeds the upload_max_filesize directive in php.ini', 'upload.error.size']; + yield 'form size' => [UPLOAD_ERR_FORM_SIZE, 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form', 'upload.error.size']; + yield 'partial' => [UPLOAD_ERR_PARTIAL, 'The uploaded file was only partially uploaded', 'upload.error.partial']; + yield 'no file' => [UPLOAD_ERR_NO_FILE, 'No file was uploaded', 'upload.error.noFile']; + yield 'no temporary directory' => [UPLOAD_ERR_NO_TMP_DIR, 'Missing a temporary folder', 'upload.error.noTemp']; + yield 'cannot write' => [UPLOAD_ERR_CANT_WRITE, 'Failed to write file to disk', 'upload.error.cannotWrite']; + yield 'extension' => [UPLOAD_ERR_EXTENSION, 'A Php extension stopped the file upload', 'upload.error.phpExtension']; + } - $this->assertTrue($file->isEmpty()); - $this->assertSame('No file was uploaded', $file->getErrorMessage()); - $this->assertSame('upload.error.noFile', $file->getErrorTranslationString()); + public function testMoveRejectsTooLongFileNames(): void + { $this->expectException(TranslatedException::class); - $file->move(__DIR__ . '/Fixtures/files', 'document.txt'); + $this->expectExceptionMessage('File name too long'); + + $this->uploadedFile(UPLOAD_ERR_OK)->move(TESTS_TMP_PATH, str_repeat('a', FileSystem::MAX_NAME_LENGTH + 1)); + } + + public function testMoveRejectsTooLongDestinationPaths(): void + { + $this->expectException(TranslatedException::class); + $this->expectExceptionMessage('Destination path too long'); + + $this->uploadedFile(UPLOAD_ERR_OK)->move(str_repeat('a/', (int) (FileSystem::MAX_PATH_LENGTH / 2)), 'file.txt'); + } + + public function testMoveAcceptsFileNamesOfTheMaximumLength(): void + { + try { + $this->uploadedFile(UPLOAD_ERR_OK)->move(TESTS_TMP_PATH, str_repeat('a', FileSystem::MAX_NAME_LENGTH)); + $this->fail('A file that was not uploaded through HTTP was moved.'); + } catch (TranslatedException $exception) { + // The name is accepted, then PHP refuses to move a file that was not uploaded through HTTP + $this->assertSame('upload.error.cannotMoveToDestination', $exception->getLanguageString()); + } + } + + public function testMoveRefusesToReplaceAnExistingFileUnlessOverwriteIsEnabled(): void + { + $this->setUpTempDirectory(); + + try { + FileSystem::write(TESTS_TMP_PATH . '/existing.txt', 'existing'); + $file = $this->uploadedFile(UPLOAD_ERR_OK); + + try { + $file->move(TESTS_TMP_PATH, 'existing.txt'); + $this->fail('An existing file was replaced without overwrite.'); + } catch (TranslatedException $exception) { + $this->assertSame('upload.error.alreadyExists', $exception->getLanguageString()); + } + + // Not an HTTP upload, so PHP refuses the move after the checks above have passed + try { + $file->move(TESTS_TMP_PATH, 'existing.txt', overwrite: true); + $this->fail('A file that was not uploaded through HTTP was moved.'); + } catch (TranslatedException $exception) { + $this->assertSame('upload.error.cannotMoveToDestination', $exception->getLanguageString()); + } + + $this->assertSame('existing', FileSystem::read(TESTS_TMP_PATH . '/existing.txt')); + } finally { + $this->tearDownTempDirectory(); + } + } + + private function uploadedFile(int $error): UploadedFile + { + return new UploadedFile('upload', [ + 'name' => 'file.txt', + 'full_path' => 'file.txt', + 'type' => 'text/plain', + 'tmp_name' => TESTS_TMP_PATH . '/php-upload', + 'error' => (string) $error, + 'size' => '0', + ]); } } diff --git a/tests/Unit/Http/VisitorTest.php b/tests/Unit/Http/VisitorTest.php index c3b1e1417..a4182f8ee 100644 --- a/tests/Unit/Http/VisitorTest.php +++ b/tests/Unit/Http/VisitorTest.php @@ -7,36 +7,105 @@ use Formwork\Http\Utils\Visitor; use Formwork\Tests\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; #[CoversClass(Visitor::class)] final class VisitorTest extends TestCase { - public function testVisitorIdentifiesBotsDevicesAndExternalSources(): void + #[DataProvider('userAgentProvider')] + public function testVisitorIsClassifiedByUserAgent(?string $userAgent, bool $bot, DeviceType $device): void { - $bot = $this->request('Googlebot/2.1', 'https://search.example/path'); - $mobile = $this->request('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148'); + $request = $this->request($userAgent); + + $this->assertSame($bot, Visitor::isBot($request)); + $this->assertSame(!$bot, Visitor::isBrowser($request)); + $this->assertSame($device, Visitor::getDeviceType($request)); + $this->assertSame($device === DeviceType::Mobile, Visitor::isMobile($request)); + $this->assertSame($device === DeviceType::Tablet, Visitor::isTablet($request)); + $this->assertSame($device === DeviceType::Desktop, Visitor::isDesktop($request)); + } + + /** + * @return iterable + */ + public static function userAgentProvider(): iterable + { + yield 'desktop browser' => ['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36', false, DeviceType::Desktop]; + yield 'iPhone' => ['Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148', false, DeviceType::Mobile]; + yield 'Android phone' => ['Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 Chrome/120.0 Mobile Safari/537.36', false, DeviceType::Mobile]; + yield 'iPad' => ['Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148', false, DeviceType::Tablet]; + yield 'Android tablet' => ['Mozilla/5.0 (Linux; Android 13; SM-T870) AppleWebKit/537.36 Chrome/120.0 Safari/537.36', false, DeviceType::Tablet]; + yield 'search engine crawler' => ['Googlebot/2.1', true, DeviceType::Desktop]; + yield 'crawler with a browser like user agent' => ['Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)', true, DeviceType::Desktop]; + yield 'command line client' => ['curl/8.0.1', true, DeviceType::Desktop]; + yield 'empty user agent' => ['', false, DeviceType::Desktop]; + yield 'missing user agent' => [null, false, DeviceType::Desktop]; + } + + public function testDeviceTypeDoesNotDependOnThePreviousRequest(): void + { + $phone = $this->request('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148'); $desktop = $this->request('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36'); - $this->assertTrue(Visitor::isBot($bot)); - $this->assertFalse(Visitor::isBrowser($bot)); - $this->assertSame('search.example', Visitor::getSource($bot)); - $this->assertSame(DeviceType::Mobile, Visitor::getDeviceType($mobile)); - $this->assertTrue(Visitor::isMobile($mobile)); + $this->assertSame(DeviceType::Mobile, Visitor::getDeviceType($phone)); $this->assertSame(DeviceType::Desktop, Visitor::getDeviceType($desktop)); - $this->assertTrue(Visitor::isDesktop($desktop)); + $this->assertSame(DeviceType::Mobile, Visitor::getDeviceType($phone)); } - public function testVisitorHandlesDirectAndSameOriginVisits(): void + #[DataProvider('refererProvider')] + public function testSourceIsTheHostOfAnExternalReferer(?string $referer, ?string $expected): void { - $this->assertSame('', Visitor::getSource($this->request('Browser/1.0'))); - $this->assertNull(Visitor::getSource($this->request('Browser/1.0', 'https://example.test/previous'))); + $this->assertSame($expected, Visitor::getSource($this->request('Browser/1.0', $referer))); + } + + /** + * @return iterable + */ + public static function refererProvider(): iterable + { + yield 'direct visit' => [null, '']; + yield 'empty referer' => ['', '']; + yield 'external site' => ['https://search.example/path?q=1', 'search.example']; + yield 'external site with a port' => ['https://search.example:8443/path', 'search.example']; + yield 'external subdomain' => ['https://blog.search.example/path', 'blog.search.example']; + yield 'subdomain of the current host' => ['https://sub.example.test/path', 'sub.example.test']; + yield 'external IP address' => ['https://192.0.2.1/path', '192.0.2.1']; + yield 'current host' => ['https://example.test/previous', null]; + yield 'current host with another case' => ['https://EXAMPLE.test/previous', null]; + yield 'current host with another port' => ['https://example.test:8080/previous', null]; + yield 'relative referer' => ['/previous', null]; + yield 'not a URI' => ['not a uri', null]; + yield 'script URI' => ['javascript:alert(1)', null]; + yield 'markup in the host' => ['https://.test/', null]; + yield 'markup closing an attribute' => ['https://">/', null]; + yield 'markup after a valid host' => ['https://a.test/', null]; + yield 'attribute injection' => ['https://evil.test"onmouseover="alert(1)/', null]; + yield 'null byte in the host' => ["https://evil.test\x00.example/", null]; + yield 'space in the host' => ['https://exa mple.test/', null]; + yield 'underscore in the host' => ['https://a_b.test/', null]; + yield 'credentials are not part of the source' => ['https://user:pass@search.example/', 'search.example']; + } + + public function testSourceIsUnknownWhenTheHostOfTheRequestIsInvalid(): void + { + $request = new Request([], [], [], [], [ + 'REQUEST_METHOD' => 'GET', + 'SERVER_NAME' => 'not a valid host', + 'SERVER_PORT' => '80', + 'HTTP_REFERER' => 'https://search.example/path', + ]); + + $this->assertNull(Visitor::getSource($request)); } - private function request(string $agent, ?string $referer = null): Request + private function request(?string $userAgent, ?string $referer = null): Request { return new Request([], [], [], [], array_filter([ - 'REQUEST_METHOD' => 'GET', 'SERVER_NAME' => 'example.test', 'SERVER_PORT' => '80', - 'HTTP_USER_AGENT' => $agent, 'HTTP_REFERER' => $referer, - ], static fn($value): bool => $value !== null)); + 'REQUEST_METHOD' => 'GET', + 'SERVER_NAME' => 'example.test', + 'SERVER_PORT' => '80', + 'HTTP_USER_AGENT' => $userAgent, + 'HTTP_REFERER' => $referer, + ], static fn(?string $value): bool => $value !== null)); } } From 69cc7496bae8df61128eeb473666170eb61e1838 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:49:18 +0000 Subject: [PATCH 23/51] Improve Router, Services and Config tests - Test the compilation of routes through a router subclass and replace the data holder test of `CompiledRoute` - Cover parameter patterns, array and closure constraints, filters, route files (prefixes, types, constraints, action parameters) and URL generation - Move the `Container` fixtures to PSR-4 files so that every test class can run on its own, and cover variadic parameters and aliases - Cover the resolution of config references (precedence, nesting, circular and forward references) Some of the new tests fail until the bugs they reveal are fixed: static parts of route paths are not quoted in the compiled regex, the current route is kept after its constraints reject it, forward and circular references in the config are not resolved or detected, escaped references are interpolated. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd --- tests/Unit/Config/ConfigTest.php | 121 ++- tests/Unit/Router/CompiledRouteTest.php | 81 +- tests/Unit/Router/Fixtures/routes.php | 13 +- tests/Unit/Router/RouteFilterTest.php | 2 +- tests/Unit/Router/RouteTest.php | 158 ++-- tests/Unit/Router/RouterTest.php | 205 ++++- tests/Unit/Services/ContainerTest.php | 731 +++++++++++++----- .../Services/Fixtures/AbstractService.php | 5 + .../Fixtures/AnotherDependentService.php | 8 + .../Services/Fixtures/ArrayOptionsService.php | 11 + .../Fixtures/AttributeDependentService.php | 10 + tests/Unit/Services/Fixtures/CircularA.php | 8 + tests/Unit/Services/Fixtures/CircularB.php | 8 + .../Fixtures/ContainerAwareLoader.php | 16 + .../Services/Fixtures/DependencyContract.php | 5 + .../Fixtures/DependencyResolvingLoader.php | 14 + .../Services/Fixtures/DependentOnContract.php | 8 + .../Fixtures/DependentOnMissingService.php | 8 + .../Fixtures/DependentOnSharedDependency.php | 8 + .../Services/Fixtures/DependentService.php | 8 + .../Unit/Services/Fixtures/FactoryProduct.php | 8 + .../Fixtures/FailingResolutionAwareLoader.php | 23 + .../Services/Fixtures/InvalidReturnLoader.php | 14 + .../Unit/Services/Fixtures/LoadedService.php | 8 + .../Fixtures/LoadedServiceWithDependency.php | 11 + .../Fixtures/NestedDependentService.php | 8 + .../OptionalAttributeDependentService.php | 10 + .../Fixtures/OptionalDependencyService.php | 8 + .../Fixtures/OptionalIntegerService.php | 8 + .../Fixtures/OptionalStringService.php | 8 + .../Services/Fixtures/ParameterizedLoader.php | 16 + .../Fixtures/PrivateConstructorService.php | 8 + .../Services/Fixtures/RecursiveLoader.php | 16 + .../Fixtures/ScalarDependentService.php | 8 + .../Services/Fixtures/SharedDependency.php | 5 + .../Unit/Services/Fixtures/SimpleService.php | 5 + .../Services/Fixtures/TestServiceLoader.php | 26 + .../Unit/Services/Fixtures/ThrowingLoader.php | 15 + .../Services/Fixtures/VariadicService.php | 13 + tests/Unit/Services/ServiceDefinitionTest.php | 51 +- 40 files changed, 1415 insertions(+), 282 deletions(-) create mode 100644 tests/Unit/Services/Fixtures/AbstractService.php create mode 100644 tests/Unit/Services/Fixtures/AnotherDependentService.php create mode 100644 tests/Unit/Services/Fixtures/ArrayOptionsService.php create mode 100644 tests/Unit/Services/Fixtures/AttributeDependentService.php create mode 100644 tests/Unit/Services/Fixtures/CircularA.php create mode 100644 tests/Unit/Services/Fixtures/CircularB.php create mode 100644 tests/Unit/Services/Fixtures/ContainerAwareLoader.php create mode 100644 tests/Unit/Services/Fixtures/DependencyContract.php create mode 100644 tests/Unit/Services/Fixtures/DependencyResolvingLoader.php create mode 100644 tests/Unit/Services/Fixtures/DependentOnContract.php create mode 100644 tests/Unit/Services/Fixtures/DependentOnMissingService.php create mode 100644 tests/Unit/Services/Fixtures/DependentOnSharedDependency.php create mode 100644 tests/Unit/Services/Fixtures/DependentService.php create mode 100644 tests/Unit/Services/Fixtures/FactoryProduct.php create mode 100644 tests/Unit/Services/Fixtures/FailingResolutionAwareLoader.php create mode 100644 tests/Unit/Services/Fixtures/InvalidReturnLoader.php create mode 100644 tests/Unit/Services/Fixtures/LoadedService.php create mode 100644 tests/Unit/Services/Fixtures/LoadedServiceWithDependency.php create mode 100644 tests/Unit/Services/Fixtures/NestedDependentService.php create mode 100644 tests/Unit/Services/Fixtures/OptionalAttributeDependentService.php create mode 100644 tests/Unit/Services/Fixtures/OptionalDependencyService.php create mode 100644 tests/Unit/Services/Fixtures/OptionalIntegerService.php create mode 100644 tests/Unit/Services/Fixtures/OptionalStringService.php create mode 100644 tests/Unit/Services/Fixtures/ParameterizedLoader.php create mode 100644 tests/Unit/Services/Fixtures/PrivateConstructorService.php create mode 100644 tests/Unit/Services/Fixtures/RecursiveLoader.php create mode 100644 tests/Unit/Services/Fixtures/ScalarDependentService.php create mode 100644 tests/Unit/Services/Fixtures/SharedDependency.php create mode 100644 tests/Unit/Services/Fixtures/SimpleService.php create mode 100644 tests/Unit/Services/Fixtures/TestServiceLoader.php create mode 100644 tests/Unit/Services/Fixtures/ThrowingLoader.php create mode 100644 tests/Unit/Services/Fixtures/VariadicService.php diff --git a/tests/Unit/Config/ConfigTest.php b/tests/Unit/Config/ConfigTest.php index 5f53217ea..7fc27220a 100644 --- a/tests/Unit/Config/ConfigTest.php +++ b/tests/Unit/Config/ConfigTest.php @@ -25,17 +25,6 @@ protected function tearDown(): void $this->tearDownTempDirectory(); } - public function testEmptyConfigIsMutableButUnresolved(): void - { - $config = new Config(); - - $this->assertFalse($config->has('missing')); - $this->assertTrue($config->hasMultiple([])); - - $this->expectException(UnresolvedConfigException::class); - $config->toArray(); - } - public function testHasAndHasMultipleDistinguishPresentNullFromMissingKeys(): void { $config = new Config(['present' => null, 'nested' => ['value' => false]], resolved: true); @@ -113,6 +102,15 @@ public static function invalidTypedGetterProvider(): iterable yield 'array rejects string' => ['getArray', 'paths', '/site', 'array']; } + public function testTypedGettersRejectMissingKeysWithoutADefault(): void + { + $config = new Config(resolved: true); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Config value for key "missing" is not a string, got null'); + $config->getString('missing'); + } + public function testTypedGettersAcceptTypedDefaultsForMissingValues(): void { $config = new Config(resolved: true); @@ -132,11 +130,14 @@ public function testSetAndSetMultipleCreateAndOverrideNestedValues(): void $config->set('system.paths.cache', '/cache'); $config->setMultiple(['system.name' => 'Formwork', 'nullable' => null]); - $this->assertTrue($config->getBool('system.debug')); - $this->assertSame('/cache', $config->getString('system.paths.cache')); - $this->assertSame('Formwork', $config->getString('system.name')); - $this->assertTrue($config->has('nullable')); - $this->assertNull($config->get('nullable')); + $this->assertSame([ + 'system' => [ + 'debug' => true, + 'paths' => ['cache' => '/cache'], + 'name' => 'Formwork', + ], + 'nullable' => null, + ], $config->toArray()); } public function testToArrayReturnsTheResolvedNestedConfiguration(): void @@ -195,6 +196,70 @@ public function testResolveRejectsAReferenceToANonStringValue(): void $config->resolve(); } + public function testResolveRejectsCircularReferences(): void + { + $config = new Config(['first' => '${second}', 'second' => '${first}']); + + $this->expectException(ConfigResolutionException::class); + $config->resolve(); + } + + public function testResolveInterpolatesEveryReferenceInAValueAndLeavesOtherValuesUntouched(): void + { + $config = new Config([ + 'name' => 'Formwork', + 'tag' => 'cms', + 'title' => '${name} (${tag})', + 'count' => 5, + 'flag' => true, + 'nested' => ['label' => '${name}/${nested.suffix}', 'suffix' => 'docs'], + 'list' => ['${name}', 'plain'], + ]); + + $config->resolve(); + + $this->assertSame([ + 'name' => 'Formwork', + 'tag' => 'cms', + 'title' => 'Formwork (cms)', + 'count' => 5, + 'flag' => true, + 'nested' => ['label' => 'Formwork/docs', 'suffix' => 'docs'], + 'list' => ['Formwork', 'plain'], + ], $config->toArray()); + } + + public function testResolvePrefersConfigKeysOverVariablesWithTheSameName(): void + { + $config = new Config(['shared' => 'from config', 'value' => '${shared}']); + + $config->resolve(['shared' => 'from variables']); + + $this->assertSame('from config', $config->get('value')); + } + + public function testResolveResolvesReferencesRegardlessOfTheirDefinitionOrder(): void + { + $config = new Config([ + 'url' => '${base}/index', + 'base' => '${%ROOT%}/site', + ]); + + $config->resolve(['%ROOT%' => '/project']); + + $this->assertSame('/project/site', $config->get('base')); + $this->assertSame('/project/site/index', $config->get('url')); + } + + public function testResolveDoesNotInterpolateEscapedReferences(): void + { + $config = new Config(['name' => 'Formwork', 'literal' => '\${name}']); + + $config->resolve(); + + $this->assertStringNotContainsString('Formwork', $config->getString('literal')); + } + public function testLoadFileLoadsYamlIntoACamelCaseKey(): void { $path = TESTS_TMP_PATH . '/site-name.yaml'; @@ -249,6 +314,16 @@ public function testLoadFromPathLoadsEachSupportedConfigurationFile(): void $this->assertFalse($config->getBool('second.enabled')); } + public function testLoadFromPathRejectsUnsupportedFiles(): void + { + FileSystem::write(TESTS_TMP_PATH . '/first.yaml', "name: First\n"); + FileSystem::write(TESTS_TMP_PATH . '/notes.txt', 'not a config file'); + + $this->expectException(ConfigLoadingException::class); + $this->expectExceptionMessage('Unsupported config file type "txt"'); + (new Config(resolved: true))->loadFromPath(TESTS_TMP_PATH); + } + public function testLoadFileRejectsMissingFiles(): void { $path = TESTS_TMP_PATH . '/missing.yaml'; @@ -277,4 +352,18 @@ public function testFromArrayReconstructsAResolvedConfig(): void $this->assertTrue($config->getBool('system.enabled')); $this->assertSame(['system' => ['enabled' => true]], $config->toArray()); } + + public function testUnresolvedConfigCanBeModifiedButNotRead(): void + { + $config = new Config(); + + $this->assertFalse($config->has('missing')); + $this->assertTrue($config->hasMultiple([])); + + $config->set('name', 'Formwork'); + $this->assertTrue($config->has('name')); + + $this->expectException(UnresolvedConfigException::class); + $config->toArray(); + } } diff --git a/tests/Unit/Router/CompiledRouteTest.php b/tests/Unit/Router/CompiledRouteTest.php index e3784f4f1..1c6d4553e 100644 --- a/tests/Unit/Router/CompiledRouteTest.php +++ b/tests/Unit/Router/CompiledRouteTest.php @@ -2,14 +2,22 @@ namespace Formwork\Tests\Unit\Router; +use Formwork\Events\EventDispatcher; +use Formwork\Http\Request; use Formwork\Router\CompiledRoute; +use Formwork\Router\Exceptions\InvalidRouteException; +use Formwork\Router\Route; +use Formwork\Router\Router; +use Formwork\Services\Container; use Formwork\Tests\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; #[CoversClass(CompiledRoute::class)] +#[CoversClass(Router::class)] final class CompiledRouteTest extends TestCase { - public function testConstructorAndAccessorsExposeCompiledRouteData(): void + public function testAccessorsExposeCompiledRouteData(): void { $compiled = new CompiledRoute('/users/{id}', '~^/users/([^/]+)$~', ['id']); @@ -17,4 +25,75 @@ public function testConstructorAndAccessorsExposeCompiledRouteData(): void $this->assertSame('~^/users/([^/]+)$~', $compiled->regex()); $this->assertSame(['id'], $compiled->params()); } + + public function testCompilationNormalizesThePathAndListsParametersInOrder(): void + { + $route = (new Route('user', 'users/{id:digits}/{tab}?'))->prefix('admin'); + + $compiled = $this->compile($route); + + $this->assertSame('/admin/users/{id:digits}/{tab}?/', $compiled->path()); + $this->assertSame(['id', 'tab'], $compiled->params()); + } + + public function testCompilationRejectsParametersWithoutSeparator(): void + { + $this->expectException(InvalidRouteException::class); + $this->expectExceptionMessage('Parameter "id" must be preceded by a separator'); + $this->compile(new Route('invalid', '/users{id}')); + } + + public function testCompilationRejectsRepeatedParameters(): void + { + $this->expectException(InvalidRouteException::class); + $this->expectExceptionMessage('Parameter "id" cannot be used more than once'); + $this->compile(new Route('invalid', '/users/{id}/{id}')); + } + + /** + * @param array $expectedGroups + */ + #[DataProvider('compiledRegexProvider')] + public function testCompiledRegexMatchesTheWholePathOnly(string $path, string $subject, bool $matches, array $expectedGroups = []): void + { + $compiled = $this->compile(new Route('test', $path)); + + $this->assertSame($matches, (bool) preg_match($compiled->regex(), $subject, $groups, PREG_UNMATCHED_AS_NULL)); + + if ($matches) { + $this->assertSame($expectedGroups, array_slice($groups, 1)); + } + } + + /** + * @return iterable}> + */ + public static function compiledRegexProvider(): iterable + { + yield 'static path' => ['/docs/', '/docs/', true]; + yield 'static path with a suffix' => ['/docs/', '/docs/extra/', false]; + yield 'static path with a prefix' => ['/docs/', '/other/docs/', false]; + yield 'static dot is matched literally' => ['/sitemap.xml', '/sitemap.xml/', true]; + yield 'static dot does not match other characters' => ['/sitemap.xml', '/sitemapxxml/', false]; + yield 'required parameter' => ['/users/{id:digits}', '/users/42/', true, ['42']]; + yield 'required parameter missing' => ['/users/{id:digits}', '/users/', false]; + yield 'required parameter invalid' => ['/users/{id:digits}', '/users/abc/', false]; + yield 'optional parameter present' => ['/archive/{year:digits}?', '/archive/2025/', true, ['2025']]; + yield 'optional parameter omitted' => ['/archive/{year:digits}?', '/archive/', true, [null]]; + yield 'custom separator' => ['/search,{term}', '/search,php/', true, ['php']]; + yield 'alternation in the pattern stays inside its group' => ['/lang/{code:en|it}', '/lang/it/', true, ['it']]; + yield 'alternation does not escape the whole path' => ['/lang/{code:en|it}', '/other/en/', false]; + } + + private function compile(Route $route): CompiledRoute + { + $router = new class (new Container(), new Request([], [], [], [], ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/']), new EventDispatcher()) extends Router { + public function compile(Route $route): CompiledRoute + { + return $this->compileRoute($route); + } + }; + + return $router->compile($route); + } } diff --git a/tests/Unit/Router/Fixtures/routes.php b/tests/Unit/Router/Fixtures/routes.php index 80d8752b9..03b8362c0 100644 --- a/tests/Unit/Router/Fixtures/routes.php +++ b/tests/Unit/Router/Fixtures/routes.php @@ -5,11 +5,17 @@ 'loaded' => [ 'path' => '/loaded/{id:digits}', 'action' => 'Formwork\Tests\Unit\Router\RouteControllerFixture@handle', - 'actionParameters' => ['fromRouteFile' => 'route'], + 'actionParameters' => ['fromRouteFile' => 'route', 'shared' => 'route'], 'where' => ['id' => ['42']], 'methods' => ['POST'], 'prefix' => '/api', ], + 'unprefixed' => [ + 'path' => '/unprefixed', + 'action' => 'Formwork\Tests\Unit\Router\RouteControllerFixture@handle', + 'types' => ['XHR'], + 'methods' => ['GET', 'POST'], + ], ], 'filters' => [ 'loaded-filter' => [ @@ -18,5 +24,10 @@ 'types' => ['HTTP'], 'prefix' => '/api', ], + 'unprefixed-filter' => [ + 'action' => 'Formwork\Tests\Unit\Router\RouteFilterFixture@handle', + 'methods' => ['POST', 'PUT'], + 'types' => ['XHR'], + ], ], ]; diff --git a/tests/Unit/Router/RouteFilterTest.php b/tests/Unit/Router/RouteFilterTest.php index e543f0e89..fa765b23a 100644 --- a/tests/Unit/Router/RouteFilterTest.php +++ b/tests/Unit/Router/RouteFilterTest.php @@ -34,7 +34,7 @@ public function testMethodsTypesAndPrefixAreFluent(): void $this->assertSame('/admin', $filter->getPrefix()); } - public function testNamedArgumentsAreRejectedByMethodsAndTypes(): void + public function testNamedArgumentsAreRejectedByMethods(): void { $filter = new RouteFilter('api', 'Filter@handle'); diff --git a/tests/Unit/Router/RouteTest.php b/tests/Unit/Router/RouteTest.php index e868494de..3fa0a4ca9 100644 --- a/tests/Unit/Router/RouteTest.php +++ b/tests/Unit/Router/RouteTest.php @@ -31,27 +31,6 @@ public function testConstructorAndDefaultProperties(): void $this->assertSame([], $route->getActionParameters()); } - #[DataProvider('pathProvider')] - public function testConstructorPreservesNameAndPath(string $name, string $path): void - { - $route = new Route($name, $path); - - $this->assertSame($name, $route->getName()); - $this->assertSame($path, $route->getPath()); - } - - /** - * @return array - */ - public static function pathProvider(): array - { - return [ - 'empty path' => ['empty', ''], - 'root path' => ['root', '/'], - 'trailing slash' => ['docs', '/docs/'], - ]; - } - public function testActionAndActionParametersAreFluentAndReadable(): void { $action = static fn(): Response => new Response('ok'); @@ -128,6 +107,50 @@ public function testParameterConstraintsAcceptArraysAndClosures(): void $this->assertSame(['id' => $constraint], $route->getConstraints()); } + #[DataProvider('patternProvider')] + public function testParameterPatternsAcceptOnlyTheirOwnValues(string $pattern, string $value, bool $matches): void + { + $router = $this->router('/value/' . $value . '/'); + $router->addRoute('value', '/value/{param:' . $pattern . '}') + ->action(static fn(RouteParams $params): Response => new Response($params->get('param'))); + + if (!$matches) { + $this->expectException(RouteNotFoundException::class); + } + + $this->assertSame($value, $router->dispatch()->content()); + } + + /** + * @return iterable + */ + public static function patternProvider(): iterable + { + yield 'any accepts a segment' => ['any', 'some-value_1', true]; + yield 'any rejects nested segments' => ['any', 'a/b', false]; + yield 'all accepts nested segments' => ['all', 'a/b/c.txt', true]; + yield 'slug accepts hyphenated words' => ['slug', 'hello-world-2', true]; + yield 'slug rejects underscores' => ['slug', 'hello_world', false]; + yield 'slug rejects leading hyphens' => ['slug', '-hello', false]; + yield 'slug rejects consecutive hyphens' => ['slug', 'hello--world', false]; + yield 'alnum accepts letters and digits' => ['alnum', 'abc123', true]; + yield 'alnum rejects hyphens' => ['alnum', 'abc-123', false]; + yield 'alpha accepts letters' => ['alpha', 'abcXYZ', true]; + yield 'alpha rejects digits' => ['alpha', 'abc1', false]; + yield 'digits accepts leading zeros' => ['digits', '007', true]; + yield 'digits rejects letters' => ['digits', '12a', false]; + yield 'xdigits accepts hexadecimal digits' => ['xdigits', 'deadbeef09', true]; + yield 'xdigits rejects other letters' => ['xdigits', 'deadbeeg', false]; + yield 'number accepts positive integers' => ['number', '12', true]; + yield 'number rejects leading zeros' => ['number', '012', false]; + yield 'number rejects decimals' => ['number', '1.5', false]; + yield 'base64 accepts padding' => ['base64', 'YWJjZA==', true]; + yield 'base64 rejects excessive padding' => ['base64', 'YWJj===', false]; + yield 'base64 rejects spaces' => ['base64', 'YW Jj', false]; + yield 'custom regex' => ['[a-c]+', 'abcab', true]; + yield 'custom regex mismatch' => ['[a-c]+', 'abd', false]; + } + #[DataProvider('matchingProvider')] public function testRouteMatchingExposesStaticAndExtractedParameters( string $routePath, @@ -160,26 +183,6 @@ public static function matchingProvider(): array ]; } - public function testRouteConstraintsControlMatching(): void - { - $router = $this->router('/users/42/'); - $route = $router->addRoute('user', '/users/{id}')->action( - static fn(): Response => new Response('matched') - )->where('id', ['42']); - - $this->assertSame('matched', $router->dispatch()->content()); - $this->assertSame($route, $router->current()); - - $router = $this->router('/users/41/'); - $router->addRoute('user', '/users/{id}')->action( - static fn(): Response => new Response('matched') - )->where('id', static fn(string $value): bool => $value === '42'); - - $this->expectException(RouteNotFoundException::class); - $this->expectExceptionMessage('No route matches with "/users/41/"'); - $router->dispatch(); - } - #[DataProvider('requirementsProvider')] public function testRouteMethodTypeAndPrefixRequirementsParticipateInMatching( string $method, @@ -213,15 +216,76 @@ public function testRouteMethodTypeAndPrefixRequirementsParticipateInMatching( public static function requirementsProvider(): array { return [ - 'matching method and prefix' => ['GET', null, '/admin', ['GET'], ['HTTP'], true], - 'HEAD is equivalent to GET' => ['HEAD', null, '/admin', ['GET'], ['HTTP'], true], - 'method mismatch' => ['POST', null, '/admin', ['GET'], ['HTTP'], false], - 'matching XHR type' => ['GET', 'XMLHttpRequest', '/admin', ['GET'], ['XHR'], true], - 'type mismatch' => ['GET', null, '/admin', ['GET'], ['XHR'], false], - 'prefix mismatch' => ['GET', null, '/other', ['GET'], ['HTTP'], false], + 'matching method and prefix' => ['GET', null, '/admin', ['GET'], ['HTTP'], true], + 'HEAD is equivalent to GET' => ['HEAD', null, '/admin', ['GET'], ['HTTP'], true], + 'method mismatch' => ['POST', null, '/admin', ['GET'], ['HTTP'], false], + 'matching XHR type' => ['GET', 'XMLHttpRequest', '/admin', ['GET'], ['XHR'], true], + 'type mismatch' => ['GET', null, '/admin', ['GET'], ['XHR'], false], + 'prefix mismatch' => ['GET', null, '/other', ['GET'], ['HTTP'], false], + 'prefix sharing only a stem' => ['GET', null, '/adm', ['GET'], ['HTTP'], false], + 'one of several methods' => ['PUT', null, '/admin', ['POST', 'PUT'], ['HTTP'], true], + 'one of several types' => ['GET', 'XMLHttpRequest', '/admin', ['GET'], ['HTTP', 'XHR'], true], + 'no allowed methods' => ['GET', null, '/admin', [], ['HTTP'], false], + 'no allowed types' => ['GET', null, '/admin', ['GET'], [], false], + 'route without the request prefix' => ['GET', null, '', ['GET'], ['HTTP'], false], ]; } + #[DataProvider('arrayConstraintProvider')] + public function testArrayConstraintsAcceptOnlyTheListedValues(string $requestPath, bool $matches): void + { + $router = $this->router($requestPath); + $route = $router->addRoute('user', '/users/{id}') + ->action(static fn(): Response => new Response('matched')) + ->where('id', ['42', '43']); + + if (!$matches) { + $this->expectException(RouteNotFoundException::class); + } + + $this->assertSame('matched', $router->dispatch()->content()); + $this->assertSame($route, $router->current()); + } + + /** + * @return iterable + */ + public static function arrayConstraintProvider(): iterable + { + yield 'first listed value' => ['/users/42/', true]; + yield 'second listed value' => ['/users/43/', true]; + yield 'unlisted value' => ['/users/41/', false]; + yield 'value containing a listed one' => ['/users/420/', false]; + } + + public function testClosureConstraintsReceiveTheParameterValue(): void + { + $received = []; + $router = $this->router('/users/42/'); + $router->addRoute('user', '/users/{id}') + ->action(static fn(): Response => new Response('matched')) + ->where('id', static function (string $value) use (&$received): bool { + $received[] = $value; + return $value === '42'; + }); + + $this->assertSame('matched', $router->dispatch()->content()); + $this->assertSame(['42'], $received); + } + + public function testRejectedClosureConstraintsLetTheNextRouteMatch(): void + { + $router = $this->router('/users/41/'); + $router->addRoute('restricted', '/users/{id}') + ->action(static fn(): Response => new Response('restricted')) + ->where('id', static fn(string $value): bool => $value === '42'); + $fallback = $router->addRoute('fallback', '/users/{id}') + ->action(static fn(): Response => new Response('fallback')); + + $this->assertSame('fallback', $router->dispatch()->content()); + $this->assertSame($fallback, $router->current()); + } + private function router(string $path, string $method = 'GET', ?string $requestedWith = null): Router { $server = [ diff --git a/tests/Unit/Router/RouterTest.php b/tests/Unit/Router/RouterTest.php index 5b4ecf7e7..ed74c1ca5 100644 --- a/tests/Unit/Router/RouterTest.php +++ b/tests/Unit/Router/RouterTest.php @@ -160,6 +160,17 @@ public function testRouteActionResolvedEventCanReplaceTheAction(): void $this->assertSame('replaced', $router->dispatch()->content()); } + public function testRouteParamsAreOnlyTheOnesOfTheMatchedRoute(): void + { + $router = $this->router('/posts/7/'); + $router->addRoute('rejected', '/posts/{slug:alpha}')->action(static fn(): Response => new Response('no')); + $router->addRoute('matched', '/posts/{id:digits}')->action(static fn(): Response => new Response('yes')); + + $router->dispatch(); + + $this->assertSame(['id' => '7'], $router->params()->toArray()); + } + #[DataProvider('generateProvider')] public function testGenerateBuildsPathsForSupportedParameterPatterns( string $path, @@ -237,6 +248,24 @@ public function testGenerateWithUsesCurrentParametersButExplicitValuesWin(): voi $this->assertSame('/users/99/overview/', $router->generateWith('user', ['id' => '99'])); } + public function testGenerateIgnoresUnknownParametersAndCastsScalarValues(): void + { + $router = $this->router('/'); + $router->addRoute('user', '/users/{id:digits}'); + + $this->assertSame('/users/42/', $router->generate('user', ['id' => 42, 'unused' => 'ignored'])); + } + + public function testGenerateOmitsOptionalParametersAndTheirSeparators(): void + { + $router = $this->router('/'); + $router->addRoute('archive', '/archive/{year:digits}?/{month:digits}?'); + + $this->assertSame('/archive/', $router->generate('archive', [])); + $this->assertSame('/archive/2025/', $router->generate('archive', ['year' => '2025'])); + $this->assertSame('/archive/2025/10/', $router->generate('archive', ['year' => '2025', 'month' => '10'])); + } + public function testRewriteRequiresADispatchedCurrentRoute(): void { $router = $this->router('/'); @@ -275,23 +304,179 @@ public function testDuplicateRouteParametersAreRejected(): void $router->dispatch(); } - public function testLoadFromFileAppliesRoutesFiltersPrefixesAndActionParameters(): void + public function testLoadFromFileAppliesTheGivenPrefixToEntriesWithoutOne(): void { - $router = $this->router('/api/loaded/42/', 'POST'); + $router = $this->router('/'); + $router->loadFromFile(__DIR__ . '/Fixtures/routes.php', prefix: '/panel'); + + $this->assertSame('/panel', $router->routes()->get('unprefixed')->getPrefix()); + $this->assertSame('/panel', $router->filters()->get('unprefixed-filter')->getPrefix()); + } + + public function testLoadFromFileAppliesPathsMethodsTypesAndConstraints(): void + { + $router = $this->router('/'); + $router->loadFromFile(__DIR__ . '/Fixtures/routes.php'); + + $loaded = $router->routes()->get('loaded'); + $unprefixed = $router->routes()->get('unprefixed'); + $filter = $router->filters()->get('unprefixed-filter'); + + $this->assertSame('/loaded/{id:digits}', $loaded->getPath()); + $this->assertSame(['POST'], $loaded->getMethods()); + $this->assertSame(['HTTP'], $loaded->getTypes()); + $this->assertSame(['id' => ['42']], $loaded->getConstraints()); + $this->assertSame(['GET', 'POST'], $unprefixed->getMethods()); + $this->assertSame(['XHR'], $unprefixed->getTypes()); + $this->assertSame([], $unprefixed->getConstraints()); + $this->assertSame(['POST', 'PUT'], $filter->getMethods()); + $this->assertSame(['XHR'], $filter->getTypes()); + } + + public function testLoadFromFileKeepsPrefixesDeclaredByEachEntryOverTheGivenOne(): void + { + $router = $this->router('/'); + $router->loadFromFile(__DIR__ . '/Fixtures/routes.php', prefix: '/ignored'); + + $this->assertSame('/api', $router->routes()->get('loaded')->getPrefix()); + $this->assertSame('/api', $router->filters()->get('loaded-filter')->getPrefix()); + } + + public function testLoadFromFileLeavesEntriesWithoutPrefixUnprefixedByDefault(): void + { + $router = $this->router('/'); + $router->loadFromFile(__DIR__ . '/Fixtures/routes.php'); + + $this->assertSame('', $router->routes()->get('unprefixed')->getPrefix()); + $this->assertSame('', $router->filters()->get('unprefixed-filter')->getPrefix()); + } + + public function testLoadFromFileMergesActionParametersGivingPriorityToTheOnesOfTheRoute(): void + { + $router = $this->router('/'); $router->loadFromFile( __DIR__ . '/Fixtures/routes.php', - prefix: '/ignored', - actionParameters: ['fromGlobal' => 'global'], + actionParameters: ['fromGlobal' => 'global', 'shared' => 'global'], ); - $route = $router->routes()->get('loaded'); - $filter = $router->filters()->get('loaded-filter'); + $this->assertSame( + ['fromRouteFile' => 'route', 'shared' => 'route', 'fromGlobal' => 'global'], + $router->routes()->get('loaded')->getActionParameters(), + ); + $this->assertSame( + ['fromGlobal' => 'global', 'shared' => 'global'], + $router->routes()->get('unprefixed')->getActionParameters(), + ); + } + + #[DataProvider('filterRequirementsProvider')] + public function testFiltersApplyOnlyWhenMethodTypeAndPrefixMatch( + string $method, + ?string $requestedWith, + string $prefix, + array $filterMethods, + array $filterTypes, + bool $applies, + ): void { + $router = $this->router('/admin/page/', $method, $requestedWith); + $router->addFilter('filter', static fn(): Response => new Response('filtered')) + ->prefix($prefix) + ->methods(...$filterMethods) + ->types(...$filterTypes); + $router->addRoute('page', '/admin/page/') + ->action(static fn(): Response => new Response('route')) + ->methods('GET', 'POST') + ->types('HTTP', 'XHR'); + + $this->assertSame($applies ? 'filtered' : 'route', $router->dispatch()->content()); + } + + /** + * @return iterable, list, bool}> + */ + public static function filterRequirementsProvider(): iterable + { + yield 'everything matches' => ['GET', null, '/admin', ['GET'], ['HTTP'], true]; + yield 'method mismatch' => ['POST', null, '/admin', ['GET'], ['HTTP'], false]; + yield 'type mismatch' => ['GET', 'XMLHttpRequest', '/admin', ['GET'], ['HTTP'], false]; + yield 'prefix mismatch' => ['GET', null, '/other', ['GET'], ['HTTP'], false]; + yield 'HEAD is handled as GET' => ['HEAD', null, '/admin', ['GET'], ['HTTP'], true]; + yield 'any of several methods' => ['POST', null, '/admin', ['PUT', 'POST'], ['HTTP'], true]; + yield 'without prefix' => ['GET', null, '', ['GET'], ['HTTP'], true]; + } + + public function testFiltersAreRunInRegistrationOrderUntilOneReturnsAResponse(): void + { + $calls = []; + $router = $this->router('/'); + $router->addFilter('first', static function () use (&$calls): null { + $calls[] = 'first'; + return null; + }); + $router->addFilter('second', static function () use (&$calls): Response { + $calls[] = 'second'; + return new Response('second'); + }); + $router->addFilter('third', static function () use (&$calls): Response { + $calls[] = 'third'; + return new Response('third'); + }); + $router->addRoute('home', '/')->action(static fn(): Response => new Response('route')); + + $this->assertSame('second', $router->dispatch()->content()); + $this->assertSame(['first', 'second'], $calls); + } + + public function testNoCurrentRouteOrParamsRemainWhenARouteIsRejectedByItsConstraints(): void + { + $router = $this->router('/users/41/'); + $router->addRoute('user', '/users/{id}') + ->action(static fn(): Response => new Response('matched')) + ->where('id', ['42']); + + try { + $router->dispatch(); + $this->fail('The route should have been rejected by its constraint.'); + } catch (RouteNotFoundException) { + // Expected + } + + $this->assertNull($router->current()); + $this->assertSame([], $router->params()->toArray()); + } + + public function testLoadedRoutesAreDispatchedHonouringTheirConstraintsAndPrefixes(): void + { + $router = $this->router('/api/loaded/42/', 'POST'); + $router->loadFromFile(__DIR__ . '/Fixtures/routes.php'); - $this->assertSame('/api', $route->getPrefix()); - $this->assertSame(['fromRouteFile' => 'route', 'fromGlobal' => 'global'], $route->getActionParameters()); - $this->assertSame(['POST'], $route->getMethods()); - $this->assertSame('/api', $filter->getPrefix()); $this->assertSame('route', $router->dispatch()->content()); + $this->assertSame(['id' => '42'], $router->params()->toArray()); + + $router = $this->router('/api/loaded/41/', 'POST'); + $router->loadFromFile(__DIR__ . '/Fixtures/routes.php'); + + $this->expectException(RouteNotFoundException::class); + $router->dispatch(); + } + + public function testLoadedXhrRoutesIgnoreRegularHttpRequests(): void + { + $router = $this->router('/unprefixed/', 'GET'); + $router->loadFromFile(__DIR__ . '/Fixtures/routes.php'); + + $this->expectException(RouteNotFoundException::class); + $router->dispatch(); + } + + public function testLoadedXhrRoutesMatchXmlHttpRequests(): void + { + $router = $this->router('/unprefixed/', 'GET', 'XMLHttpRequest'); + $router->loadFromFile(__DIR__ . '/Fixtures/routes.php'); + + $router->dispatch(); + + $this->assertSame('unprefixed', $router->current()?->getName()); } private function router( diff --git a/tests/Unit/Services/ContainerTest.php b/tests/Unit/Services/ContainerTest.php index f3c350dc9..a72431199 100644 --- a/tests/Unit/Services/ContainerTest.php +++ b/tests/Unit/Services/ContainerTest.php @@ -2,17 +2,50 @@ namespace Formwork\Tests\Unit\Services; -use Formwork\Services\Attributes\Service; +use Closure; use Formwork\Services\Container; use Formwork\Services\Exceptions\ContainerException; use Formwork\Services\Exceptions\ServiceNotFoundException; use Formwork\Services\Exceptions\ServiceResolutionException; -use Formwork\Services\ResolutionAwareServiceLoaderInterface; -use Formwork\Services\ServiceLoaderInterface; use Formwork\Tests\TestCase; +use Formwork\Tests\Unit\Services\Fixtures\AbstractService; +use Formwork\Tests\Unit\Services\Fixtures\AnotherDependentService; +use Formwork\Tests\Unit\Services\Fixtures\ArrayOptionsService; +use Formwork\Tests\Unit\Services\Fixtures\AttributeDependentService; +use Formwork\Tests\Unit\Services\Fixtures\CircularA; +use Formwork\Tests\Unit\Services\Fixtures\CircularB; +use Formwork\Tests\Unit\Services\Fixtures\ContainerAwareLoader; +use Formwork\Tests\Unit\Services\Fixtures\DependencyContract; +use Formwork\Tests\Unit\Services\Fixtures\DependencyResolvingLoader; +use Formwork\Tests\Unit\Services\Fixtures\DependentOnContract; +use Formwork\Tests\Unit\Services\Fixtures\DependentOnMissingService; +use Formwork\Tests\Unit\Services\Fixtures\DependentOnSharedDependency; +use Formwork\Tests\Unit\Services\Fixtures\DependentService; +use Formwork\Tests\Unit\Services\Fixtures\FactoryProduct; +use Formwork\Tests\Unit\Services\Fixtures\FailingResolutionAwareLoader; +use Formwork\Tests\Unit\Services\Fixtures\InvalidReturnLoader; +use Formwork\Tests\Unit\Services\Fixtures\LoadedService; +use Formwork\Tests\Unit\Services\Fixtures\NestedDependentService; +use Formwork\Tests\Unit\Services\Fixtures\OptionalAttributeDependentService; +use Formwork\Tests\Unit\Services\Fixtures\OptionalDependencyService; +use Formwork\Tests\Unit\Services\Fixtures\OptionalIntegerService; +use Formwork\Tests\Unit\Services\Fixtures\OptionalStringService; +use Formwork\Tests\Unit\Services\Fixtures\ParameterizedLoader; +use Formwork\Tests\Unit\Services\Fixtures\PrivateConstructorService; +use Formwork\Tests\Unit\Services\Fixtures\RecursiveLoader; +use Formwork\Tests\Unit\Services\Fixtures\ScalarDependentService; +use Formwork\Tests\Unit\Services\Fixtures\SharedDependency; +use Formwork\Tests\Unit\Services\Fixtures\SimpleService; +use Formwork\Tests\Unit\Services\Fixtures\TestServiceLoader; +use Formwork\Tests\Unit\Services\Fixtures\ThrowingLoader; +use Formwork\Tests\Unit\Services\Fixtures\VariadicService; +use LogicException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use Psr\Container\ContainerExceptionInterface; use RuntimeException; +use Throwable; +use TypeError; #[CoversClass(Container::class)] final class ContainerTest extends TestCase @@ -96,6 +129,37 @@ public function testServiceAttributeSelectsAConfiguredIdentifier(): void $this->assertSame($selected, $service->service); } + public function testServiceAttributeFallsBackToTheDefaultWhenTheIdentifierIsNotDefined(): void + { + $container = new Container(); + $container->define(OptionalAttributeDependentService::class); + + $this->assertNull($container->get(OptionalAttributeDependentService::class)->service); + } + + public function testServiceAttributeTakesPrecedenceOverTheDefaultWhenTheIdentifierIsDefined(): void + { + $container = new Container(); + $selected = new SimpleService(); + $container->define('selected', $selected); + $container->define(OptionalAttributeDependentService::class); + + $this->assertSame($selected, $container->get(OptionalAttributeDependentService::class)->service); + } + + public function testServiceAttributeWithoutDefaultReportsTheMissingIdentifier(): void + { + $container = new Container(); + $container->define(AttributeDependentService::class); + + try { + $container->get(AttributeDependentService::class); + $this->fail('The undefined service should have been reported.'); + } catch (ServiceNotFoundException $exception) { + $this->assertStringContainsString('"selected"', $exception->getMessage()); + } + } + public function testClosuresCanBeUsedAsFactoriesAndAreShared(): void { $container = new Container(); @@ -129,9 +193,9 @@ public function testFactoryExceptionsArePropagated(): void public function testFactoryReturningAnInvalidValueFailsAtTheFactoryBoundary(): void { $container = new Container(); - $container->define('factory', static fn(): object => 'invalid'); + $container->define('factory', static fn() => 'invalid'); - $this->expectException(\TypeError::class); + $this->expectException(ContainerExceptionInterface::class); $container->get('factory'); } @@ -200,6 +264,23 @@ public function testVariadicArgumentsMustBeArrays(): void $container->call(static fn(string ...$values): array => $values, ['values' => 'invalid']); } + public function testVariadicArgumentsAreAlwaysPassedByPosition(): void + { + $container = new Container(); + + $built = $container->build(VariadicService::class, ['values' => ['first' => 'a', 'second' => 'b']]); + + $this->assertSame(['a', 'b'], $built->values); + } + + public function testVariadicParametersAreOptional(): void + { + $container = new Container(); + + $this->assertSame([], $container->build(VariadicService::class)->values); + $this->assertSame([], $container->call(static fn(string ...$values): array => $values)); + } + public function testParameterClosuresAreEvaluatedBeforeFactoryResolution(): void { $container = new Container(); @@ -213,15 +294,224 @@ public function testParameterClosuresAreEvaluatedBeforeFactoryResolution(): void $this->assertSame('closure', $service->value); } - public function testLazyFalseResolvesImmediatelyWhileDefaultDefinitionsRemainUnresolved(): void + public function testParameterClosuresInsideNestedParametersAreEvaluated(): void + { + $container = new Container(); + $container->define(ArrayOptionsService::class) + ->parameter('options.lazy', static fn(): string => 'evaluated') + ->parameter('options.plain', 2); + + $this->assertSame(['lazy' => 'evaluated', 'plain' => 2], $container->get(ArrayOptionsService::class)->options); + } + + public function testDottedParameterNamesBuildNestedArrays(): void + { + $container = new Container(); + $container->define(ArrayOptionsService::class) + ->parameter('options.a.b', 1) + ->parameter('options.a.c', 2); + + $this->assertSame(['a' => ['b' => 1, 'c' => 2]], $container->get(ArrayOptionsService::class)->options); + } + + public function testResolutionStackIsClearedWhenAParameterClosureFails(): void + { + $container = new Container(); + $container->define(ArrayOptionsService::class) + ->parameter('options', static function (): never { + throw new RuntimeException('parameter failed'); + }); + + foreach ([1, 2] as $attempt) { + try { + $container->get(ArrayOptionsService::class); + $this->fail('The parameter closure exception should have propagated.'); + } catch (RuntimeException $exception) { + $this->assertSame('parameter failed', $exception->getMessage(), "Attempt $attempt"); + } + } + } + + public function testLazyFalseDoesNotResolveImmediately(): void { $container = new Container(); $container->define(SimpleService::class)->lazy(false); - $this->assertTrue($container->isResolved(SimpleService::class)); + $this->assertFalse($container->isResolved(SimpleService::class)); $this->assertInstanceOf(SimpleService::class, $container->get(SimpleService::class)); } + public function testEagerServicesAreResolvedExplicitly(): void + { + $container = new Container(); + $container->define(SimpleService::class)->lazy(false); + $container->define(SharedDependency::class); + + $container->resolveEagerServices(); + + $this->assertTrue($container->isResolved(SimpleService::class)); + $this->assertFalse($container->isResolved(SharedDependency::class)); + } + + public function testResolvingEagerServicesOnAnEmptyContainerDoesNothing(): void + { + $container = new Container(); + + $container->resolveEagerServices(); + + $this->assertFalse($container->has(SimpleService::class)); + } + + public function testResolvingEagerServicesTwiceKeepsTheSharedInstances(): void + { + $container = new Container(); + $container->define(SimpleService::class)->lazy(false); + + $container->resolveEagerServices(); + $first = $container->get(SimpleService::class); + $container->resolveEagerServices(); + + $this->assertSame($first, $container->get(SimpleService::class)); + } + + public function testEagerServicesAlreadyResolvedAreNotResolvedAgain(): void + { + $container = new Container(); + $container->define(SharedDependency::class, new SharedDependency()); + $container->define('loaded')->loader(TestServiceLoader::class)->lazy(false); + $container->get('loaded'); + + $container->resolveEagerServices(); + + $this->assertSame(1, TestServiceLoader::$loadCount); + $this->assertSame(1, TestServiceLoader::$resolvedCount); + } + + public function testEagerServicesUseTheConfigurationDefinedAfterLazyFalse(): void + { + $container = new Container(); + $container->define(ArrayOptionsService::class) + ->lazy(false) + ->parameter('options', ['configured' => true]); + + $container->resolveEagerServices(); + + $this->assertTrue($container->isResolved(ArrayOptionsService::class)); + $this->assertSame(['configured' => true], $container->get(ArrayOptionsService::class)->options); + } + + public function testEagerLoaderServicesAreLoadedOnce(): void + { + $container = new Container(); + $container->define(SharedDependency::class, new SharedDependency()); + $container->define('loaded')->loader(TestServiceLoader::class)->lazy(false); + + $container->resolveEagerServices(); + $container->resolveEagerServices(); + + $this->assertInstanceOf(LoadedService::class, $container->get('loaded')); + $this->assertSame(1, TestServiceLoader::$loadCount); + $this->assertSame(1, TestServiceLoader::$resolvedCount); + } + + public function testEagerServicesResolveTheirLazyDependencies(): void + { + $container = new Container(); + $container->define(SharedDependency::class); + $container->define(DependentOnSharedDependency::class)->lazy(false); + + $container->resolveEagerServices(); + + $this->assertTrue($container->isResolved(SharedDependency::class)); + } + + public function testEagerServicesCanBeReachedThroughTheirAliases(): void + { + $container = new Container(); + $container->define(SimpleService::class)->alias('simple')->lazy(false); + + $container->resolveEagerServices(); + + $this->assertTrue($container->isResolved('simple')); + } + + public function testEagerServicesDefinedWhileResolvingEagerServicesAreResolvedToo(): void + { + $container = new Container(); + $container->define('first', static function () use ($container): SimpleService { + $container->define('second', static fn(): SharedDependency => new SharedDependency())->lazy(false); + return new SimpleService(); + })->lazy(false); + + $container->resolveEagerServices(); + + $this->assertTrue($container->isResolved('second')); + } + + public function testFailingEagerServicesPropagateTheirException(): void + { + $container = new Container(); + $container->define('failing', static function (): never { + throw new RuntimeException('eager failure'); + })->lazy(false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('eager failure'); + $container->resolveEagerServices(); + } + + public function testFailingEagerServicesAreNotMarkedAsResolved(): void + { + $container = new Container(); + $container->define('failing', static function (): never { + throw new RuntimeException('eager failure'); + })->lazy(false); + + try { + $container->resolveEagerServices(); + } catch (RuntimeException) { + } + + $this->assertFalse($container->isResolved('failing')); + } + + public function testParametersSetAfterLazyFalseAreApplied(): void + { + $container = new Container(); + $container->define(ArrayOptionsService::class) + ->lazy(false) + ->parameter('options', ['configured' => true]); + + $this->assertSame(['configured' => true], $container->get(ArrayOptionsService::class)->options); + } + + public function testLoaderSetAfterLazyFalseIsApplied(): void + { + $container = new Container(); + $container->define(SharedDependency::class, new SharedDependency()); + $container->define('loaded') + ->lazy(false) + ->loader(TestServiceLoader::class); + + $this->assertInstanceOf(LoadedService::class, $container->get('loaded')); + $this->assertSame(1, TestServiceLoader::$loadCount); + } + + public function testLazyFalseServicesAreResolvedOnlyOnce(): void + { + $container = new Container(); + $container->define(SharedDependency::class, new SharedDependency()); + $container->define('loaded') + ->loader(TestServiceLoader::class) + ->lazy(false); + + $first = $container->get('loaded'); + + $this->assertSame($first, $container->get('loaded')); + $this->assertSame(1, TestServiceLoader::$loadCount); + $this->assertSame(1, TestServiceLoader::$resolvedCount); + } + public function testAliasesResolveToTheTargetAndPreserveItsIdentity(): void { $container = new Container(); @@ -246,6 +536,113 @@ public function testCircularAliasesAreRejected(): void $container->alias('two', 'one'); } + public function testRejectedAliasesLeaveExistingAliasesUntouched(): void + { + $container = new Container(); + $container->alias('a', 'b'); + $container->alias('b', 'c'); + + try { + $container->alias('c', 'a'); + $this->fail('The circular alias should have been rejected.'); + } catch (ContainerException) { + } + + $service = new SimpleService(); + $container->define('c', $service); + + $this->assertSame($service, $container->get('a')); + $this->assertFalse($container->has('d')); + } + + public function testAliasToAnUndefinedServiceIsNotAvailableUntilTheTargetIsDefined(): void + { + $container = new Container(); + $container->alias('alias', 'target'); + + $this->assertFalse($container->has('alias')); + $this->assertFalse($container->isResolved('alias')); + + try { + $container->get('alias'); + $this->fail('The undefined target should have been reported.'); + } catch (ServiceNotFoundException $exception) { + $this->assertStringContainsString('"target"', $exception->getMessage()); + } + + $service = new SimpleService(); + $container->define('target', $service); + + $this->assertTrue($container->has('alias')); + $this->assertSame($service, $container->get('alias')); + } + + public function testAliasToItselfIsRejected(): void + { + $container = new Container(); + + $this->expectException(ContainerException::class); + $container->alias('same', 'same'); + } + + public function testAliasCannotShadowADefinedService(): void + { + $container = new Container(); + $own = new SimpleService(); + $container->define('name', $own); + $container->define('target', new SimpleService()); + + try { + $container->alias('name', 'target'); + $this->fail('The alias would hide the defined service.'); + } catch (ContainerException $exception) { + $this->assertStringContainsString('name', $exception->getMessage()); + } + + $this->assertSame($own, $container->get('name')); + } + + public function testDefinitionAliasCannotShadowADefinedService(): void + { + $container = new Container(); + $own = new SimpleService(); + $container->define('name', $own); + + try { + $container->define('target', new SimpleService())->alias('name'); + $this->fail('The alias would hide the defined service.'); + } catch (ContainerException) { + } + + $this->assertSame($own, $container->get('name')); + } + + public function testInterfaceAliasCanBeDefinedBeforeItsTargetAndRepointedLater(): void + { + $container = new Container(); + $container->alias(DependencyContract::class, 'first'); + $container->define('first', new SimpleService()); + $container->define('second', new SimpleService()); + + $this->assertSame($container->get('first'), $container->get(DependencyContract::class)); + + $container->alias(DependencyContract::class, 'second'); + + $this->assertSame($container->get('second'), $container->get(DependencyContract::class)); + } + + public function testDefiningAServiceWithTheNameOfAnAliasMakesTheNewDefinitionReachable(): void + { + $container = new Container(); + $container->define('target', new SimpleService()); + $container->alias('name', 'target'); + + $own = new SimpleService(); + $container->define('name', $own); + + $this->assertSame($own, $container->get('name')); + } + public function testLoaderIsConstructedWithDependenciesAndCalledOnlyOnce(): void { $container = new Container(); @@ -262,6 +659,21 @@ public function testLoaderIsConstructedWithDependenciesAndCalledOnlyOnce(): void $this->assertSame(1, TestServiceLoader::$resolvedCount); } + public function testResolvingAnAlreadyResolvedServiceKeepsTheSharedInstance(): void + { + $container = new Container(); + $container->define('loaded')->loader(TestServiceLoader::class); + $container->define(SharedDependency::class, new SharedDependency()); + + $first = $container->get('loaded'); + $second = $container->resolve('loaded'); + + $this->assertSame($first, $second); + $this->assertSame($first, $container->get('loaded')); + $this->assertSame(1, TestServiceLoader::$loadCount); + $this->assertSame(1, TestServiceLoader::$resolvedCount); + } + public function testAliasesResolveLoaderBackedServicesToTheSharedInstance(): void { $container = new Container(); @@ -339,7 +751,7 @@ public function testLoaderReturningAnInvalidValueFailsAtTheLoaderBoundary(): voi $container = new Container(); $container->define('loaded')->loader(InvalidReturnLoader::class); - $this->expectException(\TypeError::class); + $this->expectException(TypeError::class); $container->get('loaded'); } @@ -347,7 +759,7 @@ public function testObjectDefinitionsCannotAlsoHaveLoaders(): void { $definition = (new Container())->define('service', new SimpleService()); - $this->expectException(\LogicException::class); + $this->expectException(LogicException::class); $definition->loader(TestServiceLoader::class); } @@ -403,6 +815,46 @@ public function testResolveRejectsUndefinedServices(): void $container->resolve('missing'); } + #[DataProvider('uninstantiableClassProvider')] + public function testClassesThatCannotBeInstantiatedAreReportedAsContainerErrors(string $class): void + { + $container = new Container(); + $container->define($class); + + try { + $container->get($class); + $this->fail('The class cannot be instantiated.'); + } catch (Throwable $throwable) { + $this->assertInstanceOf(ContainerExceptionInterface::class, $throwable, $throwable::class . ': ' . $throwable->getMessage()); + } + } + + /** + * @return iterable + */ + public static function uninstantiableClassProvider(): iterable + { + yield 'interface' => [DependencyContract::class]; + yield 'abstract class' => [AbstractService::class]; + yield 'private constructor' => [PrivateConstructorService::class]; + yield 'identifier that is not a class' => ['config']; + yield 'missing class' => ['Formwork\Tests\Unit\Services\Fixtures\DoesNotExist']; + } + + public function testFactoryReturningANonObjectDoesNotMarkTheServiceAsResolved(): void + { + $container = new Container(); + $container->define('factory', static fn() => 'not an object'); + + try { + $container->get('factory'); + $this->fail('The invalid factory result should have been rejected.'); + } catch (ContainerExceptionInterface) { + } + + $this->assertFalse($container->isResolved('factory')); + } + public function testMissingClassDependenciesAreReportedAsNotFound(): void { $container = new Container(); @@ -512,226 +964,97 @@ public static function defaultParameterProvider(): iterable yield 'integer' => [OptionalIntegerService::class, 7]; } - public function testRedefiningAResolvedServiceReplacesTheResolvedInstance(): void + /** + * @param Closure(Container): void $define + */ + #[DataProvider('resolvedDefinitionProvider')] + public function testResolvedServicesCannotBeRedefined(string $name, Closure $define): void { $container = new Container(); + $define($container); + $original = $container->get($name); - $first = new SimpleService(); - $second = new SimpleService(); - - $container->define('service', $first); - - $this->assertSame($first, $container->get('service')); - - $container->define('service', $second); - - $this->assertSame($second, $container->get('service')); - $this->assertNotSame($first, $container->get('service')); - } - - public function testRedefiningAResolvedDefinitionClearsItsPreviousResolution(): void - { - $container = new Container(); - - $container->define(SimpleService::class); - - $first = $container->get(SimpleService::class); - - $container->define(SimpleService::class); - - $this->assertFalse($container->isResolved(SimpleService::class)); - - $second = $container->get(SimpleService::class); + try { + $container->define($name, new SimpleService()); + $this->fail('Redefining a resolved service should have been rejected.'); + } catch (ContainerException $exception) { + $this->assertStringContainsString($name, $exception->getMessage()); + } - $this->assertNotSame($first, $second); + $this->assertTrue($container->isResolved($name)); + $this->assertSame($original, $container->get($name)); } -} - -interface DependencyContract {} - -final class SimpleService {} - -final class SharedDependency implements DependencyContract {} - -final class DependentService -{ - public function __construct(public SharedDependency $dependency, public string $label) {} -} - -final class DependentOnContract -{ - public function __construct(public DependencyContract $dependency) {} -} - -final class AttributeDependentService -{ - public function __construct(#[Service('selected')] public SimpleService $service) {} -} - -final class FactoryProduct -{ - public function __construct(public SharedDependency $dependency, public string $value) {} -} - -final class VariadicService -{ - public array $values; - public function __construct(string ...$values) + /** + * @return iterable + */ + public static function resolvedDefinitionProvider(): iterable { - $this->values = $values; + yield 'object' => ['service', static function (Container $container): void { + $container->define('service', new SimpleService()); + }]; + yield 'class' => [SimpleService::class, static function (Container $container): void { + $container->define(SimpleService::class); + }]; + yield 'factory' => ['service', static function (Container $container): void { + $container->define('service', static fn(): SimpleService => new SimpleService()); + }]; + yield 'loader' => ['service', static function (Container $container): void { + $container->define(SharedDependency::class, new SharedDependency()); + $container->define('service')->loader(TestServiceLoader::class); + }]; } -} - -final class NestedDependentService -{ - public function __construct(public DependentOnSharedDependency $dependency) {} -} - -final class DependentOnSharedDependency -{ - public function __construct(public SharedDependency $dependency) {} -} - -final class AnotherDependentService -{ - public function __construct(public SharedDependency $dependency) {} -} - -final class OptionalDependencyService -{ - public function __construct(public ?SharedDependency $dependency = null) {} -} - -class LoadedService -{ - public function __construct(public string $name = 'loaded') {} -} - -final class ContainerAwareLoader implements ServiceLoaderInterface -{ - public function __construct(private Container $container) {} - public function load(Container $container): LoadedService + public function testRejectedRedefinitionKeepsTheExistingDefinitionAndAliases(): void { - return new LoadedService($this->container === $container ? 'container-aware' : 'wrong'); - } -} - -final class ParameterizedLoader implements ServiceLoaderInterface -{ - public function __construct(private string $label) {} + $container = new Container(); + $container->define(SimpleService::class)->alias('alias'); + $service = $container->get('alias'); - public function load(Container $container): LoadedService - { - return new LoadedService($this->label); - } -} + try { + $container->define(SimpleService::class)->alias('other'); + $this->fail('Redefining a resolved service should have been rejected.'); + } catch (ContainerException) { + } -final class DependencyResolvingLoader implements ServiceLoaderInterface -{ - public function load(Container $container): LoadedService - { - return new LoadedServiceWithDependency($container->get(SharedDependency::class)); + $this->assertSame($service, $container->get(SimpleService::class)); + $this->assertSame($service, $container->get('alias')); + $this->assertFalse($container->has('other')); } -} -final class RecursiveLoader implements ServiceLoaderInterface -{ - public function __construct(private string $other) {} - - public function load(Container $container): LoadedService + public function testRedefiningAnUnresolvedServiceKeepsItsAliasesPointingToTheNewDefinition(): void { - return $container->get($this->other); - } -} + $container = new Container(); + $container->define('service', new SimpleService())->alias('alias'); -final class ThrowingLoader implements ServiceLoaderInterface -{ - public function load(Container $container): LoadedService - { - throw new RuntimeException('loader failed'); - } -} + $replacement = new SimpleService(); + $container->define('service', $replacement); -final class InvalidReturnLoader implements ServiceLoaderInterface -{ - public function load(Container $container): object - { - return 'invalid'; + $this->assertSame($replacement, $container->get('alias')); + $this->assertSame($replacement, $container->get('service')); } -} - -final class TestServiceLoader implements ResolutionAwareServiceLoaderInterface -{ - public static int $loadCount = 0; - - public static int $resolvedCount = 0; - public function __construct(private SharedDependency $dependency) {} - - public function load(Container $container): LoadedService + public function testServicesResolvedThroughAnAliasCannotBeRedefined(): void { - self::$loadCount++; - return new LoadedService($this->dependency instanceof SharedDependency ? 'loaded' : 'wrong'); - } + $container = new Container(); + $container->define('service', new SimpleService())->alias('alias'); + $container->get('alias'); - public function onResolved(object $service, Container $container): void - { - self::$resolvedCount++; + $this->expectException(ContainerException::class); + $container->define('service', new SimpleService()); } -} -final class FailingResolutionAwareLoader implements ResolutionAwareServiceLoaderInterface -{ - public static int $loadCount = 0; - - public function load(Container $container): LoadedService + public function testDefiningAnAliasNameDoesNotRequireTheAliasedServiceToBeUnresolved(): void { - self::$loadCount++; - return new LoadedService(); - } + $container = new Container(); + $container->define('target', new SimpleService()); + $container->alias('name', 'target'); + $target = $container->get('target'); - public function onResolved(object $service, Container $container): void - { - throw new RuntimeException('resolution callback failed'); - } -} + $own = new SimpleService(); + $container->define('name', $own); -final class LoadedServiceWithDependency extends LoadedService -{ - public function __construct(public SharedDependency $dependency) - { - parent::__construct('dependency'); + $this->assertSame($own, $container->get('name')); + $this->assertSame($target, $container->get('target')); } } - -final class DependentOnMissingService -{ - public function __construct(UnknownDependency $dependency) {} -} - -final class ScalarDependentService -{ - public function __construct(string $value) {} -} - -final class CircularA -{ - public function __construct(CircularB $dependency) {} -} - -final class CircularB -{ - public function __construct(CircularA $dependency) {} -} - -final class OptionalStringService -{ - public function __construct(public string $value = 'default') {} -} - -final class OptionalIntegerService -{ - public function __construct(public int $value = 7) {} -} diff --git a/tests/Unit/Services/Fixtures/AbstractService.php b/tests/Unit/Services/Fixtures/AbstractService.php new file mode 100644 index 000000000..9bc2d8328 --- /dev/null +++ b/tests/Unit/Services/Fixtures/AbstractService.php @@ -0,0 +1,5 @@ + $options + */ + public function __construct(public array $options = []) {} +} diff --git a/tests/Unit/Services/Fixtures/AttributeDependentService.php b/tests/Unit/Services/Fixtures/AttributeDependentService.php new file mode 100644 index 000000000..601573168 --- /dev/null +++ b/tests/Unit/Services/Fixtures/AttributeDependentService.php @@ -0,0 +1,10 @@ +container === $container ? 'container-aware' : 'wrong'); + } +} diff --git a/tests/Unit/Services/Fixtures/DependencyContract.php b/tests/Unit/Services/Fixtures/DependencyContract.php new file mode 100644 index 000000000..489000049 --- /dev/null +++ b/tests/Unit/Services/Fixtures/DependencyContract.php @@ -0,0 +1,5 @@ +get(SharedDependency::class)); + } +} diff --git a/tests/Unit/Services/Fixtures/DependentOnContract.php b/tests/Unit/Services/Fixtures/DependentOnContract.php new file mode 100644 index 000000000..7da206c17 --- /dev/null +++ b/tests/Unit/Services/Fixtures/DependentOnContract.php @@ -0,0 +1,8 @@ +label); + } +} diff --git a/tests/Unit/Services/Fixtures/PrivateConstructorService.php b/tests/Unit/Services/Fixtures/PrivateConstructorService.php new file mode 100644 index 000000000..b24d387ef --- /dev/null +++ b/tests/Unit/Services/Fixtures/PrivateConstructorService.php @@ -0,0 +1,8 @@ +get($this->other); + } +} diff --git a/tests/Unit/Services/Fixtures/ScalarDependentService.php b/tests/Unit/Services/Fixtures/ScalarDependentService.php new file mode 100644 index 000000000..59add1720 --- /dev/null +++ b/tests/Unit/Services/Fixtures/ScalarDependentService.php @@ -0,0 +1,8 @@ +dependency instanceof SharedDependency ? 'loaded' : 'wrong'); + } + + public function onResolved(object $service, Container $container): void + { + self::$resolvedCount++; + } +} diff --git a/tests/Unit/Services/Fixtures/ThrowingLoader.php b/tests/Unit/Services/Fixtures/ThrowingLoader.php new file mode 100644 index 000000000..01bb43507 --- /dev/null +++ b/tests/Unit/Services/Fixtures/ThrowingLoader.php @@ -0,0 +1,15 @@ +values = $values; + } +} diff --git a/tests/Unit/Services/ServiceDefinitionTest.php b/tests/Unit/Services/ServiceDefinitionTest.php index d833283af..32b12ffb9 100644 --- a/tests/Unit/Services/ServiceDefinitionTest.php +++ b/tests/Unit/Services/ServiceDefinitionTest.php @@ -5,6 +5,9 @@ use Formwork\Services\Container; use Formwork\Services\ServiceDefinition; use Formwork\Tests\TestCase; +use Formwork\Tests\Unit\Services\Fixtures\SimpleService; +use Formwork\Tests\Unit\Services\Fixtures\TestServiceLoader; +use LogicException; use PHPUnit\Framework\Attributes\CoversClass; #[CoversClass(ServiceDefinition::class)] @@ -62,14 +65,14 @@ public function testDirectObjectDefinitionsExposeTheirObject(): void $this->assertSame($object, $definition->getObject()); } - public function testLazyFalseEagerlyResolvesTheDefinition(): void + public function testLazyFalseDoesNotResolveTheDefinition(): void { $container = new Container(); $definition = $container->define(SimpleService::class); $this->assertFalse($container->isResolved(SimpleService::class)); $this->assertSame($definition, $definition->lazy(false)); - $this->assertTrue($container->isResolved(SimpleService::class)); + $this->assertFalse($container->isResolved(SimpleService::class)); } public function testLazyTrueLeavesAnUnresolvedDefinitionUnresolved(): void @@ -81,6 +84,33 @@ public function testLazyTrueLeavesAnUnresolvedDefinitionUnresolved(): void $this->assertFalse($container->isResolved(SimpleService::class)); } + public function testDefinitionsAreLazyByDefault(): void + { + $this->assertTrue((new Container())->define(SimpleService::class)->isLazy()); + } + + public function testLazinessCanBeChangedAndRestored(): void + { + $definition = (new Container())->define(SimpleService::class); + + $definition->lazy(false); + $this->assertFalse($definition->isLazy()); + + $definition->lazy(true); + $this->assertTrue($definition->isLazy()); + } + + public function testLazinessIsIndependentFromTheAliasesAndParametersOfTheDefinition(): void + { + $definition = (new Container())->define(SimpleService::class) + ->alias('alias') + ->parameter('name', 'value') + ->lazy(false); + + $this->assertFalse($definition->isLazy()); + $this->assertSame(['name' => 'value'], $definition->getParameters()); + } + public function testCallingLazyFalseAfterResolutionDoesNotReplaceTheInstance(): void { $container = new Container(); @@ -91,4 +121,21 @@ public function testCallingLazyFalseAfterResolutionDoesNotReplaceTheInstance(): $this->assertSame($resolved, $container->get(SimpleService::class)); } + + public function testObjectDefinitionsRejectLoaders(): void + { + $definition = (new Container())->define('service', new SimpleService()); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Instantiated object cannot have loaders'); + $definition->loader(TestServiceLoader::class); + } + + public function testAliasesResolveToTheSameInstanceAsTheDefinition(): void + { + $container = new Container(); + $container->define(SimpleService::class)->alias('simple'); + + $this->assertSame($container->get(SimpleService::class), $container->get('simple')); + } } From 04a55d8a98fe1f12c366da496777604671ef7dfe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:49:18 +0000 Subject: [PATCH 24/51] Improve Files, Fields, Forms and Pages tests - Test the type of files from their detected MIME type, the persistence of metadata, the file factory associations and the upload checks (traversal, MIME spoofing, SVG sanitization, explicit MIME types) - Cover the defaults, state flags and translations of fields - Replace weak assertions in the page tests (icon, status windows, slug, defaults, reload, duplicate, lifecycle events) and test search and sorting of page collections Some of the new tests fail until the bugs they reveal are fixed: URIs of files in directories sharing a prefix with a configured path, upload names that become hidden files. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd --- tests/Unit/Fields/FieldTest.php | 135 +++++- tests/Unit/Files/FileCollectionTest.php | 94 ++--- tests/Unit/Files/FileFactoryTest.php | 64 +-- tests/Unit/Files/FileTest.php | 121 ++++-- tests/Unit/Files/FileUriGeneratorTest.php | 33 ++ .../Unit/Files/Services/FileUploaderTest.php | 121 ++++++ tests/Unit/Forms/FormTest.php | 301 ++++++++----- tests/Unit/Pages/Fixtures/site/empty/page.md | 1 - tests/Unit/Pages/PageCollectionTest.php | 140 +++++-- tests/Unit/Pages/PageTest.php | 396 ++++++++++-------- 10 files changed, 978 insertions(+), 428 deletions(-) diff --git a/tests/Unit/Fields/FieldTest.php b/tests/Unit/Fields/FieldTest.php index d1d51e69e..9abede9fd 100644 --- a/tests/Unit/Fields/FieldTest.php +++ b/tests/Unit/Fields/FieldTest.php @@ -2,10 +2,14 @@ namespace Formwork\Tests\Unit\Fields; +use Formwork\Exceptions\RecursionException; use Formwork\Fields\Exceptions\ValidationException; use Formwork\Fields\Field; +use Formwork\Fields\FieldCollection; use Formwork\Tests\TestCase; +use Formwork\Translations\Translation; use PHPUnit\Framework\Attributes\CoversClass; +use UnexpectedValueException; #[CoversClass(Field::class)] final class FieldTest extends TestCase @@ -72,15 +76,134 @@ public function testValidationExceptionStillMarksTheFieldAsValidated(): void $this->assertFalse($field->isValid()); } - public function testChangingAValueAfterValidationClearsTheValidationState(): void + public function testValidationOfAFieldDependingOnItselfIsDetected(): void { - $field = new Field('name', ['type' => 'text', 'required' => true, 'value' => 'Alice']); + $field = new Field('name', ['type' => 'text']); + $field->setMethods([ + 'validate' => static function (Field $field, mixed $value): mixed { + $field->validate(); + + return $value; + }, + ]); + + $this->expectException(RecursionException::class); $field->validate(); - $this->assertTrue($field->isValidated()); + } - $field->set('value', ''); + public function testFieldsAreVisibleEnabledEditableAndOptionalByDefault(): void + { + $field = new Field('name', ['type' => 'text']); - $this->assertFalse($field->isValidated()); - $this->assertFalse($field->isValid()); + $this->assertTrue($field->isVisible()); + $this->assertFalse($field->isHidden()); + $this->assertFalse($field->isDisabled()); + $this->assertFalse($field->isReadonly()); + $this->assertFalse($field->isRequired()); + } + + public function testFieldsCannotContainOtherFields(): void + { + $this->expectException(UnexpectedValueException::class); + new Field('group', ['type' => 'text', 'fields' => []]); + } + + public function testStateFlagsCanBeSetThroughTheFieldData(): void + { + $field = new Field('name', ['type' => 'text', 'visible' => false, 'disabled' => true, 'readonly' => true, 'required' => true]); + + $this->assertFalse($field->isVisible()); + $this->assertTrue($field->isHidden()); + $this->assertTrue($field->isDisabled()); + $this->assertTrue($field->isReadonly()); + $this->assertTrue($field->isRequired()); + } + + public function testFormNameUsesBracketNotationForDottedNames(): void + { + $this->assertSame('name', (new Field('name', ['type' => 'text']))->formName()); + $this->assertSame('user[address][city]', (new Field('user.address.city', ['type' => 'text']))->formName()); + $this->assertSame('custom', (new Field('name', ['type' => 'text', 'formName' => 'custom']))->formName()); + } + + public function testLabelFallsBackToTheFieldName(): void + { + $this->assertSame('name', (new Field('name', ['type' => 'text']))->label()); + $this->assertSame('Full name', (new Field('name', ['type' => 'text', 'label' => 'Full name']))->label()); + } + + public function testValueFallsBackToTheDefaultValue(): void + { + $field = new Field('name', ['type' => 'text', 'default' => 'Anonymous']); + + $this->assertSame('Anonymous', $field->value()); + $this->assertSame('Anonymous', $field->defaultValue()); + + $field->set('value', 'Alice'); + + $this->assertSame('Alice', $field->value()); + $this->assertSame('Anonymous', $field->defaultValue()); + } + + public function testFieldWithoutValueIsEmptyAndStringConversionUsesTheValue(): void + { + $field = new Field('name', ['type' => 'text']); + + $this->assertTrue($field->isEmpty()); + $this->assertSame('', (string) $field); + + $field->set('value', 'Alice'); + + $this->assertFalse($field->isEmpty()); + $this->assertSame('Alice', (string) $field); + } + + public function testParentCollectionIsExposed(): void + { + $parent = new FieldCollection(); + + $this->assertSame($parent, (new Field('name', ['type' => 'text'], $parent))->parent()); + $this->assertNull((new Field('name', ['type' => 'text']))->parent()); + } + + public function testTranslatableKeysAreInterpolatedWithTheCurrentTranslation(): void + { + $field = new Field('name', ['type' => 'text', 'label' => '{{field.name}}', 'placeholder' => 'Static text']); + $field->setTranslation(new Translation('en', ['field.name' => 'Name'])); + + $this->assertSame('Name', $field->label()); + $this->assertSame('Static text', $field->placeholder()); + } + + public function testLocalizedValuesAreSelectedByLanguage(): void + { + $field = new Field('name', ['type' => 'text', 'label' => ['en' => 'Name', 'it' => 'Nome']]); + + $field->setTranslation(new Translation('it', [])); + + $this->assertSame('Nome', $field->label()); + } + + public function testValuesAndDefaultsAreNeverTranslated(): void + { + $field = new Field('name', ['type' => 'text', 'value' => '{{field.name}}', 'default' => '{{field.name}}']); + $field->setTranslation(new Translation('en', ['field.name' => 'Name'])); + + $this->assertSame('{{field.name}}', $field->value()); + $this->assertSame('{{field.name}}', $field->defaultValue()); + } + + public function testTranslationCanBeDisabledOrLimitedToSomeKeys(): void + { + $translation = new Translation('en', ['field.name' => 'Name']); + + $disabled = new Field('name', ['type' => 'text', 'label' => '{{field.name}}', 'translate' => false]); + $disabled->setTranslation($translation); + $this->assertSame('{{field.name}}', $disabled->label()); + + $limited = new Field('name', ['type' => 'text', 'label' => '{{field.name}}', 'placeholder' => '{{field.name}}', 'translate' => ['placeholder']]); + $limited->setTranslation($translation); + $this->assertSame('{{field.name}}', $limited->label()); + $this->assertSame('Name', $limited->placeholder()); } } diff --git a/tests/Unit/Files/FileCollectionTest.php b/tests/Unit/Files/FileCollectionTest.php index 83f9efb2b..6afc9ecf4 100644 --- a/tests/Unit/Files/FileCollectionTest.php +++ b/tests/Unit/Files/FileCollectionTest.php @@ -2,81 +2,39 @@ namespace Formwork\Tests\Unit\Files; +use Closure; use Formwork\Data\AbstractCollection; use Formwork\Files\File; use Formwork\Files\FileCollection; use Formwork\Tests\TestCase; -use Formwork\Utils\FileSystem; use LogicException; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use TypeError; #[CoversClass(FileCollection::class)] final class FileCollectionTest extends TestCase { - protected function setUp(): void - { - parent::setUp(); - $this->setUpTempDirectory(); - } - - protected function tearDown(): void - { - $this->tearDownTempDirectory(); - parent::tearDown(); - } - public function testFileCollectionIsTypedAssociativeAndImmutable(): void { - $path = TESTS_TMP_PATH . '/first.txt'; - FileSystem::write($path, '1'); - $collection = new FileCollection([new File($path)]); + $collection = new FileCollection([new File('/files/first.txt')]); $this->assertInstanceOf(AbstractCollection::class, $collection); $this->assertTrue($collection->isAssociative()); + $this->assertTrue($collection->isTyped()); $this->assertSame(File::class, $collection->dataType()); $this->assertFalse($collection->isMutable()); } - public function testListInputIsIndexedByFileName(): void - { - $firstPath = TESTS_TMP_PATH . '/first.txt'; - $secondPath = TESTS_TMP_PATH . '/second.txt'; - FileSystem::write($firstPath, '1'); - FileSystem::write($secondPath, '2'); - $first = new File($firstPath); - $second = new File($secondPath); - - $collection = new FileCollection([$first, $second]); - - $this->assertSame([$first, $second], $collection->values()); - $this->assertSame(['first.txt', 'second.txt'], $collection->keys()); - } - - public function testCollectionKeysCorrespondToEachFileName(): void - { - $firstPath = TESTS_TMP_PATH . '/first.txt'; - $secondPath = TESTS_TMP_PATH . '/second.txt'; - FileSystem::write($firstPath, '1'); - FileSystem::write($secondPath, '2'); - $collection = new FileCollection([new File($firstPath), new File($secondPath)]); - - $this->assertSame( - $collection->keys(), - array_map(static fn(File $file): string => $file->name(), $collection->values()), - ); - } - public function testListInputIsKeyedByEachFileNameAndPreservesOrder(): void { $first = new File('/files/first.txt'); $second = new File('/files/second.txt'); $collection = new FileCollection([$first, $second]); - $this->assertTrue($collection->isAssociative()); - $this->assertTrue($collection->isTyped()); - $this->assertSame(File::class, $collection->dataType()); $this->assertSame(['first.txt', 'second.txt'], $collection->keys()); $this->assertSame([$first, $second], $collection->values()); + $this->assertSame($first, $collection->get('first.txt')); $this->assertSame($first, $collection->first()); $this->assertSame($second, $collection->last()); } @@ -88,6 +46,7 @@ public function testAssociativeInputUsesItsExplicitKeys(): void $this->assertSame($file, $collection->get('alias')); $this->assertTrue($collection->has('alias')); + $this->assertFalse($collection->has('original.txt')); $this->assertSame(['alias'], $collection->keys()); } @@ -103,16 +62,19 @@ public function testEmptyCollectionHasNoItems(): void public function testCollectionRejectsNonFileItems(): void { - $this->expectException(\TypeError::class); + $this->expectException(TypeError::class); new FileCollection(['not a File']); } - public function testFilesCannotBeAddedOrRemovedBecauseThisCollectionIsImmutable(): void + public function testFilesWithTheSameNameInDifferentDirectoriesKeepOnlyTheLastOne(): void { - $collection = new FileCollection([new File('/files/one.txt')]); + $first = new File('/files/a/report.txt'); + $second = new File('/files/b/report.txt'); + + $collection = new FileCollection([$first, $second]); - $this->expectException(LogicException::class); - $collection->remove('one.txt'); + $this->assertSame(['report.txt'], $collection->keys()); + $this->assertSame($second, $collection->get('report.txt')); } public function testFilteringReturnsAFileCollectionAndRetainsFileIdentity(): void @@ -127,4 +89,30 @@ public function testFilteringReturnsAFileCollectionAndRetainsFileIdentity(): voi $this->assertSame(['second.jpg' => $second], $filtered->toArray()); $this->assertSame(['first.txt' => $first, 'second.jpg' => $second], $collection->toArray()); } + + /** + * @param Closure(FileCollection): mixed $operation + */ + #[DataProvider('mutatingOperationProvider')] + public function testImmutableCollectionRejectsMutatingOperations(Closure $operation): void + { + $collection = new FileCollection([new File('/files/one.txt')]); + + try { + $operation($collection); + $this->fail('The immutable file collection was mutated.'); + } catch (LogicException) { + $this->assertSame(['one.txt'], $collection->keys()); + } + } + + /** + * @return iterable + */ + public static function mutatingOperationProvider(): iterable + { + yield 'set' => [static fn(FileCollection $collection) => $collection->set('two.txt', new File('/files/two.txt'))]; + yield 'remove' => [static fn(FileCollection $collection) => $collection->remove('one.txt')]; + yield 'merge' => [static fn(FileCollection $collection) => $collection->merge(new FileCollection([new File('/files/two.txt')]))]; + } } diff --git a/tests/Unit/Files/FileFactoryTest.php b/tests/Unit/Files/FileFactoryTest.php index 0ef36fc2a..4117176fa 100644 --- a/tests/Unit/Files/FileFactoryTest.php +++ b/tests/Unit/Files/FileFactoryTest.php @@ -2,17 +2,21 @@ namespace Formwork\Tests\Unit\Files; +use Closure; use Formwork\Config\Config; use Formwork\Files\File; use Formwork\Files\FileFactory; use Formwork\Files\FileUriGenerator; use Formwork\Services\Container; use Formwork\Tests\TestCase; +use Formwork\Tests\UsesApplication; use Formwork\Utils\FileSystem; use PHPUnit\Framework\Attributes\CoversClass; use RuntimeException; +use stdClass; #[CoversClass(FileFactory::class)] +#[UsesApplication] final class FileFactoryTest extends TestCase { protected function setUp(): void @@ -45,7 +49,7 @@ public function testMakeBuildsAFileLoadsMetadataAndInjectsTheUriGenerator(): voi $this->assertSame('/files/sample.txt', $result->uri()); } - public function testMakeUsesTheDefaultFileClassWhenMimeTypeHasNoAssociation(): void + public function testMakeBuildsTheDefaultFileClassWhenTheMimeTypeHasNoAssociation(): void { $path = TESTS_TMP_PATH . '/sample.txt'; FileSystem::write($path, 'hello'); @@ -53,25 +57,22 @@ public function testMakeUsesTheDefaultFileClassWhenMimeTypeHasNoAssociation(): v $container = $this->createMock(Container::class); $container->expects($this->once())->method('build')->with(File::class, ['path' => $path])->willReturn($file); $container->method('get')->willReturn($this->createStub(FileUriGenerator::class)); - $config = new Config(['system' => ['files' => ['metadataExtension' => '.meta.yaml']]], resolved: true); - $this->assertSame($file, (new FileFactory($container, $config))->make($path)); + $factory = new FileFactory($container, $this->config(), ['image/png' => AssociatedFile::class]); + + $this->assertSame($file, $factory->make($path)); } - public function testMakeBuildsAnAssociatedClass(): void + public function testMakeBuildsTheClassAssociatedWithTheDetectedMimeType(): void { $path = TESTS_TMP_PATH . '/sample.txt'; FileSystem::write($path, 'hello'); - $file = new File($path); + $file = new AssociatedFile($path); $container = $this->createMock(Container::class); - $container->expects($this->once())->method('build')->with(File::class, ['path' => $path])->willReturn($file); + $container->expects($this->once())->method('build')->with(AssociatedFile::class, ['path' => $path])->willReturn($file); $container->method('get')->willReturn($this->createStub(FileUriGenerator::class)); - $config = new Config([ - 'system' => ['files' => ['metadataExtension' => '.meta.yaml']], - ], resolved: true); - // The association is keyed by the MIME type discovered from the controlled file. - $factory = new FileFactory($container, $config, ['text/plain' => File::class]); + $factory = new FileFactory($container, $this->config(), ['text/plain' => AssociatedFile::class]); $this->assertSame($file, $factory->make($path)); } @@ -81,7 +82,7 @@ public function testMakeRejectsAContainerResultThatIsNotAFile(): void $path = TESTS_TMP_PATH . '/sample.txt'; FileSystem::write($path, 'hello'); $container = $this->createStub(Container::class); - $container->method('build')->willReturn(new \stdClass()); + $container->method('build')->willReturn(new stdClass()); $config = new Config(['system' => ['files' => ['metadataExtension' => '.meta.yaml']]], resolved: true); $this->expectException(RuntimeException::class); @@ -89,28 +90,41 @@ public function testMakeRejectsAContainerResultThatIsNotAFile(): void (new FileFactory($container, $config))->make($path); } - public function testMakeUsesAnAssociatedFactoryMethod(): void + public function testMakeCallsTheAssociatedFactoryMethodOnTheBuiltObject(): void { $path = TESTS_TMP_PATH . '/sample.txt'; FileSystem::write($path, 'hello'); - $result = new File($path); - $built = new FactoryFile($path); + $builder = new AssociatedFile($path); $container = $this->createMock(Container::class); - $container->expects($this->once())->method('build')->with(FactoryFile::class, ['path' => $path])->willReturn($built); - $container->expects($this->once())->method('call')->willReturn($result); - $container->method('get')->with(FileUriGenerator::class)->willReturn($this->createStub(FileUriGenerator::class)); - $config = new Config(['system' => ['files' => ['metadataExtension' => '.meta.yaml']]], resolved: true); + $container->expects($this->once())->method('build')->with(AssociatedFile::class, ['path' => $path])->willReturn($builder); + $container->expects($this->once())->method('call') + ->willReturnCallback(static fn(Closure $factory, array $parameters): mixed => $factory(...$parameters)); + $container->method('get')->willReturn($this->createStub(FileUriGenerator::class)); - $this->assertSame($result, (new FileFactory($container, $config, [ - 'text/plain' => [FactoryFile::class, 'fromPath'], - ]))->make($path)); + $factory = new FileFactory($container, $this->config(), ['text/plain' => [AssociatedFile::class, 'fromPath']]); + $result = $factory->make($path); + + $this->assertNotSame($builder, $result); + $this->assertInstanceOf(AssociatedFile::class, $result); + $this->assertSame($path, $result->path()); + $this->assertSame('created', $result->origin); + } + + private function config(): Config + { + return new Config(['system' => ['files' => ['metadataExtension' => '.meta.yaml']]], resolved: true); } } -final class FactoryFile extends File +final class AssociatedFile extends File { - public function fromPath(string $path): File + public string $origin = 'constructed'; + + public function fromPath(string $path): static { - return new File($path); + $file = new static($path); + $file->origin = 'created'; + + return $file; } } diff --git a/tests/Unit/Files/FileTest.php b/tests/Unit/Files/FileTest.php index e5384b558..9c0513202 100644 --- a/tests/Unit/Files/FileTest.php +++ b/tests/Unit/Files/FileTest.php @@ -2,18 +2,24 @@ namespace Formwork\Tests\Unit\Files; +use Formwork\Cms\App; +use Formwork\Fields\FieldCollection; +use Formwork\Fields\FieldFactory; use Formwork\Files\Exceptions\FileUriGenerationException; use Formwork\Files\File; use Formwork\Files\FileUriGenerator; use Formwork\Parsers\Yaml; use Formwork\Schemes\Scheme; use Formwork\Tests\TestCase; +use Formwork\Tests\UsesApplication; use Formwork\Utils\Exceptions\FileNotFoundException; use Formwork\Utils\FileSystem; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use ZipArchive; #[CoversClass(File::class)] +#[UsesApplication] final class FileTest extends TestCase { protected function setUp(): void @@ -59,12 +65,38 @@ public function testMetadataIsReadLazilyAndContentHashIsStable(): void $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $file->hash()); } - #[DataProvider('typeProvider')] - public function testTypeRecognizesSupportedExtensionsWithTheirMimeTypes(string $extension, string $mime, string $type): void + #[DataProvider('contentTypeProvider')] + public function testTypeIsDerivedFromTheDetectedMimeType(string $filename, string $content, ?string $expectedType): void { - $path = TESTS_TMP_PATH . '/sample.' . $extension; + $path = TESTS_TMP_PATH . '/' . $filename; + FileSystem::write($path, $content); + + $this->assertSame($expectedType, (new File($path))->type()); + } + + /** + * @return iterable + */ + public static function contentTypeProvider(): iterable + { + $png = (string) base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', true); + + yield 'text' => ['note.txt', 'hello', 'text']; + yield 'markdown' => ['note.md', '# Title', 'text']; + yield 'image' => ['picture.png', $png, 'image']; + yield 'pdf' => ['report.pdf', '%PDF-1.4', 'pdf']; + yield 'zip archive' => ['bundle.zip', self::zipContent(), 'archive']; + yield 'gzip archive' => ['bundle.gz', (string) gzencode('content'), 'archive']; + yield 'content wins over the extension' => ['picture.txt', $png, 'image']; + yield 'unknown binary file' => ['blob.bin', "\0\1\2", null]; + } + + #[DataProvider('mimeTypeProvider')] + public function testTypeIsMappedFromTheMimeType(string $mimeType, ?string $expectedType): void + { + $path = TESTS_TMP_PATH . '/sample.dat'; FileSystem::write($path, 'placeholder'); - $file = new class ($path, $mime) extends File { + $file = new class ($path, $mimeType) extends File { public function __construct(string $path, string $mimeType) { parent::__construct($path); @@ -72,26 +104,37 @@ public function __construct(string $path, string $mimeType) } }; - // File::type caches the MIME value in the model; seed it through the public accessor. - $this->assertSame($type, $file->type()); + $this->assertSame($expectedType, $file->type()); } - public static function typeProvider(): iterable + /** + * @return iterable + */ + public static function mimeTypeProvider(): iterable { - yield 'pdf' => ['pdf', 'application/pdf', 'pdf']; - yield 'document' => ['docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'document']; - yield 'spreadsheet' => ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'spreadsheet']; - yield 'presentation' => ['pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'presentation']; - yield 'archive' => ['zip', 'application/zip', 'archive']; + yield 'word document' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'document']; + yield 'legacy word document' => ['application/msword', 'document']; + yield 'open document text' => ['application/vnd.oasis.opendocument.text', 'document']; + yield 'presentation' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'presentation']; + yield 'spreadsheet' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'spreadsheet']; + yield '7z archive' => ['application/x-7z-compressed', 'archive']; + yield 'rar archive' => ['application/x-rar-compressed', 'archive']; + yield 'plain text' => ['text/plain', 'text']; + yield 'unmapped image' => ['image/x-unmapped', null]; + yield 'unmapped application' => ['application/x-unmapped', null]; } - public function testUnknownMimeAndExtensionProducesNullType(): void + public function testTypeIsComputedOnlyOnce(): void { - $path = TESTS_TMP_PATH . '/sample.bin'; - FileSystem::write($path, "\0\1\2"); + $path = TESTS_TMP_PATH . '/note.txt'; + FileSystem::write($path, 'hello'); $file = new File($path); - $this->assertNull($file->type()); + $this->assertSame('text', $file->type()); + + FileSystem::delete($path); + + $this->assertSame('text', $file->type()); } public function testUriRequiresGeneratorAndDelegatesRelativeAndAbsoluteGeneration(): void @@ -174,24 +217,46 @@ public function testSaveWritesUserMetadataToTheConfiguredSidecarFile(): void ); } - public function testStringRepresentationUsesTheFileName(): void + public function testSaveStoresOnlyValuesDifferentFromTheSchemeDefaults(): void { - $path = TESTS_TMP_PATH . '/document.txt'; - FileSystem::write($path, 'content'); + $path = TESTS_TMP_PATH . '/sample.txt'; + $meta = $path . '.meta.yaml'; + FileSystem::write($path, 'hello'); + + $fields = new FieldCollection([ + 'caption' => $this->app()->getService(FieldFactory::class)->make('caption', ['type' => 'text', 'default' => 'untitled']), + ]); + $scheme = $this->createStub(Scheme::class); + $scheme->method('fields')->willReturn($fields); + $file = new File($path); + $file->setScheme($scheme); + $file->set('caption', 'untitled'); + $file->save(); - $this->assertSame('document.txt', (string) $file); - $this->assertSame('document.txt', $file->name()); - $this->assertSame('txt', $file->extension()); + $this->assertFileDoesNotExist($meta); + + $file->set('caption', 'A custom caption'); + $file->save(); + + $this->assertSame(['caption' => 'A custom caption'], Yaml::parseFile($meta)); } - public function testTypeClassificationIsConsistentWithTheMimeTypeAndExtension(): void + private static function zipContent(): string { - $path = TESTS_TMP_PATH . '/document.pdf'; - FileSystem::write($path, '%PDF-1.4'); - $file = new File($path); + $path = (string) tempnam(sys_get_temp_dir(), 'zip'); + $zip = new ZipArchive(); + $zip->open($path, ZipArchive::OVERWRITE); + $zip->addFromString('entry.txt', 'content'); + $zip->close(); + $content = (string) file_get_contents($path); + unlink($path); + + return $content; + } - $this->assertSame('pdf', $file->type()); - $this->assertSame($file->type(), $file->type()); + private function app(): App + { + return App::instance(); } } diff --git a/tests/Unit/Files/FileUriGeneratorTest.php b/tests/Unit/Files/FileUriGeneratorTest.php index 136a75c59..d5f212d43 100644 --- a/tests/Unit/Files/FileUriGeneratorTest.php +++ b/tests/Unit/Files/FileUriGeneratorTest.php @@ -10,6 +10,7 @@ use Formwork\Http\Request; use Formwork\Tests\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; #[CoversClass(FileUriGenerator::class)] final class FileUriGeneratorTest extends TestCase @@ -41,6 +42,17 @@ public function testGeneratesPageContentPathsWithoutNumericDirectoryPrefixes(): $this->assertSame('/about/team/team.txt', $this->generator($config, $uri)->generate(new File('/site/pages/1-about/2-team/team.txt'))); } + public function testGeneratesPageContentPathsForNestedFilesInPageDirectories(): void + { + $uri = $this->createMock(UriGenerator::class); + $uri->expects($this->once())->method('path')->with('/blog/2024/post/cover.jpg')->willReturn('/blog/2024/post/cover.jpg'); + + $this->assertSame( + '/blog/2024/post/cover.jpg', + $this->generator($this->config(), $uri)->generate(new File('/site/pages/3-blog/1-2024/post/cover.jpg')), + ); + } + public function testGeneratesUserImageAndPanelAssetPaths(): void { $config = $this->config(); @@ -79,6 +91,27 @@ public function testAbsoluteUriResolvesTheGeneratedUriAgainstTheRequestUri(): vo $this->assertSame('https://example.test/files/logo.png', $generator->generateAbsolute($file)); } + #[DataProvider('siblingDirectoryProvider')] + public function testDirectoriesSharingOnlyAPrefixWithAConfiguredPathAreNotMatched(string $path): void + { + $generator = $this->generator($this->config(), $this->createStub(UriGenerator::class)); + + $this->expectException(FileUriGenerationException::class); + $generator->generate(new File($path)); + } + + /** + * @return iterable + */ + public static function siblingDirectoryProvider(): iterable + { + yield 'site files' => ['/site/files-private/secret.png']; + yield 'processed images' => ['/assets/images-cache/abc/thumb.jpg']; + yield 'pages' => ['/site/pages-draft/about/team.txt']; + yield 'user images' => ['/site/users/images-private/avatar.png']; + yield 'panel assets' => ['/panel/assets-source/icons/menu.svg']; + } + private function config(): Config { return new Config(['system' => [ diff --git a/tests/Unit/Files/Services/FileUploaderTest.php b/tests/Unit/Files/Services/FileUploaderTest.php index 814413a43..d0f4bf9c6 100644 --- a/tests/Unit/Files/Services/FileUploaderTest.php +++ b/tests/Unit/Files/Services/FileUploaderTest.php @@ -2,6 +2,7 @@ namespace Formwork\Tests\Unit\Files\Services; +use Formwork\Cms\App; use Formwork\Config\Config; use Formwork\Exceptions\TranslatedException; use Formwork\Files\File; @@ -11,10 +12,12 @@ use Formwork\Http\Files\UploadedFile; use Formwork\Services\Container; use Formwork\Tests\TestCase; +use Formwork\Tests\UsesApplication; use Formwork\Utils\FileSystem; use PHPUnit\Framework\Attributes\CoversClass; #[CoversClass(FileUploader::class)] +#[UsesApplication] final class FileUploaderTest extends TestCase { protected function setUp(): void @@ -69,6 +72,36 @@ public function testUploadRejectsDestinationsOutsideConfiguredBaseDirectories(): $uploader->upload($uploaded, '/outside'); } + public function testUploadRejectsTraversalOutOfTheBaseDestinations(): void + { + $temp = TESTS_TMP_PATH . '/payload.txt'; + FileSystem::write($temp, 'payload'); + FileSystem::createDirectory(TESTS_TMP_PATH . '/allowed'); + $uploader = new FileUploader( + $this->config(['txt'], [TESTS_TMP_PATH . '/allowed']), + new FileFactory(new Container(), $this->config(['txt'])), + ); + + $this->expectException(TranslatedException::class); + $this->expectExceptionMessage('Invalid destination path'); + $uploader->upload($this->movableUpload($temp, 'payload.txt'), TESTS_TMP_PATH . '/allowed/../outside'); + } + + public function testUploadRejectsDirectoriesSharingOnlyAPrefixWithTheBaseDestination(): void + { + $temp = TESTS_TMP_PATH . '/payload.txt'; + FileSystem::write($temp, 'payload'); + FileSystem::createDirectory(TESTS_TMP_PATH . '/allowed-evil'); + $uploader = new FileUploader( + $this->config(['txt'], [TESTS_TMP_PATH . '/allowed']), + new FileFactory(new Container(), $this->config(['txt'])), + ); + + $this->expectException(TranslatedException::class); + $this->expectExceptionMessage('Invalid destination path'); + $uploader->upload($this->movableUpload($temp, 'payload.txt'), TESTS_TMP_PATH . '/allowed-evil'); + } + public function testUploadNormalizesNameChoosesClientExtensionAndReturnsTheFactoryFile(): void { $temp = TESTS_TMP_PATH . '/payload.txt'; @@ -103,6 +136,94 @@ public function testUploadUsesTheMimetypeExtensionWhenTheClientExtensionIsNotAss $this->assertSame($file, (new FileUploader($this->config(['txt'], [TESTS_TMP_PATH]), $factory))->upload($uploaded, TESTS_TMP_PATH)); } + public function testUploadAcceptsSubdirectoriesOfTheBaseDestinations(): void + { + $temp = TESTS_TMP_PATH . '/payload.txt'; + FileSystem::write($temp, 'payload'); + FileSystem::createDirectory(TESTS_TMP_PATH . '/allowed/nested', recursive: true); + $file = new File(TESTS_TMP_PATH . '/allowed/nested/payload.txt'); + $uploader = new FileUploader( + $this->config(['txt'], [TESTS_TMP_PATH . '/allowed']), + new FileFactory($this->fileFactoryContainer($file), $this->config(['txt'])), + ); + + $this->assertSame($file, $uploader->upload($this->movableUpload($temp, 'payload.txt'), TESTS_TMP_PATH . '/allowed/nested')); + $this->assertFileExists(TESTS_TMP_PATH . '/allowed/nested/payload.txt'); + } + + public function testUploadValidatesTheRealContentTypeInsteadOfTheClientOnes(): void + { + $temp = TESTS_TMP_PATH . '/image.jpg'; + FileSystem::write($temp, 'this is plain text, not an image'); + $uploaded = $this->movableUpload($temp, 'image.jpg'); + $uploader = new FileUploader($this->config(['jpg']), new FileFactory(new Container(), $this->config(['jpg']))); + + $this->expectException(TranslatedException::class); + $this->expectExceptionMessage('Invalid mime type text/plain'); + $uploader->upload($uploaded, TESTS_TMP_PATH); + } + + public function testExplicitAllowedMimeTypesReplaceTheConfiguredOnes(): void + { + $temp = TESTS_TMP_PATH . '/payload.txt'; + FileSystem::write($temp, 'payload'); + $file = new File(TESTS_TMP_PATH . '/payload.txt'); + $uploader = new FileUploader($this->config(['jpg']), new FileFactory($this->fileFactoryContainer($file), $this->config(['jpg']))); + + $this->assertSame($file, $uploader->upload($this->movableUpload($temp, 'payload.txt'), TESTS_TMP_PATH, allowedMimeTypes: ['text/plain'])); + + $restricted = new FileUploader($this->config(['txt']), new FileFactory(new Container(), $this->config(['txt']))); + + $this->expectException(TranslatedException::class); + $this->expectExceptionMessage('Invalid mime type text/plain'); + $restricted->upload($this->movableUpload($temp, 'payload.txt'), TESTS_TMP_PATH, allowedMimeTypes: ['image/png']); + } + + public function testUploadRejectsEmptyFilenames(): void + { + $temp = TESTS_TMP_PATH . '/payload.txt'; + FileSystem::write($temp, 'payload'); + $uploaded = $this->createStub(UploadedFile::class); + $uploaded->method('isUploaded')->willReturn(true); + $uploaded->method('tempPath')->willReturn($temp); + $uploaded->method('clientName')->willReturn('!!!.txt'); + $factory = new FileFactory($this->fileFactoryContainer(new File(TESTS_TMP_PATH . '/payload.txt')), $this->config(['txt'])); + + $this->expectException(TranslatedException::class); + $this->expectExceptionMessage('Invalid file name'); + (new FileUploader($this->config(['txt']), $factory))->upload($uploaded, TESTS_TMP_PATH); + } + + public function testUploadedSvgImagesAreSanitized(): void + { + $temp = TESTS_TMP_PATH . '/source.svg'; + FileSystem::write($temp, ''); + FileSystem::createDirectory(TESTS_TMP_PATH . '/uploads'); + $config = $this->config(['svg'], [TESTS_TMP_PATH]); + $uploader = new FileUploader($config, App::instance()->getService(FileFactory::class)); + + $file = $uploader->upload($this->movableUpload($temp, 'icon.svg'), TESTS_TMP_PATH . '/uploads'); + + $this->assertSame(TESTS_TMP_PATH . '/uploads/icon.svg', $file->path()); + $content = FileSystem::read($file->path()); + $this->assertStringContainsString('assertStringNotContainsString(''); + + $this->assertStringContainsString('<b>bold</b>', $html); + $this->assertStringNotContainsString(' ', ['allowHtml' => true]); + + $this->assertStringContainsString('bold', $html); + $this->assertStringNotContainsString('nested')); + } + + public function testDisallowedElementsCanBeEscapedInsteadOfRemoved(): void + { + $sanitizer = (new DomSanitizer()) + ->allowedElements(['p']) + ->sanitizeElementsMethod(SanitizeElementsMethod::Escape); + + $html = $sanitizer->sanitize('kept'); + + $this->assertSame('kept<script>alert(1)</script>', $html); + } + + public function testChildrenOfAllowedElementsAreSanitizedRecursively(): void + { + $sanitizer = (new DomSanitizer()) + ->allowedElements(['div', 'p', 'span']) + ->allowedAttributes(['title']); + + $html = $sanitizer->sanitize('ok'); + + $this->assertSame('ok', $html); + } + + public function testAttributesOfEscapedElementsAreNotRenderedAsMarkup(): void + { + $sanitizer = (new DomSanitizer()) + ->allowedElements(['p']) + ->sanitizeElementsMethod(SanitizeElementsMethod::Escape); + + $html = $sanitizer->sanitize(''); + + $this->assertStringNotContainsString('assertStringContainsString('<img', $html); + } + + public function testInvalidUnicodeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + (new DomSanitizer())->sanitize("\xff"); + } + + public function testUriAttributesAreNotCheckedUnlessDeclared(): void + { + $sanitizer = (new DomSanitizer())->allowedElements(['a'])->allowedAttributes(['href']); + + $this->assertSame('x', $sanitizer->sanitize('x')); + } + + #[DataProvider('safeUriProvider')] + public function testSafeUrisAreKept(string $uri): void + { + $html = $this->uriSanitizer()->sanitize('x'); + + $this->assertSame('x', $html); + } + + /** + * @return iterable + */ + public static function safeUriProvider(): iterable + { + yield 'https' => ['https://example.com/path?query=1#fragment']; + yield 'http' => ['http://example.com']; + yield 'uppercase scheme' => ['HTTPS://example.com']; + yield 'absolute path' => ['/path/to/page']; + yield 'relative path' => ['../page.html']; + yield 'query only' => ['?page=2']; + yield 'fragment only' => ['#section']; + yield 'empty' => ['']; + } + + #[DataProvider('unsafeUriProvider')] + public function testUnsafeUrisAreRemoved(string $uri): void + { + $html = $this->uriSanitizer()->sanitize('x'); + + $this->assertSame('x', $html); + } + + /** + * @return iterable + */ + public static function unsafeUriProvider(): iterable + { + yield 'javascript' => ['javascript:alert(1)']; + yield 'uppercase javascript' => ['JAVASCRIPT:alert(1)']; + yield 'mixed case javascript' => ['JaVaScRiPt:alert(1)']; + yield 'leading space' => [' javascript:alert(1)']; + yield 'leading newline' => ["\njavascript:alert(1)"]; + yield 'embedded tab' => ["java\tscript:alert(1)"]; + yield 'embedded newline' => ["java\nscript:alert(1)"]; + yield 'decimal entity' => ['javascript:alert(1)']; + yield 'hexadecimal entity' => ['javascript:alert(1)']; + yield 'entity without semicolon' => ['javascript:alert(1)']; + yield 'entity for the colon' => ['javascript:alert(1)']; + yield 'named entity for the colon' => ['javascript:alert(1)']; + yield 'named entity for the tab' => ['jav	ascript:alert(1)']; + yield 'percent encoding' => ['%6Aavascript:alert(1)']; + yield 'data' => ['data:text/html,']; + yield 'base64 data' => ['data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==']; + yield 'vbscript' => ['vbscript:msgbox(1)']; + yield 'file' => ['file:///nonexistent/secret']; + yield 'ftp' => ['ftp://example.com/file']; + yield 'protocol relative' => ['//evil.test/path']; + } + + public function testExternalUrisCanBeDisallowed(): void + { + $sanitizer = $this->uriSanitizer()->disallowExternalUris(); + + $this->assertSame( + 'externallocal', + $sanitizer->sanitize('externallocal') + ); + } + + public function testAllowedUriSchemesCanBeCustomized(): void + { + $sanitizer = $this->uriSanitizer()->allowedUriSchemes(['ftp']); + + $this->assertSame( + 'ftphttps', + $sanitizer->sanitize('ftphttps') + ); + } + + public function testEveryDeclaredUriAttributeIsChecked(): void + { + $sanitizer = (new UriAttributeSanitizer()) + ->allowedElements(['a', 'img']) + ->allowedAttributes(['href', 'src', 'title']); + + $html = $sanitizer->sanitize('x'); + + $this->assertSame('x', $html); + } + + private function uriSanitizer(): UriAttributeSanitizer + { + return (new UriAttributeSanitizer()) + ->allowedElements(['a']) + ->allowedAttributes(['href']); + } +} diff --git a/tests/Unit/Sanitizer/Fixtures/UriAttributeSanitizer.php b/tests/Unit/Sanitizer/Fixtures/UriAttributeSanitizer.php new file mode 100644 index 000000000..2e5d4c637 --- /dev/null +++ b/tests/Unit/Sanitizer/Fixtures/UriAttributeSanitizer.php @@ -0,0 +1,10 @@ +assertSame($markup, (new HtmlSanitizer())->sanitize($markup)); + } + + /** + * @return iterable + */ + public static function safeMarkupProvider(): iterable + { + yield 'text' => ['Plain text']; + yield 'formatting' => ['Hello world and everyone']; + yield 'link' => ['link']; + yield 'relative link' => ['link']; + yield 'mail link' => ['mail']; + yield 'image' => ['']; + yield 'image with srcset' => ['']; + yield 'list' => ['OneTwo']; + yield 'table' => ['A1']; + yield 'escaped text' => ['1 < 2 && 3 > 2']; + yield 'form control' => ['Go']; + yield 'meta refresh without URL' => ['']; + yield 'meta refresh to a safe URL' => ['']; + } + + #[DataProvider('dangerousElementProvider')] + public function testDangerousElementsAreRemoved(string $markup, string $expected): void + { + $this->assertSame($expected, (new HtmlSanitizer())->sanitize($markup)); + } + + /** + * @return iterable + */ + public static function dangerousElementProvider(): iterable + { + yield 'script' => ['a', 'a']; + yield 'style' => ['a', 'a']; + yield 'iframe' => ['a', 'a']; + yield 'object' => ['a', 'a']; + yield 'embed' => ['a', 'a']; + yield 'base' => ['a', 'a']; + yield 'unknown element' => ['a', '']; + yield 'mathml' => ['', '']; + yield 'noscript breakout' => ['', '']; + } + + #[DataProvider('eventHandlerProvider')] + public function testEventHandlersAreRemoved(string $attribute): void + { + $html = (new HtmlSanitizer())->sanitize('text'); + + $this->assertSame('text', $html); + } + + /** + * @return iterable + */ + public static function eventHandlerProvider(): iterable + { + yield 'onclick' => ['onclick']; + yield 'onerror' => ['onerror']; + yield 'onload' => ['onload']; + yield 'onmouseover' => ['onmouseover']; + yield 'onfocus' => ['onfocus']; + yield 'uppercase' => ['ONCLICK']; + } + + #[DataProvider('uriAttributeProvider')] + public function testEveryUriAttributeIsValidated(string $attribute): void + { + $html = (new HtmlSanitizer())->sanitize('text'); + + $this->assertSame('text', $html); + } + + /** + * @return iterable + */ + public static function uriAttributeProvider(): iterable + { + foreach (HtmlReference::URI_ATTRIBUTES as $attribute) { + yield $attribute => [$attribute]; + } + } + + /** + * Attributes taking a URL must be validated as URIs when they are allowed + */ + #[DataProvider('urlAttributeProvider')] + public function testUrlValuedAttributesAreNeverAllowedWithoutUriValidation(string $attribute): void + { + if (!in_array($attribute, HtmlReference::ALLOWED_ATTRIBUTES, true)) { + $this->assertNotContains($attribute, HtmlReference::URI_ATTRIBUTES, 'Disallowed attributes need no validation'); + return; + } + + $this->assertContains($attribute, HtmlReference::URI_ATTRIBUTES); + } + + /** + * @return iterable + */ + public static function urlAttributeProvider(): iterable + { + $attributes = [ + 'action', 'archive', 'background', 'cite', 'classid', 'codebase', 'data', 'dynsrc', 'formaction', + 'href', 'icon', 'imagesrcset', 'itemid', 'itemtype', 'longdesc', 'lowsrc', 'manifest', 'ping', + 'poster', 'profile', 'src', 'srcset', 'usemap', + ]; + + foreach ($attributes as $attribute) { + yield $attribute => [$attribute]; + } + } + + public function testFormactionWithAScriptUriIsRemoved(): void + { + $html = (new HtmlSanitizer())->sanitize('Click'); + + $this->assertSame('Click', $html); + } + + #[DataProvider('formactionProvider')] + public function testFormactionIsValidatedOnEveryControl(string $markup, string $expected): void + { + $this->assertSame($expected, (new HtmlSanitizer())->sanitize($markup)); + } + + /** + * @return iterable + */ + public static function formactionProvider(): iterable + { + yield 'input submit' => ['', '']; + yield 'input image' => ['', '']; + yield 'mixed case' => ['x', 'x']; + yield 'leading whitespace' => ['x', 'x']; + yield 'encoded scheme' => ['x', 'x']; + yield 'data URI' => ['x', 'x']; + yield 'safe URL' => ['x', 'x']; + yield 'relative URL' => ['x', 'x']; + } + + #[DataProvider('srcsetProvider')] + public function testSrcsetCandidatesAreAllValidated(string $attribute, string $value, bool $kept): void + { + $html = (new HtmlSanitizer())->sanitize(''); + + if ($kept) { + $this->assertStringContainsString($attribute . '="' . $value . '"', $html); + } else { + $this->assertSame('', $html); + } + } + + /** + * @return iterable + */ + public static function srcsetProvider(): iterable + { + yield 'single safe candidate' => ['srcset', 'a.png', true]; + yield 'safe candidates with descriptors' => ['srcset', 'a.png 1x, b.png 2x', true]; + yield 'safe candidates with widths' => ['srcset', 'small.png 320w, large.png 960w', true]; + yield 'imagesrcset' => ['imagesrcset', 'a.png 1x, b.png 2x', true]; + yield 'single unsafe candidate' => ['srcset', 'javascript:alert(1)', false]; + yield 'unsafe candidate with descriptor' => ['srcset', 'javascript:alert(1) 1x', false]; + yield 'unsafe first candidate' => ['srcset', 'javascript:alert(1) 1x, ok.png 2x', false]; + yield 'unsafe last candidate' => ['srcset', 'ok.png 1x, javascript:alert(1) 2x', false]; + yield 'unsafe candidate without space after the comma' => ['srcset', 'ok.png 1x,javascript:alert(1) 2x', false]; + yield 'unsafe candidate with surrounding spaces' => ['srcset', ' ok.png , javascript:alert(1) ', false]; + yield 'encoded unsafe candidate' => ['srcset', 'javascript:alert(1) 1x', false]; + yield 'unsafe imagesrcset' => ['imagesrcset', 'ok.png 1x, javascript:alert(1) 2x', false]; + yield 'data URI candidate' => ['srcset', 'data:text/html;base64,PHNjcmlwdD4= 1x', false]; + } + + #[DataProvider('metaRefreshProvider')] + public function testMetaRefreshWithUnsafeUrisIsRemoved(string $markup, string $expected): void + { + $this->assertSame($expected, (new HtmlSanitizer())->sanitize($markup)); + } + + /** + * @return iterable + */ + public static function metaRefreshProvider(): iterable + { + yield 'url prefix' => ['', '']; + yield 'uppercase url prefix' => ['', '']; + yield 'comma separator' => ['', '']; + yield 'surrounding spaces' => ['', '']; + yield 'encoded scheme' => ['', '']; + yield 'data URI' => ['', '']; + yield 'without url prefix' => ['', '']; + yield 'uppercase http-equiv' => ['', '']; + yield 'quoted URL' => ['', '']; + yield 'spaces around the equal sign' => ['', '']; + } + + public function testOtherMetaContentIsNotAffected(): void + { + $markup = ''; + + $this->assertSame($markup, (new HtmlSanitizer())->sanitize($markup)); + } + + public function testUnsafeSchemesAreRemovedFromLinks(): void + { + $html = (new HtmlSanitizer())->sanitize( + 'abcd' + ); + + $this->assertSame('abcd', $html); + } + + public function testExternalUrisCanBeDisallowed(): void + { + $sanitizer = (new HtmlSanitizer())->disallowExternalUris(); + + $this->assertSame( + 'externallocal', + $sanitizer->sanitize('externallocal') + ); + } + + public function testDisallowedElementsCanBeEscaped(): void + { + $sanitizer = (new HtmlSanitizer())->sanitizeElementsMethod(SanitizeElementsMethod::Escape); + + $html = $sanitizer->sanitize('a'); + + $this->assertSame('a<script>alert(1)</script>', $html); + } + + public function testEmbeddedSvgIsSanitizedWithTheSvgRules(): void + { + $html = (new HtmlSanitizer())->sanitize( + 'x' + ); + + $this->assertStringContainsString('assertStringNotContainsString('onload', $html); + $this->assertStringNotContainsString('', '']; + yield 'onload handler' => ['', '']; + yield 'onclick handler' => ['', '']; + yield 'foreignObject' => ['x', '']; + yield 'javascript href' => ['x', 'x']; + yield 'javascript xlink:href' => ['x', 'x']; + yield 'encoded javascript href' => ['x', 'x']; + yield 'data URI image' => ['', '']; + } + + public function testSafeLinksAreKept(): void + { + $svg = (new SvgSanitizer())->sanitize('x'); + + $this->assertStringContainsString('', $svg); + } + + #[DataProvider('unsafeSmilAttributeNameProvider')] + public function testSmilAnimationsCannotTargetUriOrUnknownAttributes(string $element, string $attributeName): void + { + $svg = (new SvgSanitizer())->sanitize( + '<' . $element . ' attributeName="' . $attributeName . '" to="javascript:alert(1)" values="javascript:alert(1)"/>x' + ); + + $this->assertStringNotContainsString('attributeName', $svg); + } + + /** + * @return iterable + */ + public static function unsafeSmilAttributeNameProvider(): iterable + { + foreach (['animate', 'set'] as $element) { + yield "$element href" => [$element, 'href']; + yield "$element xlink:href" => [$element, 'xlink:href']; + yield "$element src" => [$element, 'src']; + yield "$element uppercase href" => [$element, 'HREF']; + yield "$element href with leading space" => [$element, ' href']; + yield "$element href with trailing space" => [$element, 'href ']; + yield "$element event handler" => [$element, 'onclick']; + yield "$element unknown attribute" => [$element, 'unknown']; + yield "$element empty attribute" => [$element, '']; + } + + yield 'animateTransform href' => ['animateTransform', 'href']; + yield 'animateMotion href' => ['animateMotion', 'href']; + } + + #[DataProvider('safeSmilAttributeNameProvider')] + public function testSmilAnimationsOfSafeAttributesAreKept(string $element, string $attributeName): void + { + $svg = (new SvgSanitizer())->sanitize( + '<' . $element . ' attributeName="' . $attributeName . '" from="1" to="5" dur="1s"/>' + ); + + $this->assertStringContainsString('attributeName="' . $attributeName . '"', $svg); + } + + /** + * @return iterable + */ + public static function safeSmilAttributeNameProvider(): iterable + { + yield 'animate r' => ['animate', 'r']; + yield 'animate opacity' => ['animate', 'opacity']; + yield 'set fill' => ['set', 'fill']; + yield 'animateTransform transform' => ['animateTransform', 'transform']; + } + + public function testAttributeNameOutsideSmilElementsIsNotTreatedAsSmil(): void + { + $svg = (new SvgSanitizer())->sanitize(''); + + $this->assertStringNotContainsString('javascript', $svg); + $this->assertStringContainsString('r="5"', $svg); + } + + public function testSmilElementsDoNotInheritSafeAttributeNamesFromSiblings(): void + { + $svg = (new SvgSanitizer())->sanitize( + '' + ); + + $this->assertSame(1, substr_count($svg, 'attributeName')); + $this->assertStringContainsString('attributeName="r"', $svg); + } + + public function testEveryUriAttributeOfTheReferenceIsRejectedAsSmilTarget(): void + { + foreach (SvgReference::URI_ATTRIBUTES as $attribute) { + $svg = (new SvgSanitizer())->sanitize(''); + + $this->assertStringNotContainsString('attributeName', $svg, $attribute); + } + } + + /** + * @param class-string $exception + */ + #[DataProvider('invalidDocumentProvider')] + public function testInvalidDocumentsAreRejected(string $input, string $exception = UnexpectedValueException::class): void + { + $this->expectException($exception); + (new SvgSanitizer())->sanitize($input); + } + + /** + * @return iterable}|array{string}> + */ + public static function invalidDocumentProvider(): iterable + { + yield 'empty string' => ['']; + yield 'plain text' => ['not an svg']; + yield 'other root element' => ['x']; + yield 'multiple roots' => ['']; + yield 'root followed by other element' => ['x']; + yield 'malformed markup' => ['']; + yield 'external entity' => [']>&x;']; + yield 'undefined entity' => ['&x;']; + yield 'invalid unicode' => ["\xff", InvalidArgumentException::class]; + } +} diff --git a/tests/Unit/Services/Loaders/ConfigServiceLoaderTest.php b/tests/Unit/Services/Loaders/ConfigServiceLoaderTest.php new file mode 100644 index 000000000..70ca2ec76 --- /dev/null +++ b/tests/Unit/Services/Loaders/ConfigServiceLoaderTest.php @@ -0,0 +1,246 @@ + + */ + private array $plantedFiles = []; + + private string $timezone; + + protected function setUp(): void + { + parent::setUp(); + $this->timezone = date_default_timezone_get(); + } + + protected function tearDown(): void + { + foreach ($this->plantedFiles as $file) { + if (FileSystem::exists($file)) { + FileSystem::delete($file); + } + } + date_default_timezone_set($this->timezone); + parent::tearDown(); + } + + #[DataProvider('validHostProvider')] + public function testCacheFileIsNamedAfterTheNormalizedHost(string $host, string $expectedName): void + { + $cacheFile = $this->cacheFile($host); + + $this->assertSame(self::cachePath() . $expectedName, $cacheFile); + } + + /** + * @return iterable + */ + public static function validHostProvider(): iterable + { + yield 'hostname' => ['example.test', 'config.example.test.php']; + yield 'uppercase hostname' => ['EXAMPLE.test', 'config.example.test.php']; + yield 'hostname with a port' => ['example.test:8080', 'config.example.test.php']; + yield 'subdomain' => ['sub.example.test', 'config.sub.example.test.php']; + yield 'localhost' => ['localhost', 'config.localhost.php']; + yield 'IPv4 address' => ['192.0.2.1', 'config.192.0.2.1.php']; + yield 'hyphenated hostname' => ['my-site.example.test', 'config.my-site.example.test.php']; + } + + #[DataProvider('hostileHostProvider')] + public function testHostileHostsNeverProduceACacheFile(string $host): void + { + $this->assertNull($this->cacheFile($host)); + } + + /** + * @return iterable + */ + public static function hostileHostProvider(): iterable + { + yield 'parent directory' => ['..']; + yield 'current directory' => ['.']; + yield 'relative traversal' => ['../../nonexistent/secret']; + yield 'deep traversal' => ['../../../../../../../tmp/payload']; + yield 'traversal inside the name' => ['host/../../x']; + yield 'backslash traversal' => ['..\..\windows\win']; + yield 'encoded traversal' => ['%2e%2e%2f%2e%2e%2fetc']; + yield 'absolute path' => ['/nonexistent/secret']; + yield 'subdirectory' => ['a/b']; + yield 'null byte' => ["example.test\0.php"]; + yield 'newline' => ["example.test\nsecond"]; + yield 'whitespace' => ['exa mple.test']; + yield 'query string' => ['example.test?x=1']; + yield 'empty' => ['']; + } + + #[DataProvider('hostileHostProvider')] + public function testCacheFileAlwaysStaysInsideTheCacheDirectory(string $host): void + { + $cacheFile = $this->cacheFile($host); + + if ($cacheFile !== null) { + $this->assertTrue(Path::isRelativeTo($cacheFile, self::cachePath())); + $this->assertSame(Path::normalize(self::cachePath()), Path::dirname($cacheFile) . '/'); + } + + $this->addToAssertionCount(1); + } + + public function testCacheDirectoryIsCreatedWhenMissing(): void + { + $this->cacheFile('example.test'); + + $this->assertDirectoryExists(self::cachePath()); + } + + public function testConfigIsLoadedFromFilesWhenNoCacheFileExists(): void + { + $config = $this->load('uncached.test'); + + $this->assertTrue($config->has('system')); + $this->assertTrue($config->has('site')); + $this->assertSame(date_default_timezone_get(), $config->getString('system.date.timezone')); + } + + public function testConfigIsNotCachedWhenRunningFromTheCommandLine(): void + { + $file = self::cachePath() . 'config.uncached.test.php'; + $this->plantedFiles[] = $file; + + $this->load('uncached.test'); + + $this->assertFileDoesNotExist($file); + } + + public function testFreshCacheFileIsUsed(): void + { + $this->plantCache('cached.test', 'Pacific/Auckland', time() + 10); + + $config = $this->load('cached.test'); + + $this->assertSame('Pacific/Auckland', $config->getString('system.date.timezone')); + $this->assertSame('Pacific/Auckland', date_default_timezone_get()); + } + + public function testStaleCacheFileIsIgnored(): void + { + /** @todo use something like `time() - 100000` instead of `1` after the cache paths are not hardcoded anymore in the loader */ + $this->plantCache('stale.test', 'Pacific/Auckland', 1); + + $config = $this->load('stale.test'); + + $this->assertNotSame('Pacific/Auckland', $config->getString('system.date.timezone')); + } + + public function testCacheOfAnotherHostIsNotUsed(): void + { + $this->plantCache('one.test', 'Pacific/Auckland', time() + 10); + + $config = $this->load('two.test'); + + $this->assertNotSame('Pacific/Auckland', $config->getString('system.date.timezone')); + } + + public function testSessionIsConfiguredFromTheLoadedConfig(): void + { + $request = $this->request('uncached.test'); + + $config = (new ConfigServiceLoader($request, App::instance()))->load(new Container()); + + $this->assertSame($config->getInt('system.session.duration'), $this->sessionDuration($request)); + } + + public function testClearCacheRemovesCachedFilesAndRecreatesTheDirectory(): void + { + $this->plantedFiles[] = $file = self::cachePath() . 'config.to-clear.test.php'; + $this->ensureCacheDirectory(); + FileSystem::write($file, 'rootPath()); + + $this->assertFileDoesNotExist($file); + $this->assertDirectoryExists(self::cachePath()); + } + + private function request(string $host): Request + { + return new Request([], [], [], [], [ + 'REQUEST_METHOD' => 'GET', + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => '80', + 'HTTP_HOST' => $host, + ]); + } + + private function cacheFile(string $host): ?string + { + $method = new ReflectionMethod(ConfigServiceLoader::class, 'getConfigCacheFile'); + + return $method->invoke(new ConfigServiceLoader($this->request($host), App::instance())); + } + + private function load(string $host): Config + { + return (new ConfigServiceLoader($this->request($host), App::instance()))->load(new Container()); + } + + private function sessionDuration(Request $request): int + { + $session = $request->session(); + $property = new ReflectionProperty($session, 'duration'); + + return (int) $property->getValue($session); + } + + private function ensureCacheDirectory(): void + { + if (!FileSystem::isDirectory(self::cachePath(), assertExists: false)) { + FileSystem::createDirectory(self::cachePath(), recursive: true); + } + } + + private function plantCache(string $host, string $timezone, int $modifiedTime): void + { + $this->ensureCacheDirectory(); + + $file = self::cachePath() . "config.{$host}.php"; + $this->plantedFiles[] = $file; + + $config = new Config(); + $config->loadFromPath(App::instance()->systemPath() . '/config/'); + $config->resolve([ + '%ROOT_PATH%' => App::instance()->rootPath(), + '%SYSTEM_PATH%' => App::instance()->systemPath(), + ]); + $data = $config->toArray(); + $data['system']['date']['timezone'] = $timezone; + + FileSystem::write($file, 'rootPath() . '/cache/config/'; + } +} diff --git a/tests/Unit/Statistics/StatisticsTest.php b/tests/Unit/Statistics/StatisticsTest.php new file mode 100644 index 000000000..4978c7f1a --- /dev/null +++ b/tests/Unit/Statistics/StatisticsTest.php @@ -0,0 +1,496 @@ +setUpTempDirectory(); + $this->path = FileSystem::joinPaths(TESTS_TMP_PATH, 'statistics'); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testMissingDirectoryIsCreated(): void + { + $this->assertDirectoryDoesNotExist($this->path); + + $this->statistics(); + + $this->assertDirectoryExists($this->path); + } + + public function testExistingDirectoryIsReused(): void + { + FileSystem::createDirectory($this->path); + FileSystem::write(FileSystem::joinPaths($this->path, 'marker.txt'), 'kept'); + + $this->statistics(); + + $this->assertFileExists(FileSystem::joinPaths($this->path, 'marker.txt')); + } + + public function testEmptyStatisticsHaveNoData(): void + { + $statistics = $this->statistics(); + + $this->assertSame([], $statistics->getPageViews()); + $this->assertSame([], $statistics->getSources()); + $this->assertSame([], $statistics->getDevices()); + $this->assertSame(array_fill_keys($this->lastDays(7), 0), $statistics->getVisits()); + $this->assertSame(array_fill_keys($this->lastDays(7), 0), $statistics->getUniqueVisits()); + } + + public function testVisitIsTrackedInEveryRegistry(): void + { + $statistics = $this->statistics(); + + $statistics->trackVisit(); + + $today = date('Ymd'); + $this->assertSame(['/blog/post/' => 1], $statistics->getPageViews()); + $this->assertSame(1, $statistics->getVisits(1)[$today]); + $this->assertSame(1, $statistics->getUniqueVisits(1)[$today]); + $this->assertSame(['search.test' => 1], $statistics->getSources()); + $this->assertSame(['desktop' => 1], $statistics->getDevices()); + } + + public function testTrackedVisitsArePersisted(): void + { + $this->statistics()->trackVisit(); + + $statistics = $this->statistics(); + + $this->assertSame(['/blog/post/' => 1], $statistics->getPageViews()); + $this->assertSame(1, $statistics->getVisits(1)[date('Ymd')]); + } + + public function testQueryStringAndFragmentAreNotPartOfThePageKey(): void + { + $this->statistics(server: ['REQUEST_URI' => '/blog/post?utm_source=newsletter&page=2'])->trackVisit(); + + $this->assertSame(['/blog/post/' => 1], $this->statistics()->getPageViews()); + } + + public function testRepeatedVisitsWithinTheDelayAreCountedOnce(): void + { + $statistics = $this->statistics(); + + $statistics->trackVisit(); + $statistics->trackVisit(); + $this->statistics()->trackVisit(); + + $this->assertSame(['/blog/post/' => 1], $statistics->getPageViews()); + $this->assertSame(1, $statistics->getVisits(1)[date('Ymd')]); + } + + public function testRepeatedVisitsAfterTheDelayAreCountedButTheVisitorIsUnique(): void + { + $statistics = $this->statistics(options: ['visitsDelay' => 0]); + + $statistics->trackVisit(); + $statistics->trackVisit(); + $statistics->trackVisit(); + + $this->assertSame(['/blog/post/' => 3], $statistics->getPageViews()); + $this->assertSame(3, $statistics->getVisits(1)[date('Ymd')]); + $this->assertSame(1, $statistics->getUniqueVisits(1)[date('Ymd')]); + } + + public function testDifferentPagesOfTheSameVisitorAreCountedAsOneUniqueVisit(): void + { + $this->statistics(server: ['REQUEST_URI' => '/one'])->trackVisit(); + $this->statistics(server: ['REQUEST_URI' => '/two'])->trackVisit(); + + $statistics = $this->statistics(); + + $this->assertSame(['/one/' => 1, '/two/' => 1], $statistics->getPageViews()); + $this->assertSame(2, $statistics->getVisits(1)[date('Ymd')]); + $this->assertSame(1, $statistics->getUniqueVisits(1)[date('Ymd')]); + } + + public function testDifferentVisitorsAreCountedSeparately(): void + { + $this->statistics(server: ['REMOTE_ADDR' => '203.0.113.7'])->trackVisit(); + $this->statistics(server: ['REMOTE_ADDR' => '198.51.100.9'])->trackVisit(); + $this->statistics(server: ['REMOTE_ADDR' => '203.0.113.7', 'HTTP_USER_AGENT' => self::MOBILE])->trackVisit(); + + $statistics = $this->statistics(); + + $this->assertSame(3, $statistics->getVisits(1)[date('Ymd')]); + $this->assertSame(3, $statistics->getUniqueVisits(1)[date('Ymd')]); + $this->assertSame(['/blog/post/' => 3], $statistics->getPageViews()); + } + + public function testVisitorsOnTheSameNetworkShareTheAnonymizedAddress(): void + { + $this->statistics(server: ['REMOTE_ADDR' => '203.0.113.7'])->trackVisit(); + $this->statistics(server: ['REMOTE_ADDR' => '203.0.113.200'])->trackVisit(); + + $this->assertSame(1, $this->statistics()->getVisits(1)[date('Ymd')]); + } + + public function testRawAddressesAreNeverStored(): void + { + $this->statistics(server: ['REMOTE_ADDR' => '203.0.113.7'])->trackVisit(); + + foreach (FileSystem::listFiles($this->path) as $file) { + $this->assertStringNotContainsString('203.0.113.7', FileSystem::read(FileSystem::joinPaths($this->path, $file)), $file); + } + } + + public function testLocalhostVisitsAreIgnoredByDefault(): void + { + $statistics = $this->statistics(server: ['REMOTE_ADDR' => '127.0.0.1']); + + $statistics->trackVisit(); + + $this->assertSame([], $statistics->getPageViews()); + $this->assertSame(0, $statistics->getVisits(1)[date('Ymd')]); + } + + public function testLocalhostVisitsCanBeTracked(): void + { + $statistics = $this->statistics(server: ['REMOTE_ADDR' => '127.0.0.1'], options: ['trackLocalhost' => true]); + + $statistics->trackVisit(); + + $this->assertSame(['/blog/post/' => 1], $statistics->getPageViews()); + } + + #[DataProvider('botProvider')] + public function testBotsAreIgnored(string $userAgent): void + { + $statistics = $this->statistics(server: ['HTTP_USER_AGENT' => $userAgent]); + + $statistics->trackVisit(); + + $this->assertSame([], $statistics->getPageViews()); + $this->assertSame(0, $statistics->getVisits(1)[date('Ymd')]); + } + + /** + * @return iterable + */ + public static function botProvider(): iterable + { + yield 'Googlebot' => ['Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)']; + yield 'Bingbot' => ['Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)']; + yield 'curl' => ['curl/8.0.1']; + } + + public function testVisitsWithoutAnAddressAreIgnored(): void + { + $statistics = $this->statistics(server: ['REMOTE_ADDR' => null]); + + $statistics->trackVisit(); + + $this->assertSame([], $statistics->getPageViews()); + } + + public function testExternalRefererIsTrackedAsSource(): void + { + $this->statistics(server: ['HTTP_REFERER' => 'https://Search.Example:8443/results?q=1'])->trackVisit(); + + $this->assertSame(['search.example' => 1], $this->statistics()->getSources()); + } + + public function testInternalRefererIsNotTrackedAsSource(): void + { + $statistics = $this->statistics(server: ['HTTP_REFERER' => 'https://example.test/previous']); + + $statistics->trackVisit(); + + $this->assertSame([], $statistics->getSources()); + $this->assertSame(['/blog/post/' => 1], $statistics->getPageViews()); + } + + public function testDirectVisitsAreCountedWithAnEmptySource(): void + { + $this->statistics(server: ['HTTP_REFERER' => null])->trackVisit(); + + $this->assertSame(['' => 1], $this->statistics()->getSources()); + } + + /** + * Sources are rendered in the panel, so only valid host names can be stored + */ + #[DataProvider('hostileRefererProvider')] + public function testHostileRefererCannotInjectMarkupIntoTheSources(string $referer): void + { + $statistics = $this->statistics(server: ['HTTP_REFERER' => $referer]); + + $statistics->trackVisit(); + + $this->assertSame([], $statistics->getSources()); + $this->assertSame(['/blog/post/' => 1], $statistics->getPageViews()); + + foreach (FileSystem::listFiles($this->path) as $file) { + $content = FileSystem::read(FileSystem::joinPaths($this->path, $file)); + $this->assertStringNotContainsString('.test/']; + yield 'script element closing an attribute' => ['https://">/']; + yield 'image with an error handler' => ['https://a.test/']; + yield 'attribute injection' => ['https://evil.test"onmouseover="alert(1)/']; + yield 'null byte' => ["https://evil.test\x00.example/"]; + yield 'space' => ['https://exa mple.test/']; + yield 'underscore' => ['https://a_b.test/']; + yield 'not a URI' => ['not a uri']; + yield 'script URI' => ['javascript:alert(1)']; + } + + public function testDeviceTypesAreCounted(): void + { + $this->statistics(server: ['HTTP_USER_AGENT' => self::DESKTOP, 'REMOTE_ADDR' => '203.0.113.1'])->trackVisit(); + $this->statistics(server: ['HTTP_USER_AGENT' => self::MOBILE, 'REMOTE_ADDR' => '203.0.113.2'])->trackVisit(); + $this->statistics(server: ['HTTP_USER_AGENT' => self::MOBILE, 'REMOTE_ADDR' => '198.51.100.3'])->trackVisit(); + + $this->assertSame(['mobile' => 2, 'desktop' => 1], $this->statistics()->getDevices()); + } + + public function testPageViewsAreSortedByViewsDescending(): void + { + foreach (['/rare' => 1, '/popular' => 3, '/common' => 2] as $uri => $views) { + for ($i = 0; $i < $views; $i++) { + $this->statistics(server: ['REQUEST_URI' => $uri, 'REMOTE_ADDR' => "203.0.$i.1"])->trackVisit(); + } + } + + $this->assertSame(['/popular/' => 3, '/common/' => 2, '/rare/' => 1], $this->statistics()->getPageViews()); + } + + public function testSourcesAreSortedByVisitsDescending(): void + { + $this->statistics(server: ['HTTP_REFERER' => 'https://one.test/', 'REMOTE_ADDR' => '203.0.1.1'])->trackVisit(); + $this->statistics(server: ['HTTP_REFERER' => 'https://two.test/', 'REMOTE_ADDR' => '203.0.2.1'])->trackVisit(); + $this->statistics(server: ['HTTP_REFERER' => 'https://two.test/', 'REMOTE_ADDR' => '203.0.3.1'])->trackVisit(); + + $this->assertSame(['two.test' => 2, 'one.test' => 1], $this->statistics()->getSources()); + } + + public function testVisitsCoverTheRequestedNumberOfDaysEndingToday(): void + { + $statistics = $this->statistics(); + + foreach ([1, 3, 7, 30] as $limit) { + $visits = $statistics->getVisits($limit); + + // Numeric day keys are cast to integers by PHP + $this->assertSame($this->lastDays($limit), array_map('strval', array_keys($visits)), "Limit $limit"); + $this->assertSame((int) date('Ymd'), array_key_last($visits)); + } + } + + public function testStoredVisitsOfPreviousDaysAreReturnedAndMissingDaysAreZero(): void + { + $days = $this->lastDays(5); + $this->writeRegistry('visits.json', [$days[1] => 10, $days[3] => 4, '19990101' => 99]); + $this->writeRegistry('uniqueVisits.json', [$days[1] => 6]); + + $statistics = $this->statistics(); + + $this->assertSame([$days[0] => 0, $days[1] => 10, $days[2] => 0, $days[3] => 4, $days[4] => 0], $statistics->getVisits(5)); + $this->assertSame([$days[0] => 0, $days[1] => 6, $days[2] => 0, $days[3] => 0, $days[4] => 0], $statistics->getUniqueVisits(5)); + } + + public function testChartDataContainsLabelsAndTheVisitSeries(): void + { + $days = $this->lastDays(3); + $this->writeRegistry('visits.json', [$days[0] => 5, $days[2] => 2]); + $this->writeRegistry('uniqueVisits.json', [$days[0] => 3, $days[2] => 1]); + + $chart = $this->statistics()->getChartData(3); + + $this->assertSame([[5, 0, 2], [3, 0, 1]], $chart['series']); + $this->assertCount(3, $chart['labels']); + $this->assertSame( + date('D', (int) strtotime($days[2])) . "\n" . date('j', (int) strtotime($days[2])) . ' ' . date('M', (int) strtotime($days[2])), + $chart['labels'][2], + ); + } + + public function testChartDataUsesTheDefaultNumberOfDays(): void + { + $chart = $this->statistics()->getChartData(); + + $this->assertCount(7, $chart['labels']); + $this->assertCount(7, $chart['series'][0]); + $this->assertCount(7, $chart['series'][1]); + } + + public function testExpiredSessionsAreRemovedByTheCleanup(): void + { + $this->writeRegistry('sessions.json', ['expired' => time() - 100000, 'recent' => time() - 10]); + + $this->statistics(options: ['cleanup' => ['ttl' => 3600, 'probability' => 100]])->trackVisit(); + + $sessions = $this->readRegistry('sessions.json'); + $this->assertArrayNotHasKey('expired', $sessions); + $this->assertArrayHasKey('recent', $sessions); + $this->assertCount(2, $sessions); + } + + public function testVisitorsOfPreviousDaysAreRemovedByTheCleanup(): void + { + $this->writeRegistry('visitors.json', ['old-visitor' => '19990101']); + + $this->statistics(options: ['cleanup' => ['ttl' => 3600, 'probability' => 100]])->trackVisit(); + + $visitors = $this->readRegistry('visitors.json'); + $this->assertArrayNotHasKey('old-visitor', $visitors); + $this->assertSame([date('Ymd')], array_values($visitors)); + } + + public function testCleanupIsSkippedWhenTheProbabilityIsZero(): void + { + $this->writeRegistry('sessions.json', ['expired' => time() - 100000]); + $this->writeRegistry('visitors.json', ['old-visitor' => '19990101']); + + $this->statistics(options: ['cleanup' => ['ttl' => 3600, 'probability' => 0]])->trackVisit(); + + $this->assertArrayHasKey('expired', $this->readRegistry('sessions.json')); + $this->assertArrayHasKey('old-visitor', $this->readRegistry('visitors.json')); + } + + public function testLegacyVisitorKeysBasedOnTheAddressAreMigrated(): void + { + $ip = IpAnonymizer::anonymize('203.0.113.7'); + $this->writeRegistry('visitors.json', [$ip => date('Ymd')]); + + $this->statistics()->trackVisit(); + + $visitors = $this->readRegistry('visitors.json'); + $this->assertArrayNotHasKey($ip, $visitors); + $this->assertCount(1, $visitors); + // The visitor was already counted today under the legacy key, so no new unique visit is recorded + $this->assertSame(0, $this->statistics()->getUniqueVisits(1)[date('Ymd')]); + $this->assertSame(1, $this->statistics()->getVisits(1)[date('Ymd')]); + } + + public function testRegistryFilesCannotEscapeTheStatisticsDirectory(): void + { + $registries = $this->registries(); + $registries['visits'] = '../../outside.json'; + + $this->statistics(options: ['registries' => $registries])->trackVisit(); + + $this->assertFileDoesNotExist(FileSystem::joinPaths(TESTS_TMP_PATH, 'outside.json')); + $this->assertFileDoesNotExist(dirname(TESTS_TMP_PATH) . '/outside.json'); + $this->assertFileExists(FileSystem::joinPaths($this->path, 'outside.json')); + } + + /** + * @param array $server + * @param array $options + */ + private function statistics(array $server = [], array $options = []): Statistics + { + $server += [ + 'REQUEST_METHOD' => 'GET', + 'SERVER_NAME' => 'example.test', + 'SERVER_PORT' => '80', + 'REMOTE_ADDR' => '203.0.113.7', + 'REQUEST_URI' => '/blog/post', + 'HTTP_USER_AGENT' => self::DESKTOP, + 'HTTP_REFERER' => 'https://search.test/results', + ]; + + $request = new Request([], [], [], [], array_filter($server, static fn(?string $value): bool => $value !== null)); + + return new Statistics( + $options + [ + 'path' => $this->path, + 'trackLocalhost' => false, + 'visitsDelay' => 15, + 'registries' => $this->registries(), + 'cleanup' => ['ttl' => 86400, 'probability' => 0], + ], + $request, + new Translation('en', [ + 'date.weekdays.short' => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + 'date.months.short' => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + ]), + ); + } + + /** + * @return array + */ + private function registries(): array + { + return [ + 'sessions' => 'sessions.json', + 'visits' => 'visits.json', + 'uniqueVisits' => 'uniqueVisits.json', + 'visitors' => 'visitors.json', + 'pageViews' => 'pageViews.json', + 'sources' => 'sources.json', + 'devices' => 'devices.json', + ]; + } + + /** + * @return list + */ + private function lastDays(int $limit): array + { + $days = []; + for ($i = $limit - 1; $i >= 0; $i--) { + $days[] = date('Ymd', time() - $i * 86400); + } + return $days; + } + + /** + * @param array $data + */ + private function writeRegistry(string $filename, array $data): void + { + $registry = new Registry(FileSystem::joinPaths($this->path, $filename)); + foreach ($data as $key => $value) { + $registry->set((string) $key, $value); + } + $registry->save(); + } + + /** + * @return array + */ + private function readRegistry(string $filename): array + { + return (new Registry(FileSystem::joinPaths($this->path, $filename)))->toArray(); + } +} From 210554fa8b843406cbd7aaf4f30c66e4461dae8c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:19:24 +0000 Subject: [PATCH 28/51] Add UsersController tests (work in progress) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd --- .../Panel/Controllers/UsersControllerTest.php | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 tests/Unit/Panel/Controllers/UsersControllerTest.php diff --git a/tests/Unit/Panel/Controllers/UsersControllerTest.php b/tests/Unit/Panel/Controllers/UsersControllerTest.php new file mode 100644 index 000000000..865d4687f --- /dev/null +++ b/tests/Unit/Panel/Controllers/UsersControllerTest.php @@ -0,0 +1,287 @@ +setUpTempDirectory(); + $this->accountsPath = FileSystem::joinPaths(TESTS_TMP_PATH, 'accounts'); + FileSystem::createDirectory($this->accountsPath); + + // `FieldFactory` is registered lazily by the schemes service loader + App::instance()->getService(Schemes::class); + } + + protected function tearDown(): void + { + $this->closePanelSession(); + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testAdministratorsCanCreateUsersWithAnyRole(): void + { + $response = $this->create('admin', $this->newUser(role: 'admin')); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('success', $this->lastMessageType()); + $this->assertSame('admin', $this->savedUser('newuser')['role']); + } + + public function testAdministratorsCanCreateUsersWithALimitedRole(): void + { + $this->create('admin', $this->newUser(role: 'editor')); + + $this->assertSame('editor', $this->savedUser('newuser')['role']); + } + + #[DataProvider('submittedRoleProvider')] + public function testNonAdministratorsCannotAssignARoleDifferentFromTheirOwn(?string $submittedRole): void + { + $response = $this->create('editor', $this->newUser(role: $submittedRole)); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('success', $this->lastMessageType()); + $this->assertSame('editor', $this->savedUser('newuser')['role']); + } + + /** + * @return iterable + */ + public static function submittedRoleProvider(): iterable + { + yield 'administrator' => ['admin']; + yield 'own role' => ['editor']; + yield 'missing role' => [null]; + } + + public function testNonAdministratorsCannotCreateAdministratorsBySubmittingTheRoleOfTheForm(): void + { + $this->create('editor', $this->newUser(role: 'admin')); + + $this->assertNotSame('admin', $this->savedUser('newuser')['role']); + } + + public function testCreatingUsersRequiresThePermission(): void + { + $response = $this->create('editor', $this->newUser(role: 'editor'), ['panel.users' => false]); + + $this->assertSame(ResponseStatus::Forbidden, $response->status()); + $this->assertFileDoesNotExist(FileSystem::joinPaths($this->accountsPath, 'newuser.yaml')); + } + + public function testNonAdministratorsCannotSubmitUnknownRoles(): void + { + $response = $this->create('editor', $this->newUser(role: 'superuser')); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('error', $this->lastMessageType()); + $this->assertFileDoesNotExist(FileSystem::joinPaths($this->accountsPath, 'newuser.yaml')); + } + + public function testAdministratorsCannotAssignUnknownRoles(): void + { + $response = $this->create('admin', $this->newUser(role: 'superuser')); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('error', $this->lastMessageType()); + $this->assertFileDoesNotExist(FileSystem::joinPaths($this->accountsPath, 'newuser.yaml')); + } + + public function testExistingUsernamesAreNotOverwritten(): void + { + FileSystem::write(FileSystem::joinPaths($this->accountsPath, 'existing.yaml'), "username: existing\nrole: admin\n"); + + $response = $this->create('admin', $this->newUser(role: 'editor', username: 'existing'), existing: ['existing']); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('error', $this->lastMessageType()); + $this->assertSame('admin', $this->savedUser('existing')['role']); + } + + #[DataProvider('invalidUserProvider')] + public function testInvalidUsersAreNotSaved(array $overrides): void + { + $response = $this->create('admin', [...$this->newUser(role: 'editor'), ...$overrides]); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('error', $this->lastMessageType()); + $this->assertSame([], iterator_to_array(FileSystem::listFiles($this->accountsPath), false)); + } + + /** + * @return iterable}> + */ + public static function invalidUserProvider(): iterable + { + yield 'short username' => [['username' => 'ab']]; + yield 'username with a path' => [['username' => '../escape']]; + yield 'username starting with a digit' => [['username' => '1user']]; + yield 'short password' => [['password' => 'short']]; + yield 'invalid email' => [['email' => 'not an email']]; + yield 'missing full name' => [['fullname' => '']]; + yield 'unknown language' => [['language' => 'klingon']]; + yield 'array as role' => [['role' => ['admin']]]; + } + + public function testUsernamesCannotEscapeTheAccountsDirectory(): void + { + $this->create('admin', $this->newUser(role: 'editor', username: '../escape')); + + $this->assertFileDoesNotExist(FileSystem::joinPaths(TESTS_TMP_PATH, 'escape.yaml')); + } + + /** + * @return array + */ + private function newUser(?string $role, string $username = 'newuser'): array + { + return array_filter([ + 'fullname' => 'New User', + 'username' => $username, + 'password' => 'a-long-password', + 'email' => 'new@example.test', + 'language' => 'it', + 'role' => $role, + ], static fn(mixed $value): bool => $value !== null); + } + + /** + * @param array $input + * @param array $permissions + * @param list $existing + */ + private function create(string $currentRole, array $input, array $permissions = ['panel.users' => true], array $existing = []): Response + { + $app = App::instance(); + + $translations = new Translations($app->config()); + $roles = new RoleCollection([ + 'admin' => new Role('admin', 'Administrator', new Permissions(['panel.users' => true]), $translations), + 'editor' => new Role('editor', 'Editor', new Permissions(['panel.users' => true]), $translations), + ]); + + $current = $this->createStub(User::class); + $current->method('permissions')->willReturn(new Permissions($permissions)); + $current->method('isLoggedIn')->willReturn(true); + $current->method('isAdmin')->willReturn($currentRole === 'admin'); + $current->method('role')->willReturn($roles->get($currentRole)); + + $users = $this->createStub(Users::class); + $users->method('loggedIn')->willReturn($current); + $users->method('roles')->willReturn($roles); + $users->method('has')->willReturnCallback(static fn(string $username): bool => in_array($username, $existing, true)); + + $site = $this->createStub(Site::class); + $site->method('users')->willReturn($users); + + $fields = $this->fields(); + $modal = $this->createStub(Modal::class); + $modal->method('fields')->willReturn($fields); + $modals = $this->createStub(Modals::class); + $modals->method('get')->willReturn($modal); + + $this->controller = $this->makeController( + UsersController::class, + $permissions, + ['users' => ['paths' => ['accounts' => $this->accountsPath]]], + // Modal fields are named after the modal, so their values are submitted as `newUser[field]` + ['newUser' => $input], + [ + Site::class => $site, + Modals::class => $modals, + FileUploader::class => $this->createStub(FileUploader::class), + FileFactory::class => $app->getService(FileFactory::class), + ], + $current, + $users, + ); + + return $this->controller->create(new UserFactory($this->containerOf($this->controller))); + } + + private function fields(): FieldCollection + { + $factory = App::instance()->getService(FieldFactory::class); + + return new FieldCollection([ + 'fullname' => $factory->make('newUser.fullname', ['type' => 'text', 'required' => true]), + 'username' => $factory->make('newUser.username', [ + 'type' => 'text', + 'required' => true, + 'pattern' => '[a-zA-Z][a-zA-Z0-9]*([\-._][a-zA-Z0-9]+)*', + 'minLength' => 3, + 'maxLength' => 20, + ]), + 'password' => $factory->make('newUser.password', ['type' => 'password', 'required' => true, 'minLength' => 8]), + 'email' => $factory->make('newUser.email', ['type' => 'email', 'required' => true]), + 'language' => $factory->make('newUser.language', ['type' => 'select', 'required' => true, 'options' => ['it' => 'Italian', 'de' => 'German']]), + 'role' => $factory->make('newUser.role', ['type' => 'select', 'default' => 'editor', 'options' => ['admin' => 'Administrator', 'editor' => 'Editor']]), + ]); + } + + private function containerOf(UsersController $controller): Container + { + $property = new ReflectionProperty(AbstractController::class, 'container'); + + return $property->getValue($controller); + } + + /** + * @return array + */ + private function savedUser(string $username): array + { + return Yaml::parseFile(FileSystem::joinPaths($this->accountsPath, $username . '.yaml')); + } + + private function lastMessageType(): ?string + { + $property = new ReflectionProperty(AbstractController::class, 'panel'); + $notifications = $property->getValue($this->controller)->notifications(); + + return $notifications === [] ? null : end($notifications)['type']; + } +} From b8ac9581ae79ff1a2e5238d6e41bf4565d75560d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:30:33 +0000 Subject: [PATCH 29/51] Add tests for View, Metadata, templates and panel field views; complete UsersController tests Covers GHSA-34p4, GHSA-xh84, GHSA-vf6x, GHSA-5pxr, GHSA-7j46 (server-side rendering). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd --- .../Unit/Metadata/MetadataCollectionTest.php | 78 ++++++ tests/Unit/Metadata/MetadataTest.php | 96 +++++++ tests/Unit/Panel/Views/FieldViewsTest.php | 163 +++++++++++ tests/Unit/Panel/Views/PanelHeaderTest.php | 140 ++++++++++ tests/Unit/Templates/Fixtures/page.php | 2 + tests/Unit/Templates/SiteTemplatesTest.php | 168 +++++++++++ tests/Unit/View/Fixtures/other/foreign.php | 1 + tests/Unit/View/Fixtures/views/blocks.php | 1 + .../View/Fixtures/views/double-layout.php | 1 + .../Unit/View/Fixtures/views/echo-method.php | 1 + tests/Unit/View/Fixtures/views/empty.php | 0 tests/Unit/View/Fixtures/views/incomplete.php | 1 + .../View/Fixtures/views/inserts-with-vars.php | 1 + tests/Unit/View/Fixtures/views/inserts.php | 1 + .../Unit/View/Fixtures/views/layouts/main.php | 1 + .../View/Fixtures/views/layouts/outer.php | 1 + .../Fixtures/views/layouts/with-blocks.php | 1 + .../View/Fixtures/views/nested-layout.php | 1 + .../View/Fixtures/views/partials/part.php | 1 + tests/Unit/View/Fixtures/views/plain.php | 1 + tests/Unit/View/Fixtures/views/reserved.php | 1 + tests/Unit/View/Fixtures/views/throws.php | 1 + .../View/Fixtures/views/unknown-block.php | 1 + .../View/Fixtures/views/unmatched-end.php | 1 + .../Unit/View/Fixtures/views/uses-layout.php | 1 + .../Fixtures/views/uses-missing-layout.php | 1 + tests/Unit/View/Fixtures/views/vars.php | 1 + tests/Unit/View/ViewFactoryTest.php | 98 +++++++ tests/Unit/View/ViewTest.php | 263 ++++++++++++++++++ 29 files changed, 1028 insertions(+) create mode 100644 tests/Unit/Metadata/MetadataCollectionTest.php create mode 100644 tests/Unit/Metadata/MetadataTest.php create mode 100644 tests/Unit/Panel/Views/FieldViewsTest.php create mode 100644 tests/Unit/Panel/Views/PanelHeaderTest.php create mode 100644 tests/Unit/Templates/Fixtures/page.php create mode 100644 tests/Unit/Templates/SiteTemplatesTest.php create mode 100644 tests/Unit/View/Fixtures/other/foreign.php create mode 100644 tests/Unit/View/Fixtures/views/blocks.php create mode 100644 tests/Unit/View/Fixtures/views/double-layout.php create mode 100644 tests/Unit/View/Fixtures/views/echo-method.php create mode 100644 tests/Unit/View/Fixtures/views/empty.php create mode 100644 tests/Unit/View/Fixtures/views/incomplete.php create mode 100644 tests/Unit/View/Fixtures/views/inserts-with-vars.php create mode 100644 tests/Unit/View/Fixtures/views/inserts.php create mode 100644 tests/Unit/View/Fixtures/views/layouts/main.php create mode 100644 tests/Unit/View/Fixtures/views/layouts/outer.php create mode 100644 tests/Unit/View/Fixtures/views/layouts/with-blocks.php create mode 100644 tests/Unit/View/Fixtures/views/nested-layout.php create mode 100644 tests/Unit/View/Fixtures/views/partials/part.php create mode 100644 tests/Unit/View/Fixtures/views/plain.php create mode 100644 tests/Unit/View/Fixtures/views/reserved.php create mode 100644 tests/Unit/View/Fixtures/views/throws.php create mode 100644 tests/Unit/View/Fixtures/views/unknown-block.php create mode 100644 tests/Unit/View/Fixtures/views/unmatched-end.php create mode 100644 tests/Unit/View/Fixtures/views/uses-layout.php create mode 100644 tests/Unit/View/Fixtures/views/uses-missing-layout.php create mode 100644 tests/Unit/View/Fixtures/views/vars.php create mode 100644 tests/Unit/View/ViewFactoryTest.php create mode 100644 tests/Unit/View/ViewTest.php diff --git a/tests/Unit/Metadata/MetadataCollectionTest.php b/tests/Unit/Metadata/MetadataCollectionTest.php new file mode 100644 index 000000000..52cc4d25e --- /dev/null +++ b/tests/Unit/Metadata/MetadataCollectionTest.php @@ -0,0 +1,78 @@ + 'A page', 'og:title' => 'Title']); + + $this->assertCount(2, $collection); + $this->assertInstanceOf(Metadata::class, $collection->get('description')); + $this->assertSame('A page', $collection->get('description')->content()); + $this->assertSame('og', $collection->get('og:title')->prefix()); + } + + public function testCollectionCanBeEmpty(): void + { + $this->assertCount(0, new MetadataCollection([])); + } + + public function testMetadataCanBeIterated(): void + { + $collection = new MetadataCollection(['a' => '1', 'b' => '2']); + + $names = []; + foreach ($collection as $metadata) { + $names[] = $metadata->name(); + } + + $this->assertSame(['a', 'b'], $names); + } + + public function testSettingAnExistingKeyReplacesIt(): void + { + $collection = new MetadataCollection(['description' => 'old']); + + $collection->set('description', 'new'); + + $this->assertCount(1, $collection); + $this->assertSame('new', $collection->get('description')->content()); + } + + public function testMultipleMetadataCanBeSetAtOnce(): void + { + $collection = new MetadataCollection(['a' => '1']); + + $collection->setMultiple(['a' => 'changed', 'b' => '2']); + + $this->assertSame('changed', $collection->get('a')->content()); + $this->assertSame('2', $collection->get('b')->content()); + } + + public function testCloneIsIndependentFromTheOriginal(): void + { + $collection = new MetadataCollection(['a' => '1']); + + $clone = $collection->clone(); + $clone->set('a', 'changed'); + $clone->set('b', '2'); + + $this->assertSame('1', $collection->get('a')->content()); + $this->assertFalse($collection->has('b')); + } + + public function testContentIsKeptVerbatim(): void + { + $collection = new MetadataCollection(['description' => '">']); + + $this->assertSame('">', $collection->get('description')->content()); + } +} diff --git a/tests/Unit/Metadata/MetadataTest.php b/tests/Unit/Metadata/MetadataTest.php new file mode 100644 index 000000000..1ad7de39f --- /dev/null +++ b/tests/Unit/Metadata/MetadataTest.php @@ -0,0 +1,96 @@ +assertSame('description', $metadata->name()); + $this->assertSame('A page', $metadata->content()); + } + + public function testMetadataIsConvertedToItsContent(): void + { + $this->assertSame('A page', (string) new Metadata('description', 'A page')); + } + + public function testNameIsLowercased(): void + { + $this->assertSame('og:title', (new Metadata('OG:Title', 'x'))->name()); + } + + public function testContentIsNotAltered(): void + { + $content = ' & "quoted"'; + + $this->assertSame($content, (new Metadata('description', $content))->content()); + } + + #[DataProvider('prefixProvider')] + public function testPrefixIsTheNamePartBeforeTheFirstColon(string $name, ?string $expected): void + { + $metadata = new Metadata($name, 'x'); + + $this->assertSame($expected, $metadata->prefix()); + $this->assertSame($expected !== null, $metadata->hasPrefix()); + } + + /** + * @return iterable + */ + public static function prefixProvider(): iterable + { + yield 'no prefix' => ['description', null]; + yield 'open graph' => ['og:image', 'og']; + yield 'twitter' => ['twitter:card', 'twitter']; + yield 'nested prefix' => ['og:image:width', 'og']; + yield 'empty prefix' => [':name', null]; + yield 'trailing colon' => ['og:', 'og']; + } + + public function testPrefixIsLowercasedLikeTheName(): void + { + // Templates compare the prefix with lowercase values, e.g. `og` + $metadata = new Metadata('OG:Title', 'x'); + + $this->assertSame('og:title', $metadata->name()); + $this->assertSame('og', $metadata->prefix()); + } + + public function testCharsetIsRecognized(): void + { + $this->assertTrue((new Metadata('charset', 'utf-8'))->isCharset()); + $this->assertTrue((new Metadata('CHARSET', 'utf-8'))->isCharset()); + $this->assertFalse((new Metadata('description', 'utf-8'))->isCharset()); + } + + #[DataProvider('httpEquivProvider')] + public function testHttpEquivDirectivesAreRecognized(string $name, bool $expected): void + { + $this->assertSame($expected, (new Metadata($name, 'x'))->isHTTPEquiv()); + } + + /** + * @return iterable + */ + public static function httpEquivProvider(): iterable + { + yield 'content-type' => ['content-type', true]; + yield 'default-style' => ['default-style', true]; + yield 'refresh' => ['refresh', true]; + yield 'uppercase refresh' => ['REFRESH', true]; + yield 'description' => ['description', false]; + yield 'charset' => ['charset', false]; + yield 'robots' => ['robots', false]; + yield 'set-cookie' => ['set-cookie', false]; + } +} diff --git a/tests/Unit/Panel/Views/FieldViewsTest.php b/tests/Unit/Panel/Views/FieldViewsTest.php new file mode 100644 index 000000000..be8cb2537 --- /dev/null +++ b/tests/Unit/Panel/Views/FieldViewsTest.php @@ -0,0 +1,163 @@ +\' onfocus=\'alert(3)'; + + private App $app; + + protected function setUp(): void + { + parent::setUp(); + $this->app = App::instance(); + + // `FieldFactory` is registered lazily by the schemes service loader + $this->app->getService(Schemes::class); + } + + #[DataProvider('fieldTypeProvider')] + public function testHostileValuesCannotInjectMarkup(string $type, array $config): void + { + $document = $this->document($this->render($type, [ + 'label' => self::HOSTILE, + 'suggestion' => self::HOSTILE, + 'placeholder' => self::HOSTILE, + ...$config, + ], self::HOSTILE)); + + $xpath = new DOMXPath($document); + + foreach ($xpath->query('//script') as $script) { + $this->assertSame('', trim($script->textContent), 'Inline script injected'); + } + + $this->assertSame(0, $xpath->query('//img')->length, 'Image injected'); + $this->assertSame(0, $xpath->query('//@*[starts-with(name(), "on")]')->length, 'Event handler injected'); + } + + /** + * @return iterable}> + */ + public static function fieldTypeProvider(): iterable + { + yield 'text' => ['text', []]; + yield 'textarea' => ['textarea', []]; + yield 'email' => ['email', []]; + yield 'password' => ['password', []]; + yield 'slug' => ['slug', []]; + yield 'tags' => ['tags', ['options' => [self::HOSTILE, 'plain']]]; + yield 'tags with option keys' => ['tags', ['options' => [self::HOSTILE => self::HOSTILE], 'translate' => []]]; + yield 'select' => ['select', ['options' => [self::HOSTILE => self::HOSTILE, 'plain' => 'Plain'], 'translate' => []]]; + } + + public function testTextValueIsRenderedAsAnAttributeValue(): void + { + $document = $this->document($this->render('text', [], self::HOSTILE)); + + $input = (new DOMXPath($document))->query('//input')->item(0); + + $this->assertSame(self::HOSTILE, $input?->getAttribute('value')); + } + + public function testTagsAreRenderedAsACommaSeparatedValue(): void + { + $document = $this->document($this->render('tags', [], ['first', self::HOSTILE])); + + $input = (new DOMXPath($document))->query('//input')->item(0); + + $this->assertSame('first, ' . self::HOSTILE, $input?->getAttribute('value')); + } + + public function testTagOptionsAreRenderedAsEscapedJson(): void + { + $document = $this->document($this->render('tags', ['options' => ['plain', self::HOSTILE]], [])); + + $input = (new DOMXPath($document))->query('//input')->item(0); + + $this->assertSame(['plain', self::HOSTILE], json_decode((string) $input?->getAttribute('data-options'), true)); + } + + public function testTextareaValueIsRenderedAsText(): void + { + $document = $this->document($this->render('textarea', [], self::HOSTILE)); + + $textarea = (new DOMXPath($document))->query('//textarea')->item(0); + + $this->assertSame(self::HOSTILE, $textarea?->textContent); + } + + public function testLabelIsRenderedAsText(): void + { + $document = $this->document($this->render('text', ['label' => self::HOSTILE], '')); + + $label = (new DOMXPath($document))->query('//label')->item(0); + + $this->assertSame(self::HOSTILE . ':', $label?->textContent); + } + + public function testSelectOptionLabelsAreRenderedAsText(): void + { + $document = $this->document($this->render('select', ['options' => ['a' => self::HOSTILE], 'translate' => []], 'a')); + + $option = (new DOMXPath($document))->query('//option')->item(0); + + $this->assertSame(self::HOSTILE, $option?->textContent); + $this->assertSame('a', $option?->getAttribute('value')); + } + + /** + * @param array $config + */ + private function render(string $type, array $config, mixed $value): string + { + $field = $this->app->getService(FieldFactory::class)->make('field', ['type' => $type, ...$config]); + $field->set('value', $value); + + $panel = (new ReflectionClass(Panel::class))->newInstanceWithoutConstructor(); + + $methods = [ + ...(require TestInstallation::systemPath() . '/config/views/methods.php')($this->app), + ...(require TestInstallation::rootPath() . '/panel/config/views/methods.php')($this->app, $panel), + // Icons are loaded from the panel assets, which are not needed here + 'icon' => static fn(string $icon): string => '', + ]; + + $view = new View( + "@panel.fields.{$type}", + ['field' => $field, 'app' => $this->app], + ['' => TestInstallation::rootPath() . '/site/templates', 'panel' => TestInstallation::rootPath() . '/panel/views'], + $methods, + ); + + return $view->render(); + } + + private function document(string $html): DOMDocument + { + $document = new DOMDocument(); + $document->loadHTML('' . $html . '', LIBXML_NOERROR); + + return $document; + } +} diff --git a/tests/Unit/Panel/Views/PanelHeaderTest.php b/tests/Unit/Panel/Views/PanelHeaderTest.php new file mode 100644 index 000000000..9b159393f --- /dev/null +++ b/tests/Unit/Panel/Views/PanelHeaderTest.php @@ -0,0 +1,140 @@ +setUpTempDirectory(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + #[DataProvider('permissionProvider')] + public function testSiteTitleIsEscapedInThePanelHeader(bool $canEditSite): void + { + $title = ''; + + $document = $this->document($this->renderHeader($title, $canEditSite)); + $xpath = new DOMXPath($document); + + $this->assertSame(0, $xpath->query('//script')->length, 'Script injected'); + $this->assertSame(0, $xpath->query('//img')->length, 'Image injected'); + $this->assertSame(0, $xpath->query('//@*[starts-with(name(), "on")]')->length, 'Event handler injected'); + $this->assertStringContainsString($title, (string) $document->textContent); + } + + /** + * @return iterable + */ + public static function permissionProvider(): iterable + { + yield 'with the permission to edit the site options' => [true]; + yield 'without the permission to edit the site options' => [false]; + } + + public function testSiteTitleIsRenderedAsText(): void + { + $document = $this->document($this->renderHeader('Tom & "Jerry"', true)); + + $this->assertStringContainsString('Tom & "Jerry"', (string) $document->textContent); + } + + public function testOptionsLinkIsOnlyRenderedWithThePermission(): void + { + $with = $this->document($this->renderHeader('Site', true)); + $without = $this->document($this->renderHeader('Site', false)); + + $this->assertSame(1, (new DOMXPath($with))->query('//a[@href="/panel/options/site/"]')->length); + $this->assertSame(0, (new DOMXPath($without))->query('//a[@href="/panel/options/site/"]')->length); + } + + private function renderHeader(string $siteTitle, bool $canEditSite): string + { + $layout = FileSystem::read(TestInstallation::rootPath() . '/panel/views/layouts/panel.php'); + + $this->assertSame(1, preg_match('#.*?#s', $layout, $matches), 'Header not found in the panel layout'); + + $views = FileSystem::joinPaths(TESTS_TMP_PATH, 'views'); + if (!FileSystem::isDirectory($views, assertExists: false)) { + FileSystem::createDirectory($views); + } + FileSystem::write(FileSystem::joinPaths($views, 'header.php'), $matches[0]); + + $user = new class ($canEditSite) { + public function __construct(private bool $canEditSite) {} + + public function permissions(): Permissions + { + return new Permissions(['panel.options.site' => $this->canEditSite]); + } + }; + + $panel = new class ($user) { + public function __construct(private object $user) {} + + public function user(): object + { + return $this->user; + } + + public function uri(string $path): string + { + return '/panel' . $path; + } + }; + + $site = new class ($siteTitle) { + public function __construct(private string $title) {} + + public function title(): string + { + return $this->title; + } + + public function uri(): string + { + return '/'; + } + }; + + $app = App::instance(); + + $view = new View('header', ['panel' => $panel, 'site' => $site, 'app' => $app], $views, [ + ...(require TestInstallation::systemPath() . '/config/views/methods.php')($app), + 'translate' => static fn(string $key): string => $key, + 'icon' => static fn(string $icon): string => '', + ]); + + return $view->render(); + } + + private function document(string $html): DOMDocument + { + $document = new DOMDocument(); + $document->loadHTML('' . $html . '', LIBXML_NOERROR); + + return $document; + } +} diff --git a/tests/Unit/Templates/Fixtures/page.php b/tests/Unit/Templates/Fixtures/page.php new file mode 100644 index 000000000..0e7651e58 --- /dev/null +++ b/tests/Unit/Templates/Fixtures/page.php @@ -0,0 +1,2 @@ +layout('site') ?> +Page body diff --git a/tests/Unit/Templates/SiteTemplatesTest.php b/tests/Unit/Templates/SiteTemplatesTest.php new file mode 100644 index 000000000..4f40a510e --- /dev/null +++ b/tests/Unit/Templates/SiteTemplatesTest.php @@ -0,0 +1,168 @@ +setUpTempDirectory(); + $this->app = App::instance(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + #[DataProvider('hostileTitleProvider')] + public function testPageTitleIsEscapedInTheDocumentTitle(string $title): void + { + $document = $this->document($this->render(pageTitle: $title)); + + $this->assertSame($title . ' | Site', $document->getElementsByTagName('title')->item(0)?->textContent); + $this->assertNoInjectedMarkup($document); + } + + #[DataProvider('hostileTitleProvider')] + public function testSiteTitleIsEscapedInTheDocumentTitleAndMenu(string $title): void + { + $document = $this->document($this->render(siteTitle: $title)); + + $this->assertSame('Page | ' . $title, $document->getElementsByTagName('title')->item(0)?->textContent); + $this->assertSame($title, trim((string) (new DOMXPath($document))->query('//a[contains(@class, "menu-header")]')->item(0)?->textContent)); + $this->assertNoInjectedMarkup($document); + } + + /** + * @return iterable + */ + public static function hostileTitleProvider(): iterable + { + yield 'script element' => ['']; + yield 'title closing' => ['']; + yield 'image with a handler' => ['']; + yield 'comment' => ['
This is a bold statement.
Link text
Before
nested
kept
ok
\xff
Hello world and everyone
1 < 2 && 3 > 2
a
', '']; + } + + #[DataProvider('eventHandlerProvider')] + public function testEventHandlersAreRemoved(string $attribute): void + { + $html = (new HtmlSanitizer())->sanitize('
text
x