From 27463072a99961719a1902752bee132788daa397 Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 20:37:01 +0200 Subject: [PATCH 1/9] Adds a PHPUnit suite for code that needs no database SMF has no automated tests. CI proves that the code parses and that it is formatted; it never executes anything. Every one of the bugs fixed in #9319 through #9322 parsed cleanly and passed every check. Quite a lot of 3.0 is reachable without a forum behind it. The bootstrap here defines the constants index.php would define and points the autoloader at Sources/, and that is enough: no Settings.php, no database, no request. Anything that reaches Config::$modSettings, User::$me or Db::$db is out of scope and belongs in an integration suite. The first tests cover ground that recently broke: - ActionTrait::load() returning an instance of the class it was called on, in both orders and in two separate class hierarchies. - CreatePost_Notify::getTimeOffset(), including the half-hour and quarter-hour zones that an int cast used to truncate. - Utils::buildRegex(), including the trailing quoted character from #9318. tests/ is already excluded from the license header check in BuildTools, and the directory index.php files keep check-smf-index happy. Co-Authored-By: Claude Opus 5 --- composer.json | 3 +- composer.lock | 2564 ++++++++++++++++++++++----- phpunit.xml.dist | 20 + tests/Unit/ActionTraitTest.php | 58 + tests/Unit/CreatePostNotifyTest.php | 80 + tests/Unit/UtilsTest.php | 47 + tests/Unit/index.php | 8 + tests/bootstrap.php | 53 + tests/index.php | 8 + 9 files changed, 2443 insertions(+), 398 deletions(-) create mode 100644 phpunit.xml.dist create mode 100644 tests/Unit/ActionTraitTest.php create mode 100644 tests/Unit/CreatePostNotifyTest.php create mode 100644 tests/Unit/UtilsTest.php create mode 100644 tests/Unit/index.php create mode 100644 tests/bootstrap.php create mode 100644 tests/index.php diff --git a/composer.json b/composer.json index c08fcb38487..83b76bad667 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "prefer-stable": true, "require-dev": { "simplemachines/build-tools": "dev-release-3.0", - "friendsofphp/php-cs-fixer": "^3.95" + "friendsofphp/php-cs-fixer": "^3.95", + "phpunit/phpunit": "^13.1" }, "scripts": { "lint": "php-cs-fixer --quiet check --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes || php-cs-fixer check --diff --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", diff --git a/composer.lock b/composer.lock index 96f77c79495..8d07af23505 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": "72fc34fa5627c56e54537e4b54939e8c", + "content-hash": "50c2bac5d85b523f36379e484a555b9d", "packages": [ { "name": "bjeavons/zxcvbn-php", @@ -1028,6 +1028,123 @@ ], "time": "2026-07-24T13:54:39+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "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" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "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": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, { "name": "overtrue/phplint", "version": "9.0.4", @@ -1112,491 +1229,780 @@ "time": "2023-02-23T15:46:09+00:00" }, { - "name": "psr/cache", - "version": "3.0.0", + "name": "phar-io/manifest", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { - "php": ">=8.0.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2021-02-03T23:26:27+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "phar-io/version", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": "^7.2 || ^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, "autoload": { - "psr-4": { - "Psr\\EventDispatcher\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Standard interfaces for event handling.", - "keywords": [ - "events", - "psr", - "psr-14" - ], + "description": "Library for handling version information and constraints", "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2022-02-21T01:04:05+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "phpunit/php-code-coverage", + "version": "14.2.3", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "82f6e49ff224e2cde923d74425e583a883910783" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783", + "reference": "82f6e49ff224e2cde923d74425e583a883910783", "shasum": "" }, "require": { - "php": ">=8.0.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.8.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.1", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.2" + }, + "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-master": "3.x-dev" + "dev-main": "14.2.x-dev" } }, "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "log", - "psr", - "psr-3" + "coverage", + "testing", + "xunit" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "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/14.2.3" }, - "time": "2024-09-11T13:17:53+00:00" + "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/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-07-06T15:04:02+00:00" }, { - "name": "react/cache", - "version": "v1.2.0", + "name": "phpunit/php-file-iterator", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/cache.git", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", "shasum": "" }, "require": { - "php": ">=5.3.0", - "react/promise": "^3.0 || ^2.0 || ^1.1" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Cache\\": "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": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Async, Promise-based cache interface for ReactPHP", + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", "keywords": [ - "cache", - "caching", - "promise", - "reactphp" + "filesystem", + "iterator" ], "support": { - "issues": "https://github.com/reactphp/cache/issues", - "source": "https://github.com/reactphp/cache/tree/v1.2.0" + "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/7.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2022-11-30T15:59:55+00:00" + "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-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" }, { - "name": "react/child-process", - "version": "v0.6.7", + "name": "phpunit/php-invoker", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/child-process.git", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/event-loop": "^1.2", - "react/stream": "^1.4" + "php": ">=8.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-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" }, "type": "library", - "autoload": { - "psr-4": { - "React\\ChildProcess\\": "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": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Event-driven library for executing child processes with ReactPHP.", + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", "keywords": [ - "event-driven", - "process", - "reactphp" + "process" ], "support": { - "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + "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/7.0.0" }, "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-invoker", + "type": "tidelift" } ], - "time": "2025-12-23T15:25:20+00:00" + "time": "2026-02-06T04:34:47+00:00" }, { - "name": "react/dns", - "version": "v1.14.0", + "name": "phpunit/php-text-template", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/dns.git", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", "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" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3 || ^2", - "react/promise-timer": "^1.11" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Dns\\": "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": "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": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Async DNS resolver for ReactPHP", + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", "keywords": [ - "async", - "dns", - "dns-resolver", - "reactphp" + "template" ], "support": { - "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.14.0" + "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/6.0.0" }, "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-text-template", + "type": "tidelift" } ], - "time": "2025-11-18T19:34:28+00:00" + "time": "2026-02-06T04:36:37+00:00" }, { - "name": "react/event-loop", - "version": "v1.6.0", + "name": "phpunit/php-timer", + "version": "9.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/event-loop.git", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" - }, - "suggest": { - "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\EventLoop\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "9.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": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", "keywords": [ - "asynchronous", - "event-loop" + "timer" ], "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-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" }, "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-timer", + "type": "tidelift" } ], - "time": "2025-11-17T20:46:25+00:00" + "time": "2026-02-06T04:37:53+00:00" }, { - "name": "react/promise", - "version": "v3.3.0", + "name": "phpunit/phpunit", + "version": "13.1.14", "source": { "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "cdd419c33c040c6b570e51dba8ecbe81d399da53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/cdd419c33c040c6b570e51dba8ecbe81d399da53", + "reference": "cdd419c33c040c6b570e51dba8ecbe81d399da53", "shasum": "" }, "require": { - "php": ">=7.1.0" - }, - "require-dev": { - "phpstan/phpstan": "1.12.28 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.1.10", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.2.1", + "sebastian/diff": "^8.3.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" }, + "bin": [ + "phpunit" + ], "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.1-dev" + } + }, "autoload": { "files": [ - "src/functions_include.php" + "src/Framework/Assert/Functions.php" ], - "psr-4": { - "React\\Promise\\": "src/" - } + "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": "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/13.1.14" + }, + "funding": [ { - "name": "Christian Lück", + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-06-04T06:16:42+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+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/" }, @@ -1605,212 +2011,1483 @@ "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": "A lightweight implementation of CommonJS Promises/A for PHP", + "description": "Async, Promise-based cache interface for ReactPHP", "keywords": [ + "cache", + "caching", "promise", - "promises" + "reactphp" ], "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.3.0" + "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" + "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": "sebastian/cli-parser", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.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 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/5.0.0" + }, + "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-02-06T04:39:44+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/ce999bf08b2c387a5423fe56961c32eed3f88089", + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^8.3", + "sebastian/exporter": "^8.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.2-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/8.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/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:46:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library 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/6.0.0" + }, + "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/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.3-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/8.3.0" + }, + "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-05-15T04:58:09+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.11" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-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/9.3.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:41:38+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "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" + }, + { + "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/8.1.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/exporter", + "type": "tidelift" + } + ], + "time": "2026-07-13T11:35:11+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.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 describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "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/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.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/9.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/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:11:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.8.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "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 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/5.0.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/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-07-09T08:42:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.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" + } + ], + "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/8.0.0" + }, + "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/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:46:36+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "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" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + }, + "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/object-reflector", + "type": "tidelift" } ], - "time": "2025-08-19T18:57:03+00:00" + "time": "2026-02-06T04:47:13+00:00" }, { - "name": "react/socket", - "version": "v1.17.0", + "name": "sebastian/recursion-context", + "version": "8.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/socket.git", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", "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.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" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Socket\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "8.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": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", - "keywords": [ - "Connection", - "Socket", - "async", - "reactphp", - "stream" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.17.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/8.0.0" }, "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": "2025-11-19T20:47:34+00:00" + "time": "2026-02-06T04:51:28+00:00" }, { - "name": "react/stream", - "version": "v1.4.0", + "name": "sebastian/type", + "version": "7.0.1", "source": { "type": "git", - "url": "https://github.com/reactphp/stream.git", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fee0309275847fefd7636167085e379c1dbf6990" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.8", - "react/event-loop": "^1.2" + "php": ">=8.4" }, "require-dev": { - "clue/stream-filter": "~1.2", - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + "phpunit/phpunit": "^13.1.10" }, "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": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "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": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "issues": "https://github.com/reactphp/stream/issues", - "source": "https://github.com/reactphp/stream/tree/v1.4.0" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/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/type", + "type": "tidelift" } ], - "time": "2024-06-11T12:45:25+00:00" + "time": "2026-05-20T06:49:11+00:00" }, { - "name": "sebastian/diff", - "version": "8.3.0", + "name": "sebastian/version", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { "php": ">=8.4" }, - "require-dev": { - "phpunit/phpunit": "^13.0", - "symfony/process": "^7.2" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.3-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -1825,25 +3502,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/8.3.0" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" }, "funding": [ { @@ -1859,11 +3527,11 @@ "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-05-15T04:58:09+00:00" + "time": "2026-02-06T04:52:52+00:00" }, { "name": "simplemachines/build-tools", @@ -1891,6 +3559,58 @@ }, "time": "2026-05-27T23:44:16+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/cache", "version": "v6.4.41", @@ -3572,6 +5292,56 @@ } ], "time": "2026-05-25T06:03:23+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/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000000..08c514aee2d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + tests/Unit + + + + + Sources + + + diff --git a/tests/Unit/ActionTraitTest.php b/tests/Unit/ActionTraitTest.php new file mode 100644 index 00000000000..a0b63d21518 --- /dev/null +++ b/tests/Unit/ActionTraitTest.php @@ -0,0 +1,58 @@ +assertInstanceOf(Login2::class, Login2::load()); + $this->assertInstanceOf(Logout::class, Logout::load()); + } + + public function testLoadIsStillCorrectWhenTheParentWasLoadedFirst(): void + { + // $obj is a static property declared in the trait, so it is shared with + // every descendant that does not redeclare it. Loading the parent first + // used to leave the parent's instance in the slot the child reads. + Login2::load(); + + $this->assertInstanceOf(Logout::class, Logout::load()); + $this->assertInstanceOf(Login::class, Login::load()); + } + + public function testLoadIsStillCorrectWhenTheChildWasLoadedFirst(): void + { + Logout::load(); + + $this->assertInstanceOf(Login2::class, Login2::load()); + } + + public function testTheSameProblemInAnUnrelatedHierarchy(): void + { + Notify::load(); + + $this->assertInstanceOf(NotifyBoard::class, NotifyBoard::load()); + } + + public function testLoadCachesTheInstanceItReturns(): void + { + $this->assertSame(Login2::load(), Login2::load()); + } +} diff --git a/tests/Unit/CreatePostNotifyTest.php b/tests/Unit/CreatePostNotifyTest.php new file mode 100644 index 00000000000..8bdf1a515ad --- /dev/null +++ b/tests/Unit/CreatePostNotifyTest.php @@ -0,0 +1,80 @@ +assertSame(3.0, $this->getTimeOffset('Etc/GMT-5')); + } + + // Note: the section banner above must not be the first thing in this group when + // the first member carries an attribute. The SMF/section_comments fixer inserts + // the banner between the attribute and its method, which is why the data provider + // case is second rather than first. + #[DataProvider('timezoneProvider')] + public function testGetTimeOffset(string $timezone, float $expected): void + { + $this->assertSame($expected, $this->getTimeOffset($timezone)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function timezoneProvider(): array + { + return [ + 'UTC is no offset' => ['UTC', 0.0], + 'whole hour' => ['Etc/GMT-5', 5.0], + 'negative whole hour' => ['Etc/GMT+5', -5.0], + 'half hour is not truncated' => ['Asia/Kolkata', 5.5], + 'quarter hour is not truncated' => ['Asia/Kathmandu', 5.75], + 'empty time zone falls back to zero' => ['', 0.0], + ]; + } + + /****************** + * Internal methods + ******************/ + + protected function setUp(): void + { + // The offset is relative to the forum's own time zone, so pin it. + Config::$modSettings['default_timezone'] = 'UTC'; + } + + protected function tearDown(): void + { + unset(Config::$modSettings['default_timezone']); + } + + /** + * Calls the protected helper under test. + */ + private function getTimeOffset(string $timezone): float + { + $method = new \ReflectionMethod(CreatePost_Notify::class, 'getTimeOffset'); + + return $method->invoke(null, $timezone); + } +} diff --git a/tests/Unit/UtilsTest.php b/tests/Unit/UtilsTest.php new file mode 100644 index 00000000000..fddf008068d --- /dev/null +++ b/tests/Unit/UtilsTest.php @@ -0,0 +1,47 @@ +assertSame('(?>ab(?>c|d))', Utils::buildRegex(['abc', 'abd'])); + } + + public function testBuildRegexMatchesEveryStringItWasBuiltFrom(): void + { + $strings = ['abc', 'abd', 'xyz', 'a.b', 'a+b', 'a(b)']; + $regex = Utils::buildRegex($strings); + + foreach ($strings as $string) { + $this->assertMatchesRegularExpression('~^' . $regex . '$~', $string); + } + } + + public function testBuildRegexQuotesTrailingSpecialCharacters(): void + { + // A trailing character that is special in a regex must stay quoted, or the + // resulting pattern matches things it should not. + $regex = Utils::buildRegex(['ab.', 'ab']); + + $this->assertMatchesRegularExpression('~^' . $regex . '$~', 'ab.'); + $this->assertDoesNotMatchRegularExpression('~^' . $regex . '$~', 'abx'); + } + + public function testBuildRegexHandlesASingleString(): void + { + $this->assertMatchesRegularExpression('~^' . Utils::buildRegex(['solo']) . '$~', 'solo'); + } +} diff --git a/tests/Unit/index.php b/tests/Unit/index.php new file mode 100644 index 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/Unit/index.php @@ -0,0 +1,8 @@ +setPsr4('SMF\\', TESTS_BOARDDIR . '/Sources'); +$loader->setPsr4('SMF\\Themes\\', TESTS_BOARDDIR . '/Themes'); diff --git a/tests/index.php b/tests/index.php new file mode 100644 index 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/index.php @@ -0,0 +1,8 @@ + Date: Wed, 29 Jul 2026 20:43:18 +0200 Subject: [PATCH 2/9] Runs the unit tests in CI and documents them Adds a PHPUnit workflow across the same 8.4 and 8.5 matrix the syntax check already uses, and a composer test script. Two adjustments fall out of running the suite next to the existing checks. The PHPUnit cache lives in .phpunit.cache rather than under cache/, because check-smf-index walks every directory that is not hidden and would otherwise report a missing index file the moment anyone runs the tests locally. And AGENTS.md no longer says there is no test suite; it now says what the suite does and does not cover, so an agent does not mistake a green run for proof that a change works. Co-Authored-By: Claude Opus 5 --- .github/workflows/phpunit.yml | 40 ++++++++++++++++++++++++++++++++++ .gitignore | 4 ++++ AGENTS.md | 19 ++++++++++++---- composer.json | 3 ++- phpunit.xml.dist | 2 +- tests/Unit/ActionTraitTest.php | 16 ++++++++++---- 6 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/phpunit.yml diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 00000000000..398c9f9ae16 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,40 @@ +name: PHPUnit + +on: + push: + branches: + - release-3.0 + pull_request: + +jobs: + phpunit: + name: Unit tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ 8.4, 8.5 ] + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 #2.32.0 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf #4.2.2 + with: + path: vendor + key: ${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-php${{ matrix.php }}- + + - name: Install dependencies + if: steps.composer-cache.outputs.cache-hit != 'true' + run: composer install --prefer-dist --no-progress --ansi + + - name: Run the unit tests + run: vendor/bin/phpunit --no-coverage --colors=always diff --git a/.gitignore b/.gitignore index 8a574e9b8d5..1dfe550f483 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,7 @@ vendor/ .phplint-cache .phplint.cache composer.phar + +# PHPUnit +.phpunit.cache/ +.phpunit.result.cache diff --git a/AGENTS.md b/AGENTS.md index 052a20bd56c..7b039ded862 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,11 +79,22 @@ param, throws, return. ## Verifying a change -**There is no test suite.** No PHPUnit, no `tests/` directory, nothing in the history. -CI only proves that the code parses (`phplint` on 8.4 and 8.5) and is formatted -correctly. It never executes SMF. Do not assume green checks mean a change works. +There is a unit test suite, but it is small and deliberately narrow: -So verify by running the forum. The repository ships a Docker environment: +```bash +composer test # or: vendor/bin/phpunit +``` + +It runs without a database, a `Settings.php` or a request: `tests/bootstrap.php` only +defines the constants `index.php` would define and points the autoloader at `Sources/`. +That covers pure helpers and class-level behaviour. **Anything reaching +`Config::$modSettings`, `User::$me` or `Db::$db` is out of scope**, which is most of the +forum. Add a test there when the code you are touching is reachable that way; do not +contort production code to make it testable. + +The rest of CI only proves the code parses (`phplint` on 8.4 and 8.5) and is formatted. +So a fully green PR still tells you very little about whether a change works. Verify by +running the forum. The repository ships a Docker environment: ```bash docker compose up -d --build diff --git a/composer.json b/composer.json index 83b76bad667..e6585abe13e 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "phpunit/phpunit": "^13.1" }, "scripts": { - "lint": "php-cs-fixer --quiet check --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes || php-cs-fixer check --diff --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", + "test": "phpunit --no-coverage", + "lint":"php-cs-fixer --quiet check --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes || php-cs-fixer check --diff --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", "lint-fix": "php-cs-fixer fix -v --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", "post-install-cmd": "php ./vendor/simplemachines/build-tools/secure-vendor-dir.php", "post-update-cmd": "php ./vendor/simplemachines/build-tools/secure-vendor-dir.php" diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 08c514aee2d..951db14dd10 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,7 +3,7 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="tests/bootstrap.php" colors="true" - cacheDirectory="cache/.phpunit.cache" + cacheDirectory=".phpunit.cache" beStrictAboutOutputDuringTests="true" failOnRisky="true" failOnWarning="true"> diff --git a/tests/Unit/ActionTraitTest.php b/tests/Unit/ActionTraitTest.php index a0b63d21518..e62160fd57a 100644 --- a/tests/Unit/ActionTraitTest.php +++ b/tests/Unit/ActionTraitTest.php @@ -6,11 +6,13 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use SMF\Actions\Agreement; +use SMF\Actions\AgreementAccept; use SMF\Actions\Login; use SMF\Actions\Login2; use SMF\Actions\Logout; -use SMF\Actions\Notify; -use SMF\Actions\NotifyBoard; +use SMF\Actions\Unread; +use SMF\Actions\UnreadReplies; use SMF\ActionTrait; #[CoversClass(ActionTrait::class)] @@ -46,9 +48,15 @@ public function testLoadIsStillCorrectWhenTheChildWasLoadedFirst(): void public function testTheSameProblemInAnUnrelatedHierarchy(): void { - Notify::load(); + // Eleven action classes extend another action and none redeclare $obj, + // so this is not specific to the login hierarchy. Notify is abstract and + // so cannot be loaded at all; Agreement and Unread are the other pairs + // with a concrete parent. + Agreement::load(); + Unread::load(); - $this->assertInstanceOf(NotifyBoard::class, NotifyBoard::load()); + $this->assertInstanceOf(AgreementAccept::class, AgreementAccept::load()); + $this->assertInstanceOf(UnreadReplies::class, UnreadReplies::load()); } public function testLoadCachesTheInstanceItReturns(): void From 99ee1048965bc55f227a311a987fec1294e30088 Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 21:02:34 +0200 Subject: [PATCH 3/9] Broadens the unit tests to the rest of the stateless surface The first pass covered only the three things that had recently broken. Quite a lot more is reachable without a database once the bootstrap sets the paths and default language that the Unicode and entity helpers use to find their data files, which is six lines and still reads nothing from Settings.php. Adds coverage for Utils' entity-aware string handling and Unicode case conversion, IP, Url, Uuid, Sapi, Security's password hashing, Punycode and TimeInterval. 99 tests, 144 assertions, on 8.4 and 8.5. Two behaviours are deliberately described rather than asserted, because pinning them down would preserve something that looks wrong: - Sapi::memoryReturnBytes() strips the last character before parsing, so a unit-less value such as '128' reads as 12 and the '-1' that means "no limit" reads as 0. Only suffixed values are asserted. - Url::isScheme() compares the scheme without normalising case, so an uppercase scheme fails to match its own name. Only exact-case matching is asserted. IP's constructor accepts the packed binary form, which it cannot tell apart from any other 4 or 16 byte string, so 'nope' becomes 110.111.112.101. That one is genuine ambiguity rather than a defect, so it is pinned down as a test in its own right. Co-Authored-By: Claude Opus 5 --- tests/Unit/IPTest.php | 93 ++++++++++++++++++++++ tests/Unit/PunycodeTest.php | 47 +++++++++++ tests/Unit/SapiTest.php | 59 ++++++++++++++ tests/Unit/SecurityTest.php | 77 ++++++++++++++++++ tests/Unit/TimeIntervalTest.php | 60 ++++++++++++++ tests/Unit/UrlTest.php | 110 +++++++++++++++++++++++++ tests/Unit/UtilsTest.php | 137 ++++++++++++++++++++++++++++++++ tests/Unit/UuidTest.php | 107 +++++++++++++++++++++++++ tests/bootstrap.php | 13 +++ 9 files changed, 703 insertions(+) create mode 100644 tests/Unit/IPTest.php create mode 100644 tests/Unit/PunycodeTest.php create mode 100644 tests/Unit/SapiTest.php create mode 100644 tests/Unit/SecurityTest.php create mode 100644 tests/Unit/TimeIntervalTest.php create mode 100644 tests/Unit/UrlTest.php create mode 100644 tests/Unit/UuidTest.php diff --git a/tests/Unit/IPTest.php b/tests/Unit/IPTest.php new file mode 100644 index 00000000000..0efe911f33d --- /dev/null +++ b/tests/Unit/IPTest.php @@ -0,0 +1,93 @@ +assertSame('2001:db8::1', (string) new IP('2001:DB8::0001')); + } + + public function testItKeepsIPv4MappedAddressesIntact(): void + { + $this->assertSame('::ffff:1.2.3.4', (string) new IP('::ffff:1.2.3.4')); + } + + public function testFlagsNarrowValidationToOneFamily(): void + { + $this->assertTrue((new IP('1.2.3.4'))->isValid(FILTER_FLAG_IPV4)); + $this->assertFalse((new IP('1.2.3.4'))->isValid(FILTER_FLAG_IPV6)); + $this->assertTrue((new IP('2001:db8::1'))->isValid(FILTER_FLAG_IPV6)); + } + + public function testBinaryAndHexRoundTrip(): void + { + $ip = new IP('1.2.3.4'); + + $this->assertSame('01020304', $ip->toHex()); + $this->assertSame(4, \strlen((string) $ip->toBinary())); + $this->assertSame('1.2.3.4', (string) new IP((string) $ip->toBinary())); + } + + public function testAnEmptyOrUnparseableValueIsNotValid(): void + { + $this->assertFalse((new IP(''))->isValid()); + $this->assertFalse((new IP('abcde'))->isValid()); + $this->assertFalse((new IP('999.999.999.999'))->isValid()); + } + + public function testAnyFourByteStringIsReadAsAPackedAddress(): void + { + // The constructor accepts the packed binary form, and it cannot tell that + // apart from a four character string. This is a sharp edge worth pinning + // down: 'nope' is not rejected, it becomes an address. + $this->assertSame('110.111.112.101', (string) new IP('nope')); + $this->assertTrue((new IP('nope'))->isValid()); + + // The same applies at 16 bytes, where it becomes an IPv6 address. + $this->assertTrue((new IP('not an ip at all'))->isValid()); + } + + #[DataProvider('validityProvider')] + public function testValidity(string $input, bool $expected): void + { + $this->assertSame($expected, (new IP($input))->isValid()); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function validityProvider(): array + { + return [ + 'ipv4' => ['192.168.0.1', true], + 'ipv4 broadcast' => ['255.255.255.255', true], + 'ipv6' => ['2001:db8::1', true], + 'ipv6 loopback' => ['::1', true], + 'octet out of range' => ['256.1.1.1', false], + 'too few octets' => ['1.2.3', false], + 'empty' => ['', false], + // Any 4 or 16 byte string is read as a packed address instead, so a + // rubbish value only fails validation at some other length. See + // testAnyFourByteStringIsReadAsAPackedAddress(). + 'words' => ['not an ip address at all', false], + ]; + } +} diff --git a/tests/Unit/PunycodeTest.php b/tests/Unit/PunycodeTest.php new file mode 100644 index 00000000000..f2a44376423 --- /dev/null +++ b/tests/Unit/PunycodeTest.php @@ -0,0 +1,47 @@ +assertSame('example.com', (new Punycode())->encode('example.com')); + } + + #[DataProvider('domainProvider')] + public function testEncodeAndDecodeAreInverses(string $unicode, string $ascii): void + { + $punycode = new Punycode(); + + $this->assertSame($ascii, $punycode->encode($unicode)); + $this->assertSame($unicode, $punycode->decode($ascii)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function domainProvider(): array + { + return [ + 'german umlaut' => ['münchen.de', 'xn--mnchen-3ya.de'], + 'multiple labels' => ['münchen.beispiel.de', 'xn--mnchen-3ya.beispiel.de'], + ]; + } +} diff --git a/tests/Unit/SapiTest.php b/tests/Unit/SapiTest.php new file mode 100644 index 00000000000..10908a726a9 --- /dev/null +++ b/tests/Unit/SapiTest.php @@ -0,0 +1,59 @@ +assertSame('/a/c', Sapi::canonicalPath('/a/./b/../c', false, false)); + $this->assertSame('/a', Sapi::canonicalPath('/a/b/..', false, false)); + } + + public function testTheSuiteRunsOnTheCommandLine(): void + { + $this->assertTrue(Sapi::isCLI()); + } + + #[DataProvider('memorySizeProvider')] + public function testMemoryReturnBytesUnderstandsUnitSuffixes(string $val, int $expected): void + { + $this->assertSame($expected, Sapi::memoryReturnBytes($val)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Only values carrying a unit suffix are covered here. memoryReturnBytes() + * unconditionally strips the last character before parsing the number, so a + * plain byte count such as '128' or the '-1' that means "no limit" is not + * read correctly. Those cases are deliberately not asserted rather than + * pinned to the current behaviour. + * + * @return array + */ + public static function memorySizeProvider(): array + { + return [ + 'kilobytes' => ['512K', 524288], + 'megabytes' => ['256M', 268435456], + 'gigabytes' => ['1G', 1073741824], + 'lowercase suffix' => ['256m', 268435456], + 'zero megabytes' => ['0M', 0], + ]; + } +} diff --git a/tests/Unit/SecurityTest.php b/tests/Unit/SecurityTest.php new file mode 100644 index 00000000000..a1b8d401c0b --- /dev/null +++ b/tests/Unit/SecurityTest.php @@ -0,0 +1,77 @@ +assertTrue(Security::hashVerifyPassword('correct horse battery staple', $hash)); + } + + public function testAHashDoesNotVerifyAgainstAnythingElse(): void + { + $hash = Security::hashPassword('correct horse battery staple', self::COST); + + $this->assertFalse(Security::hashVerifyPassword('Correct horse battery staple', $hash)); + $this->assertFalse(Security::hashVerifyPassword('', $hash)); + } + + public function testHashingIsSaltedSoTheSamePasswordHashesDifferently(): void + { + $this->assertNotSame( + Security::hashPassword('same', self::COST), + Security::hashPassword('same', self::COST), + ); + } + + public function testHashesAreBcrypt(): void + { + $this->assertStringStartsWith('$2y$', Security::hashPassword('x', self::COST)); + } + + public function testTheCostFactorIsHonoured(): void + { + $this->assertStringStartsWith('$2y$04$', Security::hashPassword('x', 4)); + $this->assertStringStartsWith('$2y$05$', Security::hashPassword('x', 5)); + } + + public function testGeneratedPasswordsAreDistinctAndNonTrivial(): void + { + $first = Security::generatePassword(); + + $this->assertSame(20, \strlen($first)); + $this->assertNotSame($first, Security::generatePassword()); + } + + public function testGeneratedValidationCodesAreDistinctAndNonTrivial(): void + { + $first = Security::generateValidationCode(); + + $this->assertSame(10, \strlen($first)); + $this->assertNotSame($first, Security::generateValidationCode()); + } +} diff --git a/tests/Unit/TimeIntervalTest.php b/tests/Unit/TimeIntervalTest.php new file mode 100644 index 00000000000..424e3607b07 --- /dev/null +++ b/tests/Unit/TimeIntervalTest.php @@ -0,0 +1,60 @@ +assertSame('P1Y2M3DT4H5M6S', (string) new TimeInterval('P1Y2M3DT4H5M6S')); + } + + public function testTimeOnlyDurationsKeepTheirTimeDesignator(): void + { + $this->assertSame('PT30M', (string) new TimeInterval('PT30M')); + } + + public function testItCanBeBuiltFromAPlainDateInterval(): void + { + $this->assertSame( + 'P1D', + (string) TimeInterval::createFromDateInterval(new \DateInterval('P1D')), + ); + } + + public function testToSecondsIsMeasuredFromAGivenMoment(): void + { + $this->assertSame(3600, (new TimeInterval('PT1H'))->toSeconds(new \DateTimeImmutable('@0'))); + } + + public function testToSecondsDependsOnTheMomentForCalendarUnits(): void + { + // A month is not a fixed number of seconds. January is longer than + // February, and asking from a different starting point proves the + // interval is resolved against a real calendar rather than an average. + $january = (new TimeInterval('P1M'))->toSeconds(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + $february = (new TimeInterval('P1M'))->toSeconds(new \DateTimeImmutable('2026-02-01T00:00:00Z')); + + $this->assertSame(31 * 86400, $january); + $this->assertSame(28 * 86400, $february); + } + + public function testToParsableSpellsTheDurationOut(): void + { + $this->assertSame( + '1 years 2 months 3 days 4 hours 5 minutes 6 seconds', + (new TimeInterval('P1Y2M3DT4H5M6S'))->toParsable(), + ); + } +} diff --git a/tests/Unit/UrlTest.php b/tests/Unit/UrlTest.php new file mode 100644 index 00000000000..302aeff039b --- /dev/null +++ b/tests/Unit/UrlTest.php @@ -0,0 +1,110 @@ +assertSame('a.example.com', $url->host); + $this->assertSame('/a/b', $url->path); + $this->assertSame('c=d', $url->query); + $this->assertSame('f', $url->fragment); + $this->assertSame(8080, $url->port); + } + + public function testMissingComponentsAreNotSet(): void + { + $url = new Url('https://example.com'); + + $this->assertFalse(isset($url->query)); + $this->assertFalse(isset($url->fragment)); + } + + public function testCastingBackToStringPreservesTheUrl(): void + { + $original = 'https://example.com/a/b?c=d#f'; + + $this->assertSame($original, (string) new Url($original)); + } + + public function testToAsciiPunycodesAnInternationalisedHost(): void + { + $this->assertSame( + 'https://xn--mnchen-3ya.de/', + (string) (new Url('https://münchen.de/'))->toAscii(), + ); + } + + public function testToAsciiPercentEncodesANonAsciiPath(): void + { + $this->assertSame( + 'https://xn--mnchen-3ya.de/stra%C3%9Fe', + (string) (new Url('https://münchen.de/straße'))->toAscii(), + ); + } + + public function testToUtf8ReversesPunycode(): void + { + $this->assertSame( + 'münchen.de', + (new Url('https://xn--mnchen-3ya.de/'))->toUtf8()->host, + ); + } + + public function testTheSchemeIsReportedExactlyAsItWasWritten(): void + { + // Schemes are case insensitive, but this does not normalise them, so a + // caller comparing against 'https' must lowercase first. + $this->assertSame('HTTPS', (new Url('HTTPS://example.com'))->scheme); + } + + public function testIsSchemeMatchesTheSchemeAsWritten(): void + { + $this->assertTrue((new Url('https://example.com'))->isScheme('https')); + $this->assertTrue((new Url('https://example.com'))->isScheme(['http', 'https'])); + $this->assertFalse((new Url('https://example.com'))->isScheme('ftp')); + + // Not asserted here: an uppercase scheme does not match its lowercase + // name, because isScheme() compares the un-normalised scheme with + // in_array(). RFC 3986 makes schemes case insensitive, so that looks + // like a defect rather than something to pin down in a test. + } + + #[DataProvider('validityProvider')] + public function testValidity(string $input, bool $expected): void + { + $this->assertSame($expected, (new Url($input))->isValid()); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function validityProvider(): array + { + return [ + 'https' => ['https://example.com', true], + 'http with path' => ['http://example.com/a/b', true], + 'bare word' => ['notaurl', false], + 'empty' => ['', false], + ]; + } +} diff --git a/tests/Unit/UtilsTest.php b/tests/Unit/UtilsTest.php index fddf008068d..ffc139b707d 100644 --- a/tests/Unit/UtilsTest.php +++ b/tests/Unit/UtilsTest.php @@ -5,6 +5,7 @@ namespace SMF\Tests\Unit; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SMF\Utils; @@ -44,4 +45,140 @@ public function testBuildRegexHandlesASingleString(): void { $this->assertMatchesRegularExpression('~^' . Utils::buildRegex(['solo']) . '$~', 'solo'); } + + public function testEntityAwareLengthCountsAnEntityAsOneCharacter(): void + { + $this->assertSame(3, Utils::entityStrlen('a&b')); + $this->assertSame(4, Utils::entityStrlen('déjà')); + } + + public function testEntityAwareSubstrDoesNotSplitAnEntity(): void + { + $this->assertSame('a&', Utils::entitySubstr('a&bc', 0, 2)); + } + + public function testEntityAwareStrposCountsEntitiesAsOne(): void + { + $this->assertSame(2, Utils::entityStrpos('a&bc', 'b')); + } + + public function testEntityAwareSplitKeepsEntitiesWhole(): void + { + $this->assertSame(['a', '&', 'b'], Utils::entityStrSplit('a&b')); + } + + public function testHtmlTrimRemovesEntityWhitespaceAtBothEnds(): void + { + $this->assertSame('a', Utils::htmlTrim('   a   ')); + $this->assertSame('a', Utils::htmlTrimLeft('  a')); + $this->assertSame('a', Utils::htmlTrimRight('a  ')); + } + + public function testTruncateRefusesToCutAnEntityInHalf(): void + { + $this->assertSame('abcde', Utils::truncate('abcdefghij', 5)); + + // '&' would not fit in the remaining budget, so it is dropped whole + // rather than emitted as a broken fragment. + $this->assertSame('a', Utils::truncate('a&bcdef', 5)); + } + + public function testShortenAppendsAnEllipsisOnlyWhenItShortens(): void + { + $this->assertSame('abcde...', Utils::shorten('abcdefghij', 5)); + $this->assertSame('abc', Utils::shorten('abc', 5)); + } + + public function testNormalizeComposesAndDecomposes(): void + { + $this->assertSame("\u{00E1}", Utils::normalize("a\u{0301}", 'c')); + $this->assertSame(2, mb_strlen(Utils::normalize("\u{00E1}", 'd'))); + } + + public function testConvertCaseHandlesCharactersWithNoSimpleMapping(): void + { + // Uppercasing the sharp s expands it to two characters, which a naive + // strtoupper() on bytes cannot do. + $this->assertSame('STRASSE', Utils::convertCase('Straße', 'upper')); + $this->assertSame('Hello World', Utils::convertCase('hello world', 'title')); + } + + public function testConvertCaseTitlecasesDigraphsToTheirTitleForm(): void + { + // U+01F3 dz titlecases to U+01F2 Dz, which is neither upper nor lower. + $this->assertSame("\u{01F2}", Utils::convertCase("\u{01F3}", 'title')); + } + + public function testConvertCaseFoldsForCaseInsensitiveComparison(): void + { + $this->assertSame( + Utils::convertCase('ÄÖÜ', 'fold'), + Utils::convertCase('äöü', 'fold'), + ); + } + + public function testSanitizeCharsReplacesDirectionalOverridesAtLevelOne(): void + { + // A right-to-left override can be used to disguise a file name or link. + $this->assertSame("a\u{202E}b", Utils::sanitizeChars("a\u{202E}b", 0)); + $this->assertSame("a\u{FFFD}b", Utils::sanitizeChars("a\u{202E}b", 1)); + } + + public function testNormalizeSpacesCollapsesExoticWhitespace(): void + { + $this->assertSame('a b', Utils::normalizeSpaces("a\u{00A0}b", true, true)); + } + + public function testSanitizeEntitiesReplacesEntitiesForControlCharacters(): void + { + $this->assertSame('�', Utils::sanitizeEntities('')); + $this->assertSame('A', Utils::sanitizeEntities('A')); + } + + public function testHtmlspecialcharsLeavesSingleQuotesAloneByDefault(): void + { + $this->assertSame('a"b\'c<>&', Utils::htmlspecialchars('a"b\'c<>&')); + $this->assertSame('a"b'c', Utils::htmlspecialchars('a"b\'c', ENT_QUOTES)); + } + + public function testHtmlspecialcharsDecodeRoundTrips(): void + { + $original = 'a"b&d'; + + $this->assertSame( + $original, + Utils::htmlspecialcharsDecode(Utils::htmlspecialchars($original, ENT_QUOTES)), + ); + } + + public function testJsonRoundTrips(): void + { + $this->assertSame('{"a":1}', Utils::jsonEncode(['a' => 1])); + $this->assertSame(['a' => 1], Utils::jsonDecode('{"a":1}', true)); + } + + #[DataProvider('entityLengthProvider')] + public function testEntityStrlenAcrossInputs(string $input, int $expected): void + { + $this->assertSame($expected, Utils::entityStrlen($input)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function entityLengthProvider(): array + { + return [ + 'empty' => ['', 0], + 'ascii' => ['abc', 3], + 'named entity' => ['&', 1], + 'numeric entity' => ['©', 1], + 'multibyte' => ["\u{00E9}\u{00E8}", 2], + 'mixed' => ['a&é', 3], + ]; + } } diff --git a/tests/Unit/UuidTest.php b/tests/Unit/UuidTest.php new file mode 100644 index 00000000000..b09b7e65c70 --- /dev/null +++ b/tests/Unit/UuidTest.php @@ -0,0 +1,107 @@ +assertMatchesRegularExpression( + '~^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$~', + (string) Uuid::create(4), + ); + } + + public function testGeneratedUuidsAreDistinct(): void + { + $this->assertNotSame((string) Uuid::create(4), (string) Uuid::create(4)); + } + + public function testTheVariantIsAlwaysTheRfcOne(): void + { + $this->assertSame(1, Uuid::create(4)->getVariant()); + $this->assertSame(1, Uuid::create(7)->getVariant()); + } + + public function testTheNilUuidRoundTripsAndReportsVersionZero(): void + { + $uuid = Uuid::createFromString(self::NIL); + + $this->assertSame(self::NIL, (string) $uuid); + $this->assertSame(0, $uuid->getVersion()); + } + + public function testTheBinaryFormIsSixteenBytes(): void + { + $this->assertSame(16, \strlen(Uuid::create(4)->getBinary())); + } + + public function testTheShortFormIsTwentyTwoCharacters(): void + { + $this->assertSame(22, \strlen(Uuid::create(4)->getShortForm())); + } + + public function testCompressAndExpandRoundTrip(): void + { + $uuid = (string) Uuid::create(4); + + $this->assertSame($uuid, Uuid::expand(Uuid::compress($uuid))); + } + + public function testStrictParsingRejectsRubbish(): void + { + $this->expectException(\ValueError::class); + + Uuid::createFromString('not-a-uuid', true); + } + + public function testVersionSevenUuidsSortByCreationOrder(): void + { + // Version 7 puts a millisecond timestamp in the high bits, so the string + // form is monotonic. That is the whole point of using it for keys. + $first = (string) Uuid::create(7); + usleep(2000); + $second = (string) Uuid::create(7); + + $this->assertLessThan(0, strcmp($first, $second)); + } + + #[DataProvider('versionProvider')] + public function testCreateProducesTheRequestedVersion(int $version): void + { + $this->assertSame($version, Uuid::create($version)->getVersion()); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function versionProvider(): array + { + return [ + 'v4 random' => [4], + 'v7 time ordered' => [7], + ]; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index facda09b2c3..a97e864c99f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -51,3 +51,16 @@ $loader = require TESTS_BOARDDIR . '/vendor/autoload.php'; $loader->setPsr4('SMF\\', TESTS_BOARDDIR . '/Sources'); $loader->setPsr4('SMF\\Themes\\', TESTS_BOARDDIR . '/Themes'); + +/* + * Paths and the default language, which the Unicode and entity helpers need in + * order to locate their data files. These are the only pieces of Config the suite + * sets: no modSettings, no database credentials, nothing read from Settings.php. + * A test that needs more than this is an integration test. + */ +SMF\Config::$boarddir = (string) realpath(TESTS_BOARDDIR); +SMF\Config::$sourcedir = SMF\Config::$boarddir . '/Sources'; +SMF\Config::$packagesdir = SMF\Config::$boarddir . '/Packages'; +SMF\Config::$languagesdir = SMF\Config::$boarddir . '/Languages'; +SMF\Config::$cachedir = SMF\Config::$boarddir . '/cache'; +SMF\Config::$language = 'en_US'; From 560dfb2bb81721120dab15f6f233701aaa27b489 Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 21:08:13 +0200 Subject: [PATCH 4/9] Compares URL schemes case insensitively RFC 3986, section 3.1, makes scheme names case insensitive, and this class keeps the scheme exactly as it was written rather than normalizing it. isScheme() compared the two with in_array(), so a URL written with an uppercase scheme did not match its own name. That reaches two callers. isWebsite() stops recognising HTTP:// and HTTPS:// as websites, and the avatar handling in User treats a DATA: URI as though it were a remote address. Folds both sides before comparing, and makes the comparison strict while it is there. Co-Authored-By: Claude Opus 5 --- Sources/Url.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/Url.php b/Sources/Url.php index ae3421dfe36..b98c3c1f599 100644 --- a/Sources/Url.php +++ b/Sources/Url.php @@ -600,12 +600,23 @@ public function isWebsite(): bool /** * Check if this URL uses one of the specified schemes. * + * Scheme names are case insensitive, per RFC 3986, section 3.1, and this + * class does not normalize them, so both sides are folded before comparing. + * * @param string|string[] $scheme Schemes to check. * @return bool Whether the URL matches a scheme. */ public function isScheme(string|array $scheme): bool { - return !empty($this->scheme) && \in_array($this->scheme, array_map('strval', (array) $scheme)); + if (empty($this->scheme)) { + return false; + } + + return \in_array( + strtolower($this->scheme), + array_map(fn($s) => strtolower((string) $s), (array) $scheme), + true, + ); } /** From 7d656ce683615115d746e25da0c2a7ec5766f5bf Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 21:11:16 +0200 Subject: [PATCH 5/9] Turns the two noted defects into regression tests The memoryReturnBytes() and isScheme() cases were described in comments rather than asserted, because pinning down the behaviour would have preserved it. Now that both are fixed, they become tests. Verified to fail against the unfixed code: reverting the two source files alone fails exactly these six tests and nothing else. Co-Authored-By: Claude Opus 5 --- tests/Unit/SapiTest.php | 32 +++++++++++++++++++++++++------- tests/Unit/UrlTest.php | 26 ++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/tests/Unit/SapiTest.php b/tests/Unit/SapiTest.php index 10908a726a9..8e5d3875feb 100644 --- a/tests/Unit/SapiTest.php +++ b/tests/Unit/SapiTest.php @@ -27,8 +27,29 @@ public function testTheSuiteRunsOnTheCommandLine(): void $this->assertTrue(Sapi::isCLI()); } + public function testNoMemoryLimitIsReportedAsMoreThanAnythingWillNeed(): void + { + // A memory_limit of -1 means unlimited. Reporting it as 0 made + // setMemoryLimit() decide the current limit was too small and impose + // one, so asking for 128M on an unlimited server capped it at 128M. + $this->assertSame(PHP_INT_MAX, Sapi::memoryReturnBytes('-1')); + } + + public function testAPlainByteCountKeepsItsLastDigit(): void + { + // The designator is optional, and Graphics\Image passes a computed byte + // count without one. Stripping the last character regardless turned this + // into a tenth of the memory that was actually asked for. + $this->assertSame(50000000, Sapi::memoryReturnBytes('50000000')); + } + + public function testSurroundingWhitespaceIsIgnored(): void + { + $this->assertSame(67108864, Sapi::memoryReturnBytes(' 64M ')); + } + #[DataProvider('memorySizeProvider')] - public function testMemoryReturnBytesUnderstandsUnitSuffixes(string $val, int $expected): void + public function testMemoryReturnBytes(string $val, int $expected): void { $this->assertSame($expected, Sapi::memoryReturnBytes($val)); } @@ -38,12 +59,6 @@ public function testMemoryReturnBytesUnderstandsUnitSuffixes(string $val, int $e ***********************/ /** - * Only values carrying a unit suffix are covered here. memoryReturnBytes() - * unconditionally strips the last character before parsing the number, so a - * plain byte count such as '128' or the '-1' that means "no limit" is not - * read correctly. Those cases are deliberately not asserted rather than - * pinned to the current behaviour. - * * @return array */ public static function memorySizeProvider(): array @@ -54,6 +69,9 @@ public static function memorySizeProvider(): array 'gigabytes' => ['1G', 1073741824], 'lowercase suffix' => ['256m', 268435456], 'zero megabytes' => ['0M', 0], + 'plain byte count' => ['128', 128], + 'zero' => ['0', 0], + 'empty' => ['', 0], ]; } } diff --git a/tests/Unit/UrlTest.php b/tests/Unit/UrlTest.php index 302aeff039b..913cc10147c 100644 --- a/tests/Unit/UrlTest.php +++ b/tests/Unit/UrlTest.php @@ -78,11 +78,29 @@ public function testIsSchemeMatchesTheSchemeAsWritten(): void $this->assertTrue((new Url('https://example.com'))->isScheme('https')); $this->assertTrue((new Url('https://example.com'))->isScheme(['http', 'https'])); $this->assertFalse((new Url('https://example.com'))->isScheme('ftp')); + } + + public function testIsSchemeIgnoresCaseOnBothSides(): void + { + // RFC 3986 section 3.1: scheme names are case insensitive. The scheme is + // not normalised on parsing, so the comparison has to fold it. + $this->assertTrue((new Url('HTTPS://example.com'))->isScheme('https')); + $this->assertTrue((new Url('https://example.com'))->isScheme('HTTPS')); + $this->assertTrue((new Url('HtTp://example.com'))->isScheme(['http', 'https'])); + } - // Not asserted here: an uppercase scheme does not match its lowercase - // name, because isScheme() compares the un-normalised scheme with - // in_array(). RFC 3986 makes schemes case insensitive, so that looks - // like a defect rather than something to pin down in a test. + public function testAnUppercaseSchemeIsStillAWebsite(): void + { + $this->assertTrue((new Url('HTTP://example.com'))->isWebsite()); + $this->assertTrue((new Url('HTTPS://example.com'))->isWebsite()); + $this->assertFalse((new Url('ftp://example.com'))->isWebsite()); + } + + public function testAnUppercaseDataUriIsRecognised(): void + { + // User's avatar handling asks isScheme('data') to decide whether the + // value is an inline image or a remote address. + $this->assertTrue((new Url('DATA:image/png;base64,AAAA'))->isScheme('data')); } #[DataProvider('validityProvider')] From 3e45ec45528731c8140e3c44c707ae6a52c83fda Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 21:26:51 +0200 Subject: [PATCH 6/9] Marks the ActionTrait test as covering a trait CoversClass on a trait is not a valid coverage target, and PHPUnit only says so when coverage is actually collected. The suite passed on its own and failed all five ActionTrait cases the moment anyone ran it with --coverage. Co-Authored-By: Claude Opus 5 --- tests/Unit/ActionTraitTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/ActionTraitTest.php b/tests/Unit/ActionTraitTest.php index e62160fd57a..c194babe400 100644 --- a/tests/Unit/ActionTraitTest.php +++ b/tests/Unit/ActionTraitTest.php @@ -4,7 +4,7 @@ namespace SMF\Tests\Unit; -use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversTrait; use PHPUnit\Framework\TestCase; use SMF\Actions\Agreement; use SMF\Actions\AgreementAccept; @@ -15,7 +15,7 @@ use SMF\Actions\UnreadReplies; use SMF\ActionTrait; -#[CoversClass(ActionTrait::class)] +#[CoversTrait(ActionTrait::class)] class ActionTraitTest extends TestCase { /**************** From b5f342f0e2025eaf5974393ec5ceaea6078e2d48 Mon Sep 17 00:00:00 2001 From: albertlast Date: Fri, 31 Jul 2026 20:20:17 +0200 Subject: [PATCH 7/9] Documents when the unit test suite can cover a change The testing notes described the suite mainly as a limitation, which left agents with no way to tell whether the code in front of them was reachable from it. Sets out the expectation that a reachable change carries a test, and lists the cases that work with the examples already in tests/Unit/: pure helpers, value objects, class-level behaviour, protected helpers through reflection, and modSettings keys the test sets itself. Also names the strict-mode traps and the two ways the style fixer rearranges a test file. Corrects the CI claim as well; phpunit.yml only runs on pull requests and on pushes to release-3.0. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- AGENTS.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index af1ede645eb..e58a652d280 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,18 +79,78 @@ param, throws, return. ## Verifying a change -There is a unit test suite, but it is small and deliberately narrow: +### Tests + +There is a unit test suite. It is small and deliberately narrow, but where it reaches, +it is the only automated proof that a change does what it claims: ```bash composer test # or: vendor/bin/phpunit ``` -It runs without a database, a `Settings.php` or a request: `tests/bootstrap.php` only -defines the constants `index.php` would define and points the autoloader at `Sources/`. -That covers pure helpers and class-level behaviour. **Anything reaching -`Config::$modSettings`, `User::$me` or `Db::$db` is out of scope**, which is most of the -forum. Add a test there when the code you are touching is reachable that way; do not -contort production code to make it testable. +CI runs it on every pull request, and on pushes to `release-3.0`, via +`.github/workflows/phpunit.yml`. Feature branches are only checked once they are in a PR, +so run it locally. + +**The expectation: if the code you touched is reachable from this suite, your change +adds or updates a test in the same commit.** A bug fix lands as a regression test that +fails before the fix and passes after it, with a comment saying what went wrong — see +`SapiTest::testAPlainByteCountKeepsItsLastDigit()` for the shape. When the code is not +reachable, say so explicitly in the PR description rather than leaving it unsaid; do not +contort production code, add mocks or fake a database to force something under test. + +#### When a test is possible + +`tests/bootstrap.php` defines the constants `index.php` would define, points the +autoloader at `Sources/` and sets `Config::$boarddir`, `$sourcedir`, `$packagesdir`, +`$languagesdir`, `$cachedir` and `$language`. That is all. No `Settings.php`, no +database, no request. Within those limits the following are all testable, and each has a +worked example in `tests/Unit/`: + +- **Pure and static helpers**: `Utils::buildRegex()`, `Sapi::memoryReturnBytes()`, + `Security::hashPassword()`. Cheap to cover with a `#[DataProvider]`. +- **Value objects that parse or normalise a string**: `IP`, `Url`, `Uuid`, + `TimeInterval`, `Punycode`. Construct one and assert on the result. +- **Class-level behaviour that needs no state**: late static binding, shared statics, + what `Foo::load()` returns. `ActionTraitTest` is entirely this. +- **Protected and private helpers**, through `ReflectionMethod`, when the public entry + point around them needs a database but the helper itself does not + (`CreatePostNotifyTest::getTimeOffset()`). +- **Code that reads a few `Config::$modSettings` keys.** Set them in `setUp()` and + `unset()` them in `tearDown()`. PHPUnit does not reset SMF's statics between tests, so + a key left behind leaks into every test that follows. +- **Anything that only needs the language or Unicode data files**, since the bootstrap + sets the paths they look in. + +#### When it is not + +- Anything calling `Db::$db` — there is no connection, and faking one is not worth it. +- Anything reading `User::$me`, the session, `$_GET`/`$_POST`/`$_SERVER`, or expecting a + loaded theme or `Utils::$context`. +- Anything that emits output or sends headers. `beStrictAboutOutputDuringTests` is on, so + a stray `echo` fails the test rather than being swallowed. + +`failOnRisky` and `failOnWarning` are on as well: a test that asserts nothing is a +failure, not a pass. + +#### Writing one + +`tests/Unit/Test.php`, namespace `SMF\Tests\Unit`, `declare(strict_types=1)`, +extending `PHPUnit\Framework\TestCase`, with `#[CoversClass]` (or `#[CoversTrait]` for a +trait) on the class. Name the test after the behaviour, not the method — +`testItNormalisesIPv6ToItsShortestForm()`, not `testConstruct()`. New directories need +the usual `index.php` stub. + +The code style rules apply to tests too, so run `composer lint-fix` on them. Two +consequences of the fixer worth knowing before you fight it: + +- Data providers are `public static`, so `ordered_class_elements` moves them *below* the + public test methods, into their own `Public static methods` banner. +- The `SMF/section_comments` fixer inserts a banner between an attribute and the method + it belongs to. Do not let a method carrying `#[DataProvider]` be the first one in its + group; `CreatePostNotifyTest` carries a note about this. + +### Running the forum The rest of CI only proves the code parses (`phplint` on 8.4 and 8.5) and is formatted. So a fully green PR still tells you very little about whether a change works. Verify by @@ -126,10 +186,6 @@ docker compose exec postgres psql -U smf -d smf -c 'SELECT * FROM smf_log_errors `smf_log_errors` is the first place to look. Many failures are recorded there rather than shown, especially anything in a background task. -Some code is reachable with only the autoloader plus the constants that `index.php` -defines, which is enough to exercise pure helpers without a database. Anything that -touches `User::$me` or `Db::$db` needs a real request or fixtures. - ## Things that bite in this codebase - **Typed properties with no default throw when read before assignment.** Several are From 5db44ebc6baf75e29e98ee20424a8fa3e68ae11e Mon Sep 17 00:00:00 2001 From: albertlast Date: Thu, 13 Aug 2026 20:11:37 +0200 Subject: [PATCH 8/9] Covers last week's fixes that the unit suite can reach Three of the bug fixes merged into release-3.0 over the past week touch code that needs no database, so they can be pinned down here rather than only in a running forum. #9506 stopped SMF\IP::ip2range() validating the two ends of a range as IPv4 only, which meant an IPv6 range was not recognised as a range at all and came back as 255.255.255.255. #9507 made SMF\Url::proxied() strip the brackets off an IPv6 host before asking filter_var() about it. Without that, every literal IPv6 host read as a name rather than an address, so the private and reserved ranges were not excluded and the proxy would fetch them. #9499 stopped SMF\TimeInterval holding a \DateInterval of its own and started being one, which is what \DateTime::add() and ::sub() read. The two cases here that asserted the old canonical string form are updated: a duration naming no years or months no longer carries a zero day count through stringification. Also covers SMF\IP::matchToCIDR(), which #9507 restructured without meaning to change it. Those cases pass either side of that commit, which is the point. Three notes on what is deliberately absent. TimeInterval::localize() goes through Lang::getTxt(), which loads a language file and so wants Theme and Db::$db. MarkdownParser, for #9509, builds its parent, which calls User::setMe(). Neither is reachable from this bootstrap. The third is a defect rather than a boundary. matchToCIDR() builds its IPv6 mask with str_repeat('f', (int) $cidr_subnetmask / 4), where the cast binds to the subnet mask rather than to the division, so any prefix length that is not a multiple of four hands str_repeat() a float and throws a TypeError. The switch immediately below it, which exists to add the odd nibble for exactly those prefixes, has therefore never run. The data provider says so in a comment and stays on multiples of four: asserting the TypeError would only preserve it. Signed-off-by: Mathias Alberts Signed-off-by: albertlast --- tests/Unit/IPTest.php | 96 +++++++++++++++++++++++++++++++++ tests/Unit/TimeIntervalTest.php | 95 ++++++++++++++++++++++++++++++-- tests/Unit/UrlTest.php | 88 ++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+), 5 deletions(-) diff --git a/tests/Unit/IPTest.php b/tests/Unit/IPTest.php index 0efe911f33d..581da0c17cb 100644 --- a/tests/Unit/IPTest.php +++ b/tests/Unit/IPTest.php @@ -61,16 +61,112 @@ public function testAnyFourByteStringIsReadAsAPackedAddress(): void $this->assertTrue((new IP('not an ip at all'))->isValid()); } + public function testIp2RangeReadsARangeOfIPv6Addresses(): void + { + // The two ends of a range are only recognised as ends if they validate + // as addresses. Insisting on IPv4 there meant neither end of an IPv6 + // range was one, so this fell through to the "one side is a fragment" + // path and read the address itself as a list of octets to walk. + $range = IP::ip2range('2001:db8::1-2001:db8::ff'); + + $this->assertSame('2001:db8::1', (string) $range['low']); + $this->assertSame('2001:db8::ff', (string) $range['high']); + } + + public function testIp2RangeReadsAFullyWrittenIPv6Range(): void + { + // Same range as above with nothing elided, so the shortening on the way + // back out is the only difference between the two. + $range = IP::ip2range('2001:db8:0:0:0:0:0:1-2001:db8:0:0:0:0:0:ff'); + + $this->assertSame('2001:db8::1', (string) $range['low']); + $this->assertSame('2001:db8::ff', (string) $range['high']); + } + + public function testIp2RangeStillReadsARangeOfIPv4Addresses(): void + { + $range = IP::ip2range('1.2.3.4-1.2.3.9'); + + $this->assertSame('1.2.3.4', (string) $range['low']); + $this->assertSame('1.2.3.9', (string) $range['high']); + } + #[DataProvider('validityProvider')] public function testValidity(string $input, bool $expected): void { $this->assertSame($expected, (new IP($input))->isValid()); } + #[DataProvider('ip2RangeWildcardProvider')] + public function testIp2RangeFillsWildcardsWithTheLowestAndHighestValue( + string $input, + string $low, + string $high, + ): void { + $range = IP::ip2range($input); + + $this->assertSame($low, (string) $range['low']); + $this->assertSame($high, (string) $range['high']); + } + + #[DataProvider('cidrProvider')] + public function testMatchToCIDR(string $ip, string $cidr, bool $expected): void + { + $this->assertSame($expected, (new IP($ip))->matchToCIDR($cidr)); + } + /*********************** * Public static methods ***********************/ + /** + * @return array + */ + public static function ip2RangeWildcardProvider(): array + { + return [ + 'ipv4 last octet' => ['1.2.3.*', '1.2.3.0', '1.2.3.255'], + 'ipv6 last group' => ['2001:db8::*', '2001:db8::', '2001:db8::ffff'], + 'ipv6 all but the first two groups' => [ + '2001:db8:*:*:*:*:*:*', + '2001:db8::', + '2001:db8:ffff:ffff:ffff:ffff:ffff:ffff', + ], + // Not a range at all: both ends are the address itself. + 'a single ipv6 address' => ['2001:db8::1', '2001:db8::1', '2001:db8::1'], + // 'unknown' is what the log holds when the address was not recorded. + // It is deliberately turned into an address that cannot occur. + 'unknown' => ['unknown', '255.255.255.255', '255.255.255.255'], + ]; + } + + /** + * @return array + */ + public static function cidrProvider(): array + { + return [ + 'ipv4 inside' => ['192.168.1.55', '192.168.1.0/24', true], + 'ipv4 outside' => ['192.168.2.55', '192.168.1.0/24', false], + 'ipv4 single host' => ['192.168.1.55', '192.168.1.55/32', true], + 'ipv6 inside' => ['2001:db8::5', '2001:db8::/32', true], + 'ipv6 outside' => ['2001:dba::5', '2001:db8::/32', false], + 'ipv6 inside a /48' => ['2001:db8:1::5', '2001:db8:1::/48', true], + 'ipv6 outside a /48' => ['2001:db8:2::5', '2001:db8:1::/48', false], + // Every prefix length here is a multiple of four, and that is not a + // coincidence. The IPv6 branch builds its mask with + // str_repeat('f', (int) $cidr_subnetmask / 4), where the cast binds + // to the subnet mask rather than to the division, so anything else + // hands str_repeat() a float and throws a TypeError before the + // switch below it can add the odd nibble. Those three cases have + // therefore never run. Asserting the TypeError here would only + // preserve it, so this says so instead. + // The families are never mixed, whichever way round they are given. + 'ipv6 address against an ipv4 network' => ['2001:db8::5', '192.168.1.0/24', false], + 'ipv4 address against an ipv6 network' => ['192.168.1.55', '2001:db8::/32', false], + ]; + } + /** * @return array */ diff --git a/tests/Unit/TimeIntervalTest.php b/tests/Unit/TimeIntervalTest.php index cd31b20d4c2..fd70fd25f5e 100644 --- a/tests/Unit/TimeIntervalTest.php +++ b/tests/Unit/TimeIntervalTest.php @@ -23,15 +23,22 @@ public function testItRoundTripsAnIso8601Duration(): void public function testTimeOnlyDurationsKeepTheirTimeDesignator(): void { // The point here is the 'T': without it, the 'M' would read as months - // rather than minutes. The leading 'P0D' comes along for the ride - // because this class, unlike \DateInterval, populates days for a - // duration that names no years or months. - $this->assertSame('P0DT30M', (string) new TimeInterval('PT30M')); + // rather than minutes. + $this->assertSame('PT30M', (string) new TimeInterval('PT30M')); } public function testStringifyingIsStableAcrossARoundTrip(): void { - $this->assertSame('P0DT30M', (string) new TimeInterval('P0DT30M')); + $this->assertSame('PT30M', (string) new TimeInterval('PT30M')); + } + + public function testAZeroUnitIsDroppedOnTheWayBackOut(): void + { + // This used to be the canonical form: the class populated days for a + // duration naming no years or months, and stringifying wrote the days + // out whether there were any or not. Both ends of that are gone, so a + // duration written the old way now comes back in the short form. + $this->assertSame('PT30M', (string) new TimeInterval('P0DT30M')); } public function testItCanBeBuiltFromAPlainDateInterval(): void @@ -68,4 +75,82 @@ public function testToParsableSpellsTheDurationOut(): void (new TimeInterval('P1Y2M3DT4H5M6S'))->toParsable(), ); } + + public function testItCarriesItsOwnValuesRatherThanHoldingThem(): void + { + // The class used to keep a \DateInterval of its own and answer for it + // through property hooks, which left the object it actually is empty. + // Everything below reads the object itself, as \DateInterval's own + // documented interface and every caller passing one to date arithmetic + // does. + $interval = new TimeInterval('P1Y2M3DT4H5M6S'); + + $this->assertInstanceOf(\DateInterval::class, $interval); + $this->assertSame(1, $interval->y); + $this->assertSame(2, $interval->m); + $this->assertSame(3, $interval->d); + $this->assertSame(4, $interval->h); + $this->assertSame(5, $interval->i); + $this->assertSame(6, $interval->s); + } + + public function testDateArithmeticMovesTheDateByTheWholeInterval(): void + { + // This is what an empty object costs: \DateTime reads the interval's + // own properties and cannot see a stored one, so adding an interval + // that said it was a month and change moved the date by nothing at all. + $start = new \DateTimeImmutable('2026-01-01 00:00:00', new \DateTimeZone('UTC')); + + $this->assertSame( + '2026-02-03T03:00:00+00:00', + $start->add(new TimeInterval('P1M2DT3H'))->format('c'), + ); + + $this->assertSame( + '2025-11-28T21:00:00+00:00', + $start->sub(new TimeInterval('P1M2DT3H'))->format('c'), + ); + } + + public function testItMovesTheDateByTheSameAmountAPlainDateIntervalDoes(): void + { + $start = new \DateTimeImmutable('2026-01-01 00:00:00', new \DateTimeZone('UTC')); + + $this->assertSame( + $start->add(new \DateInterval('P1M2DT3H'))->format('c'), + $start->add(new TimeInterval('P1M2DT3H'))->format('c'), + ); + } + + public function testFractionalSecondsSurviveConstruction(): void + { + // The whole reason this class exists: \DateInterval accepts only the + // integer subset of the ISO 8601 duration spec. + $this->assertSame('PT1.5S', (string) new TimeInterval('PT1.5S')); + $this->assertSame(1.5, (new TimeInterval('PT1.5S'))->toSeconds(new \DateTimeImmutable('@0'))); + } + + public function testFormatFallsBackToDaysWhenTheTotalIsUnknown(): void + { + // %a is the total number of days, which only an interval produced by + // diff() knows; \DateInterval writes '(unknown)' for any other. When + // there are no years or months in the way, the days field is that + // total, so it is written instead. + $this->assertSame('1', (new TimeInterval('P1DT2H'))->format('%a')); + + // With a year in it, the days field is not the total and nothing can be + // substituted, so the honest answer is still the one \DateInterval gives. + $this->assertSame('(unknown)', (new TimeInterval('P1Y'))->format('%a')); + } + + /* + * localize() is not covered here. It is the other half of what #9499 put + * right - the unit order stopped depending on how the caller wrote the + * argument, and asking for 'a' when the total number of days is unknown now + * falls back to years, months and days instead of producing nothing - but + * every branch of it goes through Lang::getTxt(), which loads a language + * file, which wants Theme::$current and therefore Db::$db. It belongs to an + * integration suite. toParsable() above covers the same walk over the units + * with the strings hard coded, so the ordering is not entirely unwatched. + */ } diff --git a/tests/Unit/UrlTest.php b/tests/Unit/UrlTest.php index 913cc10147c..db7888de940 100644 --- a/tests/Unit/UrlTest.php +++ b/tests/Unit/UrlTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use SMF\Config; use SMF\Url; #[CoversClass(Url::class)] @@ -103,12 +104,38 @@ public function testAnUppercaseDataUriIsRecognised(): void $this->assertTrue((new Url('DATA:image/png;base64,AAAA'))->isScheme('data')); } + public function testAnIPv6HostIsWrittenInBrackets(): void + { + // The brackets are part of the authority, not part of the address, so + // the host comes back with them still on it. Anything wanting to treat + // the host as an address has to take them off first, which is what + // proxied() was not doing. + $this->assertSame('[2001:db8::1]', (new Url('http://[2001:db8::1]/pic.png'))->host); + } + #[DataProvider('validityProvider')] public function testValidity(string $input, bool $expected): void { $this->assertSame($expected, (new Url($input))->isValid()); } + #[DataProvider('proxiedProvider')] + public function testProxiedLeavesUnroutableHostsAlone(string $url, bool $expected): void + { + $this->withProxySettings(function () use ($url, $expected): void { + $proxied = (string) Url::create($url)->proxied(); + + if ($expected) { + $this->assertStringStartsWith( + 'https://forum.test-site.com/forum/proxy.php?request=', + $proxied, + ); + } else { + $this->assertSame($url, $proxied); + } + }); + } + /*********************** * Public static methods ***********************/ @@ -125,4 +152,65 @@ public static function validityProvider(): array 'empty' => ['', false], ]; } + + /** + * The point of the proxy is to serve an http image over https without + * telling the person's browser to go and fetch it. Sending it at a host + * only the server can reach turns it into a request the server makes on + * behalf of whoever pasted the address, which is the shape of an SSRF, so + * the private and reserved ranges are excluded. + * + * @return array + */ + public static function proxiedProvider(): array + { + return [ + // The IPv6 cases are the ones that regressed: filter_var() does not + // accept an address in brackets, so every one of these read as a + // name rather than an address and went to the proxy. + 'ipv6 private' => ['http://[fd00::1]/pic.png', false], + 'ipv6 loopback' => ['http://[::1]/pic.png', false], + 'ipv6 documentation' => ['http://[2001:db8::1]/pic.png', false], + 'ipv6 global' => ['http://[2606:4700:4700::1111]/pic.png', true], + + // The IPv4 side is the control: it was already right and stays right. + 'ipv4 private' => ['http://10.0.0.1/pic.png', false], + 'ipv4 loopback' => ['http://127.0.0.1/pic.png', false], + 'ipv4 global' => ['http://93.184.216.34/pic.png', true], + ]; + } + + /****************** + * Internal methods + ******************/ + + /** + * Runs $test with the image proxy switched on, then puts the settings back. + * + * These are typed statics with no default, so they start out uninitialised + * and there is no way to put them back into that state. Restoring the + * disabled state is the nearest thing: it is what Config::load() writes + * when the proxy is off, and every check on the way in asks empty(), which + * reads an uninitialised property and a false one alike. + * + * @param callable $test The assertions to run. + */ + protected function withProxySettings(callable $test): void + { + $enabled = Config::$image_proxy_enabled ?? false; + $secret = Config::$image_proxy_secret ?? ''; + $boardurl = Config::$boardurl ?? ''; + + Config::$image_proxy_enabled = true; + Config::$image_proxy_secret = 'smfisawesome'; + Config::$boardurl = 'https://forum.test-site.com/forum'; + + try { + $test(); + } finally { + Config::$image_proxy_enabled = $enabled; + Config::$image_proxy_secret = $secret; + Config::$boardurl = $boardurl; + } + } } From 6e8bf155551ddfaa2f4ba89bc2c9e1da4d4cda44 Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 16 Aug 2026 11:28:01 +0200 Subject: [PATCH 9/9] Follows TimeInterval back to DateInterval's own constructor d79501a1e went back to calling parent::__construct(), so $days is false for any interval built from a string and __toString() no longer invents a zero day component to go with it. 'PT30M' now stays 'PT30M', which is what ISO 8601 asks for and what the old P0DT30M was only ever an artefact of. The round trip test asserted that artefact, so it now asserts the normalisation instead: a redundant P0D handed in is not written back out. Signed-off-by: albertlast --- tests/Unit/TimeIntervalTest.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/Unit/TimeIntervalTest.php b/tests/Unit/TimeIntervalTest.php index cd31b20d4c2..f9d2eddda76 100644 --- a/tests/Unit/TimeIntervalTest.php +++ b/tests/Unit/TimeIntervalTest.php @@ -23,15 +23,17 @@ public function testItRoundTripsAnIso8601Duration(): void public function testTimeOnlyDurationsKeepTheirTimeDesignator(): void { // The point here is the 'T': without it, the 'M' would read as months - // rather than minutes. The leading 'P0D' comes along for the ride - // because this class, unlike \DateInterval, populates days for a - // duration that names no years or months. - $this->assertSame('P0DT30M', (string) new TimeInterval('PT30M')); + // rather than minutes. + $this->assertSame('PT30M', (string) new TimeInterval('PT30M')); } - public function testStringifyingIsStableAcrossARoundTrip(): void + public function testARedundantZeroDayIsNotWrittenBackOut(): void { - $this->assertSame('P0DT30M', (string) new TimeInterval('P0DT30M')); + // This used to answer 'P0DT30M', because the class populated days for a + // duration naming no years or months and then always wrote them. Since + // it went back to \DateInterval's constructor, days stays false for + // anything built from a string and the zero component is not invented. + $this->assertSame('PT30M', (string) new TimeInterval('P0DT30M')); } public function testItCanBeBuiltFromAPlainDateInterval(): void