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/.github/workflows/check-php.yaml b/.github/workflows/check-php.yaml index 82dd1d0b6..db02e5fc8 100644 --- a/.github/workflows/check-php.yaml +++ b/.github/workflows/check-php.yaml @@ -56,3 +56,6 @@ jobs: - name: Run Rector run: composer rector:check + + - name: Run PHPUnit tests + run: composer test 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..a90c85d5e 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": "*", @@ -45,11 +50,12 @@ "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.5" }, "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 --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 +63,8 @@ "serve": [ "Composer\\Config::disableProcessTimeout", "php bin/serve" - ] + ], + "test": "phpunit", + "test:coverage": "@php -d pcov.enabled=1 vendor/bin/phpunit --coverage-html tests/coverage" } } diff --git a/composer.lock b/composer.lock index 0f8a9b2e0..4e2424ae7 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", @@ -1883,320 +1883,264 @@ "time": "2026-09-22T09:54:55+00:00" }, { - "name": "phpstan/phpstan", - "version": "2.2.16", + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", - "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.4|^8.0" + "php": "^8.0" }, "conflict": { - "phpstan/phpstan-shim": "*" + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, - "bin": [ - "phpstan", - "phpstan.phar" - ], "type": "library", "autoload": { "files": [ - "bootstrap.php" - ] + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Ondřej Mirtes" - }, - { - "name": "Markus Staab" - }, - { - "name": "Vincent Langlet" - } - ], - "description": "PHPStan - PHP Static Analysis Tool", + "description": "Create deep copies (clones) of your objects", "keywords": [ - "dev", - "static analysis" + "clone", + "copy", + "duplicate", + "object", + "object graph" ], "support": { - "docs": "https://phpstan.org/user-guide/getting-started", - "forum": "https://github.com/phpstan/phpstan/discussions", - "issues": "https://github.com/phpstan/phpstan/issues", - "security": "https://github.com/phpstan/phpstan/security/policy", - "source": "https://github.com/phpstan/phpstan-src" + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://github.com/ondrejmirtes", - "type": "github" - }, - { - "url": "https://github.com/phpstan", + "url": "https://github.com/mnapoli", "type": "github" } ], - "time": "2026-09-25T09:31:51+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { - "name": "react/cache", - "version": "v1.2.0", + "name": "nikic/php-parser", + "version": "v5.9.0", "source": { "type": "git", - "url": "https://github.com/reactphp/cache.git", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { - "php": ">=5.3.0", - "react/promise": "^3.0 || ^2.0 || ^1.1" + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" }, "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" }, + "bin": [ + "bin/php-parse" + ], "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, "autoload": { "psr-4": { - "React\\Cache\\": "src/" + "PhpParser\\": "lib/PhpParser" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Nikita Popov" } ], - "description": "Async, Promise-based cache interface for ReactPHP", + "description": "A PHP parser written in PHP", "keywords": [ - "cache", - "caching", - "promise", - "reactphp" + "parser", + "php" ], "support": { - "issues": "https://github.com/reactphp/cache/issues", - "source": "https://github.com/reactphp/cache/tree/v1.2.0" + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2022-11-30T15:59:55+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { - "name": "react/child-process", - "version": "v0.6.7", + "name": "phar-io/manifest", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/reactphp/child-process.git", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/event-loop": "^1.2", - "react/stream": "^1.4" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/socket": "^1.16", - "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\ChildProcess\\": "src/" + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Event-driven library for executing child processes with ReactPHP.", - "keywords": [ - "event-driven", - "process", - "reactphp" - ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { - "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/theseer", + "type": "github" } ], - "time": "2025-12-23T15:25:20+00:00" + "time": "2024-03-03T12:33:53+00:00" }, { - "name": "react/dns", - "version": "v1.14.0", + "name": "phar-io/version", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/reactphp/dns.git", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { - "php": ">=5.3.0", - "react/cache": "^1.0 || ^0.6 || ^0.5", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.7 || ^1.2.1" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3 || ^2", - "react/promise-timer": "^1.11" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { - "psr-4": { - "React\\Dns\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Async DNS resolver for ReactPHP", - "keywords": [ - "async", - "dns", - "dns-resolver", - "reactphp" - ], + "description": "Library for handling version information and constraints", "support": { - "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.14.0" + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2025-11-18T19:34:28+00:00" + "time": "2022-02-21T01:04:05+00:00" }, { - "name": "react/event-loop", - "version": "v1.6.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/event-loop.git", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" - }, + "name": "phpstan/phpstan", + "version": "2.2.16", "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", "shasum": "" }, "require": { - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + "php": "^7.4|^8.0" }, - "suggest": { - "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + "conflict": { + "phpstan/phpstan-shim": "*" }, + "bin": [ + "phpstan", + "phpstan.phar" + ], "type": "library", "autoload": { - "psr-4": { - "React\\EventLoop\\": "src/" - } + "files": [ + "bootstrap.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2204,356 +2148,1957 @@ ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" + "name": "Ondřej Mirtes" }, { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Markus Staab" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "url": "https://github.com/phpstan", + "type": "github" } ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "time": "2026-09-25T09:31:51+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7440368d94738d54f2c1de24dbd3871793bbb0dd", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.9.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0.1", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.37" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-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 provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "asynchronous", - "event-loop" + "coverage", + "testing", + "xunit" ], "support": { - "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.8" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "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/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2025-11-17T20:46:25+00:00" + "time": "2026-10-03T14:03:50+00:00" }, { - "name": "react/promise", - "version": "v3.3.0", + "name": "phpunit/php-file-iterator", + "version": "6.0.2", "source": { "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", "shasum": "" }, "require": { - "php": ">=7.1.0" + "php": ">=8.3" }, "require-dev": { - "phpstan/phpstan": "1.12.28 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" + "phpunit/phpunit": "^12.5.33" }, "type": "library", - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "React\\Promise\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" }, { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "time": "2026-08-25T14:40:53+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "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": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", "keywords": [ - "promise", - "promises" + "process" ], "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.3.0" + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "time": "2025-08-19T18:57:03+00:00" + "time": "2025-02-07T04:58:58+00:00" }, { - "name": "react/socket", - "version": "v1.17.0", + "name": "phpunit/php-text-template", + "version": "5.0.1", "source": { "type": "git", - "url": "https://github.com/reactphp/socket.git", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/babab73b7ef9fae406f2eb3ee11323a61caa63d5", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/dns": "^1.13", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.6 || ^1.2.1", - "react/stream": "^1.4" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3.3 || ^2", - "react/promise-stream": "^1.4", - "react/promise-timer": "^1.11" + "phpunit/phpunit": "^12.5.37" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Socket\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" }, { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-10-03T04:51:29+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "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", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.38", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "1e87ad87fe57665a7f6e99a6e467329c11e851cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1e87ad87fe57665a7f6e99a6e467329c11e851cc", + "reference": "1e87ad87fe57665a7f6e99a6e467329c11e851cc", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.8", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.1", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.1", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.38" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-05T05:49:30+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "rector/rector", + "version": "2.6.7", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/f4aa224edb9b0630fb43826d4cb6a49b62e48d4c", + "reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.2.14" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.6.7" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2026-09-13T20:17:44+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-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 parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.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/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-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": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "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/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "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", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "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": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/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/diff", + "type": "tidelift" + } + ], + "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" ], - "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", - "keywords": [ - "Connection", - "Socket", - "async", - "reactphp", - "stream" + "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/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.17.0" + "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://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "time": "2025-11-19T20:47:34+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { - "name": "react/stream", - "version": "v1.4.0", + "name": "sebastian/recursion-context", + "version": "7.0.1", "source": { "type": "git", - "url": "https://github.com/reactphp/stream.git", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.8", - "react/event-loop": "^1.2" + "php": ">=8.3" }, "require-dev": { - "clue/stream-filter": "~1.2", - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + "phpunit/phpunit": "^12.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Stream\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", - "keywords": [ - "event-driven", - "io", - "non-blocking", - "pipe", - "reactphp", - "readable", - "stream", - "writable" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/reactphp/stream/issues", - "source": "https://github.com/reactphp/stream/tree/v1.4.0" + "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://opencollective.com/reactphp", - "type": "open_collective" + "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": "2024-06-11T12:45:25+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { - "name": "rector/rector", - "version": "2.6.7", + "name": "sebastian/type", + "version": "6.0.4", "source": { "type": "git", - "url": "https://github.com/rectorphp/rector.git", - "reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/f4aa224edb9b0630fb43826d4cb6a49b62e48d4c", - "reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { - "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.2.14" + "php": ">=8.3" }, - "conflict": { - "rector/rector-doctrine": "*", - "rector/rector-downgrade-php": "*", - "rector/rector-phpunit": "*", - "rector/rector-symfony": "*" + "require-dev": { + "phpunit/phpunit": "^12.5.25" }, - "bin": [ - "bin/rector" - ], "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, "autoload": { - "files": [ - "bootstrap.php" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "Instant Upgrade and Automated Refactoring of any PHP code", - "homepage": "https://getrector.com/", - "keywords": [ - "automation", - "dev", - "migration", - "refactoring" + "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/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.6.7" + "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/tomasvotruba", + "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-09-13T20:17:44+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { - "name": "sebastian/diff", - "version": "7.0.1", + "name": "sebastian/version", + "version": "6.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "9e935a16187e14f39b0e79910dabd614972cdb26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", - "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/9e935a16187e14f39b0e79910dabd614972cdb26", + "reference": "9e935a16187e14f39b0e79910dabd614972cdb26", "shasum": "" }, "require": { "php": ">=8.3" }, - "require-dev": { - "phpunit/phpunit": "^12.5.33", - "symfony/process": "^7.4.17" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -2568,25 +4113,16 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" - ], + "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/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.1" }, "funding": [ { @@ -2602,11 +4138,63 @@ "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", "type": "tidelift" } ], - "time": "2026-08-25T15:35:54+00:00" + "time": "2026-10-04T05:50:08+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", @@ -3735,6 +5323,56 @@ } ], "time": "2026-09-11T14:46:58+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], diff --git a/formwork/.php-cs-fixer.php b/formwork/.php-cs-fixer.php index 8fc98ff69..bd7b513c8 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/Unit/View/Fixtures/views']); $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/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/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/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..c384d3988 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 */ @@ -196,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(); @@ -244,7 +291,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/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/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/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/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); } diff --git a/formwork/src/Updater/Updater.php b/formwork/src/Updater/Updater.php index 1f916e7c6..78c194225 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,8 @@ 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')) { throw new RuntimeException(sprintf('Class %s requires the extension "zip" to be enabled', self::class)); @@ -81,8 +78,6 @@ public function __construct( if ($this->registry->toArray() === []) { $this->initializeRegistry(); } - - $this->client = new Client(['headers' => ['Accept' => 'application/vnd.github.v3+json']]); } /** @@ -181,7 +176,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 +218,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 */ @@ -249,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)) { diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 000000000..270b1bf7f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,34 @@ + + + + + tests/Unit + + + + + + ./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/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/TestCase.php b/tests/TestCase.php new file mode 100644 index 000000000..ffd13b46c --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,58 @@ +getAttributes(UsesApplication::class) !== []) { + TestApplication::boot(); + } + } + + protected function setUpTempDirectory(): void + { + if (!FileSystem::isDirectory(TESTS_TMP_PATH, assertExists: false)) { + FileSystem::createDirectory(TESTS_TMP_PATH); + } + } + + /** + * 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)) { + FileSystem::deleteDirectory(TESTS_TMP_PATH, recursive: true); + } + } +} 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/Assets/AssetCollectionTest.php b/tests/Unit/Assets/AssetCollectionTest.php new file mode 100644 index 000000000..26d5290eb --- /dev/null +++ b/tests/Unit/Assets/AssetCollectionTest.php @@ -0,0 +1,81 @@ +setUpTempDirectory(); + FileSystem::createDirectory(TESTS_TMP_PATH . '/assets', recursive: true); + + $this->collection = new AssetCollection(); + $this->collection->set('style', $this->asset('style.css', 'body {}')); + $this->collection->set('app', $this->asset('app.js', 'console.log(1);')); + $this->collection->set('logo', $this->asset('logo.svg', '')); + $this->collection->set('photo', $this->asset('photo.png', base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', true))); + $this->collection->set('notes', $this->asset('notes.txt', 'plain')); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testStylesheets(): void + { + $this->assertSame(['style'], $this->collection->stylesheets()->keys()); + } + + public function testScripts(): void + { + $this->assertSame(['app'], $this->collection->scripts()->keys()); + } + + public function testImages(): void + { + $this->assertSame(['logo', 'photo'], $this->collection->images()->keys()); + } + + public function testFiltersReturnNewCollectionsAndLeaveTheOriginalUntouched(): void + { + $stylesheets = $this->collection->stylesheets(); + + $this->assertNotSame($this->collection, $stylesheets); + $this->assertCount(5, $this->collection); + } + + public function testEmptyCollectionYieldsEmptyFilters(): void + { + $empty = new AssetCollection(); + + $this->assertCount(0, $empty->stylesheets()); + $this->assertCount(0, $empty->scripts()); + $this->assertCount(0, $empty->images()); + } + + public function testOnlyAssetsCanBeAdded(): void + { + $this->expectException(LogicException::class); + + $this->collection->set('invalid', 'not an asset'); + } + + private function asset(string $name, string $content): Asset + { + FileSystem::write(TESTS_TMP_PATH . '/assets/' . $name, $content); + return new Asset(TESTS_TMP_PATH . '/assets/' . $name, '/assets/' . $name); + } +} diff --git a/tests/Unit/Assets/AssetTest.php b/tests/Unit/Assets/AssetTest.php new file mode 100644 index 000000000..1444848ff --- /dev/null +++ b/tests/Unit/Assets/AssetTest.php @@ -0,0 +1,117 @@ +setUpTempDirectory(); + $this->path = TESTS_TMP_PATH . '/assets/style.css'; + FileSystem::createDirectory(dirname($this->path), recursive: true); + FileSystem::write($this->path, "body { color: red; }\n"); + touch($this->path, 1_700_000_000); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testMissingFilesAreRejected(): void + { + $this->expectException(AssetNotFoundException::class); + + new Asset(TESTS_TMP_PATH . '/assets/missing.css', '/assets/missing.css'); + } + + public function testDirectoriesAreNotAssets(): void + { + $this->expectException(AssetNotFoundException::class); + + new Asset(TESTS_TMP_PATH . '/assets', '/assets'); + } + + public function testPathIsNormalized(): void + { + $asset = new Asset(TESTS_TMP_PATH . '/assets/../assets/./style.css', '/assets/style.css'); + + $this->assertSame(FileSystem::normalizePath($this->path), $asset->path()); + } + + public function testUriIsNormalized(): void + { + $this->assertSame('/assets/style.css', (new Asset($this->path, '/assets//style.css'))->uri()); + } + + public function testVersionIsTheHexModificationTime(): void + { + $asset = new Asset($this->path, '/assets/style.css'); + + $this->assertSame(dechex(1_700_000_000), $asset->version()); + $this->assertSame('/assets/style.css?v=' . dechex(1_700_000_000), $asset->uri(includeVersion: true)); + } + + public function testVersionIsFixedOnceComputed(): void + { + $asset = new Asset($this->path, '/assets/style.css'); + $first = $asset->version(); + + touch($this->path, 1_800_000_000); + + $this->assertSame($first, $asset->version()); + } + + public function testIntegrityHashIsASha256SubresourceIntegrityValue(): void + { + $asset = new Asset($this->path, '/assets/style.css'); + + $expected = 'sha256-' . base64_encode(hash('sha256', "body { color: red; }\n", true)); + + $this->assertSame($expected, $asset->integrityHash()); + $this->assertMatchesRegularExpression('/^sha256-[A-Za-z0-9+\/]{43}=$/', $asset->integrityHash()); + } + + public function testMimeType(): void + { + $this->assertSame('text/css', (new Asset($this->path, '/assets/style.css'))->mimeType()); + } + + public function testContentIsReadFromDisk(): void + { + $asset = new Asset($this->path, '/assets/style.css'); + + $this->assertSame("body { color: red; }\n", $asset->content()); + + FileSystem::write($this->path, 'changed'); + $this->assertSame('changed', $asset->content(), 'Content is not cached'); + } + + public function testBase64DataUri(): void + { + $asset = new Asset($this->path, '/assets/style.css'); + + $this->assertSame('data:text/css;base64,' . base64_encode("body { color: red; }\n"), $asset->toBase64()); + } + + public function testMetadata(): void + { + $asset = new Asset($this->path, '/assets/style.css', ['async' => true, 'media' => null]); + + $this->assertTrue($asset->getMeta('async')); + $this->assertNull($asset->getMeta('missing')); + $this->assertSame('default', $asset->getMeta('missing', 'default')); + $this->assertSame('default', $asset->getMeta('media', 'default'), 'A null value counts as missing'); + } +} diff --git a/tests/Unit/Assets/AssetsTest.php b/tests/Unit/Assets/AssetsTest.php new file mode 100644 index 000000000..ff95432c6 --- /dev/null +++ b/tests/Unit/Assets/AssetsTest.php @@ -0,0 +1,192 @@ +setUpTempDirectory(); + + $this->base = TESTS_TMP_PATH . '/assets-sandbox'; + FileSystem::createDirectory($this->base . '/template/css', recursive: true); + FileSystem::createDirectory($this->base . '/panel/js', recursive: true); + FileSystem::write($this->base . '/template/css/site.css', 'body {}'); + FileSystem::write($this->base . '/template/logo.svg', ''); + FileSystem::write($this->base . '/panel/js/app.js', 'console.log(1);'); + FileSystem::write($this->base . '/secret.txt', 'outside of every namespace'); + FileSystem::write($this->base . '/panel-extra.txt', 'sibling of the panel namespace'); + + $this->assets = new Assets(); + $this->assets->setResolutionPaths([ + 'template' => ['path' => $this->base . '/template', 'uri' => '/site/assets'], + 'panel' => ['path' => $this->base . '/panel', 'uri' => '/panel/assets/'], + ]); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testKeysWithoutNamespaceUseTheTemplateNamespace(): void + { + $asset = $this->assets->get('css/site.css'); + + $this->assertSame(FileSystem::normalizePath($this->base . '/template/css/site.css'), $asset->path()); + $this->assertSame('/site/assets/css/site.css', $asset->uri()); + } + + public function testNamespacedKeysUseTheirOwnResolutionPath(): void + { + $asset = $this->assets->get('@panel/js/app.js'); + + $this->assertSame(FileSystem::normalizePath($this->base . '/panel/js/app.js'), $asset->path()); + $this->assertSame('/panel/assets/js/app.js', $asset->uri()); + } + + public function testUrisWithAndWithoutTrailingSlashAreEquivalent(): void + { + $this->assertSame('/site/assets/logo.svg', $this->assets->get('logo.svg')->uri()); + $this->assertSame('/panel/assets/js/app.js', $this->assets->get('@panel/js/app.js')->uri()); + } + + public function testAssetsAreCreatedOnceAndShared(): void + { + $this->assertSame($this->assets->get('css/site.css'), $this->assets->get('css/site.css')); + $this->assertTrue($this->assets->has('css/site.css')); + $this->assertFalse($this->assets->has('logo.svg')); + } + + public function testAddRegistersAnAssetWithMetadata(): void + { + $this->assets->add('css/site.css', ['media' => 'print']); + + $this->assertTrue($this->assets->has('css/site.css')); + $this->assertSame('print', $this->assets->get('css/site.css')->getMeta('media')); + } + + public function testAddingTheSameKeyAgainKeepsTheFirstAsset(): void + { + $this->assets->add('css/site.css', ['media' => 'print']); + $this->assets->add('css/site.css', ['media' => 'screen']); + + $this->assertSame('print', $this->assets->get('css/site.css')->getMeta('media')); + } + + public function testGetDoesNotOverrideMetadataOfAlreadyAddedAssets(): void + { + $this->assets->add('css/site.css', ['media' => 'print']); + + $this->assertSame('print', $this->assets->get('css/site.css')->getMeta('media')); + } + + public function testTypedCollectionsOnlyContainRequestedAssets(): void + { + $this->assets->add('css/site.css'); + $this->assets->add('@panel/js/app.js'); + $this->assets->add('logo.svg'); + + $this->assertSame(['css/site.css'], $this->assets->stylesheets()->keys()); + $this->assertSame(['@panel/js/app.js'], $this->assets->scripts()->keys()); + $this->assertSame(['logo.svg'], $this->assets->images()->keys()); + } + + public function testUnknownNamespacesAreRejected(): void + { + $this->expectException(AssetResolutionException::class); + $this->expectExceptionMessage('namespace "unknown" not defined'); + + $this->assets->get('@unknown/file.css'); + } + + public function testNamespaceWithoutPathIsRejected(): void + { + $this->expectException(AssetResolutionException::class); + $this->expectExceptionMessage('invalid namespaced syntax'); + + $this->assets->get('@panel'); + } + + public function testKeysWithoutTemplateNamespaceFailWhenNoneIsDefined(): void + { + $this->expectException(AssetResolutionException::class); + + (new Assets())->get('file.css'); + } + + public function testResolutionPathsCanBeExtendedWithoutLosingPreviousOnes(): void + { + FileSystem::createDirectory($this->base . '/plugin', recursive: true); + FileSystem::write($this->base . '/plugin/p.css', 'p {}'); + + $this->assets->setResolutionPaths(['plugin:demo' => ['path' => $this->base . '/plugin', 'uri' => '/plugins/demo/assets']]); + + $this->assertSame('/plugins/demo/assets/p.css', $this->assets->get('@plugin:demo/p.css')->uri()); + $this->assertSame('/site/assets/logo.svg', $this->assets->get('logo.svg')->uri()); + } + + public function testMissingFilesInAKnownNamespaceFail(): void + { + $this->expectException(FileNotFoundException::class); + + $this->assets->get('css/missing.css'); + } + + /** + * @return iterable + */ + public static function traversalKeys(): iterable + { + yield 'parent of the template namespace' => ['../secret.txt']; + yield 'nested then parent' => ['css/../../secret.txt']; + yield 'namespaced parent' => ['@panel/../secret.txt']; + yield 'sibling sharing the prefix' => ['@panel/../panel-extra.txt']; + yield 'deep traversal' => ['../../../../../../../../etc/hostname']; + yield 'backslashes' => ['..\secret.txt']; + yield 'dot segments mixed with separators' => ['css/./../../secret.txt']; + yield 'url encoded dots' => ['%2e%2e/secret.txt']; + yield 'absolute path' => ['/etc/hostname']; + yield 'null byte' => ["css/site.css\0../../secret.txt"]; + } + + #[DataProvider('traversalKeys')] + public function testAssetsOutsideTheNamespaceRootCannotBeRequested(string $key): void + { + try { + $asset = $this->assets->get($key); + } catch (Throwable) { + $this->addToAssertionCount(1); + return; + } + + $this->assertInstanceOf(Asset::class, $asset); + $this->assertTrue( + $this->isInside($asset->path(), $this->base . '/template') || $this->isInside($asset->path(), $this->base . '/panel'), + sprintf('Key "%s" resolved to "%s", outside the namespace roots', addcslashes($key, "\0"), $asset->path()) + ); + } + + private function isInside(string $path, string $root): bool + { + $root = FileSystem::normalizePath($root); + return str_starts_with($path, rtrim($root, '/\\') . DIRECTORY_SEPARATOR); + } +} diff --git a/tests/Unit/Authentication/AuthenticatorTest.php b/tests/Unit/Authentication/AuthenticatorTest.php new file mode 100644 index 000000000..8a765cde7 --- /dev/null +++ b/tests/Unit/Authentication/AuthenticatorTest.php @@ -0,0 +1,284 @@ + + */ + private array $registries = []; + + protected function setUp(): void + { + parent::setUp(); + $this->setUpUsers(); + $this->session = new InMemorySession(); + $this->users = $this->users(); + $this->alice = $this->makeUser([ + 'username' => 'alice', + 'email' => 'alice@example.test', + 'role' => 'admin', + 'hash' => Password::hash('correct-password'), + ], $this->users); + $this->makeUser([ + 'username' => 'bob', + 'email' => 'bob@example.test', + 'role' => 'editor', + 'hash' => Password::hash('bobs-password'), + ], $this->users); + } + + protected function tearDown(): void + { + $this->registries = []; + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testUsersCanLogInWithTheirUsername(): void + { + $user = $this->authenticator()->login('alice', 'correct-password'); + + $this->assertSame($this->alice, $user); + } + + public function testUsersCanLogInWithTheirEmail(): void + { + $user = $this->authenticator()->login('alice@example.test', 'correct-password'); + + $this->assertSame($this->alice, $user); + } + + public function testLoginStoresTheUsernameInTheSession(): void + { + $this->authenticator()->login('alice@example.test', 'correct-password'); + + $this->assertSame('alice', $this->session->get(Authenticator::SESSION_LOGGED_USER_KEY)); + } + + public function testLoginRegeneratesTheSessionToPreventFixation(): void + { + $this->authenticator()->login('alice', 'correct-password'); + + $this->assertSame(1, $this->session->regenerations); + } + + public function testLoginRecordsTheLastAccessTime(): void + { + $before = time(); + + $this->authenticator()->login('alice', 'correct-password'); + + $this->assertGreaterThanOrEqual($before, $this->alice->data()['lastAccess']); + $this->assertFileExists($this->accountsPath . '/alice.yaml'); + } + + public function testLoginResetsTheFailedAttempts(): void + { + $authenticator = $this->authenticator(); + + try { + $authenticator->login('alice', 'wrong'); + } catch (AuthenticationFailedException) { + } + + $this->assertSame(1, $this->attempts()); + + $authenticator->login('alice', 'correct-password'); + + $this->assertSame(0, $this->attempts()); + } + + public function testWrongPasswordsAreRejected(): void + { + $this->expectException(AuthenticationFailedException::class); + $this->expectExceptionMessage('Authentication failed for "alice"'); + $this->authenticator()->login('alice', 'wrong-password'); + } + + public function testFailedLoginsDoNotStartASession(): void + { + try { + $this->authenticator()->login('alice', 'wrong-password'); + } catch (AuthenticationFailedException) { + } + + $this->assertFalse($this->session->has(Authenticator::SESSION_LOGGED_USER_KEY)); + $this->assertSame(0, $this->session->regenerations); + } + + public function testUnknownUsersAreRejectedLikeWrongPasswords(): void + { + $this->expectException(AuthenticationFailedException::class); + $this->expectExceptionMessage('Authentication failed for "nobody"'); + $this->authenticator()->login('nobody', 'whatever'); + } + + public function testPasswordsOfOtherUsersAreRejected(): void + { + $this->expectException(AuthenticationFailedException::class); + $this->authenticator()->login('alice', 'bobs-password'); + } + + public function testFailedLoginsAreSlowedDown(): void + { + $start = microtime(true); + + try { + $this->authenticator()->login('alice', 'wrong-password'); + } catch (AuthenticationFailedException) { + } + + $this->assertGreaterThanOrEqual(0.45, microtime(true) - $start); + } + + public function testUsersWithoutAPasswordCannotLogIn(): void + { + $this->makeUser(['username' => 'carol', 'email' => 'carol@example.test', 'role' => 'editor'], $this->users); + + $this->expectException(AuthenticationFailedException::class); + $this->authenticator()->login('carol', 'anything'); + } + + public function testLoginAttemptsBeyondTheLimitAreRejectedEvenWithTheRightPassword(): void + { + $authenticator = $this->authenticator(limit: 1); + + try { + $authenticator->login('alice', 'wrong'); + } catch (AuthenticationFailedException) { + } + + $this->expectException(RateLimitExceededException::class); + $authenticator->login('alice', 'correct-password'); + } + + public function testNobodyIsLoggedInInitially(): void + { + $authenticator = $this->authenticator(); + + $this->assertFalse($authenticator->isLoggedIn()); + $this->assertNull($authenticator->getUser()); + } + + public function testLoggedInUsersAreRecognized(): void + { + $authenticator = $this->authenticator(); + $authenticator->login('alice', 'correct-password'); + + $this->assertTrue($authenticator->isLoggedIn()); + $this->assertSame($this->alice, $authenticator->getUser()); + } + + public function testLoggedInStateComesFromTheSession(): void + { + $this->session->set(Authenticator::SESSION_LOGGED_USER_KEY, 'bob'); + + $authenticator = $this->authenticator(); + + $this->assertTrue($authenticator->isLoggedIn()); + $this->assertSame('bob', $authenticator->getUser()?->username()); + } + + public function testSessionsOfDeletedUsersAreNotLoggedIn(): void + { + $this->session->set(Authenticator::SESSION_LOGGED_USER_KEY, 'ghost'); + + $authenticator = $this->authenticator(); + + $this->assertNull($authenticator->getUser()); + $this->assertFalse($authenticator->isLoggedIn(), 'A session pointing to a missing user must not count as logged in'); + } + + public function testLogoutRemovesTheUserFromTheSession(): void + { + $authenticator = $this->authenticator(); + $authenticator->login('alice', 'correct-password'); + + $authenticator->logout(); + + $this->assertFalse($authenticator->isLoggedIn()); + $this->assertNull($authenticator->getUser()); + $this->assertFalse($this->session->has(Authenticator::SESSION_LOGGED_USER_KEY)); + } + + public function testLogoutRegeneratesTheSession(): void + { + $authenticator = $this->authenticator(); + $authenticator->login('alice', 'correct-password'); + + $authenticator->logout(); + + $this->assertSame(2, $this->session->regenerations); + } + + public function testLoggingOutWithoutBeingLoggedInIsReported(): void + { + $this->expectException(UserNotLoggedException::class); + $this->authenticator()->logout(); + } + + public function testLoggingOutTwiceIsReported(): void + { + $authenticator = $this->authenticator(); + $authenticator->login('alice', 'correct-password'); + $authenticator->logout(); + + $this->expectException(UserNotLoggedException::class); + $authenticator->logout(); + } + + private function authenticator(int $limit = 10): Authenticator + { + $request = new Request([], [], [], [], [ + 'REQUEST_METHOD' => 'GET', + 'REMOTE_ADDR' => '203.0.113.7', + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => '80', + ]); + $registry = new Registry(FileSystem::joinPaths(TESTS_TMP_PATH, 'attempts.json')); + $this->registries[] = $registry; + + return new Authenticator($this->users, $this->session, new RateLimiter($registry, $limit, 300, TESTS_TMP_PATH, $request)); + } + + private function attempts(): int + { + foreach ($this->registries as $registry) { + $entries = $registry->toArray(); + if ($entries !== []) { + return (int) $entries[array_key_first($entries)][0]; + } + } + + return 0; + } +} diff --git a/tests/Unit/Authentication/Fixtures/InMemorySession.php b/tests/Unit/Authentication/Fixtures/InMemorySession.php new file mode 100644 index 000000000..31fb23ed5 --- /dev/null +++ b/tests/Unit/Authentication/Fixtures/InMemorySession.php @@ -0,0 +1,48 @@ + + */ + public array $values = []; + + public int $regenerations = 0; + + public function __construct() {} + + public function has(string $key): bool + { + return isset($this->values[$key]); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function set(string $key, mixed $value): void + { + $this->values[$key] = $value; + } + + public function remove(string $key): void + { + unset($this->values[$key]); + } + + public function regenerate(bool $preserveData = true): void + { + $this->regenerations++; + if (!$preserveData) { + $this->values = []; + } + } +} diff --git a/tests/Unit/Authentication/RateLimiterTest.php b/tests/Unit/Authentication/RateLimiterTest.php new file mode 100644 index 000000000..1d5838d6a --- /dev/null +++ b/tests/Unit/Authentication/RateLimiterTest.php @@ -0,0 +1,353 @@ + + */ + private array $registries = []; + + protected function setUp(): void + { + parent::setUp(); + $this->setUpTempDirectory(); + $this->registryFile = FileSystem::joinPaths(TESTS_TMP_PATH, 'rate-limiter', 'attempts.json'); + } + + protected function tearDown(): void + { + // Registries save themselves when destroyed, so they must go before the directory does + $this->registries = []; + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testNewVisitorsHaveNotReachedTheLimit(): void + { + $limiter = $this->limiter(3); + + $this->assertFalse($limiter->hasReachedLimit()); + $limiter->assertAllowed(); + } + + public function testResetTimeIsExposed(): void + { + $this->assertSame(120, $this->limiter(3, 120)->getResetTime()); + } + + public function testAttemptsUpToTheLimitAreAllowed(): void + { + $limiter = $this->limiter(3); + + for ($attempt = 1; $attempt <= 3; $attempt++) { + $limiter->assertAllowed(); + $this->assertFalse($limiter->hasReachedLimit(), "Attempt $attempt"); + } + } + + public function testTheAttemptAfterTheLimitIsRejectedWithTheResetTime(): void + { + $limiter = $this->limiter(3, 90); + + for ($attempt = 1; $attempt <= 3; $attempt++) { + $limiter->assertAllowed(); + } + + try { + $limiter->assertAllowed(); + $this->fail('The attempt exceeding the limit should have been rejected.'); + } catch (RateLimitExceededException $exception) { + $this->assertSame(90, $exception->getResetTime()); + } + + $this->assertTrue($limiter->hasReachedLimit()); + } + + public function testZeroLimitRejectsTheFirstAttempt(): void + { + $limiter = $this->limiter(0); + + $this->expectException(RateLimitExceededException::class); + $limiter->assertAllowed(); + } + + public function testAttemptsAreNotRegisteredOnceTheLimitIsReached(): void + { + $limiter = $this->limiter(2); + + $limiter->registerAttempt(); + $limiter->registerAttempt(); + $limiter->registerAttempt(); + $this->assertTrue($limiter->hasReachedLimit()); + + $before = $this->storedAttempts(); + + for ($i = 0; $i < 5; $i++) { + $limiter->registerAttempt(); + } + + $this->assertSame($before, $this->storedAttempts()); + } + + public function testAttemptsPersistAcrossRequests(): void + { + $this->limiter(2)->assertAllowed(); + $this->limiter(2)->assertAllowed(); + + $this->assertFalse($this->limiter(2)->hasReachedLimit()); + + $this->expectException(RateLimitExceededException::class); + $this->limiter(2)->assertAllowed(); + } + + public function testDifferentAddressesHaveIndependentCounters(): void + { + $first = $this->limiter(1, ip: '203.0.113.1'); + $first->assertAllowed(); + + try { + $first->assertAllowed(); + $this->fail('The first address should be limited.'); + } catch (RateLimitExceededException) { + } + + $this->limiter(1, ip: '203.0.113.2')->assertAllowed(); + $this->assertTrue($this->limiter(1, ip: '203.0.113.1')->hasReachedLimit()); + } + + #[DataProvider('hostProvider')] + public function testHostHeaderDoesNotAffectTheCounter(string $host): void + { + $this->limiter(2, host: 'example.test')->assertAllowed(); + $this->limiter(2, host: $host)->assertAllowed(); + + $this->expectException(RateLimitExceededException::class); + $this->limiter(2, host: 'another.test')->assertAllowed(); + } + + /** + * @return iterable + */ + public static function hostProvider(): iterable + { + yield 'different host' => ['attacker.test']; + yield 'subdomain' => ['sub.example.test']; + yield 'with port' => ['example.test:8080']; + yield 'uppercase' => ['EXAMPLE.TEST']; + yield 'IPv4 address' => ['192.0.2.1']; + yield 'IPv6 address' => ['[2001:db8::1]']; + } + + public function testRotatingHostHeadersCannotBypassTheLimit(): void + { + $limit = 3; + $rejected = 0; + + for ($attempt = 0; $attempt < 10; $attempt++) { + try { + $this->limiter($limit, host: "host-$attempt.test")->assertAllowed(); + } catch (RateLimitExceededException) { + $rejected++; + } + } + + $this->assertSame(10 - $limit, $rejected); + } + + public function testInvalidHostHeaderDoesNotBreakTheLimiter(): void + { + $limiter = $this->limiter(1, host: '../../nonexistent/secret'); + + $limiter->assertAllowed(); + + $this->expectException(RateLimitExceededException::class); + $limiter->assertAllowed(); + } + + public function testUntrustedForwardedHeadersCannotChangeTheClientIdentity(): void + { + $this->limiter(1, ip: '203.0.113.1', server: ['HTTP_X_FORWARDED_FOR' => '198.51.100.1'])->assertAllowed(); + + $this->expectException(RateLimitExceededException::class); + $this->limiter(1, ip: '203.0.113.1', server: ['HTTP_X_FORWARDED_FOR' => '198.51.100.2'])->assertAllowed(); + } + + public function testTrustedProxiesProvideTheClientIdentity(): void + { + $proxy = '10.0.0.10'; + + $this->limiter(1, ip: $proxy, server: ['HTTP_X_FORWARDED_FOR' => '198.51.100.1'], trustedProxies: [$proxy])->assertAllowed(); + $this->limiter(1, ip: $proxy, server: ['HTTP_X_FORWARDED_FOR' => '198.51.100.2'], trustedProxies: [$proxy])->assertAllowed(); + + $this->expectException(RateLimitExceededException::class); + $this->limiter(1, ip: $proxy, server: ['HTTP_X_FORWARDED_FOR' => '198.51.100.1'], trustedProxies: [$proxy])->assertAllowed(); + } + + public function testRegistryKeyDoesNotExposeTheAddress(): void + { + $this->limiter(1, ip: '203.0.113.1')->assertAllowed(); + + $keys = array_keys($this->storedEntries()); + + $this->assertCount(1, $keys); + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $keys[0]); + } + + public function testRegistryKeyDoesNotDependOnTheHost(): void + { + $this->limiter(5, host: 'one.test')->assertAllowed(); + $this->limiter(5, host: 'two.test')->assertAllowed(); + + $this->assertCount(1, $this->storedEntries()); + } + + public function testAttemptsExpireAfterTheResetTime(): void + { + $limiter = $this->limiter(1, 60); + $limiter->assertAllowed(); + + $key = array_key_first($this->storedEntries()); + $this->writeRegistry([$key => [5, time() - 61]]); + + $limiter = $this->limiter(1, 60); + + $this->assertTrue($limiter->hasReachedLimit()); + $limiter->registerAttempt(); + $this->assertFalse($limiter->hasReachedLimit()); + $this->assertSame(1, $this->storedAttempts()); + } + + public function testAttemptsDoNotExpireBeforeTheResetTime(): void + { + $limiter = $this->limiter(1, 60); + $limiter->assertAllowed(); + + $key = array_key_first($this->storedEntries()); + $this->writeRegistry([$key => [5, time() - 30]]); + + $limiter = $this->limiter(1, 60); + $limiter->registerAttempt(); + + $this->assertTrue($limiter->hasReachedLimit()); + $this->assertSame(5, $this->storedAttempts()); + } + + public function testResetAttemptsClearsTheCounter(): void + { + $limiter = $this->limiter(1); + $limiter->assertAllowed(); + + try { + $limiter->assertAllowed(); + } catch (RateLimitExceededException) { + } + + $this->assertTrue($limiter->hasReachedLimit()); + + $limiter->resetAttempts(); + + $this->assertFalse($limiter->hasReachedLimit()); + $this->assertSame([], $this->storedEntries()); + $limiter->assertAllowed(); + } + + public function testResetAttemptsDoesNotAffectOtherAddresses(): void + { + $other = $this->limiter(1, ip: '203.0.113.2'); + $other->assertAllowed(); + + $limiter = $this->limiter(1, ip: '203.0.113.1'); + $limiter->assertAllowed(); + $limiter->resetAttempts(); + + $this->assertCount(1, $this->storedEntries()); + + $this->expectException(RateLimitExceededException::class); + $this->limiter(1, ip: '203.0.113.2')->assertAllowed(); + } + + /** + * @param array $server + * @param list $trustedProxies + */ + private function limiter( + int $limit, + int $resetTime = 300, + string $ip = '203.0.113.1', + string $host = 'example.test', + array $server = [], + array $trustedProxies = [], + ): RateLimiter { + $request = new Request([], [], [], [], $server + [ + 'REQUEST_METHOD' => 'GET', + 'REMOTE_ADDR' => $ip, + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => '80', + 'HTTP_HOST' => $host, + ]); + $request->setTrustedProxies($trustedProxies); + + // Registries are persisted when the request ends, so previous limiters are flushed first + $this->endRequests(); + $this->registries[] = $registry = new Registry($this->registryFile); + + return new RateLimiter($registry, $limit, $resetTime, TESTS_TMP_PATH, $request); + } + + /** + * Persist the registries of the limiters created so far + */ + private function endRequests(): void + { + foreach ($this->registries as $registry) { + $registry->save(); + } + $this->registries = []; + } + + /** + * @return array + */ + private function storedEntries(): array + { + $this->endRequests(); + + return (new Registry($this->registryFile))->toArray(); + } + + private function storedAttempts(): int + { + $entries = $this->storedEntries(); + + return (int) $entries[array_key_first($entries)][0]; + } + + /** + * @param array $entries + */ + private function writeRegistry(array $entries): void + { + $registry = new Registry($this->registryFile); + foreach ($registry->toArray() as $key => $_) { + $registry->remove($key); + } + foreach ($entries as $key => $entry) { + $registry->set($key, $entry); + } + $registry->save(); + } +} 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/Backup/BackupperTest.php b/tests/Unit/Backup/BackupperTest.php new file mode 100644 index 000000000..c7c85f836 --- /dev/null +++ b/tests/Unit/Backup/BackupperTest.php @@ -0,0 +1,305 @@ +setUpTempDirectory(); + $this->backupPath = TESTS_TMP_PATH . '/backups'; + $this->projectPath = TESTS_TMP_PATH . '/project'; + FileSystem::createDirectory($this->projectPath . '/sub', recursive: true); + FileSystem::write($this->projectPath . '/composer.json', '{}'); + FileSystem::write($this->projectPath . '/ignored.txt', 'ignored'); + FileSystem::write($this->projectPath . '/sub/file.txt', 'nested'); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testBackupArchiveIsCreatedInTheConfiguredPath(): void + { + $destination = $this->backupper()->backup('test-backup', 'example.com'); + + $this->assertSame($this->backupPath, dirname($destination)); + $this->assertMatchesRegularExpression('/^test-backup-\d{8}-\d{6}\.zip$/', basename($destination)); + $this->assertFileExists($destination); + } + + public function testBackupContainsTheNonIgnoredFilesOnly(): void + { + $destination = $this->backupper()->backup('contents', 'example.com'); + + $zip = new ZipArchive(); + $this->assertTrue($zip->open($destination)); + $entries = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $entries[] = $zip->getNameIndex($i); + } + $zip->close(); + + $this->assertSame(['composer.json'], $entries); + } + + public function testBackupDirectoryIsCreatedRecursively(): void + { + $path = $this->backupPath . '/nested/deeper'; + + $this->backupper(['path' => $path])->backup('nested', 'example.com'); + + $this->assertDirectoryExists($path); + } + + public function testNameIsInterpolated(): void + { + $destination = $this->backupper()->backup('{{hostname}}-{{context}}', 'www.example.com'); + + $this->assertStringStartsWith('www-example-com-cli-', basename($destination)); + } + + public function testDefaultNameComesFromTheOptions(): void + { + $destination = $this->backupper(['name' => '{{hostname}}-formwork-backup'])->backup(null, 'example.org'); + + $this->assertStringStartsWith('example-org-formwork-backup-', basename($destination)); + } + + public function testHostnameComesFromTheOptionsWhenNotGiven(): void + { + $destination = $this->backupper(['name' => '{{hostname}}', 'hostname' => 'configured.example'])->backup(); + + $this->assertStringStartsWith('configured-example-', basename($destination)); + } + + public function testVersionPlaceholderIsInterpolated(): void + { + $destination = $this->backupper()->backup('v{{version}}', 'example.com'); + + $this->assertStringStartsWith('v' . App::VERSION . '-', basename($destination)); + } + + public function testRandomPlaceholdersProduceDifferentNames(): void + { + $first = $this->backupper()->backup('{{random}}', 'example.com'); + $second = $this->backupper()->backup('{{random}}', 'example.com'); + + $this->assertNotSame(basename($first), basename($second)); + } + + public function testLongNamesAreTruncatedAndStillEndWithTheSuffix(): void + { + $destination = $this->backupper()->backup(str_repeat('a', 200), 'example.com'); + + $this->assertLessThanOrEqual(75, strlen(basename($destination))); + $this->assertMatchesRegularExpression('/^a+-\d{8}-\d{6}\.zip$/', basename($destination)); + } + + public function testTrailingSeparatorsAreTrimmedBeforeTheSuffix(): void + { + $name = str_repeat('a', 40) . '--__--' . str_repeat('b', 100); + + $destination = $this->backupper()->backup($name, 'example.com'); + + $this->assertDoesNotMatchRegularExpression('/[-_]{2,}\d{8}-\d{6}\.zip$/', basename($destination)); + } + + /** + * @return iterable + */ + public static function invalidNames(): iterable + { + yield 'path traversal' => ['../escape']; + yield 'nested directory' => ['sub/backup']; + yield 'absolute path' => ['/tmp/backup']; + yield 'backslash' => ['sub\backup']; + yield 'space' => ['my backup']; + yield 'newline' => ["backup\nname"]; + yield 'null byte' => ["backup\0name"]; + yield 'unicode' => ['bäckup']; + yield 'unknown placeholder' => ['{{unknown}}']; + yield 'shell metacharacters' => ['backup;rm']; + } + + #[DataProvider('invalidNames')] + public function testInvalidNamesAreRejectedBeforeAnythingIsWritten(string $name): void + { + try { + $this->backupper()->backup($name, 'example.com'); + $this->fail('The backup name should have been rejected.'); + } catch (UnexpectedValueException $exception) { + $this->assertStringContainsString('invalid characters', $exception->getMessage()); + } + + $this->assertSame([], $this->archives()); + $this->assertFileDoesNotExist(TESTS_TMP_PATH . '/escape-' . date('Ymd') . '.zip'); + } + + /** + * @return iterable + */ + public static function hostnames(): iterable + { + yield 'ipv4' => ['192.168.1.10']; + yield 'ipv6' => ['[2001:db8::1]']; + yield 'localhost' => ['localhost']; + } + + #[DataProvider('hostnames')] + public function testEveryHostnameTheRequestCanReportProducesAValidArchiveName(string $hostname): void + { + $destination = $this->backupper(['name' => '{{hostname}}'])->backup(null, $hostname); + + $this->assertMatchesRegularExpression('/^[A-Za-z0-9._-]+\.zip$/', basename($destination)); + } + + public function testNameFallsBackToTheRequestHostWhenNoHostnameIsGiven(): void + { + $request = new Request([], [], [], [], ['SERVER_NAME' => 'request.example.com', 'HTTP_HOST' => 'request.example.com']); + + $destination = $this->backupper(['name' => '{{hostname}}'], $request)->backup(); + + $this->assertStringStartsWith('request-example-com-', basename($destination)); + } + + public function testMaxExecutionTimeIsRestoredAfterTheBackup(): void + { + $before = ini_get('max_execution_time'); + + $this->backupper(['maxExecutionTime' => '123'])->backup('timing', 'example.com'); + + $this->assertSame($before, ini_get('max_execution_time')); + } + + public function testMaxExecutionTimeIsRestoredWhenTheBackupFails(): void + { + $before = ini_get('max_execution_time'); + + try { + $this->backupper(['maxExecutionTime' => '123'])->backup('bad name', 'example.com'); + } catch (UnexpectedValueException) { + // Expected + } + + $this->assertSame($before, ini_get('max_execution_time')); + } + + public function testOldBackupsAreDeletedKeepingTheNewestOnes(): void + { + FileSystem::createDirectory($this->backupPath, recursive: true); + foreach (['oldest', 'older', 'old'] as $i => $name) { + $file = $this->backupPath . "/{$name}.zip"; + FileSystem::write($file, 'x'); + touch($file, time() - 1000 + $i * 10); + } + + $this->backupper(['maxFiles' => 2])->backup('newest', 'example.com'); + + $remaining = array_map('basename', $this->archives()); + sort($remaining); + $this->assertCount(2, $remaining); + $this->assertContains('old.zip', $remaining); + $this->assertNotContains('oldest.zip', $remaining); + $this->assertNotContains('older.zip', $remaining); + } + + public function testBackupsAreListedNewestFirst(): void + { + FileSystem::createDirectory($this->backupPath, recursive: true); + $times = ['a.zip' => 1_000, 'b.zip' => 3_000, 'c.zip' => 2_000]; + foreach ($times as $name => $time) { + FileSystem::write($this->backupPath . '/' . $name, 'x'); + touch($this->backupPath . '/' . $name, $time); + } + + $backups = $this->backupper()->getBackups(); + + $this->assertSame([3_000, 2_000, 1_000], array_keys($backups)); + $this->assertSame([$this->backupPath . '/b.zip', $this->backupPath . '/c.zip', $this->backupPath . '/a.zip'], array_values($backups)); + } + + public function testBackupsCreatedInTheSameSecondAreAllListed(): void + { + FileSystem::createDirectory($this->backupPath, recursive: true); + foreach (['first.zip', 'second.zip', 'third.zip'] as $name) { + FileSystem::write($this->backupPath . '/' . $name, 'x'); + touch($this->backupPath . '/' . $name, 5_000); + } + + $this->assertCount(3, $this->backupper()->getBackups()); + } + + public function testSameSecondBackupsAreCountedWhenPruning(): void + { + FileSystem::createDirectory($this->backupPath, recursive: true); + foreach (['a.zip', 'b.zip', 'c.zip', 'd.zip'] as $name) { + FileSystem::write($this->backupPath . '/' . $name, 'x'); + touch($this->backupPath . '/' . $name, 5_000); + } + + $this->backupper(['maxFiles' => 2])->backup('pruning', 'example.com'); + + $this->assertLessThanOrEqual(2, count($this->archives())); + } + + public function testListingCreatesAMissingBackupDirectory(): void + { + $this->assertSame([], $this->backupper()->getBackups()); + $this->assertDirectoryExists($this->backupPath); + } + + public function testListingCreatesNestedMissingBackupDirectories(): void + { + $path = $this->backupPath . '/a/b'; + + $this->assertSame([], $this->backupper(['path' => $path])->getBackups()); + $this->assertDirectoryExists($path); + } + + /** + * @return list + */ + private function archives(): array + { + if (!is_dir($this->backupPath)) { + return []; + } + return array_map(fn(string $file): string => $this->backupPath . '/' . $file, iterator_to_array(FileSystem::listFiles($this->backupPath), false)); + } + + /** + * @param array $options + */ + private function backupper(array $options = [], ?Request $request = null): Backupper + { + return new Backupper($request ?? new Request([], [], [], [], []), [ + 'path' => $this->backupPath, + 'name' => '{{hostname}}-formwork-backup', + 'maxExecutionTime' => '180', + 'maxFiles' => 10, + 'ignore' => ['*/*', 'ignored.txt'], + ...$options, + ], $this->projectPath, App::instance()); + } +} diff --git a/tests/Unit/Backup/ZipErrorsTest.php b/tests/Unit/Backup/ZipErrorsTest.php new file mode 100644 index 000000000..247501696 --- /dev/null +++ b/tests/Unit/Backup/ZipErrorsTest.php @@ -0,0 +1,62 @@ +assertSame(array_keys(ZipErrors::ERROR_MESSAGES), array_keys(ZipErrors::ERROR_LANGUAGE_STRINGS)); + } + + public function testMessagesAreNotEmpty(): void + { + foreach (ZipErrors::ERROR_MESSAGES as $code => $message) { + $this->assertNotSame('', trim($message), sprintf('Error %d has no message', $code)); + } + } + + public function testLanguageStringsExistInEveryShippedTranslation(): void + { + $strings = array_unique(ZipErrors::ERROR_LANGUAGE_STRINGS); + + foreach (['en', 'it'] as $language) { + /** @var array $translation */ + $translation = Yaml::parseFile(TestInstallation::systemPath() . "/translations/{$language}.yaml"); + + foreach ($strings as $string) { + $this->assertArrayHasKey($string, $translation, sprintf('"%s" is missing from the "%s" translation', $string, $language)); + } + } + } + + public function testKnownZipArchiveErrorCodesAreCovered(): void + { + foreach ([ZipArchive::ER_NOZIP, ZipArchive::ER_OPEN, ZipArchive::ER_NOENT, ZipArchive::ER_READ, ZipArchive::ER_EXISTS, ZipArchive::ER_INCONS, ZipArchive::ER_MEMORY, ZipArchive::ER_INVAL] as $code) { + $this->assertArrayHasKey($code, ZipErrors::ERROR_MESSAGES); + } + } + + public function testOpeningAFileThatIsNotAZipProducesACoveredCode(): void + { + $this->setUpTempDirectory(); + file_put_contents(TESTS_TMP_PATH . '/not-a-zip.zip', 'plain text'); + + try { + $status = (new ZipArchive())->open(TESTS_TMP_PATH . '/not-a-zip.zip', ZipArchive::RDONLY); + } finally { + $this->tearDownTempDirectory(); + } + + $this->assertNotTrue($status); + $this->assertArrayHasKey($status, ZipErrors::ERROR_MESSAGES); + } +} diff --git a/tests/Unit/Cache/ArrayCacheTest.php b/tests/Unit/Cache/ArrayCacheTest.php new file mode 100644 index 000000000..7724c9afe --- /dev/null +++ b/tests/Unit/Cache/ArrayCacheTest.php @@ -0,0 +1,50 @@ +set('key', 'value'); + + $this->assertFalse($second->has('key')); + } + + public function testItemsAreKeptInMemoryAsTheyAre(): void + { + $cache = new ArrayCache('test'); + $object = new stdClass(); + + $cache->set('object', $object); + + $this->assertSame($object, $cache->get('object')); + } + + protected function createCache(string $namespace = 'test', int|DateInterval|null $defaultTtl = null): AbstractCache + { + return new ArrayCache($namespace, $defaultTtl); + } + + protected function expire(AbstractCache $cache, string $key): void + { + $property = new ReflectionProperty(ArrayCache::class, 'items'); + $items = $property->getValue($cache); + $item = $items[$key]; + $items[$key] = new CacheItem($item->value(), time() - 10, $item->cachedTime() - 100); + $property->setValue($cache, $items); + } +} diff --git a/tests/Unit/Cache/CacheItemTest.php b/tests/Unit/Cache/CacheItemTest.php new file mode 100644 index 000000000..2edbdfe3d --- /dev/null +++ b/tests/Unit/Cache/CacheItemTest.php @@ -0,0 +1,83 @@ +assertInstanceOf(CacheItemInterface::class, $item); + $this->assertInstanceOf(ArraySerializable::class, $item); + $this->assertSame(['value'], $item->value()); + $this->assertSame(2000000000, $item->expirationTime()); + $this->assertSame(1900000000, $item->cachedTime()); + } + + public function testItemsWithoutExpirationTimeNeverExpire(): void + { + $item = new CacheItem('value', null, 0); + + $this->assertNull($item->expirationTime()); + $this->assertFalse($item->isExpired()); + } + + public function testItemsWithAFutureExpirationTimeAreNotExpired(): void + { + $this->assertFalse((new CacheItem('value', time() + 100, time()))->isExpired()); + } + + public function testItemsWithAPastExpirationTimeAreExpired(): void + { + $this->assertTrue((new CacheItem('value', time() - 100, time() - 200))->isExpired()); + } + + public function testItemsExpireAtTheirExpirationTime(): void + { + $this->assertTrue((new CacheItem('value', time(), time() - 10))->isExpired()); + } + + public function testItemsExpireWhenTheExpirationTimeIsZero(): void + { + $this->assertTrue((new CacheItem('value', 0, 0))->isExpired()); + } + + public function testItemsAreSerializedToArrays(): void + { + $item = new CacheItem('value', 10, 5); + + $this->assertSame(['value' => 'value', 'expirationTime' => 10, 'cachedTime' => 5], $item->toArray()); + } + + public function testItemsAreRestoredFromArrays(): void + { + $item = CacheItem::fromArray(['value' => ['a' => 1], 'expirationTime' => 10, 'cachedTime' => 5]); + + $this->assertEquals(new CacheItem(['a' => 1], 10, 5), $item); + } + + public function testItemsWithoutExpirationTimeAreRestoredFromArrays(): void + { + $original = new CacheItem('value', null, 5); + + $restored = CacheItem::fromArray($original->toArray()); + + $this->assertNull($restored->expirationTime()); + $this->assertEquals($original, $restored); + } + + public function testFalseyValuesSurviveTheRoundTrip(): void + { + foreach ([0, '', false, null, []] as $value) { + $this->assertSame($value, CacheItem::fromArray((new CacheItem($value, null, 1))->toArray())->value()); + } + } +} diff --git a/tests/Unit/Cache/CacheManagerTest.php b/tests/Unit/Cache/CacheManagerTest.php new file mode 100644 index 000000000..706d6a32c --- /dev/null +++ b/tests/Unit/Cache/CacheManagerTest.php @@ -0,0 +1,113 @@ +assertSame([], $manager->getAll()); + $this->assertFalse($manager->has('pages')); + } + + public function testCachesGivenToTheConstructorAreAvailable(): void + { + $pages = new ArrayCache('pages'); + + $manager = new CacheManager(['pages' => $pages]); + + $this->assertTrue($manager->has('pages')); + $this->assertSame($pages, $manager->get('pages')); + } + + public function testCachesCanBeAddedByNamespace(): void + { + $manager = new CacheManager(); + $pages = new ArrayCache('pages'); + + $manager->add($pages); + + $this->assertTrue($manager->has('pages')); + $this->assertSame($pages, $manager->get('pages')); + } + + public function testUnknownCachesAreReported(): void + { + $manager = new CacheManager(['pages' => new ArrayCache('pages')]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache "missing" does not exist'); + $manager->get('missing'); + } + + public function testAddingACacheWithTheSameNamespaceReplacesThePreviousOne(): void + { + $manager = new CacheManager(); + $first = new ArrayCache('pages'); + $second = new ArrayCache('pages'); + + $manager->add($first); + $manager->add($second); + + $this->assertSame($second, $manager->get('pages')); + $this->assertCount(1, $manager->getAll()); + } + + public function testMultipleCachesCanBeRetrieved(): void + { + $pages = new ArrayCache('pages'); + $files = new ArrayCache('files'); + $manager = new CacheManager(); + $manager->add($pages); + $manager->add($files); + + $this->assertSame(['files' => $files, 'pages' => $pages], $manager->getMultiple(['files', 'pages'])); + $this->assertSame([], $manager->getMultiple([])); + } + + public function testRetrievingMultipleCachesFailsWhenOneIsMissing(): void + { + $manager = new CacheManager(['pages' => new ArrayCache('pages')]); + + $this->expectException(InvalidArgumentException::class); + $manager->getMultiple(['pages', 'missing']); + } + + public function testAllCachesCanBeRetrievedByNamespace(): void + { + $pages = new ArrayCache('pages'); + $files = new ArrayCache('files'); + $manager = new CacheManager(); + $manager->add($pages); + $manager->add($files); + + $this->assertSame(['pages' => $pages, 'files' => $files], $manager->getAll()); + } + + public function testCachesAreIndependent(): void + { + $manager = new CacheManager(); + $manager->add(new ArrayCache('pages')); + $manager->add(new ArrayCache('files')); + + $manager->get('pages')->set('key', 'value'); + + $this->assertFalse($manager->get('files')->has('key')); + } + + public function testNamespaceLookupIsCaseSensitive(): void + { + $manager = new CacheManager(['pages' => new ArrayCache('pages')]); + + $this->assertFalse($manager->has('Pages')); + } +} diff --git a/tests/Unit/Cache/CacheTestCase.php b/tests/Unit/Cache/CacheTestCase.php new file mode 100644 index 000000000..13f77cd33 --- /dev/null +++ b/tests/Unit/Cache/CacheTestCase.php @@ -0,0 +1,702 @@ +createCache(); + + $this->assertInstanceOf(CacheInterface::class, $cache); + $this->assertInstanceOf(NamespacedCacheInterface::class, $cache); + $this->assertInstanceOf(CountableCache::class, $cache); + } + + public function testNamespaceAndDefaultTtlAreExposed(): void + { + $interval = new DateInterval('PT5M'); + + $this->assertSame('test', $this->createCache()->namespace()); + $this->assertNull($this->createCache()->defaultTtl()); + $this->assertSame(60, $this->createCache('test', 60)->defaultTtl()); + $this->assertSame($interval, $this->createCache('test', $interval)->defaultTtl()); + } + + #[DataProvider('validNamespaceProvider')] + public function testValidNamespacesAreAccepted(string $namespace): void + { + $this->assertSame($namespace, $this->createCache($namespace)->namespace()); + } + + /** + * @return iterable + */ + public static function validNamespaceProvider(): iterable + { + yield 'lowercase letters' => ['pages']; + yield 'digits' => ['123']; + yield 'letters and digits' => ['cache2']; + yield 'single character' => ['a']; + } + + #[DataProvider('invalidNamespaceProvider')] + public function testInvalidNamespacesAreRejected(string $namespace): void + { + $this->expectException(InvalidNamespaceException::class); + $this->createCache($namespace); + } + + /** + * @return iterable + */ + public static function invalidNamespaceProvider(): iterable + { + yield 'empty' => ['']; + yield 'uppercase' => ['Pages']; + yield 'hyphen' => ['my-cache']; + yield 'underscore' => ['my_cache']; + yield 'dot' => ['my.cache']; + yield 'space' => ['my cache']; + yield 'slash' => ['a/b']; + yield 'backslash' => ['a\b']; + yield 'parent directory' => ['..']; + yield 'path traversal' => ['../escape']; + yield 'trailing newline' => ["cache\n"]; + yield 'null byte' => ["cache\0"]; + yield 'non ASCII letters' => ['caché']; + } + + public function testInvalidNamespaceExceptionsArePsrExceptions(): void + { + try { + $this->createCache('Invalid'); + $this->fail('The namespace should have been rejected.'); + } catch (InvalidNamespaceException $exception) { + $this->assertInstanceOf(PsrInvalidArgumentException::class, $exception); + $this->assertStringContainsString('"Invalid"', $exception->getMessage()); + } + } + + public function testMissingItemsReturnTheDefault(): void + { + $cache = $this->createCache(); + + $this->assertNull($cache->get('missing')); + $this->assertSame('fallback', $cache->get('missing', 'fallback')); + $this->assertSame(0, $cache->get('missing', 0)); + $this->assertFalse($cache->has('missing')); + $this->assertNull($cache->cachedTime('missing')); + $this->assertNull($cache->getItem('missing')); + } + + #[DataProvider('valueProvider')] + public function testValuesAreStoredAndRetrievedUnchanged(mixed $value): void + { + $cache = $this->createCache(); + + $this->assertTrue($cache->set('key', $value)); + + $this->assertEquals($value, $cache->get('key', 'default')); + $this->assertTrue($cache->has('key')); + } + + /** + * @return iterable + */ + public static function valueProvider(): iterable + { + yield 'string' => ['text']; + yield 'empty string' => ['']; + yield 'multiline string' => ["line one\nline two\ttabbed"]; + yield 'string with quotes' => ['it\'s "quoted" and \ escaped']; + yield 'string with PHP code' => ['']; + yield 'string with a null byte' => ["a\0b"]; + yield 'unicode string' => ['èàù — 日本語 — 🚀']; + yield 'integer' => [42]; + yield 'zero' => [0]; + yield 'negative integer' => [-7]; + yield 'float' => [1.5]; + yield 'float with a zero fraction' => [2.0]; + yield 'true' => [true]; + yield 'false' => [false]; + yield 'null' => [null]; + yield 'empty array' => [[]]; + yield 'list' => [[1, 'two', 3.5, null, false]]; + yield 'associative array' => [['a' => 1, 'b' => ['c' => 2]]]; + yield 'array with integer keys' => [[5 => 'a', 10 => 'b']]; + yield 'nested empty arrays' => [[[], [[]]]]; + yield 'stdClass' => [(object) ['a' => 1, 'b' => [2]]]; + } + + public function testFalseAndNullValuesAreDistinctFromMisses(): void + { + $cache = $this->createCache(); + $cache->set('false', false); + $cache->set('null', null); + + $this->assertTrue($cache->has('false')); + $this->assertTrue($cache->has('null')); + $this->assertFalse($cache->get('false', 'default')); + $this->assertNull($cache->get('null', 'default')); + } + + public function testSettingAnExistingKeyReplacesTheValue(): void + { + $cache = $this->createCache(); + $cache->set('key', 'old'); + $cache->set('key', 'new'); + + $this->assertSame('new', $cache->get('key')); + $this->assertSame(1, $cache->count()); + } + + public function testKeysAreCaseSensitiveAndExact(): void + { + $cache = $this->createCache(); + $cache->set('key', 'lower'); + $cache->set('Key', 'upper'); + $cache->set('key ', 'trailing space'); + + $this->assertSame('lower', $cache->get('key')); + $this->assertSame('upper', $cache->get('Key')); + $this->assertSame('trailing space', $cache->get('key ')); + $this->assertSame(3, $cache->count()); + } + + #[DataProvider('validKeyProvider')] + public function testValidKeys(string $key): void + { + $cache = $this->createCache(); + + $cache->set($key, 'value'); + + $this->assertSame('value', $cache->get($key)); + $this->assertTrue($cache->delete($key)); + $this->assertFalse($cache->has($key)); + } + + /** + * @return iterable + */ + public static function validKeyProvider(): iterable + { + yield 'letters' => ['abc']; + yield 'zero' => ['0']; + yield 'dots' => ['..']; + yield 'with spaces' => ['a b c']; + yield 'dashes and underscores' => ['a-b_c']; + yield 'unicode' => ['ключ-键']; + yield 'symbols' => ['a#b$c%d&e*f+g=h']; + yield 'quotes' => ['it\'s "quoted"']; + yield 'very long' => [str_repeat('k', 2000)]; + yield 'newline' => ["a\nb"]; + } + + #[DataProvider('invalidKeyProvider')] + public function testInvalidKeysAreRejectedByEveryOperation(string $key): void + { + $cache = $this->createCache(); + + $operations = [ + 'get' => static fn() => $cache->get($key), + 'set' => static fn() => $cache->set($key, 'value'), + 'has' => static fn() => $cache->has($key), + 'delete' => static fn() => $cache->delete($key), + 'cachedTime' => static fn() => $cache->cachedTime($key), + 'getItem' => static fn() => $cache->getItem($key), + ]; + + foreach ($operations as $name => $operation) { + try { + $operation(); + $this->fail("{$name}() should reject the key."); + } catch (InvalidKeyException $exception) { + $this->assertInstanceOf(PsrInvalidArgumentException::class, $exception, $name); + } + } + + $this->assertSame(0, $cache->count()); + } + + /** + * @return iterable + */ + public static function invalidKeyProvider(): iterable + { + yield 'empty' => ['']; + yield 'opening brace' => ['a{b']; + yield 'closing brace' => ['a}b']; + yield 'opening parenthesis' => ['a(b']; + yield 'closing parenthesis' => ['a)b']; + yield 'slash' => ['a/b']; + yield 'backslash' => ['a\b']; + yield 'at sign' => ['a@b']; + yield 'colon' => ['a:b']; + yield 'only a reserved character' => ['/']; + yield 'path traversal' => ['../../escape']; + yield 'reserved character at the start' => ['{key']; + yield 'reserved character at the end' => ['key:']; + } + + public function testInvalidKeysDoNotChangeTheCache(): void + { + $cache = $this->createCache(); + $cache->set('valid', 'value'); + + try { + $cache->set('in/valid', 'other'); + } catch (InvalidKeyException) { + } + + $this->assertSame(['valid' => 'value'], $cache->getMultiple(['valid'])); + $this->assertSame(1, $cache->count()); + } + + public function testDeleteRemovesOnlyTheGivenItem(): void + { + $cache = $this->createCache(); + $cache->set('a', 1); + $cache->set('b', 2); + + $this->assertTrue($cache->delete('a')); + + $this->assertFalse($cache->has('a')); + $this->assertSame(2, $cache->get('b')); + $this->assertSame(1, $cache->count()); + } + + public function testDeletingAMissingItemSucceeds(): void + { + $this->assertTrue($this->createCache()->delete('missing')); + } + + public function testClearRemovesEveryItem(): void + { + $cache = $this->createCache(); + $cache->set('a', 1); + $cache->set('b', 2); + + $this->assertTrue($cache->clear()); + + $this->assertSame(0, $cache->count()); + $this->assertFalse($cache->has('a')); + $this->assertNull($cache->get('b')); + } + + public function testClearOnAnEmptyCacheSucceeds(): void + { + $cache = $this->createCache(); + + $this->assertTrue($cache->clear()); + $this->assertSame(0, $cache->count()); + } + + public function testCacheCanBeUsedAfterBeingCleared(): void + { + $cache = $this->createCache(); + $cache->set('a', 1); + $cache->clear(); + + $cache->set('a', 2); + + $this->assertSame(2, $cache->get('a')); + $this->assertSame(1, $cache->count()); + } + + public function testCountReflectsTheStoredItems(): void + { + $cache = $this->createCache(); + + $this->assertSame(0, $cache->count()); + $this->assertCount(0, $cache); + + $cache->set('a', 1); + $cache->set('b', 2); + $cache->set('c', null); + + $this->assertSame(3, $cache->count()); + $this->assertCount(3, $cache); + } + + public function testItemsWithoutTtlNeverExpire(): void + { + $cache = $this->createCache(); + + $cache->set('key', 'value'); + + $this->assertNull($cache->getItem('key')?->expirationTime()); + $this->assertFalse($cache->getItem('key')?->isExpired()); + } + + public function testExplicitTtlSetsTheExpirationTime(): void + { + $cache = $this->createCache(); + + $cache->set('key', 'value', 100); + + $item = $cache->getItem('key'); + $this->assertNotNull($item); + $this->assertSame($item->cachedTime() + 100, $item->expirationTime()); + $this->assertSame('value', $cache->get('key')); + } + + public function testDateIntervalTtlSetsTheExpirationTime(): void + { + $cache = $this->createCache(); + + $cache->set('key', 'value', new DateInterval('PT2H')); + + $item = $cache->getItem('key'); + $this->assertNotNull($item); + $this->assertSame($item->cachedTime() + 7200, $item->expirationTime()); + } + + public function testDefaultTtlIsUsedWhenNoTtlIsGiven(): void + { + $cache = $this->createCache('test', 300); + + $cache->set('key', 'value'); + + $item = $cache->getItem('key'); + $this->assertNotNull($item); + $this->assertSame($item->cachedTime() + 300, $item->expirationTime()); + } + + public function testDefaultTtlAsDateIntervalIsUsedWhenNoTtlIsGiven(): void + { + $cache = $this->createCache('test', new DateInterval('PT10M')); + + $cache->set('key', 'value'); + + $item = $cache->getItem('key'); + $this->assertNotNull($item); + $this->assertSame($item->cachedTime() + 600, $item->expirationTime()); + } + + public function testExplicitTtlOverridesTheDefaultTtl(): void + { + $cache = $this->createCache('test', 300); + + $cache->set('key', 'value', 50); + + $item = $cache->getItem('key'); + $this->assertNotNull($item); + $this->assertSame($item->cachedTime() + 50, $item->expirationTime()); + } + + public function testExpiredItemsAreMisses(): void + { + $cache = $this->createCache(); + $cache->set('key', 'value', 100); + $cache->set('other', 'kept', 100); + + $this->expire($cache, 'key'); + + $this->assertFalse($cache->has('key')); + $this->assertNull($cache->get('key')); + $this->assertSame('fallback', $cache->get('key', 'fallback')); + $this->assertNull($cache->cachedTime('key')); + $this->assertNull($cache->getItem('key')); + $this->assertSame('kept', $cache->get('other')); + } + + public function testExpiredItemsAreRemovedWhenAccessed(): void + { + $cache = $this->createCache(); + $cache->set('key', 'value', 100); + $this->expire($cache, 'key'); + + $cache->has('key'); + + $this->assertSame(0, $cache->count()); + } + + public function testExpiredItemsCanBeSetAgain(): void + { + $cache = $this->createCache(); + $cache->set('key', 'old', 100); + $this->expire($cache, 'key'); + + $cache->set('key', 'new'); + + $this->assertSame('new', $cache->get('key')); + } + + #[DataProvider('nonPositiveTtlProvider')] + public function testItemsWithANonPositiveTtlAreNotRetrievable(int|DateInterval $ttl): void + { + $cache = $this->createCache(); + + $cache->set('key', 'value', $ttl); + + $this->assertFalse($cache->has('key')); + $this->assertNull($cache->get('key')); + } + + /** + * @return iterable + */ + public static function nonPositiveTtlProvider(): iterable + { + yield 'zero' => [0]; + yield 'negative' => [-1]; + yield 'large negative' => [-86400]; + yield 'negative interval' => [self::negativeInterval()]; + } + + public function testItemsWithANonPositiveTtlAreDeletedFromTheCache(): void + { + $cache = $this->createCache(); + $cache->set('key', 'old', -86400); + $cache->set('key', 'new', 0); + + $this->assertSame(0, $cache->count(), 'A zero time-to-live must delete the item'); + } + + public function testCachedTimeIsTheTimeOfTheLastSet(): void + { + $cache = $this->createCache(); + $before = time(); + + $cache->set('key', 'value'); + + $cachedTime = $cache->cachedTime('key'); + $this->assertNotNull($cachedTime); + $this->assertGreaterThanOrEqual($before, $cachedTime); + $this->assertLessThanOrEqual(time(), $cachedTime); + } + + public function testGetItemReturnsACacheItem(): void + { + $cache = $this->createCache(); + $cache->set('key', ['a' => 1], 60); + + $item = $cache->getItem('key'); + + $this->assertInstanceOf(CacheItem::class, $item); + $this->assertSame(['a' => 1], $item->value()); + } + + public function testGetMultipleReturnsValuesAndDefaults(): void + { + $cache = $this->createCache(); + $cache->set('a', 1); + $cache->set('b', null); + + $this->assertSame(['a' => 1, 'b' => null, 'c' => 'default'], $cache->getMultiple(['a', 'b', 'c'], 'default')); + } + + public function testGetMultipleKeepsTheOrderOfTheKeys(): void + { + $cache = $this->createCache(); + $cache->set('a', 1); + $cache->set('b', 2); + + $this->assertSame(['b', 'a'], array_keys((array) $cache->getMultiple(['b', 'a']))); + } + + public function testGetMultipleAcceptsTraversables(): void + { + $cache = $this->createCache(); + $cache->set('a', 1); + + $this->assertSame(['a' => 1], (array) $cache->getMultiple(new ArrayObject(['a']))); + $this->assertSame([], (array) $cache->getMultiple([])); + } + + public function testGetMultipleRejectsInvalidKeys(): void + { + $this->expectException(InvalidKeyException::class); + $this->createCache()->getMultiple(['valid', 'in/valid']); + } + + public function testSetMultipleStoresEveryValue(): void + { + $cache = $this->createCache(); + + $this->assertTrue($cache->setMultiple(['a' => 1, 'b' => 2, 'c' => 3])); + + $this->assertSame(['a' => 1, 'b' => 2, 'c' => 3], $cache->getMultiple(['a', 'b', 'c'])); + $this->assertSame(3, $cache->count()); + } + + public function testSetMultipleAppliesTheTtlToEveryValue(): void + { + $cache = $this->createCache(); + + $cache->setMultiple(['a' => 1, 'b' => 2], 120); + + foreach (['a', 'b'] as $key) { + $item = $cache->getItem($key); + $this->assertNotNull($item); + $this->assertSame($item->cachedTime() + 120, $item->expirationTime(), $key); + } + } + + public function testSetMultipleAcceptsGenerators(): void + { + $cache = $this->createCache(); + + $cache->setMultiple((static function (): iterable { + yield 'a' => 1; + yield 'b' => 2; + })()); + + $this->assertSame(2, $cache->count()); + } + + public function testSetMultipleRejectsInvalidKeys(): void + { + $cache = $this->createCache(); + + $this->expectException(InvalidKeyException::class); + $cache->setMultiple(['valid' => 1, 'in/valid' => 2]); + } + + public function testDeleteMultipleRemovesTheGivenItems(): void + { + $cache = $this->createCache(); + $cache->setMultiple(['a' => 1, 'b' => 2, 'c' => 3]); + + $this->assertTrue($cache->deleteMultiple(['a', 'c', 'missing'])); + + $this->assertSame(['b' => 2], array_filter((array) $cache->getMultiple(['a', 'b', 'c']))); + $this->assertSame(1, $cache->count()); + } + + public function testDeleteMultipleRejectsInvalidKeys(): void + { + $this->expectException(InvalidKeyException::class); + $this->createCache()->deleteMultiple(['valid', 'in/valid']); + } + + public function testHasMultiple(): void + { + $cache = $this->createCache(); + $cache->setMultiple(['a' => 1, 'b' => 2]); + + $this->assertTrue($cache->hasMultiple(['a', 'b'])); + $this->assertFalse($cache->hasMultiple(['a', 'missing'])); + $this->assertTrue($cache->hasMultiple([])); + } + + public function testNamespacesDoNotShareItems(): void + { + $first = $this->createCache('first'); + $second = $this->createCache('second'); + + $first->set('key', 'first value'); + $second->set('key', 'second value'); + + $this->assertSame('first value', $first->get('key')); + $this->assertSame('second value', $second->get('key')); + + $first->clear(); + + $this->assertFalse($first->has('key')); + $this->assertSame('second value', $second->get('key')); + } + + public function testDeprecatedFetchReadsValues(): void + { + $cache = $this->createCache(); + $cache->set('key', 'value'); + + $value = null; + $messages = $this->captureDeprecations(static function () use ($cache, &$value): void { + $value = $cache->fetch('key'); + }); + + $this->assertSame('value', $value); + $this->assertCount(1, $messages); + $this->assertStringContainsString('fetch() is deprecated since Formwork 2.4.0', $messages[0]); + } + + public function testDeprecatedSaveStoresValues(): void + { + $cache = $this->createCache(); + + $messages = $this->captureDeprecations(static function () use ($cache): void { + $cache->save('key', 'value', 60); + }); + + $this->assertSame('value', $cache->get('key')); + $this->assertCount(1, $messages); + $this->assertStringContainsString('save() is deprecated since Formwork 2.4.0', $messages[0]); + } + + public function testDeprecatedFetchMultipleReadsValues(): void + { + $cache = $this->createCache(); + $cache->setMultiple(['a' => 1, 'b' => 2]); + + $values = null; + $messages = $this->captureDeprecations(static function () use ($cache, &$values): void { + $values = $cache->fetchMultiple(['a', 'b']); + }); + + $this->assertSame(['a' => 1, 'b' => 2], $values); + $this->assertCount(1, $messages); + $this->assertStringContainsString('fetchMultiple() is deprecated since Formwork 2.4.0', $messages[0]); + } + + public function testDeprecatedSaveMultipleStoresValues(): void + { + $cache = $this->createCache(); + + $messages = $this->captureDeprecations(static function () use ($cache): void { + $cache->saveMultiple(['a' => 1, 'b' => 2]); + }); + + $this->assertSame(2, $cache->count()); + $this->assertCount(1, $messages); + $this->assertStringContainsString('saveMultiple() is deprecated since Formwork 2.4.0', $messages[0]); + } + + public function testObjectsCanBeCached(): void + { + $cache = $this->createCache(); + $object = new stdClass(); + $object->name = 'cached'; + + $cache->set('object', $object); + + $this->assertEquals($object, $cache->get('object')); + } + + /** + * @return AbstractCache&CountableCache + */ + abstract protected function createCache(string $namespace = 'test', int|DateInterval|null $defaultTtl = null): AbstractCache; + + /** + * Make the given item expired as if its time-to-live had elapsed + */ + abstract protected function expire(AbstractCache $cache, string $key): void; + + private static function negativeInterval(): DateInterval + { + $interval = new DateInterval('PT1H'); + $interval->invert = 1; + + return $interval; + } +} diff --git a/tests/Unit/Cache/FilesCacheTest.php b/tests/Unit/Cache/FilesCacheTest.php new file mode 100644 index 000000000..57a7c16f2 --- /dev/null +++ b/tests/Unit/Cache/FilesCacheTest.php @@ -0,0 +1,278 @@ +setUpTempDirectory(); + $this->path = FileSystem::joinPaths(TESTS_TMP_PATH, 'cache'); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testPathIncludesTheNamespace(): void + { + $this->assertSame(FileSystem::joinPaths($this->path, 'pages'), $this->createCache('pages')->path()); + } + + public function testDirectoryIsNotCreatedUntilSomethingIsStored(): void + { + $cache = $this->createCache(); + + $cache->get('key'); + $cache->has('key'); + $cache->delete('key'); + $cache->clear(); + + $this->assertDirectoryDoesNotExist($cache->path()); + $this->assertSame(0, $cache->count()); + } + + public function testDirectoryIsCreatedRecursivelyOnTheFirstSet(): void + { + $cache = new FilesCache(FileSystem::joinPaths($this->path, 'deeply', 'nested'), 'test'); + + $cache->set('key', 'value'); + + $this->assertDirectoryExists($cache->path()); + } + + public function testItemsArePersistedAcrossInstances(): void + { + $this->createCache()->set('key', ['persisted' => true]); + + $this->assertSame(['persisted' => true], $this->createCache()->get('key')); + $this->assertSame(1, $this->createCache()->count()); + } + + public function testNamespacesAreStoredInSeparateDirectories(): void + { + $first = $this->createCache('first'); + $second = $this->createCache('second'); + + $first->set('key', 1); + $second->set('key', 2); + + $this->assertNotSame($first->path(), $second->path()); + $this->assertDirectoryExists($first->path()); + $this->assertDirectoryExists($second->path()); + } + + public function testClearOnlyRemovesTheItemsOfTheNamespace(): void + { + $first = $this->createCache('first'); + $second = $this->createCache('second'); + $first->set('key', 1); + $second->set('key', 2); + + $first->clear(); + + $this->assertDirectoryExists($first->path()); + $this->assertSame(2, $second->get('key')); + } + + public function testFilesAreNamedAfterTheHashOfTheKey(): void + { + $cache = $this->createCache(); + + $cache->set('some key', 'value'); + + $this->assertSame([hash('sha256', 'some key')], iterator_to_array(FileSystem::listFiles($cache->path()), false)); + } + + public function testKeysCannotInfluenceTheFilePath(): void + { + $cache = $this->createCache(); + + // Keys containing path separators are rejected, others are hashed + $cache->set('..', 'dots'); + $cache->set('.hidden', 'hidden'); + + $files = iterator_to_array(FileSystem::listFiles($cache->path()), false); + + $this->assertCount(2, $files); + foreach ($files as $file) { + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $file); + } + $this->assertFileDoesNotExist(FileSystem::joinPaths($this->path, 'test.php')); + } + + public function testStoredFilesAreWrittenAtomically(): void + { + $cache = $this->createCache(); + $cache->set('key', 'old'); + + $cache->set('key', 'new'); + + // Only the cache item remains, without temporary files + $this->assertCount(1, iterator_to_array(FileSystem::listFiles($cache->path()), false)); + } + + public function testFilesContainTheCacheItem(): void + { + $cache = $this->createCache(); + $cache->set('key', 'value', 60); + + $item = Php::parseFile(FileSystem::joinPaths($cache->path(), hash('sha256', 'key'))); + + $this->assertInstanceOf(CacheItem::class, $item); + $this->assertSame('value', $item->value()); + $this->assertSame($item->cachedTime() + 60, $item->expirationTime()); + } + + public function testValuesThatCannotBeEncodedAreRejectedWithoutStoringAnything(): void + { + $cache = $this->createCache(); + + try { + $cache->set('closure', static fn() => null); + $this->fail('Closures cannot be cached.'); + } catch (UnexpectedValueException) { + } + + $this->assertFalse($cache->has('closure')); + $this->assertSame(0, $cache->count()); + } + + public function testFailedSetKeepsThePreviousValue(): void + { + $cache = $this->createCache(); + $cache->set('key', 'previous'); + + try { + $cache->set('key', static fn() => null); + } catch (UnexpectedValueException) { + } + + $this->assertSame('previous', $cache->get('key')); + } + + #[DataProvider('invalidFileContentProvider')] + public function testFilesWithoutAValidCacheItemAreReportedAsUnexpectedValues(string $content): void + { + $cache = $this->createCache(); + $cache->set('key', 'value'); + FileSystem::write($this->fileOf($cache, 'key'), $content); + + $this->expectException(UnexpectedValueException::class); + $cache->get('key'); + } + + /** + * @return iterable + */ + public static function invalidFileContentProvider(): iterable + { + yield 'scalar' => [" [" 1];\n"]; + yield 'empty file' => ['']; + yield 'truncated file' => ["createCache(); + $cache->set('key', 'value', 100); + $this->expire($cache, 'key'); + + $this->assertNull($cache->get('key')); + + $this->assertFileDoesNotExist($this->fileOf($cache, 'key')); + } + + #[DataProvider('hashCollisionLikeKeyProvider')] + public function testSimilarKeysUseDifferentFiles(string $first, string $second): void + { + $cache = $this->createCache(); + $cache->set($first, 'first'); + $cache->set($second, 'second'); + + $this->assertSame('first', $cache->get($first)); + $this->assertSame('second', $cache->get($second)); + } + + /** + * @return iterable + */ + public static function hashCollisionLikeKeyProvider(): iterable + { + yield 'case' => ['key', 'KEY']; + yield 'whitespace' => ['key', 'key ']; + yield 'prefix' => ['key', 'key1']; + yield 'numeric strings' => ['1', '01']; + yield 'unicode normalization' => ["e\u{301}", "\u{e9}"]; + } + + public function testValuesWithObjectsOfOtherClassesRoundTrip(): void + { + $cache = $this->createCache(); + $value = ['object' => (object) ['nested' => [1, 2, 3]], 'date' => new DateTimeImmutable('2025-01-02 03:04:05')]; + + $cache->set('key', $value); + + $this->assertEquals($value, $cache->get('key')); + } + + public function testCountIgnoresNothingInTheDirectory(): void + { + $cache = $this->createCache(); + $cache->setMultiple(['a' => 1, 'b' => 2]); + + $this->assertSame(2, $cache->count()); + + $cache->delete('a'); + + $this->assertSame(1, $cache->count()); + } + + public function testStdClassValuesRoundTrip(): void + { + $cache = $this->createCache(); + $cache->set('key', new stdClass()); + + $this->assertInstanceOf(stdClass::class, $cache->get('key')); + } + + protected function createCache(string $namespace = 'test', int|DateInterval|null $defaultTtl = null): FilesCache + { + return new FilesCache($this->path, $namespace, $defaultTtl); + } + + protected function expire(AbstractCache $cache, string $key): void + { + $item = $cache->getItem($key); + + Php::encodeToFile( + new CacheItem($item?->value(), time() - 10, ($item?->cachedTime() ?? time()) - 100), + $this->fileOf($cache, $key), + ); + } + + private function fileOf(AbstractCache $cache, string $key): string + { + return FileSystem::joinPaths($cache->path(), hash('sha256', $key)); + } +} diff --git a/tests/Unit/Cms/AppTest.php b/tests/Unit/Cms/AppTest.php new file mode 100644 index 000000000..361e6d1fa --- /dev/null +++ b/tests/Unit/Cms/AppTest.php @@ -0,0 +1,435 @@ +errorReporting = error_reporting(E_ALL); + } + + protected function tearDown(): void + { + error_reporting($this->errorReporting); + } + + public function testVersionIsAValidSemanticVersion(): void + { + $this->assertSame(App::VERSION, (string) SemVer::fromString(App::VERSION)); + } + + public function testInstanceIsShared(): void + { + $this->assertSame(App::instance(), App::instance()); + } + + public function testOnlyOneApplicationCanBeCreated(): void + { + $this->expectException(LogicException::class); + + new App(); + } + + public function testRootPathIsTheOneOfTheTestInstallation(): void + { + $rootPath = App::instance()->rootPath(); + + $this->assertStringStartsWith(sys_get_temp_dir() . '/formwork-tests-', $rootPath); + $this->assertDirectoryExists($rootPath . '/site'); + $this->assertSame($rootPath . '/site/pages', App::instance()->config()->getString('system.pages.path')); + } + + public function testSystemPathIsTheDirectoryOfFormwork(): void + { + $this->assertSame(dirname(__DIR__, 3) . '/formwork', App::instance()->systemPath()); + $this->assertFileExists(App::instance()->systemPath() . '/config/services/services.php'); + } + + public function testLoadingTwiceDoesNothing(): void + { + $app = App::instance(); + $container = (new ReflectionProperty($app, 'container'))->getValue($app); + $resolved = (new ReflectionProperty($container, 'resolved'))->getValue($container); + + $app->load(); + + $this->assertSame(array_keys($resolved), array_keys((new ReflectionProperty($container, 'resolved'))->getValue($container))); + } + + public function testUnloadingStartsTheNextLoadFromScratch(): void + { + $app = App::instance(); + $site = $app->site(); + + try { + $app->unload(); + $app->load(); + + $this->assertNotSame($site, $app->site()); + $this->assertSame($app, $app->site()->get('app')); + } finally { + $this->reload(); + } + } + + public function testDynamicFieldVariablesBelongToTheLastLoad(): void + { + $app = App::instance(); + + try { + $app->unload(); + $app->load(); + $app->schemes(); + + $this->assertSame($app->site(), (DynamicFieldValue::$varsLoader)()['site']); + } finally { + $this->reload(); + } + } + + #[BackupGlobals(true)] + public function testRequestsAreBuiltFromTheGlobalsOfEachLoad(): void + { + $app = App::instance(); + + try { + $this->setRequestGlobals('/about/'); + $app->unload(); + $app->load(); + + $response = $app->router()->dispatch(); + + $this->assertSame(ResponseStatus::OK, $response->status()); + $this->assertStringContainsString('About content', $response->content()); + } finally { + $this->reload(); + } + } + + public function testLoadingDoesNotInstallTheErrorHandler(): void + { + $before = set_error_handler(static fn(): bool => false); + restore_error_handler(); + + try { + App::instance()->unload(); + App::instance()->load(); + } finally { + $this->reload(); + } + + $after = set_error_handler(static fn(): bool => false); + restore_error_handler(); + + $this->assertSame($before, $after); + } + + // Service accessors + + public function testTypedServiceAccessors(): void + { + $app = App::instance(); + + $this->assertInstanceOf(Config::class, $app->config()); + $this->assertInstanceOf(Router::class, $app->router()); + $this->assertInstanceOf(UriGenerator::class, $app->uri()); + $this->assertInstanceOf(Site::class, $app->site()); + $this->assertInstanceOf(Request::class, $app->request()); + $this->assertInstanceOf(Schemes::class, $app->schemes()); + $this->assertInstanceOf(Translations::class, $app->translations()); + $this->assertInstanceOf(Assets::class, $app->assets()); + $this->assertInstanceOf(Panel::class, $app->panel()); + $this->assertInstanceOf(EventDispatcher::class, $app->events()); + $this->assertInstanceOf(Plugins::class, $app->plugins()); + } + + public function testAccessorsReturnTheSharedServices(): void + { + $app = App::instance(); + + $this->assertSame($app->config(), $app->getService(Config::class)); + $this->assertSame($app->config(), $app->getService('config')); + $this->assertSame($app->site(), $app->getService('site')); + $this->assertSame($app->events(), $app->getService('events')); + } + + public function testServicesAreAvailableThroughMagicCalls(): void + { + $app = App::instance(); + + $this->assertSame($app->getService('templates'), $app->templates()); + $this->assertSame($app->getService('users'), $app->users()); + $this->assertSame($app->getService('csrfToken'), $app->csrfToken()); + } + + public function testUnknownMagicCallsAreRejected(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Call to undefined method ' . App::class . '::nothingLikeThis()'); + + App::instance()->nothingLikeThis(); + } + + public function testMagicCallsOnlyExposeDefinedServices(): void + { + $this->expectException(BadMethodCallException::class); + + App::instance()->container(); + } + + public function testServiceAvailability(): void + { + $app = App::instance(); + + $this->assertTrue($app->hasService(Config::class)); + $this->assertTrue($app->hasService('config')); + $this->assertFalse($app->hasService('nothing')); + $this->assertFalse($app->hasService('Nothing\Like\This')); + } + + public function testUnknownServicesAreRejected(): void + { + $this->expectException(ServiceNotFoundException::class); + + App::instance()->getService('nothing'); + } + + public function testTheApplicationAndTheContainerAreServices(): void + { + $app = App::instance(); + + $this->assertSame($app, $app->getService(App::class)); + $this->assertInstanceOf(Container::class, $app->getService(Container::class)); + } + + /** + * Updater and Backupper write inside the installation when they are created + * + * @return iterable + */ + public static function services(): iterable + { + $definitions = []; + $container = new class extends Container { + /** + * @return list + */ + public function names(): array + { + return array_keys($this->defined); + } + }; + (require TestInstallation::systemPath() . '/config/services/services.php')($container); + + foreach ($container->names() as $name) { + if (in_array($name, [Updater::class, Backupper::class], true)) { + continue; + } + $definitions[$name] = [$name]; + } + + yield from $definitions; + } + + #[DataProvider('services')] + public function testEveryDeclaredServiceCanBeResolved(string $name): void + { + $this->assertIsObject(App::instance()->getService($name)); + } + + // Error handler + + public function testWarningsAreTurnedIntoExceptions(): void + { + $handler = $this->errorHandler(); + + try { + $handler(E_WARNING, 'Something happened', '/path/file.php', 12); + $this->fail('Warnings must be converted to exceptions.'); + } catch (ErrorException $exception) { + $this->assertSame('Something happened', $exception->getMessage()); + $this->assertSame(E_WARNING, $exception->getSeverity()); + $this->assertSame('/path/file.php', $exception->getFile()); + $this->assertSame(12, $exception->getLine()); + } + } + + /** + * @return iterable + */ + public static function severities(): iterable + { + yield 'notice' => [E_NOTICE]; + yield 'warning' => [E_WARNING]; + yield 'user warning' => [E_USER_WARNING]; + yield 'user error' => [E_USER_ERROR]; + } + + #[DataProvider('severities')] + public function testEverySeverityExceptUserDeprecationsIsConverted(int $severity): void + { + $this->expectException(ErrorException::class); + + $this->errorHandler()($severity, 'message', 'file.php', 1); + } + + public function testEngineDeprecationsAreConverted(): void + { + $handler = $this->errorHandler(); + $previous = error_reporting(E_ALL); + + try { + $this->expectException(ErrorException::class); + $handler(E_DEPRECATED, 'engine deprecation', 'file.php', 1); + } finally { + error_reporting($previous); + } + } + + public function testUserDeprecationsAreLeftToThePhpHandler(): void + { + $this->assertFalse($this->errorHandler()(E_USER_DEPRECATED, 'deprecated', 'file.php', 1)); + } + + public function testSilencedErrorsAreIgnored(): void + { + $handler = $this->errorHandler(); + $previous = error_reporting(0); + + try { + $result = $handler(E_WARNING, 'silenced', 'file.php', 1); + } finally { + error_reporting($previous); + } + + $this->assertFalse($result); + } + + public function testErrorsOutsideTheErrorReportingLevelAreIgnored(): void + { + $handler = $this->errorHandler(); + $previous = error_reporting(E_ALL & ~E_NOTICE); + + try { + $result = $handler(E_NOTICE, 'ignored', 'file.php', 1); + } finally { + error_reporting($previous); + } + + $this->assertFalse($result); + } + + public function testErrorsAreNotDisplayed(): void + { + $display = ini_set('display_errors', '1'); + + try { + $this->loadApplicationWithErrorHandler(); + + $this->assertSame('0', ini_get('display_errors')); + } finally { + ini_set('display_errors', (string) $display); + } + } + + /** + * @return callable(int, string, string, int): bool + */ + private function errorHandler(): callable + { + if (self::$errorHandler === null) { + self::$errorHandler = $this->loadApplicationWithErrorHandler(); + } + + return self::$errorHandler; + } + + /** + * Install the error handler of the application, leaving the handlers as they were found + * + * @return callable(int, string, string, int): bool + */ + private function loadApplicationWithErrorHandler(): callable + { + $before = set_error_handler(static fn(): bool => false); + restore_error_handler(); + + // The error handler is installed when the application runs + (new ReflectionMethod(App::class, 'loadErrorHandler'))->invoke(App::instance()); + + $handler = set_error_handler(static fn(): bool => false); + restore_error_handler(); + + restore_error_handler(); + + $this->assertNotSame($before, $handler, 'The application must have installed its error handler'); + $this->assertIsCallable($handler); + + return $handler; + } + + /** + * Load again the application of the tests from the current globals + */ + private function reload(): void + { + App::instance()->unload(); + App::instance()->load(); + } + + private function setRequestGlobals(string $uri): void + { + $_SERVER = [ + ...$_SERVER, + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => $uri, + 'SCRIPT_NAME' => '/index.php', + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => '80', + 'HTTP_HOST' => 'localhost', + ]; + $_GET = $_POST = $_COOKIE = $_FILES = []; + } +} diff --git a/tests/Unit/Cms/Events/ExceptionThrownEventTest.php b/tests/Unit/Cms/Events/ExceptionThrownEventTest.php new file mode 100644 index 000000000..5dade2b42 --- /dev/null +++ b/tests/Unit/Cms/Events/ExceptionThrownEventTest.php @@ -0,0 +1,60 @@ +request(); + + $event = new ExceptionThrownEvent($throwable, $request); + + $this->assertInstanceOf(Event::class, $event); + $this->assertSame('exceptionThrown', $event->name()); + $this->assertSame($throwable, $event->throwable()); + $this->assertSame($request, $event->request()); + } + + public function testErrorsAreAcceptedAsThrowables(): void + { + $error = new TypeError('wrong type'); + + $this->assertSame($error, (new ExceptionThrownEvent($error, $this->request()))->throwable()); + } + + public function testDataContainsTheThrowableAndTheRequest(): void + { + $throwable = new Exception('failure'); + $request = $this->request(); + + $data = (new ExceptionThrownEvent($throwable, $request))->data(); + + $this->assertSame(['throwable' => $throwable, 'request' => $request], $data); + } + + public function testPropagationCanBeStopped(): void + { + $event = new ExceptionThrownEvent(new Exception(), $this->request()); + + $event->stopPropagation(); + + $this->assertTrue($event->isPropagationStopped()); + } + + private function request(): Request + { + return new Request([], [], [], [], ['REQUEST_METHOD' => 'GET', 'SERVER_NAME' => 'localhost', 'SERVER_PORT' => '80']); + } +} diff --git a/tests/Unit/Cms/Events/ResponseBeforeSendEventTest.php b/tests/Unit/Cms/Events/ResponseBeforeSendEventTest.php new file mode 100644 index 000000000..86b07c054 --- /dev/null +++ b/tests/Unit/Cms/Events/ResponseBeforeSendEventTest.php @@ -0,0 +1,50 @@ +request(); + + $event = new ResponseBeforeSendEvent($response, $request); + + $this->assertInstanceOf(Event::class, $event); + $this->assertSame('responseBeforeSend', $event->name()); + $this->assertSame($response, $event->response()); + $this->assertSame($request, $event->request()); + } + + public function testListenersCanModifyTheResponse(): void + { + $response = new Response('original'); + $event = new ResponseBeforeSendEvent($response, $this->request()); + + $event->response()->headers()->set('X-Listener', 'yes'); + + $this->assertSame('yes', $response->headers()->get('X-Listener')); + } + + public function testDataContainsTheResponseAndTheRequest(): void + { + $response = new Response('content'); + $request = $this->request(); + + $this->assertSame(['response' => $response, 'request' => $request], (new ResponseBeforeSendEvent($response, $request))->data()); + } + + private function request(): Request + { + return new Request([], [], [], [], ['REQUEST_METHOD' => 'GET', 'SERVER_NAME' => 'localhost', 'SERVER_PORT' => '80']); + } +} diff --git a/tests/Unit/Cms/Events/RoutesEventsTest.php b/tests/Unit/Cms/Events/RoutesEventsTest.php new file mode 100644 index 000000000..3e796a2ee --- /dev/null +++ b/tests/Unit/Cms/Events/RoutesEventsTest.php @@ -0,0 +1,51 @@ + $class + */ + #[DataProvider('eventProvider')] + public function testEventsExposeTheRouter(string $class, string $name): void + { + $router = $this->createStub(Router::class); + + $event = new $class($router); + + $this->assertInstanceOf(Event::class, $event); + $this->assertSame($name, $event->name()); + $this->assertSame($router, $event->router()); + $this->assertSame(['router' => $router], $event->data()); + } + + /** + * @return iterable + */ + public static function eventProvider(): iterable + { + yield 'before load' => [RoutesBeforeLoadEvent::class, 'routesBeforeLoad']; + yield 'after load' => [RoutesAfterLoadEvent::class, 'routesAfterLoad']; + } + + public function testEventsHaveDifferentNames(): void + { + $router = $this->createStub(Router::class); + + $this->assertNotSame( + (new RoutesBeforeLoadEvent($router))->name(), + (new RoutesAfterLoadEvent($router))->name(), + ); + } +} diff --git a/tests/Unit/Cms/SiteTest.php b/tests/Unit/Cms/SiteTest.php new file mode 100644 index 000000000..db32481f8 --- /dev/null +++ b/tests/Unit/Cms/SiteTest.php @@ -0,0 +1,290 @@ +setUpTempDirectory(); + $this->site = $this->siteFromFiles([ + 'index/index.md' => "---\ntitle: Home\n---\nHome\n", + 'error/error.md' => "---\ntitle: Error\n---\nNot found\n", + '1-first/page.md' => "---\ntitle: First\n---\nFirst\n", + '1-first/nested/page.md' => "---\ntitle: Nested\n---\nNested\n", + '2-second/page.md' => "---\ntitle: Second\n---\nSecond\n", + '_hidden/page.md' => "---\ntitle: Hidden\n---\nHidden\n", + 'no-content/readme.txt' => 'a directory without a content file', + 'empty-directory/.gitkeep' => '', + ]); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + // Identity + + public function testSiteIsASite(): void + { + $this->assertTrue($this->site->isSite()); + $this->assertFalse($this->site->isIndexPage()); + $this->assertFalse($this->site->isErrorPage()); + $this->assertFalse($this->site->isIndexOrErrorPage()); + $this->assertFalse($this->site->isDeletable()); + $this->assertSame('site', $this->site->getModelIdentifier()); + $this->assertSame($this->site, $this->site->site()); + } + + public function testRouteAndSlugOfTheSite(): void + { + $this->assertSame('/', $this->site->route()); + $this->assertSame('', $this->site->slug()); + $this->assertNull($this->site->canonicalRoute()); + $this->assertNull($this->site->parent()); + } + + public function testSiteIsStringifiedThroughItsTitle(): void + { + $site = $this->siteFromFiles([]); + $site->set('title', 'My site'); + + $this->assertSame('My site', (string) $site); + } + + // Finding pages + + public function testPagesAreFoundByRoute(): void + { + $this->assertSame('First', $this->site->findPage('/first')?->title()); + $this->assertSame('First', $this->site->findPage('/first/')?->title()); + $this->assertSame('First', $this->site->findPage('first')?->title()); + $this->assertSame('Nested', $this->site->findPage('/first/nested')?->title()); + } + + public function testNumberPrefixesAreNotPartOfTheRoute(): void + { + $this->assertNotNull($this->site->findPage('/second')); + $this->assertNull($this->site->findPage('/2-second')?->route()); + $this->assertNull($this->site->findPage('/1-first')?->route()); + } + + public function testTheRootRouteIsTheIndexPage(): void + { + $this->assertSame($this->site->indexPage(), $this->site->findPage('/')); + $this->assertSame('/index/', $this->site->indexPage()->route()); + } + + public function testUnknownRoutesAreNotFound(): void + { + $this->assertNull($this->site->findPage('/missing')?->route()); + $this->assertNull($this->site->findPage('/first/missing')?->route()); + $this->assertNull($this->site->findPage('/missing/nested')?->route()); + $this->assertNull($this->site->findPage('')?->route()); + $this->assertNull($this->site->findPage('//')?->route()); + $this->assertNull($this->site->findPage('/first//nested')?->route()); + } + + public function testRoutesAreCaseSensitive(): void + { + $this->assertNull($this->site->findPage('/FIRST')?->route()); + } + + /** + * @return iterable + */ + public static function dotRoutes(): iterable + { + yield 'current directory' => ['/first/./nested']; + yield 'parent directory' => ['/first/../second']; + yield 'above the content' => ['/../']; + yield 'dots only' => ['/..']; + yield 'encoded dots' => ['/first/%2e%2e/second']; + yield 'backslash' => ['/first\nested']; + yield 'null byte' => ["/first\0"]; + } + + #[DataProvider('dotRoutes')] + public function testRoutesWithDotSegmentsNeverEscapeTheContentTree(string $route): void + { + $this->assertNull($this->site->findPage($route)?->route()); + } + + public function testDirectoriesWithoutContentAreNotListed(): void + { + $routes = array_values($this->site->children()->everyItem()->route()->toArray()); + + $this->assertNotContains('/no-content/', $routes); + $this->assertNotContains('/empty-directory/', $routes); + $this->assertNotContains('/_hidden/', $routes); + } + + public function testPagesAreRetrievedOnce(): void + { + $path = $this->site->contentPath() . '1-first/'; + + $this->assertSame($this->site->retrievePage($path), $this->site->retrievePage($path)); + $this->assertSame($this->site->findPage('/first'), $this->site->findPage('/first')); + } + + public function testRetrievedPagesAreOrderedByPath(): void + { + $routes = array_values($this->site->retrievePages((string) $this->site->contentPath())->everyItem()->route()->toArray()); + + $this->assertSame(array_values(array_filter($routes, fn($route) => $route !== null)), $routes); + $this->assertSame(['/first/', '/second/'], array_slice($routes, 0, 2)); + } + + public function testRecursiveRetrievalIncludesNestedPages(): void + { + $content = (string) $this->site->contentPath(); + + $flat = array_values($this->site->retrievePages($content)->everyItem()->route()->toArray()); + $recursive = array_values($this->site->retrievePages($content, recursive: true)->everyItem()->route()->toArray()); + + $this->assertNotContains('/first/nested/', $flat); + $this->assertContains('/first/nested/', $recursive); + } + + public function testPagesAndChildrenAgree(): void + { + $this->assertSame($this->site->children(), $this->site->pages()); + $this->assertTrue($this->site->hasPages()); + $this->assertFalse($this->siteFromFiles([])->hasPages()); + } + + // Index and error pages + + public function testErrorPage(): void + { + $this->assertSame('/error/', $this->site->errorPage()->route()); + } + + public function testMissingIndexPageIsReported(): void + { + $site = $this->siteFromFiles(['first/page.md' => "---\ntitle: First\n---\n"]); + + $this->expectException(PageNotFoundException::class); + + $site->indexPage(); + } + + public function testMissingErrorPageIsReported(): void + { + $site = $this->siteFromFiles(['first/page.md' => "---\ntitle: First\n---\n"]); + + $this->expectException(PageNotFoundException::class); + + $site->errorPage(); + } + + // Current page + + public function testCurrentPageIsUnsetByDefaultAndCanBeSet(): void + { + $this->assertNull($this->site->currentPage()); + + $page = $this->pageAt($this->site, '/first'); + + $this->assertSame($page, $this->site->setCurrentPage($page)); + $this->assertSame($page, $this->site->currentPage()); + } + + // Route aliases + + public function testRouteAliasesAreNormalized(): void + { + $site = $this->siteFromFiles([]); + $site->set('routeAliases', ['/old-url/' => 'new-url', 'plain' => '/target/']); + + $this->assertSame(['old-url' => '/new-url/', 'plain' => '/target/'], $site->routeAliases()); + $this->assertSame('/new-url/', $site->resolveRouteAlias('old-url')); + $this->assertNull($site->resolveRouteAlias('unknown')); + } + + // Modification time + + public function testLastModifiedTimeIsThatOfTheContentDirectory(): void + { + touch((string) $this->site->contentPath(), 1_700_000_000); + + $this->assertSame(1_700_000_000, $this->site->lastModifiedTime()); + } + + public function testModifiedSinceLooksIntoNestedFiles(): void + { + $content = (string) $this->site->contentPath(); + $old = time() - 1000; + $this->touchTree($content, $old); + + $this->assertFalse($this->site->modifiedSince($old + 10)); + + touch($content . '1-first/nested/page.md', time()); + touch($content . '1-first/nested/', time()); + touch($content . '1-first/', time()); + + $this->assertTrue($this->site->modifiedSince($old + 10)); + } + + // Metadata + + public function testMetadataIncludesTheDefaultsAndTheConfiguredValues(): void + { + $site = $this->siteFromFiles([]); + $site->set('metadata', ['description' => 'A site', 'robots' => 'noindex']); + + $metadata = $site->metadata(); + + $this->assertSame('A site', $metadata->get('description')?->content()); + $this->assertSame('noindex', $metadata->get('robots')?->content()); + $this->assertNotNull($metadata->get('charset')); + } + + public function testMetadataIsBuiltOnce(): void + { + $this->assertSame($this->site->metadata(), $this->site->metadata()); + } + + // Misc + + public function testSchemesAndLanguagesComeFromTheApplication(): void + { + $this->assertSame(App::instance()->schemes(), $this->site->schemes()); + $this->assertSame('config.site', $this->site->scheme()->id()); + $this->assertSame(App::instance()->getService(Languages::class), $this->site->languages()); + } + + public function testFindPageReturnsPageInstances(): void + { + $this->assertContainsOnlyInstancesOf(Page::class, [$this->site->findPage('/first'), $this->site->indexPage()]); + } + + private function touchTree(string $path, int $time): void + { + foreach (FileSystem::listRecursive($path) as $item) { + touch(FileSystem::joinPaths($path, $item), $time); + } + touch($path, $time); + } +} diff --git a/tests/Unit/Cms/UriGeneratorTest.php b/tests/Unit/Cms/UriGeneratorTest.php new file mode 100644 index 000000000..f20101347 --- /dev/null +++ b/tests/Unit/Cms/UriGeneratorTest.php @@ -0,0 +1,76 @@ +generator('/subdir/index.php'); + + $this->assertSame('/subdir/page/', $generator->path('/page/')); + $this->assertSame('/subdir/page/', $generator->path('page/')); + $this->assertSame('/subdir/assets/a.css', $generator->path('/assets/a.css')); + } + + public function testInstallationInTheDocumentRoot(): void + { + $generator = $this->generator('/index.php'); + + $this->assertSame('/page/', $generator->path('/page/')); + $this->assertSame('/', $generator->path('/')); + } + + public function testSlashesAreNormalized(): void + { + $this->assertSame('/subdir/a/b/', $this->generator('/subdir/index.php')->path('//a///b//')); + } + + public function testRoutesAreGeneratedByNameAndPlacedUnderTheRoot(): void + { + $router = $this->createMock(Router::class); + $router->expects($this->once())->method('generate')->with('page', ['page' => 'about'])->willReturn('/about/'); + + $generator = $this->generator('/subdir/index.php', $router); + + $this->assertSame('/subdir/about/', $generator->route('page', ['page' => 'about'])); + } + + /** + * @return iterable + */ + public static function traversals(): iterable + { + yield 'parent directory' => ['../secret']; + yield 'several parents' => ['../../../../secret']; + yield 'nested parents' => ['a/../../../secret']; + } + + #[DataProvider('traversals')] + public function testPathsCannotClimbAboveTheRequestRoot(string $path): void + { + $uri = $this->generator('/subdir/deeper/index.php')->path($path); + + $this->assertStringStartsWith('/subdir/deeper/', $uri); + } + + public function testQueryStringsAndFragmentsArePreserved(): void + { + $this->assertSame('/subdir/page/?a=1#top', $this->generator('/subdir/index.php')->path('/page/?a=1#top')); + } + + private function generator(string $scriptName, ?Router $router = null): UriGenerator + { + $request = new Request([], [], [], [], ['SCRIPT_NAME' => $scriptName, 'REQUEST_METHOD' => 'GET']); + + return new UriGenerator($request, $router ?? $this->createStub(Router::class)); + } +} diff --git a/tests/Unit/Config/ConfigTest.php b/tests/Unit/Config/ConfigTest.php new file mode 100644 index 000000000..7fc27220a --- /dev/null +++ b/tests/Unit/Config/ConfigTest.php @@ -0,0 +1,369 @@ +setUpTempDirectory(); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + } + + 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 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); + + $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->assertSame([ + 'system' => [ + 'debug' => true, + 'paths' => ['cache' => '/cache'], + 'name' => 'Formwork', + ], + 'nullable' => null, + ], $config->toArray()); + } + + 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 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'; + 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 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'; + + $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()); + } + + 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/Controllers/AbstractControllerTest.php b/tests/Unit/Controllers/AbstractControllerTest.php new file mode 100644 index 000000000..624d5b125 --- /dev/null +++ b/tests/Unit/Controllers/AbstractControllerTest.php @@ -0,0 +1,203 @@ +setUpTempDirectory(); + FileSystem::createDirectory(TESTS_TMP_PATH . '/views'); + FileSystem::write(TESTS_TMP_PATH . '/views/greeting.php', 'Hello escape($name) ?>'); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testNameIsDerivedFromTheClassName(): void + { + $this->assertSame('exposed', $this->controller()->name()); + } + + public function testViewsAreRenderedThroughTheViewFactory(): void + { + $factory = new ViewFactory(['escape' => static fn(string $text): string => htmlspecialchars($text)], ['test' => TESTS_TMP_PATH . '/views'], App::instance()); + + $controller = $this->controller(services: [ViewFactory::class => $factory]); + + $this->assertSame('Hello <World>', $controller->render('@test.greeting', ['name' => ''])); + } + + // Redirects + + public function testRedirectsGoThroughTheApplicationUri(): void + { + $response = $this->controller()->redirectTo('/panel/login/'); + + $this->assertSame(ResponseStatus::Found, $response->status()); + $this->assertSame(App::instance()->uri()->path('/panel/login/'), $response->headers()->get('Location')); + } + + public function testRedirectStatusAndHeadersCanBeChosen(): void + { + $response = $this->controller()->redirectTo('/moved/', ResponseStatus::MovedPermanently, ['X-Custom' => 'value']); + + $this->assertSame(ResponseStatus::MovedPermanently, $response->status()); + $this->assertSame('value', $response->headers()->get('X-Custom')); + } + + public function testRedirectToRefererReturnsToTheSameSitePage(): void + { + $response = $this->controller(server: ['REQUEST_URI' => '/current/', 'HTTP_REFERER' => 'http://localhost/previous/'])->redirectBack(); + + $this->assertSame('http://localhost/previous/', $response->headers()->get('Location')); + } + + public function testRedirectToRefererFallsBackToTheDefaultWithoutReferer(): void + { + $response = $this->controller(server: ['REQUEST_URI' => '/current/'])->redirectBack(default: '/home/'); + + $this->assertSame(App::instance()->uri()->path('/home/'), $response->headers()->get('Location')); + } + + public function testRedirectToRefererDoesNotLoopOnTheCurrentPage(): void + { + $response = $this->controller(server: ['SCRIPT_NAME' => '/site/index.php', 'REQUEST_URI' => '/site/current/', 'HTTP_REFERER' => 'http://localhost/site/current/'])->redirectBack(default: '/home/'); + + $this->assertSame(App::instance()->uri()->path('/home/'), $response->headers()->get('Location')); + } + + /** + * @return iterable + */ + public static function foreignReferers(): iterable + { + yield 'other host' => ['https://evil.example/previous/']; + yield 'other scheme' => ['https://localhost/previous/']; + yield 'host as prefix of another host' => ['http://localhost.evil.example/previous/']; + yield 'host with userinfo' => ['http://localhost@evil.example/previous/']; + yield 'protocol relative' => ['//evil.example/previous/']; + yield 'javascript scheme' => ['javascript:alert(1)']; + yield 'data scheme' => ['data:text/html,']; + yield 'other port' => ['http://localhost:8080/previous/']; + yield 'relative path' => ['/previous/']; + yield 'garbage' => ['not a url']; + } + + #[DataProvider('foreignReferers')] + public function testRedirectToRefererNeverLeavesTheSite(string $referer): void + { + $response = $this->controller(server: ['REQUEST_URI' => '/current/', 'HTTP_REFERER' => $referer])->redirectBack(default: '/home/'); + + $location = (string) $response->headers()->get('Location'); + $this->assertSame(App::instance()->uri()->path('/home/'), $location, sprintf('Referer "%s" was trusted', $referer)); + } + + public function testRedirectToRefererHonoursTheBasePath(): void + { + $inside = $this->controller(server: ['REQUEST_URI' => '/panel/current/', 'HTTP_REFERER' => 'http://localhost/panel/previous/'])->redirectBack(default: '/home/', base: '/panel/'); + $outside = $this->controller(server: ['REQUEST_URI' => '/panel/current/', 'HTTP_REFERER' => 'http://localhost/other/'])->redirectBack(default: '/home/', base: '/panel/'); + + $this->assertSame('http://localhost/panel/previous/', $inside->headers()->get('Location')); + $this->assertSame(App::instance()->uri()->path('/home/'), $outside->headers()->get('Location')); + } + + public function testRedirectToRefererKeepsStatusAndHeaders(): void + { + $response = $this->controller(server: ['REQUEST_URI' => '/current/', 'HTTP_REFERER' => 'http://localhost/previous/'])->redirectBack(ResponseStatus::SeeOther, ['X-Custom' => 'value']); + + $this->assertSame(ResponseStatus::SeeOther, $response->status()); + $this->assertSame('value', $response->headers()->get('X-Custom')); + } + + public function testRedirectToRefererDoesNotLoopOnTheCurrentPageOfASiteInTheDocumentRoot(): void + { + $response = $this->controller(server: ['REQUEST_URI' => '/current/', 'HTTP_REFERER' => 'http://localhost/current/'])->redirectBack(default: '/home/'); + + $this->assertSame(App::instance()->uri()->path('/home/'), $response->headers()->get('Location')); + } + + // Forwarding + + public function testRequestsCanBeForwardedToAnotherController(): void + { + $response = $this->controller()->forwardTo(ForwardTarget::class, 'greet', ['name' => 'Ada']); + + $this->assertSame('Hello Ada from forwardtarget', $response->content()); + } + + public function testForwardedActionsReceiveTheirDefaults(): void + { + $this->assertSame('Hello nobody from forwardtarget', $this->controller()->forwardTo(ForwardTarget::class, 'greet')->content()); + } + + /** + * @return iterable + */ + public static function notControllers(): iterable + { + yield 'plain class' => [stdClass::class]; + yield 'response' => [Response::class]; + yield 'the abstract controller itself' => [AbstractController::class]; + } + + #[DataProvider('notControllers')] + public function testOnlyControllersCanBeForwardedTo(string $class): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Controllers must extend'); + + $this->controller()->forwardTo($class, 'anything'); + } + + public function testNonExistentClassesCannotBeForwardedTo(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->controller()->forwardTo('Nothing\Like\This', 'anything'); + } + + // Routes + + public function testRoutesAreGeneratedByTheRouter(): void + { + $router = $this->createMock(Router::class); + $router->expects($this->once())->method('generate')->with('page', ['page' => 'about'])->willReturn('/about/'); + + $this->assertSame('/about/', $this->controller(router: $router)->route('page', ['page' => 'about'])); + } + + /** + * @param array $server + * @param array $services + */ + private function controller(array $server = [], array $services = [], ?Router $router = null): ExposedController + { + return $this->makeController(ExposedController::class, server: $server, services: $services, router: $router); + } +} diff --git a/tests/Unit/Controllers/AssetsControllerTest.php b/tests/Unit/Controllers/AssetsControllerTest.php new file mode 100644 index 000000000..2ab887d68 --- /dev/null +++ b/tests/Unit/Controllers/AssetsControllerTest.php @@ -0,0 +1,180 @@ +setUpTempDirectory(); + + $this->processPath = $this->createDirectory(TESTS_TMP_PATH . '/images'); + $this->templatesPath = $this->createDirectory(TESTS_TMP_PATH . '/templates'); + + $this->createDirectory($this->processPath . '/abc123'); + FileSystem::write($this->processPath . '/abc123/photo.jpg', 'image bytes'); + FileSystem::write(TESTS_TMP_PATH . '/secret.txt', 'outside of the images directory'); + + $this->createDirectory($this->templatesPath . '/assets/css'); + FileSystem::write($this->templatesPath . '/assets/css/site.css', 'body {}'); + FileSystem::write($this->templatesPath . '/secret.php', 'tearDownTempDirectory(); + parent::tearDown(); + } + + // asset() + + public function testProcessedImagesAreServedWithLongLivedCacheHeaders(): void + { + $response = $this->controller()->asset(new RouteParams(['type' => 'images', 'id' => 'abc123', 'name' => 'photo.jpg'])); + + $this->assertInstanceOf(FileResponse::class, $response); + $this->assertSame($this->processPath . '/abc123/photo.jpg', $this->fileOf($response)); + $this->assertSame('private, max-age=31536000, immutable', $response->headers()->get('Cache-Control')); + } + + public function testMissingImagesFallBackToTheErrorPage(): void + { + $response = $this->controller()->asset(new RouteParams(['type' => 'images', 'id' => 'abc123', 'name' => 'missing.jpg'])); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + $this->assertSame('error page', $response->content()); + } + + public function testDirectoriesAreNotServed(): void + { + $response = $this->controller()->asset(new RouteParams(['type' => 'images', 'id' => 'abc123', 'name' => '.'])); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + } + + public function testRoutesWithoutTypeAreRedirectedWithADeprecation(): void + { + $router = $this->createStub(Router::class); + $router->method('rewrite')->willReturnCallback(static fn(array $params): string => '/assets/' . $params['type'] . '/abc123/photo.jpg/'); + $controller = $this->controller($router); + $response = null; + + $messages = $this->captureDeprecations(function () use ($controller, &$response): void { + $response = $controller->asset(new RouteParams(['id' => 'abc123', 'name' => 'photo.jpg'])); + }); + + $this->assertCount(1, $messages); + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame(ResponseStatus::MovedPermanently, $response->status()); + $this->assertStringEndsWith('/assets/images/abc123/photo.jpg', (string) $response->headers()->get('Location')); + } + + /** + * @return iterable + */ + public static function imageTraversals(): iterable + { + yield 'parent as id' => ['..', 'secret.txt']; + yield 'parent in the name' => ['abc123', '../../secret.txt']; + yield 'nested parents in the name' => ['abc123', 'x/../../../secret.txt']; + yield 'backslashes' => ['abc123', '..\..\secret.txt']; + yield 'url encoded parents' => ['abc123', '%2e%2e/%2e%2e/secret.txt']; + yield 'null byte' => ['abc123', "photo.jpg\0../../secret.txt"]; + } + + #[DataProvider('imageTraversals')] + public function testFilesOutsideTheImagesDirectoryAreNeverServed(string $id, string $name): void + { + $response = $this->controller()->asset(new RouteParams(['type' => 'images', 'id' => $id, 'name' => $name])); + + $this->assertNotInstanceOf(FileResponse::class, $response, 'Served ' . ($response instanceof FileResponse ? $this->fileOf($response) : '')); + $this->assertSame(ResponseStatus::NotFound, $response->status()); + } + + // template() + + public function testTemplateAssetsAreServed(): void + { + $response = $this->controller()->template(new RouteParams(['file' => 'css/site.css'])); + + $this->assertInstanceOf(FileResponse::class, $response); + $this->assertSame($this->templatesPath . '/assets/css/site.css', $this->fileOf($response)); + $this->assertNull($response->headers()->get('Cache-Control'), 'Without a version query string the asset must not be cached forever'); + } + + public function testVersionedTemplateAssetsAreCachedForever(): void + { + $response = $this->controller(query: ['v' => 'abc'])->template(new RouteParams(['file' => 'css/site.css'])); + + $this->assertSame('private, max-age=31536000, immutable', $response->headers()->get('Cache-Control')); + } + + public function testMissingTemplateAssetsFallBackToTheErrorPage(): void + { + $response = $this->controller()->template(new RouteParams(['file' => 'css/missing.css'])); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + } + + /** + * @return iterable + */ + public static function templateTraversals(): iterable + { + yield 'parent directory' => ['../secret.php']; + yield 'two parents' => ['../../outside.txt']; + yield 'nested parents' => ['css/../../secret.php']; + yield 'many parents' => ['../../../../../../../../../../etc/hostname']; + yield 'backslashes' => ['..\secret.php']; + yield 'absolute path' => ['/etc/hostname']; + } + + #[DataProvider('templateTraversals')] + public function testTemplateSourcesAndOtherFilesAreNotServedAsAssets(string $file): void + { + $response = $this->controller()->template(new RouteParams(['file' => $file])); + + $this->assertNotInstanceOf(FileResponse::class, $response, 'Served ' . ($response instanceof FileResponse ? $this->fileOf($response) : '')); + } + + /** + * @param array $query + */ + private function controller(?Router $router = null, array $query = []): AssetsController + { + return $this->makeController(AssetsController::class, [ + 'images' => ['processPath' => $this->processPath], + 'templates' => ['path' => $this->templatesPath], + ], $query, router: $router); + } + + private function fileOf(Response $response): string + { + return (string) (new ReflectionProperty($response, 'path'))->getValue($response); + } +} diff --git a/tests/Unit/Controllers/ErrorsControllerTest.php b/tests/Unit/Controllers/ErrorsControllerTest.php new file mode 100644 index 000000000..5f96a517a --- /dev/null +++ b/tests/Unit/Controllers/ErrorsControllerTest.php @@ -0,0 +1,242 @@ +setUpTempDirectory(); + + $this->outputBufferLevel = ob_get_level(); + + // Error responses discard every active output buffer, including the ones used by the test runner + $this->errorLog = TESTS_TMP_PATH . '/error.log'; + FileSystem::write($this->errorLog, ''); + $this->originalErrorLog = ini_get('error_log'); + } + + protected function tearDown(): void + { + ini_set('error_log', $this->originalErrorLog === false ? '' : $this->originalErrorLog); + while (ob_get_level() < $this->outputBufferLevel) { + ob_start(); + } + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testErrorsControllerCanBeUsedWhereTheErrorsInterfaceIsExpected(): void + { + $this->assertInstanceOf(ErrorsControllerInterface::class, $this->controller()); + } + + public function testNotFoundResponse(): void + { + $response = $this->controller()->notFound(); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + $this->assertStringContainsString('Not Found', $response->content()); + } + + public function testForbiddenResponse(): void + { + $this->assertSame(ResponseStatus::Forbidden, $this->controller()->forbidden()->status()); + } + + public function testInternalServerErrorResponse(): void + { + $response = $this->controller()->internalServerError(new RuntimeException('boom')); + + $this->assertSame(ResponseStatus::InternalServerError, $response->status()); + } + + public function testStatusIsTakenFromTheArgument(): void + { + $this->assertSame(ResponseStatus::ServiceUnavailable, $this->controller()->error(ResponseStatus::ServiceUnavailable)->status()); + } + + public function testErrorDetailsAreNotRevealedToRemoteVisitors(): void + { + $exception = new RuntimeException('database password is hunter2'); + + $response = $this->controller(debug: false)->internalServerError($exception); + + $this->assertStringNotContainsString('hunter2', $response->content()); + $this->assertStringNotContainsString(__FILE__, $response->content()); + $this->assertStringNotContainsString('RuntimeException', $response->content()); + } + + public function testErrorDetailsAreNotRevealedInJsonToRemoteVisitors(): void + { + $response = $this->controller(debug: false, xhr: true)->internalServerError(new RuntimeException('database password is hunter2')); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertStringNotContainsString('hunter2', $response->content()); + } + + public function testErrorDetailsAreShownInDebugMode(): void + { + $response = $this->controller(debug: true)->internalServerError(new RuntimeException('visible message')); + + $this->assertStringContainsString('visible message', $response->content()); + } + + public function testErrorDetailsAreShownToLocalhost(): void + { + $response = $this->controller(debug: false, ip: '127.0.0.1')->internalServerError(new RuntimeException('visible to localhost')); + + $this->assertStringContainsString('visible to localhost', $response->content()); + } + + public function testExceptionMessagesAreEscapedInTheDebugPage(): void + { + $response = $this->controller(debug: true)->internalServerError(new RuntimeException('')); + + $this->assertStringNotContainsString('', $response->content()); + } + + public function testAjaxRequestsReceiveJson(): void + { + $response = $this->controller(xhr: true)->notFound(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame(ResponseStatus::NotFound, $response->status()); + $this->assertSame('error', json_decode($response->content(), true)['status']); + } + + public function testAjaxDebugResponsesIncludeTheExceptionMessage(): void + { + $response = $this->controller(debug: true, xhr: true)->internalServerError(new RuntimeException('visible message')); + + $this->assertStringContainsString('visible message', $response->content()); + } + + public function testThrowablesAreLogged(): void + { + $this->controller()->internalServerError(new RuntimeException('logged message')); + + $log = FileSystem::read($this->errorLog); + $this->assertStringContainsString('Uncaught RuntimeException: logged message', $log); + } + + public function testNothingIsLoggedWithoutAThrowable(): void + { + $this->controller()->notFound(); + + $this->assertSame('', FileSystem::read($this->errorLog)); + } + + public function testPendingOutputIsDiscarded(): void + { + echo 'partial output that must not be sent'; + ob_start(); + echo 'more output'; + + $this->controller()->notFound(); + + $this->assertSame(0, ob_get_level()); + } + + // Trace + + public function testTraceStartsWithTheOriginOfTheException(): void + { + $exception = new Exception('origin'); + + $trace = $this->trace($exception); + + $this->assertSame($exception->getFile(), $trace[0]['file']); + $this->assertSame($exception->getLine(), $trace[0]['line']); + $this->assertArrayNotHasKey('function', $trace[0]); + $this->assertCount(count($exception->getTrace()) + 1, $trace); + } + + public function testTraceOfNullIsEmpty(): void + { + $this->assertSame([], $this->trace(null)); + } + + public function testErrorsWhoseFirstFrameIsTheOriginAreNotDuplicated(): void + { + $error = new ErrorException('warning', 0, E_WARNING, 'origin.php', 10); + $frame = ['file' => 'origin.php', 'line' => 10, 'function' => 'strlen']; + + $trace = $this->trace($this->exceptionWithTrace($error, [$frame, ['file' => 'caller.php', 'line' => 3, 'function' => 'run']])); + + $this->assertSame([$frame, ['file' => 'caller.php', 'line' => 3, 'function' => 'run']], $trace); + } + + public function testApplicationErrorHandlerFrameIsDropped(): void + { + $error = new ErrorException('warning', 0, E_WARNING, 'origin.php', 10); + $handler = ['class' => App::class, 'function' => 'App->{closure}', 'type' => '->']; + $frame = ['file' => 'origin.php', 'line' => 10, 'function' => 'strlen']; + + $trace = $this->trace($this->exceptionWithTrace($error, [$handler, $frame])); + + $this->assertSame([$frame], $trace); + } + + /** + * @param list> $frames + */ + private function exceptionWithTrace(ErrorException $exception, array $frames): ErrorException + { + (new ReflectionProperty(Exception::class, 'trace'))->setValue($exception, $frames); + return $exception; + } + + /** + * @return array> + */ + private function trace(?Throwable $throwable): array + { + $controller = $this->controller(); + + return (new ReflectionMethod($controller, 'getTrace'))->invoke($controller, $throwable); + } + + private function controller(bool $debug = false, bool $xhr = false, string $ip = '203.0.113.7'): ErrorsController + { + // PHPUnit points the error log to its own file while a test runs, so the redirection must happen inside the test + ini_set('error_log', $this->errorLog); + + return $this->makeController( + ErrorsController::class, + ['debug' => ['enabled' => $debug]], + server: ['REMOTE_ADDR' => $ip] + ($xhr ? ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'] : []), + services: [ViewFactory::class => App::instance()->getService(ViewFactory::class)], + ); + } +} diff --git a/tests/Unit/Controllers/FilesControllerTest.php b/tests/Unit/Controllers/FilesControllerTest.php new file mode 100644 index 000000000..4fc0ce230 --- /dev/null +++ b/tests/Unit/Controllers/FilesControllerTest.php @@ -0,0 +1,98 @@ +setUpTempDirectory(); + + $this->filesPath = $this->createDirectory(TESTS_TMP_PATH . '/site/files'); + FileSystem::write($this->filesPath . '/document.pdf', '%PDF-1.4 content'); + $this->createDirectory($this->filesPath . '/nested'); + FileSystem::write($this->filesPath . '/nested/inner.txt', 'inner'); + FileSystem::write(TESTS_TMP_PATH . '/site/config.yaml', 'secret: configuration'); + FileSystem::write(TESTS_TMP_PATH . '/site/secret.txt', 'outside of the files directory'); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testFilesAreServed(): void + { + $response = $this->controller()->file(new RouteParams(['name' => 'document.pdf'])); + + $this->assertInstanceOf(FileResponse::class, $response); + $this->assertSame($this->filesPath . '/document.pdf', $this->fileOf($response)); + } + + public function testMissingFilesFallBackToTheErrorPage(): void + { + $response = $this->controller()->file(new RouteParams(['name' => 'missing.pdf'])); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + $this->assertSame('error page', $response->content()); + } + + public function testDirectoriesAreNotServed(): void + { + $this->assertSame(ResponseStatus::NotFound, $this->controller()->file(new RouteParams(['name' => 'nested']))->status()); + $this->assertSame(ResponseStatus::NotFound, $this->controller()->file(new RouteParams(['name' => '.']))->status()); + } + + /** + * @return iterable + */ + public static function traversals(): iterable + { + yield 'parent directory' => ['../secret.txt']; + yield 'nested parents' => ['nested/../../secret.txt']; + yield 'configuration' => ['../config.yaml']; + yield 'backslashes' => ['..\secret.txt']; + yield 'many parents' => ['../../../../../../../../../../etc/hostname']; + yield 'null byte' => ["document.pdf\0../secret.txt"]; + } + + #[DataProvider('traversals')] + public function testFilesOutsideTheFilesDirectoryAreNeverServed(string $name): void + { + $response = $this->controller()->file(new RouteParams(['name' => $name])); + + $this->assertNotInstanceOf(FileResponse::class, $response, 'Served ' . ($response instanceof FileResponse ? $this->fileOf($response) : '')); + $this->assertSame(ResponseStatus::NotFound, $response->status()); + } + + private function controller(): FilesController + { + return $this->makeController(FilesController::class, ['files' => ['paths' => ['site' => $this->filesPath]]]); + } + + private function fileOf(Response $response): string + { + return (string) (new ReflectionProperty($response, 'path'))->getValue($response); + } +} diff --git a/tests/Unit/Controllers/Fixtures/BuildsControllers.php b/tests/Unit/Controllers/Fixtures/BuildsControllers.php new file mode 100644 index 000000000..42ea8cb6f --- /dev/null +++ b/tests/Unit/Controllers/Fixtures/BuildsControllers.php @@ -0,0 +1,81 @@ + $class + * @param array $system Overrides of the `system` configuration + * @param array $site Overrides of the `site` configuration + * @param array $query Request query + * @param array $server Overrides of the request server data + * @param array $services Additional or replacing container services + * + * @template T of object + * + * @return T + */ + protected function makeController(string $class, array $system = [], array $query = [], array $server = [], array $services = [], ?Router $router = null, array $site = []): object + { + $app = App::instance(); + + $config = new Config([ + 'system' => array_replace_recursive($app->config()->get('system'), $system), + 'site' => array_replace_recursive($app->config()->get('site') ?? [], $site), + ], resolved: true); + + $request = new Request([], $query, [], [], $server + [ + 'REQUEST_METHOD' => 'GET', + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => '80', + 'REMOTE_ADDR' => '203.0.113.7', + ]); + + $this->router = $router ?? $this->createStub(Router::class); + + $container = new ForwardingContainer(replacePageController: $class !== PageController::class); + + foreach ([ + Container::class => $container, + App::class => $app, + Config::class => $config, + Request::class => $request, + Router::class => $this->router, + EventDispatcher::class => new EventDispatcher(), + ] as $name => $service) { + $container->define($name, $services[$name] ?? $service); + unset($services[$name]); + } + + foreach ($services as $name => $service) { + $container->define($name, $service); + } + + return $container->build($class); + } + + protected function createDirectory(string $path): string + { + if (!FileSystem::isDirectory($path, assertExists: false)) { + FileSystem::createDirectory($path, recursive: true); + } + return $path; + } +} diff --git a/tests/Unit/Controllers/Fixtures/ErrorPageController.php b/tests/Unit/Controllers/Fixtures/ErrorPageController.php new file mode 100644 index 000000000..d7d5d7f06 --- /dev/null +++ b/tests/Unit/Controllers/Fixtures/ErrorPageController.php @@ -0,0 +1,17 @@ +name; + } + + public function render(string $name, array $data = []): string + { + return $this->view($name, $data); + } + + public function makeForm(string $name, FieldCollection $fields): Form + { + return $this->form($name, $fields); + } + + public function redirectTo(string $route, ResponseStatus $status = ResponseStatus::Found, array $headers = []): RedirectResponse + { + return $this->redirect($route, $status, $headers); + } + + public function redirectBack(ResponseStatus $status = ResponseStatus::Found, array $headers = [], string $default = '/', string $base = '/'): RedirectResponse + { + return $this->redirectToReferer($status, $headers, $default, $base); + } + + public function forwardTo(string $controller, string $action, array $parameters = []): Response + { + return $this->forward($controller, $action, $parameters); + } + + public function route(string $name, array $params = []): string + { + return $this->generateRoute($name, $params); + } +} diff --git a/tests/Unit/Controllers/Fixtures/ForwardTarget.php b/tests/Unit/Controllers/Fixtures/ForwardTarget.php new file mode 100644 index 000000000..0b8df364f --- /dev/null +++ b/tests/Unit/Controllers/Fixtures/ForwardTarget.php @@ -0,0 +1,14 @@ +name); + } +} diff --git a/tests/Unit/Controllers/Fixtures/ForwardingContainer.php b/tests/Unit/Controllers/Fixtures/ForwardingContainer.php new file mode 100644 index 000000000..4f41788fe --- /dev/null +++ b/tests/Unit/Controllers/Fixtures/ForwardingContainer.php @@ -0,0 +1,23 @@ +replacePageController) { + return new ErrorPageController(); + } + + return parent::build($class, $parameters); + } +} diff --git a/tests/Unit/Controllers/PageControllerTest.php b/tests/Unit/Controllers/PageControllerTest.php new file mode 100644 index 000000000..95f366b0a --- /dev/null +++ b/tests/Unit/Controllers/PageControllerTest.php @@ -0,0 +1,376 @@ + + */ + private array $siteSettings = []; + + private ArrayCache $cache; + + private Statistics $statistics; + + private string $statisticsPath; + + private int $renderedPages = 0; + + private EventDispatcher $dispatcher; + + protected function setUp(): void + { + parent::setUp(); + $this->setUpTempDirectory(); + + $this->site = $this->siteFromFiles([ + 'index/index.md' => "---\ntitle: Home\n---\nHome content\n", + 'about/page.md' => "---\ntitle: About us\n---\nAbout content\n", + 'about/diagram.svg' => '', + 'draft/page.md' => "---\ntitle: Draft\npublished: false\n---\nDraft content\n", + 'canonical/page.md' => "---\ntitle: Canonical\ncanonicalRoute: /about/\n---\nCanonical content\n", + 'blog/blog.md' => "---\ntitle: Blog\n---\nBlog content\n", + 'error/error.md' => "---\ntitle: Not found\n---\nThis page does not exist\n", + ]); + + $this->cache = new ArrayCache('pages'); + $this->statisticsPath = TESTS_TMP_PATH . '/statistics'; + + $request = new Request([], [], [], [], ['REMOTE_ADDR' => '203.0.113.7', 'REQUEST_METHOD' => 'GET', 'HTTP_USER_AGENT' => 'Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0']); + $options = App::instance()->config()->getArray('site.statistics'); + $this->statistics = new Statistics([...$options, 'path' => $this->statisticsPath], $request, new Translation('en', [])); + + $this->renderedPages = 0; + $this->dispatcher = new EventDispatcher(); + $this->dispatcher->on('pageOutput', function (PageOutputEvent $event): void { + ++$this->renderedPages; + }); + } + + protected function tearDown(): void + { + $this->tearDownTempDirectory(); + parent::tearDown(); + } + + public function testPublishedPagesAreRendered(): void + { + $response = $this->load('/about/'); + + $this->assertSame(ResponseStatus::OK, $response->status()); + $this->assertStringContainsString('About us', $response->content()); + $this->assertStringContainsString('', $response->content()); + } + + public function testTheIndexPageIsUsedWhenNoPageIsRequested(): void + { + $response = $this->load(null); + + $this->assertSame(ResponseStatus::OK, $response->status()); + $this->assertNotSame($this->load('/about/')->content(), $response->content()); + } + + public function testUnpublishedPagesAreNotRevealed(): void + { + $response = $this->load('/draft/'); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + $this->assertStringNotContainsString('Draft content', $response->content()); + } + + public function testUnknownRoutesRenderTheErrorPage(): void + { + $response = $this->load('/does-not-exist/'); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + } + + public function testErrorActionRendersTheErrorPage(): void + { + $response = $this->controller()->error(); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + } + + public function testFilesOfAPageAreServedByTheirRoute(): void + { + $response = $this->load('/about/diagram.svg'); + + $this->assertInstanceOf(FileResponse::class, $response); + $this->assertSame($this->site->contentPath() . 'about/diagram.svg', $this->fileOf($response)); + } + + /** + * @return iterable + */ + public static function fileTraversals(): iterable + { + yield 'parent segment' => ['/about/../draft/page.md']; + yield 'deep parents' => ['/about/../../../../../../../etc/hostname']; + yield 'parent of the content' => ['/../composer.json']; + yield 'backslashes' => ['/about/..\draft\page.md']; + yield 'page content files' => ['/about/page.md']; + } + + #[DataProvider('fileTraversals')] + public function testFilesOutsideThePageDirectoryAreNeverServed(string $route): void + { + $response = $this->load($route); + + $this->assertNotInstanceOf(FileResponse::class, $response, 'Served ' . ($response instanceof FileResponse ? $this->fileOf($response) : '')); + } + + public function testPagesWithACanonicalRouteAreRedirected(): void + { + $response = $this->load('/canonical/'); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame(ResponseStatus::MovedPermanently, $response->status()); + $this->assertStringEndsWith('/about/', (string) $response->headers()->get('Location')); + } + + public function testPaginationIsRefusedForPagesThatDoNotAllowIt(): void + { + $response = $this->load('/about/', ['paginationPage' => '2']); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + } + + public function testTaxonomyRoutesAreRefusedForPagesThatDoNotAllowThem(): void + { + $response = $this->load('/about/', ['taxonomy' => 'tags', 'taxonomyTerm' => 'php']); + + $this->assertSame(ResponseStatus::NotFound, $response->status()); + } + + public function testPaginationIsAcceptedForPagesThatAllowIt(): void + { + $response = $this->load('/blog/', ['paginationPage' => '1']); + + $this->assertSame(ResponseStatus::OK, $response->status()); + } + + // Maintenance + + public function testMaintenanceModeServesTheMaintenancePage(): void + { + $this->siteSettings['maintenance.enabled'] = true; + + $response = $this->load('/about/'); + + $this->assertSame(ResponseStatus::ServiceUnavailable, $response->status()); + $this->assertStringNotContainsString('About content', $response->content()); + } + + // Statistics + + public function testVisitsToPublishedPagesAreTracked(): void + { + $this->load('/about/'); + + $this->assertFileExists($this->statisticsPath . '/visits.json'); + } + + public function testVisitsAreNotTrackedWhenStatisticsAreDisabled(): void + { + $this->load('/about/', site: ['statistics' => ['enabled' => false]]); + + $this->assertFileDoesNotExist($this->statisticsPath . '/visits.json'); + } + + public function testVisitsToErrorPagesAreNotTracked(): void + { + $this->load('/does-not-exist/'); + $this->load('/draft/'); + + $this->assertFileDoesNotExist($this->statisticsPath . '/visits.json'); + } + + public function testVisitsDuringMaintenanceAreNotTracked(): void + { + $this->siteSettings['maintenance.enabled'] = true; + + $this->load('/about/'); + + $this->assertFileDoesNotExist($this->statisticsPath . '/visits.json'); + } + + // Cache + + public function testCachedPagesAreNotRenderedAgain(): void + { + $first = $this->load('/about/', system: ['cache' => ['enabled' => true]]); + $second = $this->load('/about/', system: ['cache' => ['enabled' => true]]); + + $this->assertSame($first->content(), $second->content()); + $this->assertSame(1, $this->renderedPages); + } + + public function testCachingIsOffByDefault(): void + { + $this->load('/about/', system: ['cache' => ['enabled' => false]]); + $this->load('/about/', system: ['cache' => ['enabled' => false]]); + + $this->assertSame(2, $this->renderedPages); + $this->assertSame(0, $this->cache->count()); + } + + public function testCachedPagesCarryValidators(): void + { + $response = $this->load('/about/', system: ['cache' => ['enabled' => true]]); + + $this->assertNotNull($response->headers()->get('ETag')); + $this->assertNotNull($response->headers()->get('Last-Modified')); + } + + public function testRequestsWithAQueryStringAreNotCached(): void + { + $this->load('/about/', system: ['cache' => ['enabled' => true]], query: ['utm_source' => 'newsletter']); + $this->load('/about/', system: ['cache' => ['enabled' => true]], query: ['utm_source' => 'newsletter']); + + $this->assertSame(2, $this->renderedPages); + } + + public function testPostRequestsAreNotCached(): void + { + $this->load('/about/', system: ['cache' => ['enabled' => true]], server: ['REQUEST_METHOD' => 'POST']); + $this->load('/about/', system: ['cache' => ['enabled' => true]], server: ['REQUEST_METHOD' => 'POST']); + + $this->assertSame(2, $this->renderedPages); + } + + public function testErrorPagesAreNotCached(): void + { + $this->load('/does-not-exist/', system: ['cache' => ['enabled' => true]]); + $this->load('/does-not-exist/', system: ['cache' => ['enabled' => true]]); + + $this->assertSame(0, $this->cache->count()); + } + + public function testDifferentRoutesUseDifferentCacheEntries(): void + { + $about = $this->load('/about/', system: ['cache' => ['enabled' => true]]); + $home = $this->load('/', system: ['cache' => ['enabled' => true]]); + + $this->assertNotSame($about->content(), $home->content()); + $this->assertSame(2, $this->cache->count()); + } + + public function testCachedPagesAreInvalidatedWhenTheContentChanges(): void + { + $this->load('/about/', system: ['cache' => ['enabled' => true]]); + $this->assertSame(1, $this->renderedPages); + + $content = (string) $this->site->contentPath(); + FileSystem::write($content . 'about/page.md', "---\ntitle: About us\n---\nUpdated content\n"); + touch($content . 'about/page.md', time() + 10); + touch($content, time() + 10); + + $response = $this->load('/about/', system: ['cache' => ['enabled' => true]]); + + $this->assertSame(2, $this->renderedPages); + $this->assertStringContainsString('Updated content', $response->content()); + } + + public function testCorruptedCacheEntriesAreDiscarded(): void + { + $this->cache->set(rawurlencode('/about/'), 'not a response'); + + $response = $this->load('/about/', system: ['cache' => ['enabled' => true]]); + + $this->assertInstanceOf(Response::class, $response); + $this->assertStringContainsString('About us', $response->content()); + } + + /** + * @param array $system + * @param array $site + * @param array $query + * @param array $server + */ + private function load(?string $route, array $parameters = [], array $system = [], array $site = [], array $query = [], array $server = []): Response + { + if (array_is_list($parameters) === false && $route === null) { + $parameters = []; + } + + $routeParameters = $route === null ? $parameters : ['page' => $route] + $parameters; + + return $this->controller($route ?? '/', $system, $site, $query, $server)->load(new RouteParams($routeParameters), $this->statistics); + } + + /** + * @param array $system + * @param array $site + * @param array $query + * @param array $server + */ + private function controller(string $requested = '/', array $system = [], array $site = [], array $query = [], array $server = []): PageController + { + // The current page is fixed once per request, so every controller gets its own site + $freshSite = $this->siteFromPath((string) $this->site->contentPath()); + foreach ($this->siteSettings as $key => $value) { + $freshSite->set($key, $value); + } + + $router = $this->createStub(Router::class); + $router->method('request')->willReturn($requested); + $router->method('rewrite')->willReturnCallback(static fn(array $params): string => (string) ($params['page'] ?? '/')); + $router->method('current')->willReturn(null); + + return $this->makeController( + PageController::class, + $system, + $query, + $server, + [ + Site::class => $freshSite, + 'cache.pages' => $this->cache, + AbstractCache::class => $this->cache, + EventDispatcher::class => $this->dispatcher, + ViewFactory::class => App::instance()->getService(ViewFactory::class), + ], + $router, + $site, + ); + } + + private function fileOf(Response $response): string + { + return (string) (new ReflectionProperty($response, 'path'))->getValue($response); + } +} diff --git a/tests/Unit/Data/CollectionDataProxyTest.php b/tests/Unit/Data/CollectionDataProxyTest.php new file mode 100644 index 000000000..21b590989 --- /dev/null +++ b/tests/Unit/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/Unit/Data/CollectionTest.php b/tests/Unit/Data/CollectionTest.php new file mode 100644 index 000000000..6914869c5 --- /dev/null +++ b/tests/Unit/Data/CollectionTest.php @@ -0,0 +1,1258 @@ +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 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 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); + } + + 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); + + $this->assertSameSize($data, $collection); + + $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 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 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 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); + } + + 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->assertSameSize($collection, $shuffledCollection); + + 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); + } + + #[DataProvider('phpFunctionNameProvider')] + public function testFilterByComparesValuesThatAreNamesOfPhpFunctions(string $word): void + { + $collection = Collection::from([ + ['word' => $word], + ['word' => 'other'], + ['word' => 'something else'], + ]); + + $this->assertSame([['word' => $word]], $collection->filterBy('word', $word)->values()); + } + + /** + * @return iterable + */ + public static function phpFunctionNameProvider(): iterable + { + foreach (['date', 'time', 'count', 'key', 'max', 'current'] as $word) { + yield $word => [$word]; + } + } + + public function testFilterByComparesLooselyWithoutAnExplicitComparison(): void + { + $collection = Collection::from([['n' => 1], ['n' => '1'], ['n' => 2], ['n' => true]]); + + $this->assertSame([['n' => 1], ['n' => '1'], ['n' => true]], $collection->filterBy('n', 1)->values()); + $this->assertSame([['n' => 1]], $collection->filterBy('n', '===', 1)->values()); + $this->assertSame([['n' => 1], ['n' => '1'], ['n' => true]], $collection->filterBy('n', '==', '1')->values()); + $this->assertSame([['n' => '1'], ['n' => 2], ['n' => true]], $collection->filterBy('n', '!==', 1)->values()); + } + + 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 testSortByIsCaseInsensitiveAndPreservesKeysByDefault(): void + { + $collection = Collection::from(['x' => ['name' => 'b'], 'y' => ['name' => 'A'], 'z' => ['name' => 'C']]); + + $this->assertSame(['y', 'x', 'z'], $collection->sortBy('name')->keys()); + $this->assertSame([0, 1, 2], $collection->sortBy('name', preserveKeys: false)->keys()); + $this->assertSame(['y', 'z', 'x'], $collection->sortBy('name', caseSensitive: true)->keys()); + $this->assertSame(['z', 'x', 'y'], $collection->sortBy('name', SORT_DESC)->keys()); + } + + 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 testUnionTypedCollectionsAcceptEveryListedType(): void + { + $collection = Collection::of('int|string', [1, 'two'], mutable: true); + + $collection->add(3); + $collection->add('four'); + + $this->assertSame([1, 'two', 3, 'four'], $collection->values()); + + $this->expectException(LogicException::class); + $collection->add(5.5); + } + + public function testUnionTypedCollectionsRejectInitialDataOfOtherTypes(): void + { + $this->expectException(LogicException::class); + Collection::of('int|string', [1, 2.5]); + } + + public function testUnionTypedAssociativeCollectionsValidateSetValues(): void + { + $collection = Collection::of('int|string', ['a' => 1], associative: true, mutable: true); + + $collection->set('b', 'two'); + $this->assertSame(['a' => 1, 'b' => 'two'], $collection->toArray()); + + $this->expectException(LogicException::class); + $collection->set('c', null); + } + + 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 testMoveItemKeepsTheKeysOfAssociativeCollections(): void + { + $collection = Collection::from(['a' => 'A', 'b' => 'B', 'c' => 'C'], mutable: true); + + $collection->moveItem(2, 0); + + $this->assertSame(['c' => 'C', 'a' => 'A', 'b' => 'B'], $collection->toArray()); + } + + 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 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); + } + + /** + * @param Closure(Collection): mixed $operation + */ + #[DataProvider('immutableOperationProvider')] + public function testImmutableCollectionRejectsEveryMutatingOperation(bool $associative, Closure $operation): void + { + $collection = $associative + ? Collection::from(['a' => 'A', 'b' => 'B'], mutable: false) + : Collection::from(['A', 'B'], mutable: false); + $before = $collection->toArray(); + + try { + $operation($collection); + $this->fail('An immutable collection was mutated.'); + } catch (LogicException) { + // Expected + } + + $this->assertSame($before, $collection->toArray()); + } + + /** + * @return iterable + */ + public static function immutableOperationProvider(): iterable + { + yield 'add' => [false, static fn(Collection $collection) => $collection->add('C')]; + yield 'pull' => [false, static fn(Collection $collection) => $collection->pull('A')]; + yield 'set' => [true, static fn(Collection $collection) => $collection->set('c', 'C')]; + yield 'remove' => [true, static fn(Collection $collection) => $collection->remove('a')]; + yield 'merge' => [false, static fn(Collection $collection) => $collection->merge(Collection::from(['C']))]; + + // Move operations are not considered mutating, since collections are considered equivalent regardless of item order + // yield 'move item' => [false, static fn(Collection $collection) => $collection->moveItem(0, 1)]; + // yield 'move item of an associative collection' => [true, static fn(Collection $collection) => $collection->moveItem(0, 1)]; + } + + public function testMutableCollectionsRejectOperationsOfTheOtherKind(): void + { + $list = Collection::from(['A'], mutable: true); + $map = Collection::from(['a' => 'A'], mutable: true); + + foreach ( + [ + static fn() => $list->set('b', 'B'), + static fn() => $list->remove('a'), + static fn() => $map->add('B'), + static fn() => $map->pull('A'), + ] as $operation + ) { + try { + $operation(); + $this->fail('An operation of the wrong kind was accepted.'); + } catch (LogicException) { + // Expected + } + } + + $this->assertSame(['A'], $list->toArray()); + $this->assertSame(['a' => 'A'], $map->toArray()); + } + + 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 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()); + } +} diff --git a/tests/Unit/Data/Exceptions/InvalidValueExceptionTest.php b/tests/Unit/Data/Exceptions/InvalidValueExceptionTest.php new file mode 100644 index 000000000..0a5d7e860 --- /dev/null +++ b/tests/Unit/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/Unit/Data/Fixtures/DataArrayableFixture.php b/tests/Unit/Data/Fixtures/DataArrayableFixture.php new file mode 100644 index 000000000..bfc9ed83c --- /dev/null +++ b/tests/Unit/Data/Fixtures/DataArrayableFixture.php @@ -0,0 +1,16 @@ +data = $data; + } +} diff --git a/tests/Unit/Data/Fixtures/DataCountableFixture.php b/tests/Unit/Data/Fixtures/DataCountableFixture.php new file mode 100644 index 000000000..5c36bb36d --- /dev/null +++ b/tests/Unit/Data/Fixtures/DataCountableFixture.php @@ -0,0 +1,16 @@ +data = $data; + } +} diff --git a/tests/Unit/Data/Fixtures/DataGetterFixture.php b/tests/Unit/Data/Fixtures/DataGetterFixture.php new file mode 100644 index 000000000..0e75f2322 --- /dev/null +++ b/tests/Unit/Data/Fixtures/DataGetterFixture.php @@ -0,0 +1,15 @@ +data = $data; + } +} diff --git a/tests/Unit/Data/Fixtures/DataIteratorFixture.php b/tests/Unit/Data/Fixtures/DataIteratorFixture.php new file mode 100644 index 000000000..528aa1fe8 --- /dev/null +++ b/tests/Unit/Data/Fixtures/DataIteratorFixture.php @@ -0,0 +1,16 @@ +data = $data; + } +} diff --git a/tests/Unit/Data/Fixtures/DataMultipleGetterFixture.php b/tests/Unit/Data/Fixtures/DataMultipleGetterFixture.php new file mode 100644 index 000000000..0e01461ec --- /dev/null +++ b/tests/Unit/Data/Fixtures/DataMultipleGetterFixture.php @@ -0,0 +1,15 @@ +data = $data; + } +} diff --git a/tests/Unit/Data/Fixtures/DataMultipleSetterFixture.php b/tests/Unit/Data/Fixtures/DataMultipleSetterFixture.php new file mode 100644 index 000000000..3d9b295d4 --- /dev/null +++ b/tests/Unit/Data/Fixtures/DataMultipleSetterFixture.php @@ -0,0 +1,20 @@ +data = $data; + } + + public function data(): array + { + return $this->data; + } +} diff --git a/tests/Unit/Data/Fixtures/DataSetterFixture.php b/tests/Unit/Data/Fixtures/DataSetterFixture.php new file mode 100644 index 000000000..5f56b2098 --- /dev/null +++ b/tests/Unit/Data/Fixtures/DataSetterFixture.php @@ -0,0 +1,20 @@ +data = $data; + } + + public function data(): array + { + return $this->data; + } +} diff --git a/tests/Unit/Data/PaginationTest.php b/tests/Unit/Data/PaginationTest.php new file mode 100644 index 000000000..bdafbfff5 --- /dev/null +++ b/tests/Unit/Data/PaginationTest.php @@ -0,0 +1,150 @@ +assertSame(10, $pagination->length()); + $this->assertSame(5, $pagination->pages()); + $this->assertSame($pagination->pages(), $pagination->lastPage()); + $this->assertSame(1, $pagination->firstPage()); + $this->assertSame(1, $pagination->currentPage()); + $this->assertSame(0, $pagination->offset()); + $this->assertTrue($pagination->hasPages()); + $this->assertTrue($pagination->isFirstPage()); + $this->assertFalse($pagination->isLastPage()); + $this->assertTrue($pagination->hasNextPage()); + $this->assertFalse($pagination->hasPreviousPage()); + $this->assertSame(1, $pagination->previousPage()); + $this->assertSame(2, $pagination->nextPage()); + } + + public function testMiddlePageProperties(): void + { + $pagination = new Pagination(Collection::from(range(1, 50)), 10); + + $pagination->setCurrentPage(3); + + $this->assertSame(3, $pagination->currentPage()); + $this->assertSame(20, $pagination->offset()); + $this->assertFalse($pagination->isFirstPage()); + $this->assertFalse($pagination->isLastPage()); + $this->assertTrue($pagination->hasNextPage()); + $this->assertTrue($pagination->hasPreviousPage()); + $this->assertSame(2, $pagination->previousPage()); + $this->assertSame(4, $pagination->nextPage()); + } + + public function testLastPageProperties(): void + { + $pagination = new Pagination(Collection::from(range(1, 50)), 10); + + $pagination->setCurrentPage(5); + + $this->assertSame(40, $pagination->offset()); + $this->assertFalse($pagination->isFirstPage()); + $this->assertTrue($pagination->isLastPage()); + $this->assertFalse($pagination->hasNextPage()); + $this->assertTrue($pagination->hasPreviousPage()); + $this->assertSame(4, $pagination->previousPage()); + $this->assertSame(5, $pagination->nextPage()); + } + + #[DataProvider('pageCountProvider')] + public function testNumberOfPagesIsTheCeilingOfItemsOverLength(int $items, int $length, int $expectedPages): void + { + $pagination = new Pagination(Collection::from($items > 0 ? range(1, $items) : []), $length); + + $this->assertSame($expectedPages, $pagination->pages()); + $this->assertSame($expectedPages > 1, $pagination->hasPages()); + } + + /** + * @return iterable + */ + public static function pageCountProvider(): iterable + { + yield 'exact fit' => [50, 10, 5]; + yield 'one item over' => [51, 10, 6]; + yield 'one item short' => [49, 10, 5]; + yield 'fewer items than the length' => [3, 10, 1]; + yield 'exactly one page' => [10, 10, 1]; + yield 'one item per page' => [5, 1, 5]; + yield 'empty collection' => [0, 10, 1]; + } + + public function testHasTellsWhetherAPageExists(): void + { + $pagination = new Pagination(Collection::from(range(1, 25)), 10); + + $this->assertFalse($pagination->has(0)); + $this->assertFalse($pagination->has(-1)); + $this->assertTrue($pagination->has(1)); + $this->assertTrue($pagination->has(3)); + $this->assertFalse($pagination->has(4)); + } + + public function testNavigationStaysInsideTheExistingPages(): void + { + $pagination = new Pagination(Collection::from([1]), 10); + + $this->assertTrue($pagination->isFirstPage()); + $this->assertTrue($pagination->isLastPage()); + $this->assertSame(1, $pagination->previousPage()); + $this->assertSame(1, $pagination->nextPage()); + } + + #[DataProvider('invalidCurrentPageProvider')] + public function testOffsetIsNeverNegative(int $currentPage): void + { + $pagination = new Pagination(Collection::from(range(1, 50)), 10); + + $rejected = false; + + try { + $pagination->setCurrentPage($currentPage); + } catch (InvalidArgumentException) { + $rejected = true; + } + + $this->assertTrue($rejected || $pagination->offset() >= 0, 'A non-positive page must be rejected or clamped to a valid offset'); + } + + /** + * @return iterable + */ + public static function invalidCurrentPageProvider(): iterable + { + yield 'zero' => [0]; + yield 'negative' => [-3]; + } + + #[DataProvider('invalidLengthProvider')] + public function testNonPositiveLengthsAreRejected(int $length): void + { + $this->expectException(Throwable::class); + new Pagination(Collection::from(range(1, 5)), $length); + } + + /** + * @return iterable + */ + public static function invalidLengthProvider(): iterable + { + yield 'zero' => [0]; + yield 'negative' => [-5]; + } +} diff --git a/tests/Unit/Data/Traits/DataArrayableTest.php b/tests/Unit/Data/Traits/DataArrayableTest.php new file mode 100644 index 000000000..a186d75bd --- /dev/null +++ b/tests/Unit/Data/Traits/DataArrayableTest.php @@ -0,0 +1,19 @@ + 'value1', 'key2' => 'value2']; + $fixture = new DataArrayableFixture($data); + $this->assertSame($data, $fixture->toArray()); + } +} diff --git a/tests/Unit/Data/Traits/DataCountableTest.php b/tests/Unit/Data/Traits/DataCountableTest.php new file mode 100644 index 000000000..091edc677 --- /dev/null +++ b/tests/Unit/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/Unit/Data/Traits/DataGetterTest.php b/tests/Unit/Data/Traits/DataGetterTest.php new file mode 100644 index 000000000..005f9929e --- /dev/null +++ b/tests/Unit/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/Unit/Data/Traits/DataIteratorTest.php b/tests/Unit/Data/Traits/DataIteratorTest.php new file mode 100644 index 000000000..61e370aba --- /dev/null +++ b/tests/Unit/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/Unit/Data/Traits/DataMultipleGetterTest.php b/tests/Unit/Data/Traits/DataMultipleGetterTest.php new file mode 100644 index 000000000..9956b0baa --- /dev/null +++ b/tests/Unit/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/Unit/Data/Traits/DataMultipleSetterTest.php b/tests/Unit/Data/Traits/DataMultipleSetterTest.php new file mode 100644 index 000000000..17e3c1801 --- /dev/null +++ b/tests/Unit/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/Unit/Data/Traits/DataSetterTest.php b/tests/Unit/Data/Traits/DataSetterTest.php new file mode 100644 index 000000000..f998f6b91 --- /dev/null +++ b/tests/Unit/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()); + } +} diff --git a/tests/Unit/Debug/CodeDumperTest.php b/tests/Unit/Debug/CodeDumperTest.php new file mode 100644 index 000000000..ad15ee33f --- /dev/null +++ b/tests/Unit/Debug/CodeDumperTest.php @@ -0,0 +1,271 @@ +setValue(null, false); + } + + public function testLinesAroundTheTargetAreShown(): void + { + $html = $this->dumpLine(self::SAMPLE, 12, 2); + + $this->assertSame([10, 11, 12, 13, 14], $this->lineNumbers($html)); + } + + public function testTheTargetLineIsHighlighted(): void + { + $html = $this->dumpLine(self::SAMPLE, 12, 2); + + $this->assertSame(1, substr_count($html, '')); + $this->assertMatchesRegularExpression('/\s*12 <\/span>/', $html); + } + + public function testContextIsClampedToTheBeginningOfTheFile(): void + { + $this->assertSame([1, 2, 3, 4], $this->lineNumbers($this->dumpLine(self::SAMPLE, 2, 2))); + } + + public function testContextIsClampedToTheEndOfTheFile(): void + { + $lines = $this->lineNumbers($this->dumpLine(self::SAMPLE, 83, 5)); + + $this->assertSame(84, max($lines)); + $this->assertSame(78, min($lines)); + } + + public function testNegativeContextShowsTheWholeFile(): void + { + $this->assertSame(range(1, 84), $this->lineNumbers($this->dumpLine(self::SAMPLE, 10, -1))); + } + + public function testZeroContextShowsOnlyTheTargetLine(): void + { + $this->assertSame([10], $this->lineNumbers($this->dumpLine(self::SAMPLE, 10, 0))); + } + + public function testLineNumbersAreAlignedWhenCrossingPowersOfTen(): void + { + $html = $this->dumpLine(self::SAMPLE, 9, 1); + + preg_match_all('/(\s*\d+ )<\/span>/', $html, $matches); + + $widths = array_values(array_unique(array_map('strlen', $matches[1]))); + $this->assertCount(1, $widths, 'All line labels must have the same width: ' . json_encode($matches[1])); + } + + public function testTargetLinesBeyondTheEndOfTheFileDoNotBreakTheDump(): void + { + $html = $this->dumpLine(self::SAMPLE, 500, 2); + + $this->assertStringContainsString('
', $html);
+    }
+
+    public function testSourceIsHighlightedByTokenType(): void
+    {
+        $html = $this->dumpLine(self::SAMPLE, 8, 1);
+
+        $this->assertStringContainsString('final', $html);
+        $this->assertStringContainsString('Sample', $html);
+    }
+
+    public function testSourceIsEscaped(): void
+    {
+        $html = $this->dumpLine(self::HOSTILE, 4, -1);
+
+        $this->assertStringNotContainsString('',
+            'function' => 'run',
+            'type'     => '->',
+        ]);
+
+        $this->assertStringNotContainsString('' => 1]);
+
+        $this->assertStringNotContainsString('": {"nested": 1}}');
+
+        $document = new DOMDocument();
+        @$document->loadHTML('' . $this->body($payload));
+
+        $this->assertSame(0, $document->getElementsByTagName('script')->length);
+        $this->assertSame(0, $document->getElementsByTagName('b')->length);
+    }
+
+    public function testClosuresShowTheirSignature(): void
+    {
+        $closure = static fn(int $count, string ...$names): bool => true;
+
+        $output = $this->body($closure);
+
+        $this->assertStringContainsString('Closure(', $output);
+        $this->assertStringContainsString('int $count', $output);
+        $this->assertStringContainsString('...$names', $output);
+        $this->assertStringContainsString('): bool', $output);
+    }
+
+    public function testClosuresShowTheirDefinitionSite(): void
+    {
+        $output = $this->body(static fn() => null);
+
+        $this->assertStringContainsString('file', $output);
+        $this->assertStringContainsString(basename(__FILE__), $output);
+        $this->assertStringContainsString('line', $output);
+    }
+
+    public function testStaticClosuresDoNotExposeAThisObject(): void
+    {
+        $output = $this->body(static fn() => null);
+
+        $this->assertStringNotContainsString('this', $output);
+    }
+
+    public function testNonStaticClosuresExposeTheirScope(): void
+    {
+        $output = $this->body(fn() => $this);
+
+        $this->assertStringContainsString('scope', $output);
+        $this->assertStringContainsString(self::class, $output);
+    }
+
+    public function testResources(): void
+    {
+        $handle = fopen('php://memory', 'r');
+        $this->assertNotFalse($handle);
+
+        try {
+            $output = $this->body($handle);
+        } finally {
+            fclose($handle);
+        }
+
+        $this->assertStringContainsString('resource(stream', $output);
+    }
+
+    public function testClosedResourcesAreDumpedInsteadOfBreakingTheDump(): void
+    {
+        $handle = fopen('php://memory', 'r');
+        $this->assertNotFalse($handle);
+        fclose($handle);
+
+        $this->assertStringContainsString('resource', $this->body($handle));
+    }
+
+    // dumpToString() and dump()
+
+    public function testDumpToStringWrapsTheOutputInAPreElement(): void
+    {
+        $output = Debug::dumpToString(1);
+
+        $this->assertSame('
1
', $output); + } + + public function testStylesAreEchoedOnceWhenRequested(): void + { + ob_start(); + Debug::dumpToString(1, true); + Debug::dumpToString(2, true); + $echoed = (string) ob_get_clean(); + + $this->assertSame(1, substr_count($echoed, '

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' => ['