diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fe0d9b1..7ca7d3b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -164,8 +164,11 @@ jobs: - name: Tests with coverage run: composer test-coverage - - name: Mutation testing - run: composer test-mutation + # Mutation testing is not run here: infection pulls thecodingmachine/safe + # at dev-master, which autoloads 80 files eagerly on every request and + # emits deprecations on PHP 8.5. The package is out of composer.json until + # the minimum PHP version moves past 8.1; resources/infection.json5 stays, + # so `composer require --dev infection/infection` restores the setup. - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e # v8.2.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2746932..26934f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.8.0](https://github.com/phalcon/talon/releases/tag/v0.8.0) (2026-07-15) + +### Changed + +### Added + +- Added a REST/JSON test surface: `Phalcon\Talon\Traits\RestTrait` (the full verb set, file uploads, request headers that persist across requests, `amBearerAuthenticated()`/`amHttpAuthenticated()`, redirect control, and response grabbers), `Phalcon\Talon\Traits\RestAssertionsTrait` (status, range, body, header, and JSON assertions), and `Phalcon\Talon\PHPUnit\AbstractRestTestCase` which composes both. A raw string body is sent as `application/json` unless a content type is set, and `$files` takes either a plain path or the `$_FILES` shape. [#19](https://github.com/phalcon/talon/issues/19) +- Added `Phalcon\Talon\Http\HttpCode` - HTTP status constants plus `getDescription()`, which returns the `404 (Not Found)` form. It is deliberately an independent implementation of the standard reason phrases rather than a lookup into the application under test, so that asserting an application's emitted status string against it actually asserts something. [#19](https://github.com/phalcon/talon/issues/19) +- Added `Phalcon\Talon\Http\JsonType` - validates a decoded JSON document against a map of type expectations (`string`, `integer`, `float`, `boolean`, `array`, `null`, the `:date` filter, `|` unions, and nested maps). Keys absent from the map are ignored, so a map can describe just the part of an envelope a test cares about. `float` accepts a whole number too - JSON has a single number type, so `{"price": 10}` decodes to an int and a strict `float` would fail on every round value. `integer` stays strict. [#19](https://github.com/phalcon/talon/issues/19) +- Added `Phalcon\Talon\Http\JsonSubset` - recursive subset matching, so a fragment can be asserted against a full document. Keys and list elements present in the response but absent from the expectation are ignored, and list elements match in any order. An empty expected list is the exception: `['data' => []]` asserts that `data` is empty rather than matching any `data` at all. [#19](https://github.com/phalcon/talon/issues/19) +- Added `TALON_REST_URL` to `Settings::fromEnv()`, readable via `Settings::get('rest_url')` and defaulting to `http://127.0.0.1:8080`. It is resolved through Talon's shared `Settings` and so is the same for every test in a run; `RestTrait::useRestBaseUrl()` points an individual test somewhere else. [#19](https://github.com/phalcon/talon/issues/19) + +### Fixed + +### Removed + ## [0.7.0](https://github.com/phalcon/talon/releases/tag/v0.7.0) (2026-07-10) ### Changed diff --git a/composer.json b/composer.json index ffad2a2..b29d2e6 100644 --- a/composer.json +++ b/composer.json @@ -8,13 +8,14 @@ "php": "^8.1", "phalcon/cli-options-parser": "^2.0", "symfony/browser-kit": "^6.4 || ^7.0", - "symfony/dom-crawler": "^6.4 || ^7.0" + "symfony/dom-crawler": "^6.4 || ^7.0", + "symfony/http-client": "^6.4 || ^7.0", + "symfony/mime": "^6.4 || ^7.0" }, "require-dev": { "ext-pdo": "*", "ext-sqlite3": "*", "friendsofphp/php-cs-fixer": "^3", - "infection/infection": "^0.29.9", "pds/composer-script-names": "^1", "pds/skeleton": "^1", "phalcon/phalcon": "^6.0@alpha", diff --git a/composer.lock b/composer.lock index af3b9b1..f7eb07c 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": "e4a97efb596a2e37bbed760a3e42f816", + "content-hash": "12ef0c0be4c7597a869ca71b8c7f564f", "packages": [ { "name": "masterminds/html5", @@ -153,6 +153,109 @@ ], "time": "2023-11-24T16:04:00+00:00" }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+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": "symfony/browser-kit", "version": "v6.4.42", @@ -225,6 +328,77 @@ ], "time": "2026-06-08T07:06:12+00:00" }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, { "name": "symfony/dom-crawler", "version": "v6.4.40", @@ -297,42 +471,60 @@ "time": "2026-05-19T20:33:22+00:00" }, { - "name": "symfony/polyfill-ctype", - "version": "v1.37.0", + "name": "symfony/http-client", + "version": "v6.4.42", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + "url": "https://github.com/symfony/http-client.git", + "reference": "b71b312ca0f211fbb19a20a51bde50fbefb66a99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "url": "https://api.github.com/repos/symfony/http-client/zipball/b71b312ca0f211fbb19a20a51bde50fbefb66a99", + "reference": "b71b312ca0f211fbb19a20a51bde50fbefb66a99", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.3" }, "provide": { - "ext-ctype": "*" + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" }, - "suggest": { - "ext-ctype": "For best performance" + "require-dev": { + "amphp/amp": "^2.5", + "amphp/http-client": "^4.2.1", + "amphp/http-tunnel": "^1.0", + "amphp/socket": "^1.1", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -340,24 +532,21 @@ ], "authors": [ { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for ctype functions", + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" + "http" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + "source": "https://github.com/symfony/http-client/tree/v6.4.42" }, "funding": [ { @@ -377,46 +566,42 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-06-12T09:42:32+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "name": "symfony/http-client-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", "shasum": "" }, "require": { - "ext-iconv": "*", - "php": ">=7.2" - }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" + "php": ">=8.1" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -432,17 +617,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Generic abstractions related to HTTP clients", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" }, "funding": [ { @@ -462,37 +648,53 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" - } - ], - "packages-dev": [ + "time": "2026-06-05T06:23:12+00:00" + }, { - "name": "clue/ndjson-react", - "version": "v1.3.0", + "name": "symfony/mime", + "version": "v6.4.41", "source": { "type": "git", - "url": "https://github.com/clue/reactphp-ndjson.git", - "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + "url": "https://github.com/symfony/mime.git", + "reference": "5575d37f8841e4e31d5df79ab3db078ae557ff8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", - "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "url": "https://api.github.com/repos/symfony/mime/zipball/5575d37f8841e4e31d5df79ab3db078ae557ff8e", + "reference": "5575d37f8841e4e31d5df79ab3db078ae557ff8e", "shasum": "" }, "require": { - "php": ">=5.3", - "react/stream": "^1.2" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<5.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", - "react/event-loop": "^1.2" + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.4|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3" }, "type": "library", "autoload": { "psr-4": { - "Clue\\React\\NDJson\\": "src/" - } + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -500,78 +702,79 @@ ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", - "homepage": "https://github.com/clue/reactphp-ndjson", + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", "keywords": [ - "NDJSON", - "json", - "jsonlines", - "newline", - "reactphp", - "streaming" + "mime", + "mime-type" ], "support": { - "issues": "https://github.com/clue/reactphp-ndjson/issues", - "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + "source": "https://github.com/symfony/mime/tree/v6.4.41" }, "funding": [ { - "url": "https://clue.engineering/support", + "url": "https://symfony.com/sponsor", "type": "custom" }, { - "url": "https://github.com/clue", + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2022-12-23T10:58:28+00:00" + "time": "2026-05-23T14:40:34+00:00" }, { - "name": "colinodell/json5", - "version": "v3.0.0", + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/colinodell/json5.git", - "reference": "5724d21bc5c910c2560af1b8915f0cc0163579c8" + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/colinodell/json5/zipball/5724d21bc5c910c2560af1b8915f0cc0163579c8", - "reference": "5724d21bc5c910c2560af1b8915f0cc0163579c8", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "^8.0" + "php": ">=7.2" }, - "require-dev": { - "mikehaertl/php-shellcommand": "^1.7.0", - "phpstan/phpstan": "^1.10.57", - "scrutinizer/ocular": "^1.9", - "squizlabs/php_codesniffer": "^3.8.1", - "symfony/finder": "^6.0|^7.0", - "symfony/phpunit-bridge": "^7.0.3" + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" }, - "bin": [ - "bin/json5" - ], "type": "library", "extra": { - "branch-alias": { - "dev-main": "4.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { "files": [ - "src/global.php" + "bootstrap.php" ], "psr-4": { - "ColinODell\\Json5\\": "src" + "Symfony\\Polyfill\\Ctype\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -580,84 +783,79 @@ ], "authors": [ { - "name": "Colin O'Dell", - "email": "colinodell@gmail.com", - "homepage": "https://www.colinodell.com", - "role": "Developer" + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "UTF-8 compatible JSON5 parser for PHP", - "homepage": "https://github.com/colinodell/json5", + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", "keywords": [ - "JSON5", - "json", - "json5_decode", - "json_decode" + "compatibility", + "ctype", + "polyfill", + "portable" ], "support": { - "issues": "https://github.com/colinodell/json5/issues", - "source": "https://github.com/colinodell/json5/tree/v3.0.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { - "url": "https://www.colinodell.com/sponsor", + "url": "https://symfony.com/sponsor", "type": "custom" }, { - "url": "https://www.paypal.me/colinpodell/10.00", - "type": "custom" + "url": "https://github.com/fabpot", + "type": "github" }, { - "url": "https://github.com/colinodell", + "url": "https://github.com/nicolas-grekas", "type": "github" }, { - "url": "https://www.patreon.com/colinodell", - "type": "patreon" + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2024-02-09T13:06:12+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "composer/pcre", - "version": "3.4.0", + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", - "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<2.2.2" + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" }, - "require-dev": { - "phpstan/phpstan": "^2", - "phpstan/phpstan-deprecation-rules": "^2", - "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^9" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-main": "3.x-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Composer\\Pcre\\": "src" + "Symfony\\Polyfill\\Intl\\Idn\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -666,65 +864,88 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.4.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { - "url": "https://packagist.com", + "url": "https://symfony.com/sponsor", "type": "custom" }, { - "url": "https://github.com/composer", + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2026-06-07T11:47:49+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { - "name": "composer/semver", - "version": "3.4.4", + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", "source": { "type": "git", - "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" + "php": ">=7.2" }, - "require-dev": { - "phpstan/phpstan": "^1.11", - "symfony/phpunit-bridge": "^3 || ^7" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.x-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Composer\\Semver\\": "src" - } + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -732,73 +953,84 @@ ], "authors": [ { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", "keywords": [ - "semantic", - "semver", - "validation", - "versioning" + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" ], "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { - "url": "https://packagist.com", + "url": "https://symfony.com/sponsor", "type": "custom" }, { - "url": "https://github.com/composer", + "url": "https://github.com/fabpot", "type": "github" - } + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } ], - "time": "2025-08-20T19:15:30+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { - "name": "composer/xdebug-handler", - "version": "3.0.5", + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", "source": { "type": "git", - "url": "https://github.com/composer/xdebug-handler.git", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { - "composer/pcre": "^1 || ^2 || ^3", - "php": "^7.2.5 || ^8.0", - "psr/log": "^1 || ^2 || ^3" + "ext-iconv": "*", + "php": ">=7.2" }, - "require-dev": { - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Composer\\XdebugHandler\\": "src" + "Symfony\\Polyfill\\Mbstring\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -807,84 +1039,80 @@ ], "authors": [ { - "name": "John Stevenson", - "email": "john-stevenson@blueyonder.co.uk" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Restarts a process without Xdebug.", + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", "keywords": [ - "Xdebug", - "performance" + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" ], "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { - "url": "https://packagist.com", + "url": "https://symfony.com/sponsor", "type": "custom" }, { - "url": "https://github.com/composer", + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-05-06T16:37:16+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { - "name": "ergebnis/agent-detector", - "version": "1.2.0", + "name": "symfony/polyfill-php83", + "version": "v1.38.2", "source": { "type": "git", - "url": "https://github.com/ergebnis/agent-detector.git", - "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", - "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "shasum": "" }, "require": { - "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.51.0", - "ergebnis/license": "^2.7.0", - "ergebnis/php-cs-fixer-config": "^6.60.2", - "ergebnis/phpstan-rules": "^2.13.1", - "ergebnis/phpunit-slow-test-detector": "^2.24.0", - "ergebnis/rector-rules": "^1.18.1", - "fakerphp/faker": "^1.24.1", - "infection/infection": "^0.26.6", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.54", - "phpstan/phpstan-deprecation-rules": "^2.0.4", - "phpstan/phpstan-phpunit": "^2.0.16", - "phpstan/phpstan-strict-rules": "^2.0.10", - "phpunit/phpunit": "^9.6.34", - "rector/rector": "^2.4.2" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.2-dev" - }, - "composer-normalize": { - "indent-size": 2, - "indent-style": "space" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Ergebnis\\AgentDetector\\": "src/" - } + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -892,45 +1120,84 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a detector for detecting the presence of an agent.", - "homepage": "https://github.com/ergebnis/agent-detector", + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/ergebnis/agent-detector/issues", - "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", - "source": "https://github.com/ergebnis/agent-detector" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, - "time": "2026-05-07T08:19:07+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:51:48+00:00" }, { - "name": "evenement/evenement", - "version": "v3.0.2", + "name": "symfony/service-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/igorw/evenement.git", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { - "php": ">=7.0" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, - "require-dev": { - "phpunit/phpunit": "^9 || ^6" + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, "autoload": { "psr-4": { - "Evenement\\": "src/" - } + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -938,53 +1205,75 @@ ], "authors": [ { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Événement is a very simple event dispatching library for PHP", + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", "keywords": [ - "event-dispatcher", - "event-emitter" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "issues": "https://github.com/igorw/evenement/issues", - "source": "https://github.com/igorw/evenement/tree/v3.0.2" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, - "time": "2023-08-08T05:53:35+00:00" - }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T09:55:08+00:00" + } + ], + "packages-dev": [ { - "name": "fidry/cpu-core-counter", - "version": "1.3.0", + "name": "clue/ndjson-react", + "version": "v1.3.0", "source": { "type": "git", - "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": ">=5.3", + "react/stream": "^1.2" }, "require-dev": { - "fidry/makefile": "^0.2.0", - "fidry/php-cs-fixer-config": "^1.1.2", - "phpstan/extension-installer": "^1.2.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-deprecation-rules": "^2.0.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^8.5.31 || ^9.5.26", - "webmozarts/strict-phpunit": "^7.5" + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" }, "type": "library", "autoload": { "psr-4": { - "Fidry\\CpuCoreCounter\\": "src/" + "Clue\\React\\NDJson\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -993,98 +1282,77 @@ ], "authors": [ { - "name": "Théo FIDRY", - "email": "theo.fidry@gmail.com" + "name": "Christian Lück", + "email": "christian@clue.engineering" } ], - "description": "Tiny utility to get the number of CPU cores.", + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", "keywords": [ - "CPU", - "core" + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" ], "support": { - "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" }, "funding": [ { - "url": "https://github.com/theofidry", + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", "type": "github" } ], - "time": "2025-08-14T07:29:31+00:00" + "time": "2022-12-23T10:58:28+00:00" }, { - "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.13", + "name": "composer/pcre", + "version": "3.4.0", "source": { "type": "git", - "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "ea941114a002eb5e5876f190223deec1066c482c" + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/ea941114a002eb5e5876f190223deec1066c482c", - "reference": "ea941114a002eb5e5876f190223deec1066c482c", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { - "clue/ndjson-react": "^1.3", - "composer/semver": "^3.4", - "composer/xdebug-handler": "^3.0.5", - "ergebnis/agent-detector": "^1.2", - "ext-filter": "*", - "ext-hash": "*", - "ext-json": "*", - "ext-tokenizer": "*", - "fidry/cpu-core-counter": "^1.3", - "php": "^7.4 || ^8.0", - "react/child-process": "^0.6.6", - "react/event-loop": "^1.5", - "react/socket": "^1.16", - "react/stream": "^1.4", - "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", - "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", - "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", - "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", - "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", - "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", - "symfony/polyfill-mbstring": "^1.37", - "symfony/polyfill-php80": "^1.37", - "symfony/polyfill-php81": "^1.37", - "symfony/polyfill-php84": "^1.37", - "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", - "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.11.0", - "infection/infection": "^0.32.7", - "justinrainbow/json-schema": "^6.10.0", - "keradus/cli-executor": "^2.3", - "mikey179/vfsstream": "^1.6.12", - "php-coveralls/php-coveralls": "^2.9.1", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", - "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56", - "symfony/polyfill-php85": "^1.38", - "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", - "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, - "suggest": { - "ext-dom": "For handling output formats in XML", - "ext-mbstring": "For handling non-UTF8 characters." + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } }, - "bin": [ - "php-cs-fixer" - ], - "type": "application", "autoload": { "psr-4": { - "PhpCsFixer\\": "src/" - }, - "exclude-from-classmap": [ - "src/**/Internal/" - ] + "Composer\\Pcre\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1092,422 +1360,425 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Dariusz Rumiński", - "email": "dariusz.ruminski@gmail.com" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" } ], - "description": "A tool to automatically fix PHP code style", + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", "keywords": [ - "Static code analysis", - "fixer", - "standards", - "static analysis" + "PCRE", + "preg", + "regex", + "regular expression" ], "support": { - "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.13" + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { - "url": "https://github.com/keradus", + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", "type": "github" } ], - "time": "2026-07-10T09:23:21+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { - "name": "infection/abstract-testframework-adapter", - "version": "0.5.0", + "name": "composer/semver", + "version": "3.4.4", "source": { "type": "git", - "url": "https://github.com/infection/abstract-testframework-adapter.git", - "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b" + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/abstract-testframework-adapter/zipball/18925e20d15d1a5995bb85c9dc09e8751e1e069b", - "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^2.17", - "phpunit/phpunit": "^9.5" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { "psr-4": { - "Infection\\AbstractTestFramework\\": "src/" + "Composer\\Semver\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" } ], - "description": "Abstract Test Framework Adapter for Infection", + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], "support": { - "issues": "https://github.com/infection/abstract-testframework-adapter/issues", - "source": "https://github.com/infection/abstract-testframework-adapter/tree/0.5.0" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { - "url": "https://github.com/infection", - "type": "github" + "url": "https://packagist.com", + "type": "custom" }, { - "url": "https://opencollective.com/infection", - "type": "open_collective" + "url": "https://github.com/composer", + "type": "github" } ], - "time": "2021-08-17T18:49:12+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { - "name": "infection/extension-installer", - "version": "0.1.2", + "name": "composer/xdebug-handler", + "version": "3.0.5", "source": { "type": "git", - "url": "https://github.com/infection/extension-installer.git", - "reference": "9b351d2910b9a23ab4815542e93d541e0ca0cdcf" + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/extension-installer/zipball/9b351d2910b9a23ab4815542e93d541e0ca0cdcf", - "reference": "9b351d2910b9a23ab4815542e93d541e0ca0cdcf", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", "shasum": "" }, "require": { - "composer-plugin-api": "^1.1 || ^2.0" + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" }, "require-dev": { - "composer/composer": "^1.9 || ^2.0", - "friendsofphp/php-cs-fixer": "^2.18, <2.19", - "infection/infection": "^0.15.2", - "php-coveralls/php-coveralls": "^2.4", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.10", - "phpstan/phpstan-phpunit": "^0.12.6", - "phpstan/phpstan-strict-rules": "^0.12.2", - "phpstan/phpstan-webmozart-assert": "^0.12.2", - "phpunit/phpunit": "^9.5", - "vimeo/psalm": "^4.8" - }, - "type": "composer-plugin", - "extra": { - "class": "Infection\\ExtensionInstaller\\Plugin" + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" }, + "type": "library", "autoload": { "psr-4": { - "Infection\\ExtensionInstaller\\": "src/" + "Composer\\XdebugHandler\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" } ], - "description": "Infection Extension Installer", + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], "support": { - "issues": "https://github.com/infection/extension-installer/issues", - "source": "https://github.com/infection/extension-installer/tree/0.1.2" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" }, "funding": [ { - "url": "https://github.com/infection", + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", "type": "github" }, { - "url": "https://opencollective.com/infection", - "type": "open_collective" + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" } ], - "time": "2021-10-20T22:08:34+00:00" + "time": "2024-05-06T16:37:16+00:00" }, { - "name": "infection/include-interceptor", - "version": "0.2.5", + "name": "ergebnis/agent-detector", + "version": "1.2.0", "source": { "type": "git", - "url": "https://github.com/infection/include-interceptor.git", - "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7" + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/include-interceptor/zipball/0cc76d95a79d9832d74e74492b0a30139904bdf7", - "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", "shasum": "" }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.16", - "infection/infection": "^0.15.0", - "phan/phan": "^2.4 || ^3", - "php-coveralls/php-coveralls": "^2.2", - "phpstan/phpstan": "^0.12.8", - "phpunit/phpunit": "^8.5", - "vimeo/psalm": "^3.8" + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, "autoload": { "psr-4": { - "Infection\\StreamWrapper\\": "src/" + "Ergebnis\\AgentDetector\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" } ], - "description": "Stream Wrapper: Include Interceptor. Allows to replace included (autoloaded) file with another one.", + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", "support": { - "issues": "https://github.com/infection/include-interceptor/issues", - "source": "https://github.com/infection/include-interceptor/tree/0.2.5" + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2021-08-09T10:03:57+00:00" + "time": "2026-05-07T08:19:07+00:00" }, { - "name": "infection/infection", - "version": "0.29.9", + "name": "evenement/evenement", + "version": "v3.0.2", "source": { "type": "git", - "url": "https://github.com/infection/infection.git", - "reference": "beac2ca971b37dd7feb92fe2d3e705c175b2360b" + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/beac2ca971b37dd7feb92fe2d3e705c175b2360b", - "reference": "beac2ca971b37dd7feb92fe2d3e705c175b2360b", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", "shasum": "" }, "require": { - "colinodell/json5": "^2.2 || ^3.0", - "composer-runtime-api": "^2.0", - "composer/xdebug-handler": "^2.0 || ^3.0", - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "fidry/cpu-core-counter": "^0.4.0 || ^0.5.0 || ^1.0", - "infection/abstract-testframework-adapter": "^0.5.0", - "infection/extension-installer": "^0.1.0", - "infection/include-interceptor": "^0.2.5", - "infection/mutator": "^0.4", - "justinrainbow/json-schema": "^5.3", - "nikic/php-parser": "^5.3", - "ondram/ci-detector": "^4.1.0", - "php": "^8.1", - "sanmai/later": "^0.1.1", - "sanmai/pipeline": "^5.1 || ^6", - "sebastian/diff": "^3.0.2 || ^4.0 || ^5.0 || ^6.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^5.4 || ^6.0 || ^7.0", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "thecodingmachine/safe": "dev-master as 2.5.0", - "webmozart/assert": "^1.11" - }, - "conflict": { - "antecedent/patchwork": "<2.1.25", - "dg/bypass-finals": "<1.4.1", - "phpunit/php-code-coverage": ">9,<9.1.4 || >9.2.17,<9.2.21" + "php": ">=7.0" }, "require-dev": { - "ext-simplexml": "*", - "fidry/makefile": "^1.0", - "helmich/phpunit-json-assert": "^3.0", - "phpstan/extension-installer": "^1.1.0", - "phpstan/phpstan": "^1.10.15", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpstan/phpstan-strict-rules": "^1.1.0", - "phpstan/phpstan-webmozart-assert": "^1.0.2", - "phpunit/phpunit": "^10.5", - "rector/rector": "^1.0", - "sidz/phpstan-rules": "^0.4", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0", - "thecodingmachine/phpstan-safe-rule": "^1.2.0" + "phpunit/phpunit": "^9 || ^6" }, - "bin": [ - "bin/infection" - ], "type": "library", "autoload": { "psr-4": { - "Infection\\": "src/" + "Evenement\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com", - "homepage": "https://twitter.com/maks_rafalko" - }, - { - "name": "Oleg Zhulnev", - "homepage": "https://github.com/sidz" - }, - { - "name": "Gert de Pagter", - "homepage": "https://github.com/BackEndTea" - }, - { - "name": "Théo FIDRY", - "email": "theo.fidry@gmail.com", - "homepage": "https://twitter.com/tfidry" - }, - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com", - "homepage": "https://www.alexeykopytko.com" - }, - { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" } ], - "description": "Infection is a Mutation Testing framework for PHP. The mutation adequacy score can be used to measure the effectiveness of a test set in terms of its ability to detect faults.", + "description": "Événement is a very simple event dispatching library for PHP", "keywords": [ - "coverage", - "mutant", - "mutation framework", - "mutation testing", - "testing", - "unit testing" + "event-dispatcher", + "event-emitter" ], "support": { - "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.29.9" + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2024-12-08T22:23:44+00:00" + "time": "2023-08-08T05:53:35+00:00" }, { - "name": "infection/mutator", - "version": "0.4.1", + "name": "fidry/cpu-core-counter", + "version": "1.3.0", "source": { "type": "git", - "url": "https://github.com/infection/mutator.git", - "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d" + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/mutator/zipball/3c976d721b02b32f851ee4e15d553ef1e9186d1d", - "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0" + "php": "^7.2 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^10" + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" }, "type": "library", "autoload": { "psr-4": { - "Infection\\Mutator\\": "src/" + "Fidry\\CpuCoreCounter\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" } ], - "description": "Mutator interface to implement custom mutators (mutation operators) for Infection", + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], "support": { - "issues": "https://github.com/infection/mutator/issues", - "source": "https://github.com/infection/mutator/tree/0.4.1" + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" }, "funding": [ { - "url": "https://github.com/infection", + "url": "https://github.com/theofidry", "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" } ], - "time": "2025-04-29T08:19:52+00:00" + "time": "2025-08-14T07:29:31+00:00" }, { - "name": "justinrainbow/json-schema", - "version": "5.3.4", + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.13", "source": { "type": "git", - "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "7df70ffaf31d98726801b4bc099e1fbdbe2e5e54" + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "ea941114a002eb5e5876f190223deec1066c482c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/7df70ffaf31d98726801b4bc099e1fbdbe2e5e54", - "reference": "7df70ffaf31d98726801b4bc099e1fbdbe2e5e54", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/ea941114a002eb5e5876f190223deec1066c482c", + "reference": "ea941114a002eb5e5876f190223deec1066c482c", "shasum": "" }, "require": { - "php": ">=7.1" + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", - "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." }, "bin": [ - "bin/validate-json" + "php-cs-fixer" ], - "type": "library", + "type": "application", "autoload": { "psr-4": { - "JsonSchema\\": "src/JsonSchema/" - } + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1515,33 +1786,32 @@ ], "authors": [ { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" } ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", + "description": "A tool to automatically fix PHP code style", "keywords": [ - "json", - "schema" + "Static code analysis", + "fixer", + "standards", + "static analysis" ], "support": { - "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/5.3.4" + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.13" }, - "time": "2026-05-04T18:54:58+00:00" + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-07-10T09:23:21+00:00" }, { "name": "matthiasmullie/minify", @@ -1776,91 +2046,13 @@ "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.7.0" - }, - "time": "2025-12-06T11:56:16+00:00" - }, - { - "name": "ondram/ci-detector", - "version": "4.2.0", - "source": { - "type": "git", - "url": "https://github.com/OndraM/ci-detector.git", - "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/OndraM/ci-detector/zipball/8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", - "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.13.2", - "lmc/coding-standard": "^3.0.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.1.0", - "phpstan/phpstan": "^1.2.0", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpunit/phpunit": "^9.6.13" - }, - "type": "library", - "autoload": { - "psr-4": { - "OndraM\\CiDetector\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ondřej Machulda", - "email": "ondrej.machulda@gmail.com" - } - ], - "description": "Detect continuous integration environment and provide unified access to properties of current build", - "keywords": [ - "CircleCI", - "Codeship", - "Wercker", - "adapter", - "appveyor", - "aws", - "aws codebuild", - "azure", - "azure devops", - "azure pipelines", - "bamboo", - "bitbucket", - "buddy", - "ci-info", - "codebuild", - "continuous integration", - "continuousphp", - "devops", - "drone", - "github", - "gitlab", - "interface", - "jenkins", - "pipelines", - "sourcehut", - "teamcity", - "travis" + "php" ], "support": { - "issues": "https://github.com/OndraM/ci-detector/issues", - "source": "https://github.com/OndraM/ci-detector/tree/4.2.0" + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2024-03-12T13:22:30+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "pds/composer-script-names", @@ -2749,59 +2941,6 @@ ], "time": "2026-06-11T16:56:53+00:00" }, - { - "name": "psr/container", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" - }, - "time": "2021-11-05T16:47:00+00:00" - }, { "name": "psr/event-dispatcher", "version": "1.0.0", @@ -2905,56 +3044,6 @@ }, "time": "2023-04-04T09:54:51+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", @@ -3481,135 +3570,6 @@ ], "time": "2024-06-11T12:45:25+00:00" }, - { - "name": "sanmai/later", - "version": "0.1.5", - "source": { - "type": "git", - "url": "https://github.com/sanmai/later.git", - "reference": "cf5164557d19930295892094996f049ea12ba14d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sanmai/later/zipball/cf5164557d19930295892094996f049ea12ba14d", - "reference": "cf5164557d19930295892094996f049ea12ba14d", - "shasum": "" - }, - "require": { - "php": ">=7.4" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^3.35.1", - "infection/infection": ">=0.27.6", - "phan/phan": ">=2", - "php-coveralls/php-coveralls": "^2.0", - "phpstan/phpstan": ">=1.4.5", - "phpunit/phpunit": ">=9.5 <10", - "vimeo/psalm": ">=2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "0.1.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Later\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com" - } - ], - "description": "Later: deferred wrapper object", - "support": { - "issues": "https://github.com/sanmai/later/issues", - "source": "https://github.com/sanmai/later/tree/0.1.5" - }, - "funding": [ - { - "url": "https://github.com/sanmai", - "type": "github" - } - ], - "time": "2024-12-06T02:36:26+00:00" - }, - { - "name": "sanmai/pipeline", - "version": "6.12", - "source": { - "type": "git", - "url": "https://github.com/sanmai/pipeline.git", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sanmai/pipeline/zipball/ad7dbc3f773eeafb90d5459522fbd8f188532e25", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^3.17", - "infection/infection": ">=0.10.5", - "league/pipeline": "^0.3 || ^1.0", - "phan/phan": ">=1.1", - "php-coveralls/php-coveralls": "^2.4.1", - "phpstan/phpstan": ">=0.10", - "phpunit/phpunit": ">=9.4", - "vimeo/psalm": ">=2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "v6.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Pipeline\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com" - } - ], - "description": "General-purpose collections pipeline", - "support": { - "issues": "https://github.com/sanmai/pipeline/issues", - "source": "https://github.com/sanmai/pipeline/tree/6.12" - }, - "funding": [ - { - "url": "https://github.com/sanmai", - "type": "github" - } - ], - "time": "2024-10-17T02:22:57+00:00" - }, { "name": "sebastian/cli-parser", "version": "2.0.1", @@ -4718,78 +4678,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.42" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-15T05:35:29+00:00" - }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.7.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/console/tree/v6.4.42" }, "funding": [ { @@ -4809,7 +4698,7 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-06-15T05:35:29+00:00" }, { "name": "symfony/event-dispatcher", @@ -5266,91 +5155,6 @@ ], "time": "2026-05-26T05:58:03+00:00" }, - { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "suggest": { - "ext-intl": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-25T13:48:31+00:00" - }, { "name": "symfony/polyfill-php80", "version": "v1.37.0", @@ -5660,93 +5464,6 @@ ], "time": "2026-05-23T13:47:21+00:00" }, - { - "name": "symfony/service-contracts", - "version": "v3.7.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "conflict": { - "ext-psr": "<1.1|>=2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-16T09:55:08+00:00" - }, { "name": "symfony/stopwatch", "version": "v6.4.24", @@ -5902,150 +5619,6 @@ ], "time": "2026-05-12T11:44:19+00:00" }, - { - "name": "thecodingmachine/safe", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/thecodingmachine/safe.git", - "reference": "4fbc0088994d486b0012d67116b90825f17a9309" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/4fbc0088994d486b0012d67116b90825f17a9309", - "reference": "4fbc0088994d486b0012d67116b90825f17a9309", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpstan/phpstan": "^2", - "phpunit/phpunit": "^10", - "squizlabs/php_codesniffer": "^3.2" - }, - "default-branch": true, - "type": "library", - "autoload": { - "files": [ - "lib/special_cases.php", - "generated/apache.php", - "generated/apcu.php", - "generated/array.php", - "generated/bzip2.php", - "generated/calendar.php", - "generated/classobj.php", - "generated/com.php", - "generated/cubrid.php", - "generated/curl.php", - "generated/datetime.php", - "generated/dir.php", - "generated/eio.php", - "generated/errorfunc.php", - "generated/exec.php", - "generated/fileinfo.php", - "generated/filesystem.php", - "generated/filter.php", - "generated/fpm.php", - "generated/ftp.php", - "generated/funchand.php", - "generated/gettext.php", - "generated/gmp.php", - "generated/gnupg.php", - "generated/hash.php", - "generated/ibase.php", - "generated/ibmDb2.php", - "generated/iconv.php", - "generated/image.php", - "generated/imap.php", - "generated/info.php", - "generated/inotify.php", - "generated/json.php", - "generated/ldap.php", - "generated/libxml.php", - "generated/lzf.php", - "generated/mailparse.php", - "generated/mbstring.php", - "generated/misc.php", - "generated/mysql.php", - "generated/mysqli.php", - "generated/network.php", - "generated/oci8.php", - "generated/opcache.php", - "generated/openssl.php", - "generated/outcontrol.php", - "generated/pcntl.php", - "generated/pcre.php", - "generated/pgsql.php", - "generated/posix.php", - "generated/ps.php", - "generated/pspell.php", - "generated/readline.php", - "generated/rnp.php", - "generated/rpminfo.php", - "generated/rrd.php", - "generated/sem.php", - "generated/session.php", - "generated/shmop.php", - "generated/sockets.php", - "generated/sodium.php", - "generated/solr.php", - "generated/spl.php", - "generated/sqlsrv.php", - "generated/ssdeep.php", - "generated/ssh2.php", - "generated/stream.php", - "generated/strings.php", - "generated/swoole.php", - "generated/uodbc.php", - "generated/uopz.php", - "generated/url.php", - "generated/var.php", - "generated/xdiff.php", - "generated/xml.php", - "generated/xmlrpc.php", - "generated/yaml.php", - "generated/yaz.php", - "generated/zip.php", - "generated/zlib.php" - ], - "classmap": [ - "lib/DateTime.php", - "lib/DateTimeImmutable.php", - "lib/Exceptions/", - "generated/Exceptions/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHP core functions that throw exceptions instead of returning FALSE on error", - "support": { - "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/master" - }, - "funding": [ - { - "url": "https://github.com/OskarStark", - "type": "github" - }, - { - "url": "https://github.com/shish", - "type": "github" - }, - { - "url": "https://github.com/silasjoisten", - "type": "github" - }, - { - "url": "https://github.com/staabm", - "type": "github" - } - ], - "time": "2026-03-03T12:18:24+00:00" - }, { "name": "theseer/tokenizer", "version": "1.3.1", @@ -6095,64 +5668,6 @@ } ], "time": "2025-11-17T20:03:58+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.12.1", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^7.2 || ^8.0" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" - }, - "time": "2025-10-29T15:56:20+00:00" } ], "aliases": [], diff --git a/resources/infection.json5 b/resources/infection.json5 index 6eaedcb..14bd5d2 100644 --- a/resources/infection.json5 +++ b/resources/infection.json5 @@ -29,8 +29,22 @@ "Phalcon\\Talon\\Cli\\Input::wantsVersion" ] }, + "Break_": { + // The break only stops scanning once a match is found; without it + // the loop keeps looking and $found stays true. Same answer, more + // work - not observable from a test. + "ignore": ["Phalcon\\Talon\\Http\\JsonSubset::containsList"] + }, "CastString": { "ignore": [ + // json_encode() only returns false for input it cannot encode + // (malformed UTF-8, recursion, INF/NAN). Params reaching a REST + // verb are always encodable, so the cast is a no-op. + "Phalcon\\Talon\\Traits\\RestTrait::send", + // (string) applies to $error, which is only rendered when + // assertNull() fails - i.e. when it is a non-null string. The + // cast is a no-op on every path that can observe it. + "Phalcon\\Talon\\Traits\\RestAssertionsTrait::assertResponseMatchesJsonType", // Discovered suite names are non-numeric filename stems and // preg_replace with a fixed valid pattern never returns null; // these casts only satisfy static analysis. Method-scoped: @@ -79,9 +93,14 @@ "ignore": ["Phalcon\\Talon\\Bootstrap\\Runner::initEnvironment::99"] }, "IncrementInteger": { - // Every accumulated query starts with the "\n" separator, so - // substr($query, 1, ...) only removes what trim() strips anyway. - "ignore": ["Phalcon\\Talon\\Database\\StatementSplitter::split::64"] + "ignore": [ + // Every accumulated query starts with the "\n" separator, so + // substr($query, 1, ...) only removes what trim() strips anyway. + "Phalcon\\Talon\\Database\\StatementSplitter::split::64", + // The type DSL has no spec carrying two colons, so raising + // explode()'s limit cannot change how a spec splits. + "Phalcon\\Talon\\Http\\JsonType::matchesSingle" + ] }, "LogicalOrAllSubExprNegation": { "ignore": [ @@ -99,7 +118,7 @@ "MatchArmRemoval": { // The default arm is dead code: getDatabaseOptions() already threw // UnknownDriver for any driver outside DRIVERS before the match. - "ignore": ["Phalcon\\Talon\\Settings::getDatabaseDsn::186"] + "ignore": ["Phalcon\\Talon\\Settings::getDatabaseDsn::250"] }, "MethodCallRemoval": { "ignore": [ @@ -116,7 +135,7 @@ "Throw_": { // Same dead default arm; line-scoped so the live throw in // getDatabaseOptions() stays mutable. - "ignore": ["Phalcon\\Talon\\Settings::getDatabaseDsn::201"] + "ignore": ["Phalcon\\Talon\\Settings::getDatabaseDsn::265"] }, "UnwrapTrim": { // Lines are trimmed before being accumulated, so the final diff --git a/resources/php-cs-fixer.php b/resources/php-cs-fixer.php index 44bc681..f118601 100644 --- a/resources/php-cs-fixer.php +++ b/resources/php-cs-fixer.php @@ -15,17 +15,50 @@ ->in([__DIR__ . '/../src', __DIR__ . '/../tests']); return (new PhpCsFixer\Config()) + ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) + // declare_strict_types is a risky rule. ->setRiskyAllowed(true) - ->setRules([ - '@PSR12' => true, - 'declare_strict_types' => true, - 'blank_line_between_import_groups' => true, - 'ordered_imports' => [ - 'sort_algorithm' => 'alpha', - 'imports_order' => ['class', 'function', 'const'], - ], - 'no_unused_imports' => true, - 'array_syntax' => ['syntax' => 'short'], - ]) - ->setFinder($finder) - ->setCacheFile(__DIR__ . '/../tests/_output/.php-cs-fixer.cache'); + ->setUsingCache(true) + ->setCacheFile(__DIR__ . '/../tests/_output/.php-cs-fixer.cache') + ->setRules( + [ + // The two rules below are a local addition on top of the ordering + // rules shared with the other Phalcon projects. They are kept here + // until the global coding standard is agreed, at which point they + // should move into the shared set rather than stay a divergence. + // PSR-12 (via phpcs) checks neither, so nothing else enforces them. + 'declare_strict_types' => true, + 'no_unused_imports' => true, + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => ['class', 'function', 'const'], + ], + 'ordered_class_elements' => [ + 'sort_algorithm' => 'alpha', + 'order' => [ + 'use_trait', + 'case', + 'constant_public', + 'constant_protected', + 'constant_private', + 'property_public_static', + 'property_protected_static', + 'property_private_static', + 'property_public', + 'property_protected', + 'property_private', + 'construct', + 'destruct', + 'magic', + 'phpunit', + 'method_public_static', + 'method_protected_static', + 'method_private_static', + 'method_public', + 'method_protected', + 'method_private', + ], + ], + ] + ) + ->setFinder($finder); diff --git a/src/Contracts/Settings.php b/src/Contracts/Settings.php index bf887b1..872c77d 100644 --- a/src/Contracts/Settings.php +++ b/src/Contracts/Settings.php @@ -15,6 +15,9 @@ interface Settings { + public function cachePath(string $relative = ''): string; + + public function dataPath(string $relative = ''): string; public function get(string $key, mixed $default = null): mixed; public function getDatabaseDsn(string $driver): string; @@ -29,10 +32,6 @@ public function getDatabaseOptions(string $driver): array; */ public function getServiceOptions(string $name): array; - public function cachePath(string $relative = ''): string; - - public function dataPath(string $relative = ''): string; - public function logsPath(string $relative = ''): string; public function outputPath(string $relative = ''): string; diff --git a/src/Http/HttpCode.php b/src/Http/HttpCode.php new file mode 100644 index 0000000..8ff94c4 --- /dev/null +++ b/src/Http/HttpCode.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Http; + +use function sprintf; + +/** + * HTTP status codes and their standard reason phrases. + * + * getDescription() is deliberately an independent implementation of the + * ' ()' format rather than a lookup into the application under + * test - an application asserting its own emitted string against its own table + * would assert nothing. + */ +final class HttpCode +{ + public const ACCEPTED = 202; + public const BAD_GATEWAY = 502; + public const BAD_REQUEST = 400; + public const CONFLICT = 409; + public const CREATED = 201; + public const FORBIDDEN = 403; + public const FOUND = 302; + public const GATEWAY_TIMEOUT = 504; + public const GONE = 410; + public const INTERNAL_SERVER_ERROR = 500; + public const METHOD_NOT_ALLOWED = 405; + public const MOVED_PERMANENTLY = 301; + public const NO_CONTENT = 204; + public const NOT_ACCEPTABLE = 406; + public const NOT_FOUND = 404; + public const NOT_IMPLEMENTED = 501; + public const NOT_MODIFIED = 304; + public const OK = 200; + public const PERMANENT_REDIRECT = 308; + public const SEE_OTHER = 303; + public const SERVICE_UNAVAILABLE = 503; + public const TEMPORARY_REDIRECT = 307; + public const TOO_MANY_REQUESTS = 429; + public const UNAUTHORIZED = 401; + public const UNPROCESSABLE_ENTITY = 422; + public const UNSUPPORTED_MEDIA_TYPE = 415; + + /** @var array */ + private const PHRASES = [ + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 204 => 'No Content', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 409 => 'Conflict', + 410 => 'Gone', + 415 => 'Unsupported Media Type', + 422 => 'Unprocessable Entity', + 429 => 'Too Many Requests', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + ]; + + public static function getDescription(int $code): string + { + return sprintf('%d (%s)', $code, self::PHRASES[$code] ?? 'Unknown'); + } +} diff --git a/src/Http/JsonSubset.php b/src/Http/JsonSubset.php new file mode 100644 index 0000000..a49546e --- /dev/null +++ b/src/Http/JsonSubset.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Http; + +use function array_is_list; +use function array_key_exists; +use function is_array; + +/** + * Recursive subset matching for JSON documents. + * + * Keys and list elements present in the actual document but absent from the + * expected one are ignored, so a fragment can be asserted against a full + * envelope. List elements match in any order. + * + * The one place this stops being a subset is the empty list: ['data' => []] + * requires data to be empty rather than matching any data at all. Read as a + * pure subset it would match everything, which is never what the caller meant + * and asserts nothing. + */ +final class JsonSubset +{ + public static function contains(mixed $actual, mixed $expected): bool + { + if (!is_array($expected)) { + return $actual === $expected; + } + + if (!is_array($actual)) { + return false; + } + + if (array_is_list($expected)) { + return self::containsList($actual, $expected); + } + + foreach ($expected as $key => $value) { + if (!array_key_exists($key, $actual) || !self::contains($actual[$key], $value)) { + return false; + } + } + + return true; + } + + /** + * @param array $actual + * @param array $expected + */ + private static function containsList(array $actual, array $expected): bool + { + if ([] === $expected) { + return [] === $actual; + } + + foreach ($expected as $item) { + $found = false; + foreach ($actual as $candidate) { + if (self::contains($candidate, $item)) { + $found = true; + + break; + } + } + + if (!$found) { + return false; + } + } + + return true; + } +} diff --git a/src/Http/JsonType.php b/src/Http/JsonType.php new file mode 100644 index 0000000..a9ab42a --- /dev/null +++ b/src/Http/JsonType.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Http; + +use function array_key_exists; +use function explode; +use function get_debug_type; +use function is_array; +use function is_bool; +use function is_float; +use function is_int; +use function is_string; +use function sprintf; +use function str_contains; +use function strtotime; + +/** + * Validates a decoded JSON document against a map of type expectations. + * + * Keys absent from the type map are ignored, so a map can describe the part of + * an envelope a test cares about while the document carries more. + * + * Supported specs: 'array', 'boolean', 'float', 'integer', 'null', 'string', + * optionally suffixed with ':date', and joined with '|' to form a union. + * + * 'float' accepts an int as well as a float. JSON has a single number type, so + * json_decode() hands back int(10) for {"price": 10} and float(10.5) for + * {"price": 10.5} - the same field is one or the other depending on the value + * it happens to carry. A strict 'float' would fail on every whole number. + * 'integer' stays strict, so it still rejects 10.5. + */ +final class JsonType +{ + /** + * @param array $types + * + * @return string|null null when the document matches, otherwise the reason + */ + public static function match(mixed $actual, array $types, string $path = ''): ?string + { + if (!is_array($actual)) { + return sprintf("Key '%s' expected an object, got '%s'", $path, get_debug_type($actual)); + } + + foreach ($types as $key => $expected) { + $current = self::path($path, $key); + + if (!array_key_exists($key, $actual)) { + return sprintf("Key '%s' is missing", $current); + } + + $error = self::matchValue($actual[$key], $expected, $current); + if (null !== $error) { + return $error; + } + } + + return null; + } + + private static function matchesFilter(mixed $value, string $filter): bool + { + return match ($filter) { + 'date' => is_string($value) && false !== strtotime($value), + default => false, + }; + } + + private static function matchesSingle(mixed $value, string $alternative): bool + { + $filter = null; + if (str_contains($alternative, ':')) { + [$alternative, $filter] = explode(':', $alternative, 2); + } + + $matches = match ($alternative) { + 'array' => is_array($value), + 'boolean' => is_bool($value), + 'float' => is_float($value) || is_int($value), + 'integer' => is_int($value), + 'null' => null === $value, + 'string' => is_string($value), + default => false, + }; + + if (!$matches) { + return false; + } + + return null === $filter || self::matchesFilter($value, $filter); + } + + private static function matchesSpec(mixed $value, string $spec): bool + { + foreach (explode('|', $spec) as $alternative) { + if (self::matchesSingle($value, $alternative)) { + return true; + } + } + + return false; + } + + /** + * Checks one key's value against its spec: a nested map recurses, anything + * else is a leaf. Keeping the two failure modes apart - the spec is not a + * string, or the value does not satisfy it - is what lets each report + * itself, rather than one branch re-deriving which case it caught. + */ + private static function matchValue(mixed $value, mixed $expected, string $path): ?string + { + if (is_array($expected)) { + return self::match($value, $expected, $path); + } + + if (!is_string($expected)) { + return self::typeError($path, get_debug_type($expected), $value); + } + + if (!self::matchesSpec($value, $expected)) { + return self::typeError($path, $expected, $value); + } + + return null; + } + + /** + * @param array-key $key + */ + private static function path(string $path, mixed $key): string + { + return '' === $path ? (string) $key : $path . '.' . $key; + } + + private static function typeError(string $path, string $expected, mixed $value): string + { + return sprintf("Key '%s' expected '%s', got '%s'", $path, $expected, get_debug_type($value)); + } +} diff --git a/src/PHPUnit/AbstractBrowserTestCase.php b/src/PHPUnit/AbstractBrowserTestCase.php index 2b9f0e3..ba4352a 100644 --- a/src/PHPUnit/AbstractBrowserTestCase.php +++ b/src/PHPUnit/AbstractBrowserTestCase.php @@ -18,8 +18,8 @@ abstract class AbstractBrowserTestCase extends AbstractUnitTestCase { - use BrowserTrait; use BrowserAssertionsTrait; + use BrowserTrait; protected function setUp(): void { diff --git a/src/PHPUnit/AbstractFunctionalTestCase.php b/src/PHPUnit/AbstractFunctionalTestCase.php index 62f155a..03aa2d5 100644 --- a/src/PHPUnit/AbstractFunctionalTestCase.php +++ b/src/PHPUnit/AbstractFunctionalTestCase.php @@ -18,6 +18,6 @@ abstract class AbstractFunctionalTestCase extends AbstractUnitTestCase { - use FunctionalTrait; use FunctionalAssertionsTrait; + use FunctionalTrait; } diff --git a/src/PHPUnit/AbstractRestTestCase.php b/src/PHPUnit/AbstractRestTestCase.php new file mode 100644 index 0000000..35ec9e9 --- /dev/null +++ b/src/PHPUnit/AbstractRestTestCase.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\PHPUnit; + +use Phalcon\Talon\Traits\RestAssertionsTrait; +use Phalcon\Talon\Traits\RestTrait; + +/** + * Requests cross a real HTTP boundary, so - unlike AbstractBrowserTestCase - + * there is no in-process $_SESSION to isolate between tests. + */ +abstract class AbstractRestTestCase extends AbstractUnitTestCase +{ + use RestAssertionsTrait; + use RestTrait; +} diff --git a/src/PHPUnit/AbstractUnitTestCase.php b/src/PHPUnit/AbstractUnitTestCase.php index 852d67b..23d7da0 100644 --- a/src/PHPUnit/AbstractUnitTestCase.php +++ b/src/PHPUnit/AbstractUnitTestCase.php @@ -32,8 +32,8 @@ abstract class AbstractUnitTestCase extends TestCase { - use ReflectionTrait; use FileSystemTrait; + use ReflectionTrait; protected function setUp(): void { diff --git a/src/Settings.php b/src/Settings.php index 7982b4c..995981c 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -168,12 +168,76 @@ public static function fromEnv(array $overrides = []): self [ 'dump_file' => $env('dump_file'), 'initial_queries' => $env('initial_queries'), + 'rest_url' => $env('TALON_REST_URL', 'http://127.0.0.1:8080'), ], [], $services, ); } + private static function discoverRoot(): string + { + $start = getcwd() ?: '.'; + $current = $start; + + while (true) { + if (file_exists($current . '/composer.json')) { + return $current; + } + + $parent = dirname($current); + if ($parent === $current) { + return $start; + } + + $current = $parent; + } + } + + /** + * @param array $config + * + * @return array + */ + private static function section(array $config, string $key): array + { + $value = $config[$key] ?? []; + if (!is_array($value)) { + return []; + } + + /** @var array $value */ + return $value; + } + + /** + * @param array $config + * + * @return array> + */ + private static function sectionOfArrays(array $config, string $key): array + { + $result = []; + foreach (self::section($config, $key) as $name => $value) { + if (is_array($value)) { + /** @var array $value */ + $result[(string) $name] = $value; + } + } + + return $result; + } + + public function cachePath(string $relative = ''): string + { + return $this->dir('cache', 'tests/_output/cache', $relative); + } + + public function dataPath(string $relative = ''): string + { + return $this->dir('data', 'tests/_data', $relative); + } + public function get(string $key, mixed $default = null): mixed { return $this->extra[$key] ?? $default; @@ -222,16 +286,6 @@ public function getServiceOptions(string $name): array return ($this->services[$name] ?? null)?->getOptions() ?? []; } - public function cachePath(string $relative = ''): string - { - return $this->dir('cache', 'tests/_output/cache', $relative); - } - - public function dataPath(string $relative = ''): string - { - return $this->dir('data', 'tests/_data', $relative); - } - public function logsPath(string $relative = ''): string { return $this->dir('logs', 'tests/_output/logs', $relative); @@ -275,25 +329,6 @@ private function dir(string $key, string $default, string $relative): string return $this->rootPath($sub); } - private static function discoverRoot(): string - { - $start = getcwd() ?: '.'; - $current = $start; - - while (true) { - if (file_exists($current . '/composer.json')) { - return $current; - } - - $parent = dirname($current); - if ($parent === $current) { - return $start; - } - - $current = $parent; - } - } - /** * @param array $options */ @@ -303,38 +338,4 @@ private function optString(array $options, string $key, string $default = ''): s return is_scalar($value) ? (string) $value : $default; } - - /** - * @param array $config - * - * @return array - */ - private static function section(array $config, string $key): array - { - $value = $config[$key] ?? []; - if (!is_array($value)) { - return []; - } - - /** @var array $value */ - return $value; - } - - /** - * @param array $config - * - * @return array> - */ - private static function sectionOfArrays(array $config, string $key): array - { - $result = []; - foreach (self::section($config, $key) as $name => $value) { - if (is_array($value)) { - /** @var array $value */ - $result[(string) $name] = $value; - } - } - - return $result; - } } diff --git a/src/Traits/BrowserTrait.php b/src/Traits/BrowserTrait.php index bd35b66..52646db 100644 --- a/src/Traits/BrowserTrait.php +++ b/src/Traits/BrowserTrait.php @@ -33,8 +33,6 @@ trait BrowserTrait private ?Form $form = null; - abstract protected function appFactory(): callable; - public function clickLink(string $text, ?string $context = null): void { $crawler = null === $context @@ -90,6 +88,8 @@ public function visitPage(string $url): void $this->form = null; } + abstract protected function appFactory(): callable; + protected function browser(): Client { if (null === $this->client) { diff --git a/src/Traits/FunctionalAssertionsTrait.php b/src/Traits/FunctionalAssertionsTrait.php index 19a1157..fe36637 100644 --- a/src/Traits/FunctionalAssertionsTrait.php +++ b/src/Traits/FunctionalAssertionsTrait.php @@ -23,8 +23,6 @@ */ trait FunctionalAssertionsTrait { - abstract public function getContent(): string; - public function assertAction(string $expected): void { $this->assertSame($expected, $this->dispatcher()->getActionName()); @@ -71,6 +69,7 @@ public function assertResponseContentContains(string $needle): void { $this->assertStringContainsString($needle, $this->getContent()); } + abstract public function getContent(): string; abstract protected function dispatcher(): Dispatcher; diff --git a/src/Traits/FunctionalTrait.php b/src/Traits/FunctionalTrait.php index 9dc7d39..984b5ee 100644 --- a/src/Traits/FunctionalTrait.php +++ b/src/Traits/FunctionalTrait.php @@ -36,8 +36,6 @@ trait FunctionalTrait private mixed $response = null; - abstract protected function appFactory(): callable; - public function dispatch(string $url): void { $factory = $this->appFactory(); @@ -56,6 +54,8 @@ public function getContent(): string return $this->response()->getContent(); } + abstract protected function appFactory(): callable; + protected function dispatcher(): Dispatcher { $dispatcher = $this->di()->getShared('dispatcher'); diff --git a/src/Traits/RestAssertionsTrait.php b/src/Traits/RestAssertionsTrait.php new file mode 100644 index 0000000..c9ddbbb --- /dev/null +++ b/src/Traits/RestAssertionsTrait.php @@ -0,0 +1,193 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Traits; + +use Phalcon\Talon\Http\JsonSubset; +use Phalcon\Talon\Http\JsonType; + +use function is_array; +use function json_decode; +use function json_last_error; +use function sprintf; + +use const JSON_ERROR_NONE; + +/** + * @mixin \PHPUnit\Framework\TestCase + */ +trait RestAssertionsTrait +{ + public function assertHttpHeader(string $name, ?string $value = null): void + { + $actual = $this->grabHttpHeader($name); + + $this->assertNotNull($actual, sprintf("Failed asserting that header '%s' is present", $name)); + + if (null !== $value) { + $this->assertSame($value, $actual); + } + } + + public function assertNoHttpHeader(string $name, ?string $value = null): void + { + $actual = $this->grabHttpHeader($name); + + if (null === $value) { + $this->assertNull($actual, sprintf("Failed asserting that header '%s' is absent", $name)); + + return; + } + + $this->assertNotSame($value, $actual); + } + + public function assertResponseCodeIs(int $code): void + { + $this->assertSame($code, $this->grabResponseCode()); + } + + public function assertResponseCodeIsClientError(): void + { + $this->assertResponseCodeInRange(400, 499); + } + + public function assertResponseCodeIsNot(int $code): void + { + $this->assertNotSame($code, $this->grabResponseCode()); + } + + public function assertResponseCodeIsRedirection(): void + { + $this->assertResponseCodeInRange(300, 399); + } + + public function assertResponseCodeIsServerError(): void + { + $this->assertResponseCodeInRange(500, 599); + } + + public function assertResponseCodeIsSuccessful(): void + { + $this->assertResponseCodeInRange(200, 299); + } + + public function assertResponseContains(string $text): void + { + $this->assertStringContainsString($text, $this->grabResponse()); + } + + /** + * @param array $json + */ + public function assertResponseContainsJson(array $json): void + { + $this->assertNotSame([], $json, $this->emptyFragmentMessage()); + + $this->assertTrue( + JsonSubset::contains($this->decodedResponse(), $json), + 'Failed asserting that the response contains the given JSON fragment. Response: ' + . $this->grabResponse() + ); + } + + public function assertResponseEquals(string $expected): void + { + $this->assertSame($expected, $this->grabResponse()); + } + + public function assertResponseIsJson(): void + { + $content = $this->grabResponse(); + json_decode($content, true); + + $this->assertSame( + JSON_ERROR_NONE, + json_last_error(), + 'Failed asserting that the response is valid JSON. Response: ' . $content + ); + } + + /** + * @param array $types + */ + public function assertResponseMatchesJsonType(array $types): void + { + $error = JsonType::match($this->decodedResponse(), $types); + + $this->assertNull( + $error, + sprintf('Failed asserting that the response matches the given JSON types. %s', (string) $error) + ); + } + + public function assertResponseNotContains(string $text): void + { + $this->assertStringNotContainsString($text, $this->grabResponse()); + } + + /** + * @param array $json + */ + public function assertResponseNotContainsJson(array $json): void + { + $this->assertNotSame([], $json, $this->emptyFragmentMessage()); + + $this->assertFalse( + JsonSubset::contains($this->decodedResponse(), $json), + 'Failed asserting that the response does not contain the given JSON fragment.' + ); + } + + /** + * @param array $types + */ + public function assertResponseNotMatchesJsonType(array $types): void + { + $this->assertNotNull( + JsonType::match($this->decodedResponse(), $types), + 'Failed asserting that the response does not match the given JSON types.' + ); + } + abstract public function grabHttpHeader(string $name): ?string; + + abstract public function grabResponse(): string; + + abstract public function grabResponseCode(): int; + + private function assertResponseCodeInRange(int $low, int $high): void + { + $code = $this->grabResponseCode(); + + $this->assertGreaterThanOrEqual($low, $code); + $this->assertLessThanOrEqual($high, $code); + } + + /** + * @return array + */ + private function decodedResponse(): array + { + $decoded = json_decode($this->grabResponse(), true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * Traits cannot declare constants until PHP 8.2, so this stands in for one. + */ + private function emptyFragmentMessage(): string + { + return 'An empty fragment asserts nothing; pass the fragment you mean to assert.'; + } +} diff --git a/src/Traits/RestTrait.php b/src/Traits/RestTrait.php new file mode 100644 index 0000000..a222344 --- /dev/null +++ b/src/Traits/RestTrait.php @@ -0,0 +1,319 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Traits; + +use Phalcon\Talon\Exceptions\ResponseNotDispatched; +use Phalcon\Talon\Talon; +use Symfony\Component\BrowserKit\AbstractBrowser; +use Symfony\Component\BrowserKit\Exception\BadMethodCallException; +use Symfony\Component\BrowserKit\HttpBrowser; +use Symfony\Component\BrowserKit\Response; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +use function base64_encode; +use function basename; +use function http_build_query; +use function in_array; +use function is_string; +use function json_encode; +use function ltrim; +use function rtrim; +use function str_contains; +use function str_replace; +use function str_starts_with; +use function strtolower; +use function strtoupper; + +/** + * @mixin \PHPUnit\Framework\TestCase + */ +trait RestTrait +{ + private ?string $restBaseUrlOverride = null; + + private ?AbstractBrowser $restClient = null; + + /** @var array */ + private array $restRequestHeaders = []; + + public function amBearerAuthenticated(string $token): void + { + $this->haveHttpHeader('Authorization', 'Bearer ' . $token); + } + + public function amHttpAuthenticated(string $username, string $password): void + { + $this->haveHttpHeader('Authorization', 'Basic ' . base64_encode($username . ':' . $password)); + } + + /** + * Returns the first value of a response header. A header sent more than + * once (Set-Cookie being the usual case) reports only its first value. + */ + public function grabHttpHeader(string $name): ?string + { + // getHeader() is declared string|array|null, but the array arm is only + // reachable with $first = false. + /** @var string|null $value */ + $value = $this->restResponse()->getHeader($name); + + return $value; + } + + public function grabResponse(): string + { + return $this->restResponse()->getContent(); + } + + public function grabResponseCode(): int + { + return $this->restResponse()->getStatusCode(); + } + + /** + * Sets a request header for this and every later request, until it is + * unset. + */ + public function haveHttpHeader(string $name, string $value): void + { + $this->restRequestHeaders[$this->headerKey($name)] = $value; + } + + /** + * @param array|string $params + * @param array $files + */ + public function send(string $method, string $url, array|string $params = [], array $files = []): void + { + $method = strtoupper($method); + $uri = $this->restUrl($url); + + if (is_string($params)) { + // HttpBrowser wraps an unlabelled string body in a TextPart, which + // goes out as text/plain. On a REST surface a raw body is JSON far + // more often than not, so name it - per request, and only when the + // caller has not named it already (the union keeps the left side). + $server = $this->restRequestHeaders + [$this->headerKey('Content-Type') => 'application/json']; + + $this->restBrowser()->request($method, $uri, [], $this->normalizeFiles($files), $server, $params); + + return; + } + + // Files force a body, so a bodyless verb carrying them falls through to + // the multipart path rather than silently dropping them. + if ([] === $files && in_array($method, ['DELETE', 'GET', 'HEAD', 'OPTIONS'], true)) { + if ([] !== $params) { + $uri .= (str_contains($uri, '?') ? '&' : '?') . http_build_query($params); + } + + $this->restBrowser()->request($method, $uri, [], [], $this->restRequestHeaders); + + return; + } + + if ([] === $files && $this->sendsJson()) { + $this->restBrowser()->request( + $method, + $uri, + [], + [], + $this->restRequestHeaders, + (string) json_encode($params) + ); + + return; + } + + $this->restBrowser()->request($method, $uri, $params, $this->normalizeFiles($files), $this->restRequestHeaders); + } + + /** + * @param array $params + */ + public function sendDelete(string $url, array $params = []): void + { + $this->send('DELETE', $url, $params); + } + + /** + * @param array $params + */ + public function sendGet(string $url, array $params = []): void + { + $this->send('GET', $url, $params); + } + + /** + * @param array $params + */ + public function sendHead(string $url, array $params = []): void + { + $this->send('HEAD', $url, $params); + } + + /** + * @param array $params + */ + public function sendOptions(string $url, array $params = []): void + { + $this->send('OPTIONS', $url, $params); + } + + /** + * @param array|string $params + * @param array $files + */ + public function sendPatch(string $url, array|string $params = [], array $files = []): void + { + $this->send('PATCH', $url, $params, $files); + } + + /** + * @param array|string $params + * @param array $files + */ + public function sendPost(string $url, array|string $params = [], array $files = []): void + { + $this->send('POST', $url, $params, $files); + } + + /** + * @param array|string $params + * @param array $files + */ + public function sendPut(string $url, array|string $params = [], array $files = []): void + { + $this->send('PUT', $url, $params, $files); + } + + public function startFollowingRedirects(): void + { + $this->restBrowser()->followRedirects(true); + } + + public function stopFollowingRedirects(): void + { + $this->restBrowser()->followRedirects(false); + } + + public function unsetHttpHeader(string $name): void + { + unset($this->restRequestHeaders[$this->headerKey($name)]); + } + + /** + * Points this test at a base URL, ahead of the one Settings resolves. + * Without it the URL comes from TALON_REST_URL via Talon's shared Settings, + * which is process-global and cannot vary per test. + */ + public function useRestBaseUrl(string $url): void + { + $this->restBaseUrlOverride = $url; + } + + protected function restBaseUrl(): string + { + if (null !== $this->restBaseUrlOverride) { + return $this->restBaseUrlOverride; + } + + $url = Talon::settings()->get('rest_url'); + + return is_string($url) && '' !== $url ? $url : 'http://127.0.0.1:8080'; + } + + protected function restBrowser(): AbstractBrowser + { + if (null !== $this->restClient) { + return $this->restClient; + } + + $client = new HttpBrowser($this->restHttpClient()); + $client->followRedirects(false); + + $this->restClient = $client; + + return $client; + } + + /** + * The transport seam. Returning null lets HttpBrowser build a real client; + * a test overrides this with a MockHttpClient so the suite exercises the + * same request-building path without a live server. + */ + protected function restHttpClient(): ?HttpClientInterface + { + return null; + } + + /** + * Header names map onto PHP's HTTP_* server convention, so '-' and '_' are + * indistinguishable: 'Content-Type' and 'Content_Type' address the same + * header and the later call wins. + */ + private function headerKey(string $name): string + { + return 'HTTP_' . strtoupper(str_replace('-', '_', $name)); + } + + /** + * Accepts either a plain path or the $_FILES shape BrowserKit wants. + * BrowserKit's own handling of a plain path is to stop reading the list and + * send nothing at all, so a caller who passes the obvious thing gets a + * silently file-less request. + * + * @param array $files + * + * @return array + */ + private function normalizeFiles(array $files): array + { + $normalized = []; + + foreach ($files as $field => $file) { + $normalized[$field] = is_string($file) + ? ['tmp_name' => $file, 'name' => basename($file)] + : $file; + } + + return $normalized; + } + + private function restResponse(): Response + { + try { + return $this->restBrowser()->getInternalResponse(); + } catch (BadMethodCallException) { + throw new ResponseNotDispatched(); + } + } + + private function restUrl(string $url): string + { + if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) { + return $url; + } + + return rtrim($this->restBaseUrl(), '/') . '/' . ltrim($url, '/'); + } + + private function sendsJson(): bool + { + return str_contains( + strtolower($this->restRequestHeaders['HTTP_CONTENT_TYPE'] ?? ''), + 'json' + ); + } +} diff --git a/tests/Fakes/Browser/FixtureSmokeTest.php b/tests/Fakes/Browser/FixtureSmokeTest.php index 128dd86..065a64a 100644 --- a/tests/Fakes/Browser/FixtureSmokeTest.php +++ b/tests/Fakes/Browser/FixtureSmokeTest.php @@ -20,11 +20,6 @@ final class FixtureSmokeTest extends TestCase { use FunctionalTrait; - protected function appFactory(): callable - { - return static fn () => require __DIR__ . '/app.php'; - } - public function testFormRendersWithCsrfField(): void { $this->dispatch('/browser/form'); @@ -40,4 +35,9 @@ public function testSecuredShowsGuestWithoutSession(): void $this->assertStringContainsString('Guest', $this->getContent()); } + + protected function appFactory(): callable + { + return static fn () => require __DIR__ . '/app.php'; + } } diff --git a/tests/Fakes/Rest/DefaultSeamConsumer.php b/tests/Fakes/Rest/DefaultSeamConsumer.php new file mode 100644 index 0000000..48c934c --- /dev/null +++ b/tests/Fakes/Rest/DefaultSeamConsumer.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Fakes\Rest; + +use Phalcon\Talon\PHPUnit\AbstractRestTestCase; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Reaches restBaseUrl()/restHttpClient() from a subclass without overriding + * them - PublicApiConsumer overrides both, which masks the trait's own + * definitions and hides whether they are still reachable from a child class. + */ +final class DefaultSeamConsumer extends AbstractRestTestCase +{ + public function rawBaseUrl(): string + { + return $this->restBaseUrl(); + } + + public function rawHttpClient(): ?HttpClientInterface + { + return $this->restHttpClient(); + } +} diff --git a/tests/Fakes/Rest/PublicApiConsumer.php b/tests/Fakes/Rest/PublicApiConsumer.php new file mode 100644 index 0000000..2af0c7e --- /dev/null +++ b/tests/Fakes/Rest/PublicApiConsumer.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Fakes\Rest; + +use Phalcon\Talon\PHPUnit\AbstractRestTestCase; +use Symfony\Component\BrowserKit\AbstractBrowser; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Exposes the REST traits to a scope outside the AbstractRestTestCase + * hierarchy, so tests can verify the public API stays publicly callable and + * the protected restBaseUrl()/restBrowser()/restHttpClient() seams stay + * subclass-accessible. + */ +final class PublicApiConsumer extends AbstractRestTestCase +{ + public const BODY = '{"jsonapi":{"version":"1.0"},"data":[{"id":1,"name":"Acme"}]}'; + + private int $status = 200; + + public function rawBaseUrl(): string + { + return $this->restBaseUrl(); + } + + public function rawBrowser(): AbstractBrowser + { + return $this->restBrowser(); + } + + public function rawHttpClient(): ?HttpClientInterface + { + return $this->restHttpClient(); + } + + public function respondWith(int $status): void + { + $this->status = $status; + } + + protected function restBaseUrl(): string + { + return 'http://api.test:8080'; + } + + protected function restHttpClient(): HttpClientInterface + { + return new MockHttpClient(fn (): MockResponse => new MockResponse( + self::BODY, + [ + 'http_code' => $this->status, + 'response_headers' => ['Content-Type' => 'application/json'], + ] + )); + } +} diff --git a/tests/Fakes/Rest/upload.txt b/tests/Fakes/Rest/upload.txt new file mode 100644 index 0000000..2a4eb7e --- /dev/null +++ b/tests/Fakes/Rest/upload.txt @@ -0,0 +1 @@ +talon upload fixture diff --git a/tests/Traits/BrowserTraitTest.php b/tests/Traits/BrowserTraitTest.php index cea347a..b1c902c 100644 --- a/tests/Traits/BrowserTraitTest.php +++ b/tests/Traits/BrowserTraitTest.php @@ -21,19 +21,6 @@ final class BrowserTraitTest extends AbstractBrowserTestCase { - protected function appFactory(): callable - { - return static fn () => require __DIR__ . '/../Fakes/Browser/app.php'; - } - - public function testVisitAndAssertPageText(): void - { - $this->visitPage('/browser/form'); - - $this->assertPageContainsText('Log In'); - $this->assertPageMissingText('Welcome back'); - } - public function testAssertBeforeVisitThrows(): void { $this->expectException(ResponseNotDispatched::class); @@ -41,41 +28,13 @@ public function testAssertBeforeVisitThrows(): void $this->assertPageContainsText('anything'); } - public function testLoginFlowFollowsRedirectAndKeepsSession(): void - { - $this->visitPage('/browser/form'); - $this->fillField('name', 'sarah'); - $this->selectOption('active', 'Yes'); - $this->pressButton('Log In'); - - $this->assertPageContainsText('Welcome sarah'); - $this->assertPageContainsText('active=Yes'); - } - - public function testSessionPersistsAcrossASecondRequest(): void - { - $this->visitPage('/browser/form'); - $this->fillField('name', 'sarah'); - $this->pressButton('Log In'); - - $this->visitPage('/browser/secured'); - $this->assertPageContainsText('Welcome sarah'); - } - - public function testSecuredIsGuestInAFreshTest(): void + public function testAssertPageMissingTextFailsWhenTextIsPresent(): void { - $this->visitPage('/browser/secured'); - - $this->assertPageContainsText('Guest'); - } + $this->visitPage('/browser/landed'); - public function testPressButtonBySelectorSubmitsTheForm(): void - { - $this->visitPage('/browser/form'); - $this->fillField('name', 'john'); - $this->pressButton('//form/*[@type="submit"]'); + $this->expectException(AssertionFailedError::class); - $this->assertPageContainsText('Welcome john'); + $this->assertPageMissingText('landed ok'); } public function testClickLinkByText(): void @@ -94,14 +53,6 @@ public function testClickLinkWithinContext(): void $this->assertPageContainsText('landed ok'); } - public function testPressButtonByLabelWithoutFilling(): void - { - $this->visitPage('/browser/search'); - $this->pressButton('Search'); - - $this->assertPageContainsText('landed ok'); - } - public function testCookieJarReadAndWrite(): void { $this->visitPage('/browser/cookie'); @@ -112,75 +63,72 @@ public function testCookieJarReadAndWrite(): void $this->assertPageContainsText('cookie sent=crunchy'); } - public function testSetCookieIsClearedWhenTheAppExpiresIt(): void + public function testFillFieldWithNoFormThrows(): void { - // A set cookie is host-scoped, so a response that expires it on the same - // host actually evicts it from the jar (an empty-domain cookie would not). - $this->setCookie('talon', 'crunchy'); - $this->assertSame('crunchy', $this->getCookie('talon')); + $this->visitPage('/browser/landed'); - $this->visitPage('/browser/cookieClear'); + $this->expectException(ElementNotFound::class); - $this->assertNull($this->getCookie('talon')); + $this->fillField('name', 'value'); } - public function testMissingLinkThrows(): void + public function testGetCookieReturnsNullWhenAbsent(): void { $this->visitPage('/browser/menu'); - $this->expectException(ElementNotFound::class); - $this->expectExceptionMessage('Could not find link "Nonexistent" on the page'); - - $this->clickLink('Nonexistent'); + $this->assertNull($this->getCookie('nope')); } - public function testGetCookieReturnsNullWhenAbsent(): void + public function testLoginFlowFollowsRedirectAndKeepsSession(): void { - $this->visitPage('/browser/menu'); + $this->visitPage('/browser/form'); + $this->fillField('name', 'sarah'); + $this->selectOption('active', 'Yes'); + $this->pressButton('Log In'); - $this->assertNull($this->getCookie('nope')); + $this->assertPageContainsText('Welcome sarah'); + $this->assertPageContainsText('active=Yes'); } - public function testPressButtonWithNoFormThrows(): void + public function testMissingLinkThrows(): void { - $this->visitPage('/browser/landed'); + $this->visitPage('/browser/menu'); $this->expectException(ElementNotFound::class); - $this->expectExceptionMessage('Could not find button "Save" on the page'); + $this->expectExceptionMessage('Could not find link "Nonexistent" on the page'); - $this->pressButton('Save'); + $this->clickLink('Nonexistent'); } - public function testPressButtonByXPathWithoutFilling(): void + public function testPressButtonByLabelWithoutFilling(): void { $this->visitPage('/browser/search'); - $this->pressButton('//button'); + $this->pressButton('Search'); $this->assertPageContainsText('landed ok'); } - public function testFillFieldWithNoFormThrows(): void + public function testPressButtonByParenthesizedXPath(): void { - $this->visitPage('/browser/landed'); - - $this->expectException(ElementNotFound::class); + $this->visitPage('/browser/search'); + $this->pressButton('(//button)[1]'); - $this->fillField('name', 'value'); + $this->assertPageContainsText('landed ok'); } - public function testAssertPageMissingTextFailsWhenTextIsPresent(): void + public function testPressButtonBySelectorSubmitsTheForm(): void { - $this->visitPage('/browser/landed'); - - $this->expectException(AssertionFailedError::class); + $this->visitPage('/browser/form'); + $this->fillField('name', 'john'); + $this->pressButton('//form/*[@type="submit"]'); - $this->assertPageMissingText('landed ok'); + $this->assertPageContainsText('Welcome john'); } - public function testPressButtonByParenthesizedXPath(): void + public function testPressButtonByXPathWithoutFilling(): void { $this->visitPage('/browser/search'); - $this->pressButton('(//button)[1]'); + $this->pressButton('//button'); $this->assertPageContainsText('landed ok'); } @@ -196,6 +144,16 @@ public function testPressButtonDoesNotTreatPlainLabelsAsXPath(): void $this->pressButton('button'); } + public function testPressButtonWithNoFormThrows(): void + { + $this->visitPage('/browser/landed'); + + $this->expectException(ElementNotFound::class); + $this->expectExceptionMessage('Could not find button "Save" on the page'); + + $this->pressButton('Save'); + } + public function testPressButtonWithUnmatchedXPathThrows(): void { $this->visitPage('/browser/search'); @@ -205,6 +163,43 @@ public function testPressButtonWithUnmatchedXPathThrows(): void $this->pressButton('//form/input[@type="submit"]'); } + public function testSecuredIsGuestInAFreshTest(): void + { + $this->visitPage('/browser/secured'); + + $this->assertPageContainsText('Guest'); + } + + public function testSessionPersistsAcrossASecondRequest(): void + { + $this->visitPage('/browser/form'); + $this->fillField('name', 'sarah'); + $this->pressButton('Log In'); + + $this->visitPage('/browser/secured'); + $this->assertPageContainsText('Welcome sarah'); + } + + public function testSetCookieIsClearedWhenTheAppExpiresIt(): void + { + // A set cookie is host-scoped, so a response that expires it on the same + // host actually evicts it from the jar (an empty-domain cookie would not). + $this->setCookie('talon', 'crunchy'); + $this->assertSame('crunchy', $this->getCookie('talon')); + + $this->visitPage('/browser/cookieClear'); + + $this->assertNull($this->getCookie('talon')); + } + + public function testVisitAndAssertPageText(): void + { + $this->visitPage('/browser/form'); + + $this->assertPageContainsText('Log In'); + $this->assertPageMissingText('Welcome back'); + } + public function testVisitPageAlwaysTargetsLocalhost(): void { // Even with another default host configured, visitPage() pins the @@ -217,4 +212,8 @@ public function testVisitPageAlwaysTargetsLocalhost(): void $this->assertInstanceOf(Request::class, $request); $this->assertSame('http://localhost/browser/landed', $request->getUri()); } + protected function appFactory(): callable + { + return static fn () => require __DIR__ . '/../Fakes/Browser/app.php'; + } } diff --git a/tests/Traits/DatabaseTraitTest.php b/tests/Traits/DatabaseTraitTest.php index 6a65833..5418e46 100644 --- a/tests/Traits/DatabaseTraitTest.php +++ b/tests/Traits/DatabaseTraitTest.php @@ -45,17 +45,17 @@ protected function tearDown(): void parent::tearDown(); } - public function testAssertInDatabasePasses(): void - { - $this->assertInDatabase('users', ['id' => 1]); - } - public function testAssertInDatabaseFailsWhenMissing(): void { $this->expectException(AssertionFailedError::class); $this->assertInDatabase('users', ['id' => 999]); } + public function testAssertInDatabasePasses(): void + { + $this->assertInDatabase('users', ['id' => 1]); + } + public function testAssertNotInDatabasePasses(): void { $this->assertNotInDatabase('users', ['id' => 999]); @@ -73,6 +73,17 @@ public function testGetConnectionLoadsSchemaOnceWhenDumpFileConfigured(): void $this->assertInDatabase('seeded_users', ['id' => 1]); } + public function testGetDriverReturnsCurrentEnvDriver(): void + { + putenv('driver=pgsql'); + + try { + $this->assertSame('pgsql', $this->getDriver()); + } finally { + putenv('driver'); + } + } + public function testPublicApiVisibility(): void { // Execute each helper so this test covers the mutated method bodies @@ -103,15 +114,4 @@ public function testPublicApiVisibility(): void ); } } - - public function testGetDriverReturnsCurrentEnvDriver(): void - { - putenv('driver=pgsql'); - - try { - $this->assertSame('pgsql', $this->getDriver()); - } finally { - putenv('driver'); - } - } } diff --git a/tests/Traits/FunctionalInvalidApplicationTest.php b/tests/Traits/FunctionalInvalidApplicationTest.php index e463ade..57076d7 100644 --- a/tests/Traits/FunctionalInvalidApplicationTest.php +++ b/tests/Traits/FunctionalInvalidApplicationTest.php @@ -22,15 +22,15 @@ final class FunctionalInvalidApplicationTest extends TestCase { use FunctionalTrait; - protected function appFactory(): callable - { - return static fn () => new stdClass(); - } - public function testDispatchRejectsAppWithoutHandle(): void { $this->expectException(InvalidApplication::class); $this->dispatch('/'); } + + protected function appFactory(): callable + { + return static fn () => new stdClass(); + } } diff --git a/tests/Traits/FunctionalMissingServiceTest.php b/tests/Traits/FunctionalMissingServiceTest.php index e21fcc9..c6f15f4 100644 --- a/tests/Traits/FunctionalMissingServiceTest.php +++ b/tests/Traits/FunctionalMissingServiceTest.php @@ -21,13 +21,8 @@ final class FunctionalMissingServiceTest extends TestCase { - use FunctionalTrait; use FunctionalAssertionsTrait; - - protected function appFactory(): callable - { - return static fn (): object => new FakeAppWithMissingDispatcher(); - } + use FunctionalTrait; public function testMissingDispatcherThrows(): void { @@ -37,4 +32,9 @@ public function testMissingDispatcherThrows(): void $this->assertController('test'); } + + protected function appFactory(): callable + { + return static fn (): object => new FakeAppWithMissingDispatcher(); + } } diff --git a/tests/Traits/FunctionalNullDiTest.php b/tests/Traits/FunctionalNullDiTest.php index 77f493c..6aa5f6d 100644 --- a/tests/Traits/FunctionalNullDiTest.php +++ b/tests/Traits/FunctionalNullDiTest.php @@ -22,8 +22,17 @@ final class FunctionalNullDiTest extends TestCase { - use FunctionalTrait; use FunctionalAssertionsTrait; + use FunctionalTrait; + + public function testNullDiThrows(): void + { + $this->dispatch('/test/hello'); + + $this->expectException(ResponseNotDispatched::class); + + $this->assertController('test'); + } protected function appFactory(): callable { @@ -34,13 +43,4 @@ protected function resolveDi(InjectionAwareInterface $application): DiInterface { throw new ResponseNotDispatched(); } - - public function testNullDiThrows(): void - { - $this->dispatch('/test/hello'); - - $this->expectException(ResponseNotDispatched::class); - - $this->assertController('test'); - } } diff --git a/tests/Traits/FunctionalRealNullDiTest.php b/tests/Traits/FunctionalRealNullDiTest.php index 82e70ed..042b847 100644 --- a/tests/Traits/FunctionalRealNullDiTest.php +++ b/tests/Traits/FunctionalRealNullDiTest.php @@ -32,8 +32,23 @@ */ final class FunctionalRealNullDiTest extends TestCase { - use FunctionalTrait; use FunctionalAssertionsTrait; + use FunctionalTrait; + + public function testRealResolveDiThrowsWhenGetDiReturnsNull(): void + { + if (!Environment::viaImplementation()) { + $this->markTestSkipped( + 'InjectionAwareInterface::getDI() is only nullable under the phalcon/phalcon (v6) provider' + ); + } + + $this->dispatch('/test/hello'); + + $this->expectException(ResponseNotDispatched::class); + + $this->assertController('test'); + } protected function appFactory(): callable { @@ -55,19 +70,4 @@ public function setDI(DiInterface $di): void }; }; } - - public function testRealResolveDiThrowsWhenGetDiReturnsNull(): void - { - if (!Environment::viaImplementation()) { - $this->markTestSkipped( - 'InjectionAwareInterface::getDI() is only nullable under the phalcon/phalcon (v6) provider' - ); - } - - $this->dispatch('/test/hello'); - - $this->expectException(ResponseNotDispatched::class); - - $this->assertController('test'); - } } diff --git a/tests/Traits/FunctionalTraitTest.php b/tests/Traits/FunctionalTraitTest.php index bd87722..8705367 100644 --- a/tests/Traits/FunctionalTraitTest.php +++ b/tests/Traits/FunctionalTraitTest.php @@ -27,52 +27,40 @@ final class FunctionalTraitTest extends TestCase { - use FunctionalTrait; use FunctionalAssertionsTrait; + use FunctionalTrait; - protected function appFactory(): callable - { - return static fn () => require __DIR__ . '/../Fakes/App/app.php'; - } - - public function testDispatchAndAssertControllerAction(): void + public function testAssertActionFailsOnMismatch(): void { $this->dispatch('/test/hello'); - $this->assertController('test'); - $this->assertAction('hello'); - $this->assertResponseContentContains('Operator'); - $this->assertStringContainsString('Operator', $this->getContent()); - } - - public function testAssertHeader(): void - { - $this->dispatch('/test/header'); + $this->expectException(AssertionFailedError::class); - $this->assertHeader(['X-Talon' => 'yes']); + $this->assertAction('other'); } - public function testAssertResponseCode(): void + public function testAssertDispatchIsForwarded(): void { - $this->dispatch('/test/status'); + $this->dispatch('/test/forward'); - $this->assertResponseCode(404); - $this->assertResponseCode('404'); + $this->assertDispatchIsForwarded(); + $this->assertAction('empty'); } - public function testAssertRedirectTo(): void + public function testAssertDispatchIsForwardedFailsWhenNotForwarded(): void { - $this->dispatch('/test/redirect'); + $this->dispatch('/test/hello'); - $this->assertRedirectTo('/target'); + $this->expectException(AssertionFailedError::class); + + $this->assertDispatchIsForwarded(); } - public function testAssertDispatchIsForwarded(): void + public function testAssertHeader(): void { - $this->dispatch('/test/forward'); + $this->dispatch('/test/header'); - $this->assertDispatchIsForwarded(); - $this->assertAction('empty'); + $this->assertHeader(['X-Talon' => 'yes']); } public function testAssertionBeforeDispatchThrows(): void @@ -82,47 +70,54 @@ public function testAssertionBeforeDispatchThrows(): void $this->assertController('test'); } - public function testGetContentBeforeDispatchThrows(): void + public function testAssertRedirectTo(): void { - $this->expectException(ResponseNotDispatched::class); + $this->dispatch('/test/redirect'); - $this->getContent(); + $this->assertRedirectTo('/target'); } - public function testAssertActionFailsOnMismatch(): void + public function testAssertResponseCode(): void { - $this->dispatch('/test/hello'); - - $this->expectException(AssertionFailedError::class); + $this->dispatch('/test/status'); - $this->assertAction('other'); + $this->assertResponseCode(404); + $this->assertResponseCode('404'); } - public function testAssertDispatchIsForwardedFailsWhenNotForwarded(): void + public function testAssertResponseCodeFailsWhenStatusHeaderMissing(): void { $this->dispatch('/test/hello'); $this->expectException(AssertionFailedError::class); - $this->assertDispatchIsForwarded(); + $this->assertResponseCode(404); } - public function testAssertResponseCodeFailsWhenStatusHeaderMissing(): void + public function testAssertResponseContentContainsFailsOnMissingNeedle(): void { $this->dispatch('/test/hello'); $this->expectException(AssertionFailedError::class); - $this->assertResponseCode(404); + $this->assertResponseContentContains('absent'); } - public function testAssertResponseContentContainsFailsOnMissingNeedle(): void + public function testDispatchAndAssertControllerAction(): void { $this->dispatch('/test/hello'); - $this->expectException(AssertionFailedError::class); + $this->assertController('test'); + $this->assertAction('hello'); + $this->assertResponseContentContains('Operator'); + $this->assertStringContainsString('Operator', $this->getContent()); + } - $this->assertResponseContentContains('absent'); + public function testGetContentBeforeDispatchThrows(): void + { + $this->expectException(ResponseNotDispatched::class); + + $this->getContent(); } public function testProtectedHelpersAreAccessibleFromSubclass(): void @@ -176,4 +171,9 @@ public function testPublicApiIsCallableFromOutside(): void $fixture->dispatch('/test/status'); $fixture->assertResponseCode(404); } + + protected function appFactory(): callable + { + return static fn () => require __DIR__ . '/../Fakes/App/app.php'; + } } diff --git a/tests/Traits/RestAssertionsTraitTest.php b/tests/Traits/RestAssertionsTraitTest.php new file mode 100644 index 0000000..85466ff --- /dev/null +++ b/tests/Traits/RestAssertionsTraitTest.php @@ -0,0 +1,374 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Traits; + +use Phalcon\Talon\PHPUnit\AbstractRestTestCase; +use PHPUnit\Framework\AssertionFailedError; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final class RestAssertionsTraitTest extends AbstractRestTestCase +{ + private string $body = ''; + + /** @var array */ + private array $headers = ['Content-Type' => 'application/json']; + + private int $status = 200; + /** + * Each range assertion is pinned at both bounds, one code inside and one + * outside, so shifting either literal by one is observable. + * + * @return array + */ + public static function providerRangeBoundaries(): array + { + return [ + '199 not successful' => [199, 'assertResponseCodeIsSuccessful', false], + '200 successful' => [200, 'assertResponseCodeIsSuccessful', true], + '299 successful' => [299, 'assertResponseCodeIsSuccessful', true], + '300 not successful' => [300, 'assertResponseCodeIsSuccessful', false], + '299 not redirection' => [299, 'assertResponseCodeIsRedirection', false], + '300 redirection' => [300, 'assertResponseCodeIsRedirection', true], + '399 redirection' => [399, 'assertResponseCodeIsRedirection', true], + '400 not redirection' => [400, 'assertResponseCodeIsRedirection', false], + '399 not client error' => [399, 'assertResponseCodeIsClientError', false], + '400 client error' => [400, 'assertResponseCodeIsClientError', true], + '499 client error' => [499, 'assertResponseCodeIsClientError', true], + '500 not client error' => [500, 'assertResponseCodeIsClientError', false], + '499 not server error' => [499, 'assertResponseCodeIsServerError', false], + '500 server error' => [500, 'assertResponseCodeIsServerError', true], + '599 server error' => [599, 'assertResponseCodeIsServerError', true], + '600 not server error' => [600, 'assertResponseCodeIsServerError', false], + ]; + } + + public function testAssertHttpHeaderFailsWhenTheHeaderIsAbsent(): void + { + $this->respondWith('{}'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches("/header 'X-Absent' is present/"); + + $this->assertHttpHeader('X-Absent'); + } + + public function testAssertResponseCodeIs(): void + { + $this->respondWith('{}', 201); + + $this->assertResponseCodeIs(201); + $this->assertResponseCodeIsNot(200); + } + + public function testAssertResponseCodeIsFails(): void + { + $this->respondWith('{}', 404); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseCodeIs(200); + } + + public function testAssertResponseCodeIsNotFailsWhenTheCodeMatches(): void + { + $this->respondWith('{}', 200); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseCodeIsNot(200); + } + + public function testAssertResponseContainsFailsWhenTheTextIsAbsent(): void + { + $this->respondWith('{"a":1}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseContains('"b"'); + } + + /** + * ['data' => []] must assert that data IS empty, not merely that the key is + * there - the vacuous reading is the one a caller never means. + */ + public function testAssertResponseContainsJsonEmptyListMeansEmpty(): void + { + $this->respondWith('{"data":[{"id":1}]}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseContainsJson(['data' => []]); + } + + /** + * The failure message carries the response body - without it a failing + * fragment assertion says nothing about what actually came back. + */ + public function testAssertResponseContainsJsonFailsAndNamesTheResponse(): void + { + $this->respondWith('{"data":[{"name":"Acme"}]}'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches('/Response: \{"data":\[\{"name":"Acme"\}\]\}/'); + + $this->assertResponseContainsJson(['data' => [['name' => 'Nope']]]); + } + + public function testAssertResponseContainsJsonMatchesFragment(): void + { + $this->respondWith('{"jsonapi":{"version":"1.0"},"data":[{"id":1,"name":"Acme"}]}'); + + $this->assertResponseContainsJson(['data' => [['name' => 'Acme']]]); + $this->assertResponseNotContainsJson(['data' => [['name' => 'Other']]]); + } + + public function testAssertResponseContainsJsonRejectsAnEmptyFragment(): void + { + $this->respondWith('{"a":1}'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches( + '/An empty fragment asserts nothing; pass the fragment you mean to assert/' + ); + + $this->assertResponseContainsJson([]); + } + + public function testAssertResponseEqualsFailsOnADifferentBody(): void + { + $this->respondWith('{"a":1}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseEquals('{"a":2}'); + } + + public function testAssertResponseIsJson(): void + { + $this->respondWith('{"a":1}'); + + $this->assertResponseIsJson(); + } + + public function testAssertResponseIsJsonFailsOnGarbage(): void + { + $this->respondWith('not json'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseIsJson(); + } + + public function testAssertResponseIsJsonFailureNamesTheResponse(): void + { + $this->respondWith('not json at all'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches('/Response: not json at all/'); + + $this->assertResponseIsJson(); + } + + public function testAssertResponseMatchesJsonType(): void + { + $this->respondWith( + '{"jsonapi":{"version":"1.0"},' + . '"meta":{"timestamp":"2026-07-15T10:30:00+00:00",' + . '"hash":"abc"}}' + ); + + $this->assertResponseMatchesJsonType([ + 'jsonapi' => ['version' => 'string'], + 'meta' => [ + 'timestamp' => 'string:date', + 'hash' => 'string', + ], + ]); + } + + public function testAssertResponseMatchesJsonTypeFailureNamesThePath(): void + { + $this->respondWith('{"meta":{"hash":1}}'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches('/meta\.hash/'); + + $this->assertResponseMatchesJsonType(['meta' => ['hash' => 'string']]); + } + + public function testAssertResponseNotContainsFailsWhenTheTextIsPresent(): void + { + $this->respondWith('{"a":1}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseNotContains('"a"'); + } + + public function testAssertResponseNotContainsJsonFailsWhenTheFragmentMatches(): void + { + $this->respondWith('{"data":[{"name":"Acme"}]}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseNotContainsJson(['data' => [['name' => 'Acme']]]); + } + + public function testAssertResponseNotContainsJsonRejectsAnEmptyFragment(): void + { + $this->respondWith('{"a":1}'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches( + '/An empty fragment asserts nothing; pass the fragment you mean to assert/' + ); + + $this->assertResponseNotContainsJson([]); + } + + public function testAssertResponseNotMatchesJsonType(): void + { + $this->respondWith('{"meta":{"hash":1}}'); + + $this->assertResponseNotMatchesJsonType(['meta' => ['hash' => 'string']]); + } + + public function testAssertResponseNotMatchesJsonTypeFailsWhenTheTypesMatch(): void + { + $this->respondWith('{"meta":{"hash":"abc"}}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseNotMatchesJsonType(['meta' => ['hash' => 'string']]); + } + + public function testBodyAssertions(): void + { + $this->respondWith('{"a":1}'); + + $this->assertResponseEquals('{"a":1}'); + $this->assertResponseContains('"a"'); + $this->assertResponseNotContains('"b"'); + } + + public function testHeaderAssertions(): void + { + $this->respondWith('{}'); + + $this->assertHttpHeader('Content-Type'); + $this->assertHttpHeader('Content-Type', 'application/json'); + $this->assertNoHttpHeader('X-Absent'); + $this->assertNoHttpHeader('Content-Type', 'text/html'); + } + + public function testHeaderAssertionsFailOnAWrongValue(): void + { + $this->respondWith('{}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertHttpHeader('Content-Type', 'text/html'); + } + + public function testJsonAssertionsTreatANonObjectResponseAsEmpty(): void + { + $this->respondWith('"just a string"'); + + $this->assertResponseNotContainsJson(['a' => 1]); + } + + public function testNoHttpHeaderFailsWhenTheHeaderIsPresent(): void + { + $this->respondWith('{}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertNoHttpHeader('Content-Type'); + } + + public function testNoHttpHeaderFailsWhenTheValueMatches(): void + { + $this->respondWith('{}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertNoHttpHeader('Content-Type', 'application/json'); + } + + /** + * The range assertions are two comparisons against literal bounds, so each + * bound must be pinned from both sides - a test that only uses a mid-range + * code cannot tell 400..499 from 401..498. + * + * @dataProvider providerRangeBoundaries + */ + public function testRangeAssertionBoundaries( + int $code, + string $method, + bool $shouldPass + ): void { + $this->respondWith('{}', $code); + + if (!$shouldPass) { + $this->expectException(AssertionFailedError::class); + } + + $this->$method(); + + // On the passing path $method() has already asserted the bounds; assert + // the code round-tripped so the test carries an assertion of its own. + $this->assertSame($code, $this->grabResponseCode()); + } + + public function testRangeAssertions(): void + { + $this->respondWith('{}', 204); + $this->assertResponseCodeIsSuccessful(); + + $this->respondWith('{}', 302); + $this->assertResponseCodeIsRedirection(); + + $this->respondWith('{}', 404); + $this->assertResponseCodeIsClientError(); + + $this->respondWith('{}', 503); + $this->assertResponseCodeIsServerError(); + } + + protected function restBaseUrl(): string + { + return 'http://api.test:8080'; + } + + protected function restHttpClient(): HttpClientInterface + { + return new MockHttpClient(fn (): MockResponse => new MockResponse( + $this->body, + [ + 'http_code' => $this->status, + 'response_headers' => $this->headers, + ] + )); + } + + private function respondWith(string $body, int $status = 200): void + { + $this->body = $body; + $this->status = $status; + + $this->sendGet('/x'); + } +} diff --git a/tests/Traits/RestPublicApiTest.php b/tests/Traits/RestPublicApiTest.php new file mode 100644 index 0000000..a261411 --- /dev/null +++ b/tests/Traits/RestPublicApiTest.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Traits; + +use Phalcon\Talon\Tests\Fakes\Rest\DefaultSeamConsumer; +use Phalcon\Talon\Tests\Fakes\Rest\PublicApiConsumer; +use PHPUnit\Framework\TestCase; +use Symfony\Component\BrowserKit\HttpBrowser; +use Symfony\Component\HttpClient\MockHttpClient; + +final class RestPublicApiTest extends TestCase +{ + public function testActionsAreCallableFromOutsideTheHierarchy(): void + { + $subject = new PublicApiConsumer('restPublicApi'); + + $subject->haveHttpHeader('X-Token', 'abc'); + $subject->unsetHttpHeader('X-Token'); + $subject->amBearerAuthenticated('tok'); + $subject->amHttpAuthenticated('sarah', 'secret'); + $subject->startFollowingRedirects(); + $subject->stopFollowingRedirects(); + + $subject->send('GET', '/x'); + $subject->sendGet('/companies'); + $subject->sendPost('/companies', ['name' => 'Acme']); + $subject->sendPut('/companies/1', ['name' => 'Acme']); + $subject->sendPatch('/companies/1', ['name' => 'Acme']); + $subject->sendDelete('/companies/1'); + $subject->sendHead('/companies'); + $subject->sendOptions('/companies'); + + $this->assertSame(200, $subject->grabResponseCode()); + $this->assertSame('application/json', $subject->grabHttpHeader('Content-Type')); + $this->assertSame(PublicApiConsumer::BODY, $subject->grabResponse()); + } + + public function testAssertionsAreCallableFromOutsideTheHierarchy(): void + { + $subject = new PublicApiConsumer('restPublicApi'); + $subject->sendGet('/companies'); + + $subject->assertResponseCodeIs(200); + $subject->assertResponseCodeIsNot(404); + $subject->assertResponseCodeIsSuccessful(); + $subject->assertResponseIsJson(); + $subject->assertResponseEquals(PublicApiConsumer::BODY); + $subject->assertResponseContains('Acme'); + $subject->assertResponseNotContains('Nope'); + $subject->assertResponseContainsJson(['data' => [['name' => 'Acme']]]); + $subject->assertResponseNotContainsJson(['data' => [['name' => 'Nope']]]); + $subject->assertResponseMatchesJsonType(['jsonapi' => ['version' => 'string']]); + $subject->assertResponseNotMatchesJsonType(['jsonapi' => ['version' => 'integer']]); + $subject->assertHttpHeader('Content-Type'); + $subject->assertHttpHeader('Content-Type', 'application/json'); + $subject->assertNoHttpHeader('X-Absent'); + $subject->assertNoHttpHeader('Content-Type', 'text/html'); + + $this->assertSame(200, $subject->grabResponseCode()); + } + + /** + * PublicApiConsumer overrides both seams, so it cannot show whether the + * trait's own definitions stay reachable from a child class. This one does + * not override them. + */ + public function testDefaultSeamsStayReachableFromASubclass(): void + { + $subject = new DefaultSeamConsumer('restDefaultSeams'); + + $this->assertSame('http://127.0.0.1:8080', $subject->rawBaseUrl()); + $this->assertNull($subject->rawHttpClient()); + + $subject->useRestBaseUrl('http://injected.test:9000'); + + $this->assertSame('http://injected.test:9000', $subject->rawBaseUrl()); + } + + public function testRangeAssertionsAreCallableFromOutsideTheHierarchy(): void + { + $subject = new PublicApiConsumer('restPublicApi'); + + $subject->respondWith(302); + $subject->sendGet('/x'); + $subject->assertResponseCodeIsRedirection(); + + $subject->respondWith(404); + $subject->sendGet('/x'); + $subject->assertResponseCodeIsClientError(); + + $subject->respondWith(503); + $subject->sendGet('/x'); + $subject->assertResponseCodeIsServerError(); + + $this->assertSame(503, $subject->grabResponseCode()); + } + + public function testSeamsStaySubclassAccessible(): void + { + $subject = new PublicApiConsumer('restPublicApi'); + + $this->assertSame('http://api.test:8080', $subject->rawBaseUrl()); + $this->assertInstanceOf(HttpBrowser::class, $subject->rawBrowser()); + $this->assertInstanceOf(MockHttpClient::class, $subject->rawHttpClient()); + } +} diff --git a/tests/Traits/RestTraitDefaultsTest.php b/tests/Traits/RestTraitDefaultsTest.php new file mode 100644 index 0000000..52d21f4 --- /dev/null +++ b/tests/Traits/RestTraitDefaultsTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Traits; + +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Talon\Settings; +use Phalcon\Talon\Talon; +use Phalcon\Talon\Traits\RestTrait; + +/** + * Covers the seams RestTraitTest overrides: the default (real) HTTP client and + * the base URL resolved from Settings. + */ +final class RestTraitDefaultsTest extends AbstractUnitTestCase +{ + use RestTrait; + + protected function tearDown(): void + { + Talon::reset(); + + parent::tearDown(); + } + + public function testDefaultHttpClientIsNull(): void + { + $this->assertNull($this->restHttpClient()); + } + + public function testRestBaseUrlComesFromSettings(): void + { + Talon::useSettings(Settings::fromArray([ + 'root' => '/app', + 'rest_url' => 'http://from.settings:9000', + ])); + + $this->assertSame('http://from.settings:9000', $this->restBaseUrl()); + } + + public function testRestBaseUrlFallsBackWhenEmpty(): void + { + Talon::useSettings(Settings::fromArray([ + 'root' => '/app', + 'rest_url' => '', + ])); + + $this->assertSame('http://127.0.0.1:8080', $this->restBaseUrl()); + } + + public function testRestBaseUrlFallsBackWhenNotAString(): void + { + Talon::useSettings(Settings::fromArray([ + 'root' => '/app', + 'rest_url' => 123, + ])); + + $this->assertSame('http://127.0.0.1:8080', $this->restBaseUrl()); + } +} diff --git a/tests/Traits/RestTraitTest.php b/tests/Traits/RestTraitTest.php new file mode 100644 index 0000000..bc1c7c3 --- /dev/null +++ b/tests/Traits/RestTraitTest.php @@ -0,0 +1,485 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Traits; + +use Closure; +use Phalcon\Talon\Exceptions\ResponseNotDispatched; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Talon\Traits\RestTrait; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +use function array_shift; +use function base64_encode; +use function is_string; +use function json_decode; +use function stripos; + +final class RestTraitTest extends AbstractUnitTestCase +{ + use RestTrait; + + /** + * Uploads use a fixture rather than __FILE__: a multipart body embeds the + * file's contents, so uploading the test itself makes it "contain" every + * string this file asserts on, and the assertions pass whatever was sent. + */ + private const FIXTURE = __DIR__ . '/../Fakes/Rest/upload.txt'; + + /** + * @var array, body: string}> + */ + private array $requests = []; + + /** @var array */ + private array $responses = []; + + protected function setUp(): void + { + parent::setUp(); + + $this->useRestBaseUrl('http://api.test:8080'); + } + + /** + * @return array + */ + public static function providerBodylessVerbs(): array + { + return [ + ['DELETE'], + ['GET'], + ['HEAD'], + ['OPTIONS'], + ]; + } + + public function testAbsoluteHttpsUrlIsNotPrefixed(): void + { + $this->sendGet('https://secure.test/ping'); + + $this->assertSame('https://secure.test/ping', $this->requests[0]['url']); + } + + public function testAbsoluteUrlIsNotPrefixed(): void + { + $this->sendGet('http://other.test/ping'); + + $this->assertSame('http://other.test/ping', $this->requests[0]['url']); + } + + public function testAmBearerAuthenticatedSetsAuthorizationHeader(): void + { + $this->amBearerAuthenticated('tok123'); + $this->sendGet('/secure'); + + $this->assertContains('authorization: Bearer tok123', $this->requests[0]['headers']); + } + + public function testAmHttpAuthenticatedSetsBasicHeader(): void + { + $this->amHttpAuthenticated('sarah', 'secret'); + $this->sendGet('/secure'); + + $this->assertContains( + 'authorization: Basic ' . base64_encode('sarah:secret'), + $this->requests[0]['headers'] + ); + } + + /** + * A trailing slash on the configured base URL must not produce '//' once + * the path is joined on. + */ + public function testBaseUrlTrailingSlashIsNotDoubled(): void + { + $this->useRestBaseUrl('http://api.test:8080/'); + $this->sendGet('/companies'); + + $this->assertSame('http://api.test:8080/companies', $this->requests[0]['url']); + } + + /** + * GET/HEAD/OPTIONS/DELETE put their params in the query string; every other + * verb puts them in the body. Asserting the query proves each verb is in + * the bodyless set - a body-carrying verb would leave the URL bare. + * + * @dataProvider providerBodylessVerbs + */ + public function testBodylessVerbsSendParamsAsQuery(string $method): void + { + $this->send($method, '/companies', ['page' => 2]); + + $this->assertSame($method, $this->requests[0]['method']); + $this->assertSame('http://api.test:8080/companies?page=2', $this->requests[0]['url']); + $this->assertSame('', $this->requests[0]['body']); + } + + /** + * A caller-set server parameter must survive header changes - rebuilding the + * whole server bag used to wipe it. + */ + public function testCallerServerParametersSurviveHeaderChanges(): void + { + $this->restBrowser()->setServerParameter('HTTP_X_CUSTOM', 'kept'); + $this->haveHttpHeader('X-Token', 'abc'); + $this->sendGet('/one'); + $this->unsetHttpHeader('X-Token'); + $this->sendGet('/two'); + + $this->assertContains('x-custom: kept', $this->requests[0]['headers']); + $this->assertContains('x-custom: kept', $this->requests[1]['headers']); + } + + public function testContentTypeMatchIsCaseInsensitive(): void + { + $this->haveHttpHeader('Content-Type', 'APPLICATION/JSON'); + $this->sendPost('/login', ['username' => 'sarah']); + + $this->assertSame( + ['username' => 'sarah'], + json_decode($this->requests[0]['body'], true) + ); + } + + public function testDoesNotFollowRedirectsByDefault(): void + { + $this->responses = [ + new MockResponse('', [ + 'http_code' => 302, + 'response_headers' => ['Location' => 'http://api.test:8080/target'], + ]), + ]; + + $this->sendGet('/start'); + + $this->assertCount(1, $this->requests); + $this->assertSame(302, $this->grabResponseCode()); + } + + public function testGrabHttpHeader(): void + { + $this->sendGet('/companies'); + + $this->assertSame('application/json', $this->grabHttpHeader('Content-Type')); + $this->assertNull($this->grabHttpHeader('X-Absent')); + } + + public function testGrabResponseAndCode(): void + { + $this->sendGet('/companies'); + + $this->assertSame('{"data":{"id":1}}', $this->grabResponse()); + $this->assertSame(201, $this->grabResponseCode()); + } + + public function testGrabResponseBeforeSendingThrows(): void + { + $this->expectException(ResponseNotDispatched::class); + + $this->grabResponse(); + } + + public function testHeaderPersistsAcrossRequests(): void + { + $this->haveHttpHeader('X-Token', 'abc'); + $this->sendGet('/one'); + $this->sendGet('/two'); + + $this->assertContains('x-token: abc', $this->requests[0]['headers']); + $this->assertContains('x-token: abc', $this->requests[1]['headers']); + } + + /** + * A header set once the browser already exists must still reach the wire - + * restBrowser() only syncs on construction, so haveHttpHeader() has to push + * it itself. + */ + public function testHeaderSetAfterTheFirstRequestApplies(): void + { + $this->sendGet('/one'); + $this->haveHttpHeader('X-Late', 'v'); + $this->sendGet('/two'); + + $this->assertNotContains('x-late: v', $this->requests[0]['headers']); + $this->assertContains('x-late: v', $this->requests[1]['headers']); + } + + public function testQueryAppendedWithAmpersandWhenUrlHasQuery(): void + { + $this->sendGet('/companies?filter=x', ['page' => 2]); + + $this->assertSame('http://api.test:8080/companies?filter=x&page=2', $this->requests[0]['url']); + } + + public function testRawStringBodyKeepsACallerSuppliedContentType(): void + { + $this->haveHttpHeader('Content-Type', 'application/xml'); + $this->sendPost('/login', ''); + + $this->assertContains('content-type: application/xml', $this->requests[0]['headers']); + $this->assertNotContains('content-type: application/json', $this->requests[0]['headers']); + } + + public function testSendGetAppendsQueryParameters(): void + { + $this->sendGet('/companies', ['page' => 2]); + + $this->assertSame('http://api.test:8080/companies?page=2', $this->requests[0]['url']); + } + + public function testSendGetBuildsAbsoluteUrlFromBase(): void + { + $this->sendGet('/companies'); + + $this->assertSame('GET', $this->requests[0]['method']); + $this->assertSame('http://api.test:8080/companies', $this->requests[0]['url']); + } + + /** + * HttpBrowser would otherwise wrap an unlabelled string body in a TextPart + * and send it as text/plain, which a JSON API rejects. + */ + public function testSendPostAcceptsRawStringBodyAndNamesItJson(): void + { + $this->sendPost('/login', '{"raw":true}'); + + $this->assertSame('{"raw":true}', $this->requests[0]['body']); + $this->assertContains('content-type: application/json', $this->requests[0]['headers']); + } + + public function testSendPostSendsFormParametersByDefault(): void + { + $this->sendPost('/login', ['username' => 'sarah']); + + $this->assertSame('POST', $this->requests[0]['method']); + // Asserted as urlencoded rather than by field name: 'username' and + // 'sarah' both appear in a JSON body too, so a looser check cannot tell + // the form path from the JSON one. + $this->assertSame('username=sarah', $this->requests[0]['body']); + } + + public function testSendPostSerializesJsonWhenContentTypeIsJson(): void + { + $this->haveHttpHeader('Content-Type', 'application/json'); + $this->sendPost('/login', ['username' => 'sarah']); + + $this->assertSame( + ['username' => 'sarah'], + json_decode($this->requests[0]['body'], true) + ); + } + + /** + * A plain path is what a caller reaches for first, and BrowserKit's own + * response to one is to send no files at all, silently - so both shapes + * have to work. + */ + public function testSendPostUploadsAFileGivenAPlainPath(): void + { + $this->sendPost('/upload', ['name' => 'Acme'], ['doc' => self::FIXTURE]); + + $this->assertSame('POST', $this->requests[0]['method']); + $this->assertStringContainsString('multipart/form-data', $this->contentTypeOf(0)); + $this->assertStringContainsString('filename="upload.txt"', $this->requests[0]['body']); + $this->assertStringContainsString('talon upload fixture', $this->requests[0]['body']); + $this->assertStringContainsString('Acme', $this->requests[0]['body']); + } + + public function testSendPostUploadsAFileGivenTheFilesShape(): void + { + $this->sendPost('/upload', [], ['doc' => ['tmp_name' => self::FIXTURE, 'name' => 'custom.txt']]); + + $this->assertStringContainsString('multipart/form-data', $this->contentTypeOf(0)); + $this->assertStringContainsString('filename="custom.txt"', $this->requests[0]['body']); + } + + /** + * More than one file, because a single-file test cannot notice a + * normalisation that quietly keeps only the first. + */ + public function testSendPostUploadsSeveralFiles(): void + { + $this->sendPost('/upload', [], [ + 'first' => self::FIXTURE, + 'second' => ['tmp_name' => self::FIXTURE, 'name' => 'second.txt'], + ]); + + $this->assertStringContainsString('filename="upload.txt"', $this->requests[0]['body']); + $this->assertStringContainsString('filename="second.txt"', $this->requests[0]['body']); + } + + public function testSendUppercasesTheMethod(): void + { + $this->send('get', '/companies'); + + $this->assertSame('GET', $this->requests[0]['method']); + } + + public function testStartFollowingRedirectsFollowsLocation(): void + { + $this->responses = [ + new MockResponse('', [ + 'http_code' => 302, + 'response_headers' => ['Location' => 'http://api.test:8080/target'], + ]), + new MockResponse('done', ['http_code' => 200]), + ]; + + $this->startFollowingRedirects(); + $this->sendGet('/start'); + + $this->assertCount(2, $this->requests); + $this->assertSame('http://api.test:8080/target', $this->requests[1]['url']); + $this->assertSame('done', $this->grabResponse()); + } + + /** + * stopFollowingRedirects() must actually turn following off, not merely + * agree with the constructor default. + */ + public function testStopFollowingRedirectsAfterStartingDoesNotFollow(): void + { + $this->responses = [ + new MockResponse('', [ + 'http_code' => 302, + 'response_headers' => ['Location' => 'http://api.test:8080/target'], + ]), + ]; + + $this->startFollowingRedirects(); + $this->stopFollowingRedirects(); + $this->sendGet('/start'); + + $this->assertCount(1, $this->requests); + $this->assertSame(302, $this->grabResponseCode()); + } + + public function testStopFollowingRedirectsDoesNotFollow(): void + { + $this->responses = [ + new MockResponse('', [ + 'http_code' => 302, + 'response_headers' => ['Location' => 'http://api.test:8080/target'], + ]), + ]; + + $this->stopFollowingRedirects(); + $this->sendGet('/start'); + + $this->assertCount(1, $this->requests); + $this->assertSame(302, $this->grabResponseCode()); + } + + public function testUnsetHttpHeaderRemovesIt(): void + { + $this->haveHttpHeader('X-Token', 'abc'); + $this->sendGet('/one'); + $this->unsetHttpHeader('X-Token'); + $this->sendGet('/two'); + + $this->assertContains('x-token: abc', $this->requests[0]['headers']); + $this->assertNotContains('x-token: abc', $this->requests[1]['headers']); + } + + public function testUseRestBaseUrlOverridesSettings(): void + { + $this->useRestBaseUrl('http://other.test:9000'); + $this->sendGet('/companies'); + + $this->assertSame('http://other.test:9000/companies', $this->requests[0]['url']); + } + + public function testVerbsDispatchTheRightMethod(): void + { + $this->sendPut('/a'); + $this->sendPatch('/b'); + $this->sendDelete('/c'); + $this->sendHead('/d'); + $this->sendOptions('/e'); + + $this->assertSame('PUT', $this->requests[0]['method']); + $this->assertSame('PATCH', $this->requests[1]['method']); + $this->assertSame('DELETE', $this->requests[2]['method']); + $this->assertSame('HEAD', $this->requests[3]['method']); + $this->assertSame('OPTIONS', $this->requests[4]['method']); + } + + protected function restHttpClient(): HttpClientInterface + { + return new MockHttpClient( + /** + * @param array $options + */ + function (string $method, string $url, array $options): MockResponse { + /** @var array $headers */ + $headers = $options['headers'] ?? []; + + $this->requests[] = [ + 'method' => $method, + 'url' => $url, + 'headers' => $headers, + 'body' => $this->readBody($options['body'] ?? ''), + ]; + + return array_shift($this->responses) ?? new MockResponse( + '{"data":{"id":1}}', + [ + 'http_code' => 201, + 'response_headers' => ['Content-Type' => 'application/json'], + ] + ); + } + ); + } + + private function contentTypeOf(int $index): string + { + foreach ($this->requests[$index]['headers'] as $header) { + if (stripos($header, 'content-type:') === 0) { + return $header; + } + } + + return ''; + } + + /** + * A multipart body arrives as the Closure Symfony normalizes an iterable + * into, not a string; drain it so tests can assert on what was sent. + */ + private function readBody(mixed $body): string + { + if (is_string($body)) { + return $body; + } + + if (!$body instanceof Closure) { + return ''; + } + + $content = ''; + while (true) { + $chunk = $body(16372); + if (!is_string($chunk) || '' === $chunk) { + break; + } + + $content .= $chunk; + } + + return $content; + } +} diff --git a/tests/Traits/ResultSetTraitTest.php b/tests/Traits/ResultSetTraitTest.php index 516904a..44cc22d 100644 --- a/tests/Traits/ResultSetTraitTest.php +++ b/tests/Traits/ResultSetTraitTest.php @@ -24,6 +24,25 @@ final class ResultSetTraitTest extends TestCase { use ResultSetTrait; + public function testAcceptsResultsetSubclass(): void + { + $this->assertInstanceOf(Simple::class, $this->mockResultSet([], Simple::class)); + } + + public function testEmptyMock(): void + { + $mock = $this->mockResultSet([]); + + $this->assertCount(0, $mock); + $this->assertNull($mock->getFirst()); + } + + public function testInvalidClassThrows(): void + { + $this->expectException(InvalidResultsetClass::class); + $this->mockResultSet([], self::class); + } + public function testMockReportsCountAndFirstLast(): void { $first = $this->createMock(ModelInterface::class); @@ -38,12 +57,15 @@ public function testMockReportsCountAndFirstLast(): void $this->assertSame([$first, $middle, $last], $mock->toArray()); } - public function testEmptyMock(): void + public function testMockResultSetIsPublic(): void { - $mock = $this->mockResultSet([]); + // The call covers the method body so infection pairs this test + // with the visibility mutant; the reflection check observes it. + $this->mockResultSet([]); - $this->assertCount(0, $mock); - $this->assertNull($mock->getFirst()); + $this->assertTrue( + (new ReflectionMethod(self::class, 'mockResultSet'))->isPublic() + ); } public function testMockSupportsIteration(): void @@ -62,23 +84,6 @@ public function testMockSupportsIteration(): void $this->assertFalse($mock->valid()); } - public function testInvalidClassThrows(): void - { - $this->expectException(InvalidResultsetClass::class); - $this->mockResultSet([], self::class); - } - - public function testMockResultSetIsPublic(): void - { - // The call covers the method body so infection pairs this test - // with the visibility mutant; the reflection check observes it. - $this->mockResultSet([]); - - $this->assertTrue( - (new ReflectionMethod(self::class, 'mockResultSet'))->isPublic() - ); - } - public function testSeekReadsTheInjectedRows(): void { $first = $this->createMock(ModelInterface::class); @@ -91,9 +96,4 @@ public function testSeekReadsTheInjectedRows(): void $this->addToAssertionCount(1); } - - public function testAcceptsResultsetSubclass(): void - { - $this->assertInstanceOf(Simple::class, $this->mockResultSet([], Simple::class)); - } } diff --git a/tests/Traits/ServicesTraitTest.php b/tests/Traits/ServicesTraitTest.php index 4c73ea1..c9c5e5a 100644 --- a/tests/Traits/ServicesTraitTest.php +++ b/tests/Traits/ServicesTraitTest.php @@ -35,13 +35,18 @@ protected function tearDown(): void parent::tearDown(); } - public function testRedisRoundTrip(): void + public function testDoesNotHaveKeys(): void { - $key = 'talon:test:' . uniqid(); - $this->setRedisKey($key, 'value'); + $this->assertTrue($this->doesNotHaveRedisKey('talon:absent:' . uniqid())); + $this->assertTrue($this->doesNotHaveMemcachedKey('talon_absent_' . uniqid())); + } - $this->assertTrue($this->hasRedisKey($key)); - $this->assertSame('value', $this->getRedisKey($key)); + public function testHasMemcachedKeyWithStoredFalse(): void + { + $key = 'talon_false_' . uniqid(); + $this->setMemcachedKey($key, false); + + $this->assertTrue($this->hasMemcachedKey($key)); } public function testMemcachedRoundTrip(): void @@ -53,18 +58,13 @@ public function testMemcachedRoundTrip(): void $this->assertSame('value', $this->getMemcachedKey($key)); } - public function testHasMemcachedKeyWithStoredFalse(): void + public function testRedisRoundTrip(): void { - $key = 'talon_false_' . uniqid(); - $this->setMemcachedKey($key, false); - - $this->assertTrue($this->hasMemcachedKey($key)); - } + $key = 'talon:test:' . uniqid(); + $this->setRedisKey($key, 'value'); - public function testDoesNotHaveKeys(): void - { - $this->assertTrue($this->doesNotHaveRedisKey('talon:absent:' . uniqid())); - $this->assertTrue($this->doesNotHaveMemcachedKey('talon_absent_' . uniqid())); + $this->assertTrue($this->hasRedisKey($key)); + $this->assertSame('value', $this->getRedisKey($key)); } public function testSendRedisCommand(): void diff --git a/tests/Unit/Bootstrap/RunnerTest.php b/tests/Unit/Bootstrap/RunnerTest.php index d1e5c64..6bd03b3 100644 --- a/tests/Unit/Bootstrap/RunnerTest.php +++ b/tests/Unit/Bootstrap/RunnerTest.php @@ -51,41 +51,6 @@ protected function tearDown(): void parent::tearDown(); } - public function testRunsStagesInOrderWithHooks(): void - { - /** @var ArrayObject $order */ - $order = new ArrayObject(); - $settings = Settings::fromArray(['root' => '/app']); - - $runner = new RecordingRunner($settings, $order); - - $runner - ->before(Stage::Environment, function () use ($order): void { - $order->append('before-env'); - }) - ->after(Stage::Settings, function () use ($order): void { - $order->append('after-settings'); - }); - - $result = $runner->boot(); - - $this->assertSame($settings, $result); - $this->assertSame( - ['before-env', 'env', 'dirs', 'settings', 'after-settings'], - $order->getArrayCopy() - ); - } - - public function testRealRunnerRegistersSettingsIntoTalon(): void - { - Talon::reset(); - $settings = Settings::fromArray(['root' => dirname(__DIR__, 3)]); - - Runner::for($settings)->boot(); - - $this->assertSame($settings, Talon::settings()); - } - public function testBootCreatesTheOutputDirectory(): void { Talon::reset(); @@ -247,4 +212,39 @@ public function testInitEnvironmentTunesXdebugIniWhenLoaded(): void $this->assertSame('4', ini_get('xdebug.var_display_max_depth')); } } + + public function testRealRunnerRegistersSettingsIntoTalon(): void + { + Talon::reset(); + $settings = Settings::fromArray(['root' => dirname(__DIR__, 3)]); + + Runner::for($settings)->boot(); + + $this->assertSame($settings, Talon::settings()); + } + + public function testRunsStagesInOrderWithHooks(): void + { + /** @var ArrayObject $order */ + $order = new ArrayObject(); + $settings = Settings::fromArray(['root' => '/app']); + + $runner = new RecordingRunner($settings, $order); + + $runner + ->before(Stage::Environment, function () use ($order): void { + $order->append('before-env'); + }) + ->after(Stage::Settings, function () use ($order): void { + $order->append('after-settings'); + }); + + $result = $runner->boot(); + + $this->assertSame($settings, $result); + $this->assertSame( + ['before-env', 'env', 'dirs', 'settings', 'after-settings'], + $order->getArrayCopy() + ); + } } diff --git a/tests/Unit/Browser/ClientTest.php b/tests/Unit/Browser/ClientTest.php index d9e1d4f..07a5dd5 100644 --- a/tests/Unit/Browser/ClientTest.php +++ b/tests/Unit/Browser/ClientTest.php @@ -26,38 +26,20 @@ final class ClientTest extends TestCase { - public function testGetRendersContent(): void - { - $client = $this->client(); - $crawler = $client->request('GET', 'http://localhost/browser/form'); - - $this->assertStringContainsString('html()); - } - - public function testPostEchoesParameters(): void - { - $client = $this->client(); - $crawler = $client->request('POST', 'http://localhost/browser/echo', ['q' => 'hi']); - - $this->assertStringContainsString('post:hi', $crawler->text()); - } - - public function testRedirectIsFollowed(): void + public function testAppWithoutGetDiSkipsCookieExtraction(): void { - $client = $this->client(); - $crawler = $client->request('GET', 'http://localhost/browser/bounce'); + $client = new Client(static fn () => new FakeAppWithoutGetDi()); + $client->request('GET', 'http://localhost/'); - $this->assertStringContainsString('landed ok', $crawler->text()); + $this->assertSame([], $client->getCookieJar()->all()); } - public function testSetCookieHeaderLandsInTheJar(): void + public function testBareDiWithoutCookiesServiceSkipsCookieExtraction(): void { - $client = $this->client(); - $client->request('GET', 'http://localhost/browser/cookie'); + $client = new Client(static fn () => new FakeAppWithBareDi()); + $client->request('GET', 'http://localhost/'); - $cookie = $client->getCookieJar()->get('baked'); - $this->assertNotNull($cookie); - $this->assertSame('yummy', $cookie->getValue()); + $this->assertSame([], $client->getCookieJar()->all()); } public function testCookiesServiceCookiesLandInTheJar(): void @@ -70,14 +52,29 @@ public function testCookiesServiceCookiesLandInTheJar(): void $this->assertSame('value', $cookie->getValue()); } - public function testRedirectLoopRaisesInsteadOfRecursing(): void + public function testCookieVariantsSurviveExtraction(): void { - $client = $this->client(); + $client = new Client(static fn () => new FakeAppWithMalformedCookies()); + $client->request('GET', 'http://localhost/'); - $this->expectException(LogicException::class); - $this->expectExceptionMessage('maximum number'); + $jar = $client->getCookieJar(); - $client->request('GET', 'http://localhost/browser/loop'); + // Kills continue->break on the malformed-cookie guards and the + // rawurlencode cast: the int-valued cookie sits after them. + $answer = $jar->get('answer'); + $this->assertNotNull($answer); + $this->assertSame('42', $answer->getValue()); + + // A path-scoped cookie is bucketed under its own path, not '/'. + $this->assertNull($jar->get('scoped')); + $scoped = $jar->get('scoped', '/sub'); + $this->assertNotNull($scoped); + $this->assertSame('v', $scoped->getValue()); + + // Expiration 0 must produce a session cookie without an Expires attribute. + $sess = $jar->get('sess'); + $this->assertNotNull($sess); + $this->assertNull($sess->getExpiresTime()); } public function testFactoryWithoutHandleThrows(): void @@ -88,21 +85,26 @@ public function testFactoryWithoutHandleThrows(): void $client->request('GET', 'http://localhost/'); } - - public function testAppWithoutGetDiSkipsCookieExtraction(): void + public function testGetRendersContent(): void { - $client = new Client(static fn () => new FakeAppWithoutGetDi()); - $client->request('GET', 'http://localhost/'); + $client = $this->client(); + $crawler = $client->request('GET', 'http://localhost/browser/form'); - $this->assertSame([], $client->getCookieJar()->all()); + $this->assertStringContainsString('html()); } - public function testBareDiWithoutCookiesServiceSkipsCookieExtraction(): void + public function testMalformedCookiesAreSkipped(): void { - $client = new Client(static fn () => new FakeAppWithBareDi()); + $client = new Client(static fn () => new FakeAppWithMalformedCookies()); $client->request('GET', 'http://localhost/'); - $this->assertSame([], $client->getCookieJar()->all()); + $this->assertNull($client->getCookieJar()->get('malformed')); + $this->assertNull($client->getCookieJar()->get('nonScalar')); + } + + public function testMaxRedirectsIsCapped(): void + { + $this->assertSame(20, $this->client()->getMaxRedirects()); } public function testNonCookiesServiceSkipsCookieExtraction(): void @@ -113,60 +115,47 @@ public function testNonCookiesServiceSkipsCookieExtraction(): void $this->assertSame([], $client->getCookieJar()->all()); } - public function testMalformedCookiesAreSkipped(): void + public function testNonDiContainerSkipsCookieExtraction(): void { - $client = new Client(static fn () => new FakeAppWithMalformedCookies()); + $client = new Client(static fn () => new FakeAppWithNonDiContainer()); $client->request('GET', 'http://localhost/'); - $this->assertNull($client->getCookieJar()->get('malformed')); - $this->assertNull($client->getCookieJar()->get('nonScalar')); + $this->assertSame([], $client->getCookieJar()->all()); } - public function testCookieVariantsSurviveExtraction(): void + public function testPostEchoesParameters(): void { - $client = new Client(static fn () => new FakeAppWithMalformedCookies()); - $client->request('GET', 'http://localhost/'); - - $jar = $client->getCookieJar(); - - // Kills continue->break on the malformed-cookie guards and the - // rawurlencode cast: the int-valued cookie sits after them. - $answer = $jar->get('answer'); - $this->assertNotNull($answer); - $this->assertSame('42', $answer->getValue()); - - // A path-scoped cookie is bucketed under its own path, not '/'. - $this->assertNull($jar->get('scoped')); - $scoped = $jar->get('scoped', '/sub'); - $this->assertNotNull($scoped); - $this->assertSame('v', $scoped->getValue()); + $client = $this->client(); + $crawler = $client->request('POST', 'http://localhost/browser/echo', ['q' => 'hi']); - // Expiration 0 must produce a session cookie without an Expires attribute. - $sess = $jar->get('sess'); - $this->assertNotNull($sess); - $this->assertNull($sess->getExpiresTime()); + $this->assertStringContainsString('post:hi', $crawler->text()); } - public function testMaxRedirectsIsCapped(): void + public function testQueryStringReachesTheApp(): void { - $this->assertSame(20, $this->client()->getMaxRedirects()); + $client = $this->client(); + $crawler = $client->request('GET', 'http://localhost/browser/query?q=needle'); + + $this->assertStringContainsString('uri=/browser/query?q=needle|', $crawler->text()); + $this->assertStringContainsString('|got=needle', $crawler->text()); } - public function testNonDiContainerSkipsCookieExtraction(): void + public function testRedirectIsFollowed(): void { - $client = new Client(static fn () => new FakeAppWithNonDiContainer()); - $client->request('GET', 'http://localhost/'); + $client = $this->client(); + $crawler = $client->request('GET', 'http://localhost/browser/bounce'); - $this->assertSame([], $client->getCookieJar()->all()); + $this->assertStringContainsString('landed ok', $crawler->text()); } - public function testQueryStringReachesTheApp(): void + public function testRedirectLoopRaisesInsteadOfRecursing(): void { - $client = $this->client(); - $crawler = $client->request('GET', 'http://localhost/browser/query?q=needle'); + $client = $this->client(); - $this->assertStringContainsString('uri=/browser/query?q=needle|', $crawler->text()); - $this->assertStringContainsString('|got=needle', $crawler->text()); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('maximum number'); + + $client->request('GET', 'http://localhost/browser/loop'); } public function testRequestWithoutAPathDispatchesTheEmptyPath(): void @@ -187,6 +176,16 @@ public function testResponseDefaultsAreApplied(): void $this->assertSame('text/html; charset=UTF-8', $response->getHeader('Content-Type')); } + public function testSetCookieHeaderLandsInTheJar(): void + { + $client = $this->client(); + $client->request('GET', 'http://localhost/browser/cookie'); + + $cookie = $client->getCookieJar()->get('baked'); + $this->assertNotNull($cookie); + $this->assertSame('yummy', $cookie->getValue()); + } + public function testSetCookieHeaderUsesGmtSuffixedExpires(): void { $client = $this->client(); diff --git a/tests/Unit/Cli/InputTest.php b/tests/Unit/Cli/InputTest.php index 52d190f..272ef5b 100644 --- a/tests/Unit/Cli/InputTest.php +++ b/tests/Unit/Cli/InputTest.php @@ -18,6 +18,13 @@ final class InputTest extends TestCase { + public function testAllowlistedOptionsCombine(): void + { + $input = Input::fromArgv(['talon', '-h', '--version']); + + $this->assertTrue($input->wantsHelp()); + $this->assertTrue($input->wantsVersion()); + } public function testCommandAndArguments(): void { $input = Input::fromArgv(['talon', 'run', 'mysql', 'pgsql']); @@ -29,14 +36,6 @@ public function testCommandAndArguments(): void $this->assertFalse($input->wantsVersion()); } - public function testAllowlistedOptionsCombine(): void - { - $input = Input::fromArgv(['talon', '-h', '--version']); - - $this->assertTrue($input->wantsHelp()); - $this->assertTrue($input->wantsVersion()); - } - public function testDashDashProtectsAllowlistedTokens(): void { // After '--' even talon's own flags forward verbatim. diff --git a/tests/Unit/Cli/SuiteMapTest.php b/tests/Unit/Cli/SuiteMapTest.php index 807ffc0..98439a5 100644 --- a/tests/Unit/Cli/SuiteMapTest.php +++ b/tests/Unit/Cli/SuiteMapTest.php @@ -23,33 +23,6 @@ final class SuiteMapTest extends TestCase { - public function testConfiguredProjectMergesGlobalsIntoSuites(): void - { - $map = new SuiteMap($this->fixture('configured')); - - $unit = $map->resolve('unit'); - $this->assertSame($this->fixture('configured') . '/custom/unit.xml', $unit->config); - $this->assertSame(['extension=fake.so'], $unit->phpFlags); - $this->assertSame(['GLOBAL_ENV' => 'yes', 'SHARED' => 'global'], $unit->env); - $this->assertSame(['--testdox'], $unit->args); - - $db = $map->resolve('db'); - $this->assertSame(['extension=fake.so', 'memory_limit=1G'], $db->phpFlags); - $this->assertSame( - ['GLOBAL_ENV' => 'yes', 'SHARED' => 'suite', 'DB_ONLY' => '1'], - $db->env - ); - $this->assertSame('db', $map->defaultSuite()); - } - - public function testConfigExistenceIsReported(): void - { - $map = new SuiteMap($this->fixture('configured')); - - $this->assertTrue($map->resolve('unit')->configExists()); - $this->assertFalse($map->resolve('db')->configExists()); - } - public function testCastsScalarConfigValuesToStrings(): void { $map = new SuiteMap($this->fixture('casts')); @@ -69,6 +42,32 @@ public function testClashSkipsTheDuplicateButKeepsLaterDiscoveries(): void $this->assertSame(['custom', 'zz'], array_keys($map->suites())); } + public function testConfigExistenceIsReported(): void + { + $map = new SuiteMap($this->fixture('configured')); + + $this->assertTrue($map->resolve('unit')->configExists()); + $this->assertFalse($map->resolve('db')->configExists()); + } + public function testConfiguredProjectMergesGlobalsIntoSuites(): void + { + $map = new SuiteMap($this->fixture('configured')); + + $unit = $map->resolve('unit'); + $this->assertSame($this->fixture('configured') . '/custom/unit.xml', $unit->config); + $this->assertSame(['extension=fake.so'], $unit->phpFlags); + $this->assertSame(['GLOBAL_ENV' => 'yes', 'SHARED' => 'global'], $unit->env); + $this->assertSame(['--testdox'], $unit->args); + + $db = $map->resolve('db'); + $this->assertSame(['extension=fake.so', 'memory_limit=1G'], $db->phpFlags); + $this->assertSame( + ['GLOBAL_ENV' => 'yes', 'SHARED' => 'suite', 'DB_ONLY' => '1'], + $db->env + ); + $this->assertSame('db', $map->defaultSuite()); + } + public function testConventionalProjectDiscoversSuites(): void { $map = new SuiteMap($this->fixture('conventional')); diff --git a/tests/Unit/Database/ConnectionTest.php b/tests/Unit/Database/ConnectionTest.php index aecc8ab..e733922 100644 --- a/tests/Unit/Database/ConnectionTest.php +++ b/tests/Unit/Database/ConnectionTest.php @@ -32,6 +32,20 @@ public function testExecuteAndSelect(): void $this->assertSame('john.connor@skynet.dev', $rows[0]['email']); } + public function testInitialQueriesRunAfterConnect(): void + { + $settings = Settings::fromArray([ + 'root' => '/app', + 'db' => ['sqlite' => ['dbname' => ':memory:']], + 'initial_queries' => 'CREATE TABLE seeded (id INTEGER);', + ]); + + $conn = new Connection($settings, 'sqlite'); + $conn->execute('INSERT INTO seeded VALUES (1)'); + + $this->assertCount(1, $conn->select('seeded')); + } + public function testLoadSchemaFromFile(): void { $conn = $this->sqlite(); @@ -95,20 +109,6 @@ public function testSelectReturnsAllMatchingRows(): void $this->assertCount(2, $conn->select('users', ['email' => 'a@b.c'])); } - public function testInitialQueriesRunAfterConnect(): void - { - $settings = Settings::fromArray([ - 'root' => '/app', - 'db' => ['sqlite' => ['dbname' => ':memory:']], - 'initial_queries' => 'CREATE TABLE seeded (id INTEGER);', - ]); - - $conn = new Connection($settings, 'sqlite'); - $conn->execute('INSERT INTO seeded VALUES (1)'); - - $this->assertCount(1, $conn->select('seeded')); - } - public function testSqliteWalPragmaApplied(): void { // WAL mode requires a real file - sqlite silently falls back to diff --git a/tests/Unit/Database/StatementSplitterTest.php b/tests/Unit/Database/StatementSplitterTest.php index 9f5b1e2..cd32684 100644 --- a/tests/Unit/Database/StatementSplitterTest.php +++ b/tests/Unit/Database/StatementSplitterTest.php @@ -18,31 +18,16 @@ final class StatementSplitterTest extends TestCase { - public function testSplitsOnSemicolonAndSkipsComments(): void + public function testBalancedDollarQuotedStatementEndsAtDelimiter(): void { - $sql = "-- a comment\nCREATE TABLE a(id int);\n# another\nINSERT INTO a VALUES (1);\n"; + $sql = "CREATE FUNCTION f() AS \$\$\nBEGIN RETURN 1; END;\n\$\$;\nSELECT 2;\n"; $this->assertSame( - ['CREATE TABLE a(id int)', 'INSERT INTO a VALUES (1)'], + ["CREATE FUNCTION f() AS \$\$\nBEGIN RETURN 1; END;\n\$\$", 'SELECT 2'], StatementSplitter::split($sql) ); } - public function testKeepsDollarQuotedBlockTogether(): void - { - $sql = "CREATE FUNCTION f() RETURNS int AS \$\$\nBEGIN\nRETURN 1;\nEND;\n\$\$ LANGUAGE plpgsql;\n"; - - $result = StatementSplitter::split($sql); - - $this->assertCount(1, $result); - $this->assertStringContainsString('RETURN 1;', $result[0]); - } - - public function testTrailingStatementWithoutDelimiterIsReturned(): void - { - $this->assertSame(['SELECT 1'], StatementSplitter::split('SELECT 1')); - } - public function testHandlesDelimiterChange(): void { $sql = "DELIMITER ;;\nCREATE A;;\nDELIMITER ;\nSELECT 1;\n"; @@ -50,16 +35,6 @@ public function testHandlesDelimiterChange(): void $this->assertSame(['CREATE A', 'SELECT 1'], StatementSplitter::split($sql)); } - public function testBalancedDollarQuotedStatementEndsAtDelimiter(): void - { - $sql = "CREATE FUNCTION f() AS \$\$\nBEGIN RETURN 1; END;\n\$\$;\nSELECT 2;\n"; - - $this->assertSame( - ["CREATE FUNCTION f() AS \$\$\nBEGIN RETURN 1; END;\n\$\$", 'SELECT 2'], - StatementSplitter::split($sql) - ); - } - public function testHyphenAsSecondCharacterIsNotAComment(): void { $this->assertSame(['D-1'], StatementSplitter::split("D-1;\n")); @@ -72,6 +47,16 @@ public function testIndentedCommentLinesAreSkipped(): void $this->assertSame(['SELECT 1'], StatementSplitter::split($sql)); } + public function testKeepsDollarQuotedBlockTogether(): void + { + $sql = "CREATE FUNCTION f() RETURNS int AS \$\$\nBEGIN\nRETURN 1;\nEND;\n\$\$ LANGUAGE plpgsql;\n"; + + $result = StatementSplitter::split($sql); + + $this->assertCount(1, $result); + $this->assertStringContainsString('RETURN 1;', $result[0]); + } + public function testLowercaseDelimiterDirectiveIsHonored(): void { $sql = "delimiter ;;\nCREATE A;;\ndelimiter ;\nSELECT 1;\n"; @@ -83,4 +68,18 @@ public function testMultiLineStatementKeepsLineBreaks(): void { $this->assertSame(["SELECT\n1"], StatementSplitter::split("SELECT\n1;\n")); } + public function testSplitsOnSemicolonAndSkipsComments(): void + { + $sql = "-- a comment\nCREATE TABLE a(id int);\n# another\nINSERT INTO a VALUES (1);\n"; + + $this->assertSame( + ['CREATE TABLE a(id int)', 'INSERT INTO a VALUES (1)'], + StatementSplitter::split($sql) + ); + } + + public function testTrailingStatementWithoutDelimiterIsReturned(): void + { + $this->assertSame(['SELECT 1'], StatementSplitter::split('SELECT 1')); + } } diff --git a/tests/Unit/EnvironmentTest.php b/tests/Unit/EnvironmentTest.php index f3c0379..0368fc9 100644 --- a/tests/Unit/EnvironmentTest.php +++ b/tests/Unit/EnvironmentTest.php @@ -18,17 +18,16 @@ final class EnvironmentTest extends TestCase { - public function testPhalconIsAvailableInTheTestImage(): void - { - $this->assertTrue(Environment::phalconAvailable()); - } - public function testExactlyOneProviderReportsTrue(): void { $this->assertTrue( Environment::viaExtension() || Environment::viaImplementation() ); } + public function testPhalconIsAvailableInTheTestImage(): void + { + $this->assertTrue(Environment::phalconAvailable()); + } public function testProvidersAreMutuallyExclusive(): void { diff --git a/tests/Unit/Http/HttpCodeTest.php b/tests/Unit/Http/HttpCodeTest.php new file mode 100644 index 0000000..728f8b3 --- /dev/null +++ b/tests/Unit/Http/HttpCodeTest.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Unit\Http; + +use Phalcon\Talon\Http\HttpCode; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; + +use function constant; + +final class HttpCodeTest extends AbstractUnitTestCase +{ + /** + * @return array + */ + public static function providerConstants(): array + { + return [ + ['ACCEPTED', 202], + ['BAD_GATEWAY', 502], + ['BAD_REQUEST', 400], + ['CONFLICT', 409], + ['CREATED', 201], + ['FORBIDDEN', 403], + ['FOUND', 302], + ['GATEWAY_TIMEOUT', 504], + ['GONE', 410], + ['INTERNAL_SERVER_ERROR', 500], + ['METHOD_NOT_ALLOWED', 405], + ['MOVED_PERMANENTLY', 301], + ['NOT_ACCEPTABLE', 406], + ['NOT_FOUND', 404], + ['NOT_IMPLEMENTED', 501], + ['NOT_MODIFIED', 304], + ['NO_CONTENT', 204], + ['OK', 200], + ['PERMANENT_REDIRECT', 308], + ['SEE_OTHER', 303], + ['SERVICE_UNAVAILABLE', 503], + ['TEMPORARY_REDIRECT', 307], + ['TOO_MANY_REQUESTS', 429], + ['UNAUTHORIZED', 401], + ['UNPROCESSABLE_ENTITY', 422], + ['UNSUPPORTED_MEDIA_TYPE', 415], + ]; + } + + /** + * @return array + */ + public static function providerDescriptions(): array + { + return [ + [200, '200 (OK)'], + [201, '201 (Created)'], + [202, '202 (Accepted)'], + [204, '204 (No Content)'], + [301, '301 (Moved Permanently)'], + [302, '302 (Found)'], + [303, '303 (See Other)'], + [304, '304 (Not Modified)'], + [307, '307 (Temporary Redirect)'], + [308, '308 (Permanent Redirect)'], + [400, '400 (Bad Request)'], + [401, '401 (Unauthorized)'], + [403, '403 (Forbidden)'], + [404, '404 (Not Found)'], + [405, '405 (Method Not Allowed)'], + [406, '406 (Not Acceptable)'], + [409, '409 (Conflict)'], + [410, '410 (Gone)'], + [415, '415 (Unsupported Media Type)'], + [422, '422 (Unprocessable Entity)'], + [429, '429 (Too Many Requests)'], + [500, '500 (Internal Server Error)'], + [501, '501 (Not Implemented)'], + [502, '502 (Bad Gateway)'], + [503, '503 (Service Unavailable)'], + [504, '504 (Gateway Timeout)'], + ]; + } + + /** + * Every constant must resolve to the code it names, and every constant must + * have a phrase - a constant without one would silently report 'Unknown'. + * + * @dataProvider providerConstants + */ + public function testConstantResolvesToItsCodeAndHasAPhrase(string $name, int $code): void + { + $this->assertSame($code, constant(HttpCode::class . '::' . $name)); + $this->assertStringNotContainsString('Unknown', HttpCode::getDescription($code)); + } + + public function testGetDescriptionForUnknownCode(): void + { + $this->assertSame('599 (Unknown)', HttpCode::getDescription(599)); + } + + /** + * @dataProvider providerDescriptions + */ + public function testGetDescriptionReturnsCodeAndPhrase(int $code, string $expected): void + { + $this->assertSame($expected, HttpCode::getDescription($code)); + } +} diff --git a/tests/Unit/Http/JsonSubsetTest.php b/tests/Unit/Http/JsonSubsetTest.php new file mode 100644 index 0000000..2460929 --- /dev/null +++ b/tests/Unit/Http/JsonSubsetTest.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Unit\Http; + +use Phalcon\Talon\Http\JsonSubset; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; + +final class JsonSubsetTest extends AbstractUnitTestCase +{ + public function testEmptyExpectedListAgainstEmptyActualList(): void + { + $this->assertTrue(JsonSubset::contains(['data' => []], ['data' => []])); + } + + /** + * The trap this guards: ['data' => []] must mean "data is empty", not + * "data exists". rest-api's seeSuccessJsonResponse() defaults straight + * into this shape. + */ + public function testEmptyExpectedListRequiresEmptyActualList(): void + { + $this->assertFalse(JsonSubset::contains(['data' => [['id' => 1]]], ['data' => []])); + $this->assertTrue(JsonSubset::contains(['data' => []], ['data' => []])); + } + + /** + * An empty expected list means "empty", not "anything" - as a pure subset + * it would match every document and assert nothing. + */ + public function testEmptyExpectedRequiresEmptyActual(): void + { + $this->assertFalse(JsonSubset::contains(['a' => 1], [])); + $this->assertTrue(JsonSubset::contains([], [])); + } + + public function testExpectedArrayAgainstScalarActualFails(): void + { + $this->assertFalse(JsonSubset::contains(['a' => 'scalar'], ['a' => ['b' => 1]])); + } + + public function testExpectedListAgainstEmptyActualListFails(): void + { + $this->assertFalse(JsonSubset::contains(['data' => []], ['data' => [['id' => 1]]])); + } + + public function testListElementMatchesOutOfOrder(): void + { + $actual = ['data' => [['id' => 1], ['id' => 2], ['id' => 3]]]; + + $this->assertTrue(JsonSubset::contains($actual, ['data' => [['id' => 3], ['id' => 1]]])); + } + + public function testListElementNotPresentFails(): void + { + $actual = ['data' => [['id' => 1]]]; + + $this->assertFalse(JsonSubset::contains($actual, ['data' => [['id' => 9]]])); + } + + public function testMatchesFragmentOfLargerDocument(): void + { + $actual = [ + 'jsonapi' => ['version' => '1.0'], + 'data' => [['id' => 1, 'name' => 'Acme']], + 'meta' => ['timestamp' => '2026-07-15T00:00:00+00:00'], + ]; + + $this->assertTrue(JsonSubset::contains($actual, ['data' => [['name' => 'Acme']]])); + $this->assertTrue(JsonSubset::contains($actual, ['jsonapi' => ['version' => '1.0']])); + } + + public function testMismatchedScalarFails(): void + { + $this->assertFalse(JsonSubset::contains(['a' => 1], ['a' => 2])); + } + + public function testMissingKeyFails(): void + { + $this->assertFalse(JsonSubset::contains(['a' => 1], ['b' => 1])); + } + + public function testNestedListInsideMapInsideList(): void + { + $actual = [ + 'data' => [ + ['id' => 1, 'tags' => ['a', 'b']], + ['id' => 2, 'tags' => ['c']], + ], + ]; + + $this->assertTrue(JsonSubset::contains($actual, ['data' => [['tags' => ['b']]]])); + $this->assertFalse(JsonSubset::contains($actual, ['data' => [['tags' => ['z']]]])); + } + + public function testNullExpectedMatchesNullActual(): void + { + $this->assertTrue(JsonSubset::contains(['a' => null], ['a' => null])); + $this->assertFalse(JsonSubset::contains(['a' => 0], ['a' => null])); + } + + public function testScalarComparisonIsStrict(): void + { + $this->assertFalse(JsonSubset::contains(['a' => 1], ['a' => '1'])); + } + + public function testTopLevelScalarComparison(): void + { + $this->assertTrue(JsonSubset::contains('same', 'same')); + $this->assertFalse(JsonSubset::contains('one', 'other')); + } +} diff --git a/tests/Unit/Http/JsonTypeTest.php b/tests/Unit/Http/JsonTypeTest.php new file mode 100644 index 0000000..837f94a --- /dev/null +++ b/tests/Unit/Http/JsonTypeTest.php @@ -0,0 +1,193 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Unit\Http; + +use Phalcon\Talon\Http\JsonType; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; + +final class JsonTypeTest extends AbstractUnitTestCase +{ + public function testDateFilterAcceptsIsoDate(): void + { + $this->assertNull(JsonType::match(['t' => '2026-07-15T10:30:00+00:00'], ['t' => 'string:date'])); + } + + public function testDateFilterRejectsNonDateString(): void + { + $this->assertSame( + "Key 't' expected 'string:date', got 'string'", + JsonType::match(['t' => 'not a date'], ['t' => 'string:date']) + ); + } + + public function testEmptyTypeMapMatchesAnything(): void + { + $this->assertNull(JsonType::match(['a' => 1], [])); + } + + /** + * JSON has one number type: {"price": 10} decodes to int and + * {"price": 10.5} to float, so 'float' has to accept both or it fails on + * every whole number. 'integer' stays strict. + */ + public function testFloatAcceptsWholeNumbersButIntegerStaysStrict(): void + { + $this->assertNull(JsonType::match(['a' => 1], ['a' => 'float'])); + $this->assertNull(JsonType::match(['a' => 1.5], ['a' => 'float'])); + $this->assertNotNull(JsonType::match(['a' => 1.5], ['a' => 'integer'])); + $this->assertNull(JsonType::match(['a' => 1], ['a' => 'integer'])); + $this->assertNotNull(JsonType::match(['a' => 'x'], ['a' => 'float'])); + $this->assertNotNull(JsonType::match(['a' => null], ['a' => 'float'])); + } + + /** + * A decoded JSON array is int-keyed, so the path built for a nested map at + * an integer key must still be a string. + */ + public function testIntegerKeyedNestedMapReportsAStringPath(): void + { + $this->assertSame( + "Key '0.a' expected 'string', got 'int'", + JsonType::match([['a' => 1]], [0 => ['a' => 'string']]) + ); + } + + /** + * A nested map that matches must not end the walk - the keys after it still + * have to be checked. + */ + public function testKeyAfterAMatchingNestedMapIsStillChecked(): void + { + $this->assertSame( + "Key 'meta.hash' expected 'string', got 'int'", + JsonType::match( + [ + 'jsonapi' => ['version' => '1.0'], + 'meta' => ['hash' => 1], + ], + [ + 'jsonapi' => ['version' => 'string'], + 'meta' => ['hash' => 'string'], + ] + ) + ); + } + + public function testMatchesTheRestApiEnvelope(): void + { + $actual = [ + 'jsonapi' => ['version' => '1.0'], + 'data' => [['id' => 1]], + 'meta' => [ + 'timestamp' => '2026-07-15T10:30:00+00:00', + 'hash' => 'a1b2c3', + ], + ]; + + $result = JsonType::match( + $actual, + [ + 'jsonapi' => ['version' => 'string'], + 'meta' => [ + 'timestamp' => 'string:date', + 'hash' => 'string', + ], + ] + ); + + $this->assertNull($result); + } + + public function testMissingKeyFails(): void + { + $this->assertSame("Key 'b' is missing", JsonType::match(['a' => 1], ['b' => 'integer'])); + } + + public function testMissingNestedKeyReportsPath(): void + { + $this->assertSame( + "Key 'meta.hash' is missing", + JsonType::match(['meta' => ['timestamp' => 'x']], ['meta' => ['hash' => 'string']]) + ); + } + + public function testNestedMapAgainstNonArrayFails(): void + { + $this->assertSame( + "Key 'meta' expected an object, got 'string'", + JsonType::match(['meta' => 'scalar'], ['meta' => ['hash' => 'string']]) + ); + } + + public function testNonStringNonArraySpecFails(): void + { + $this->assertSame( + "Key 'a' expected 'int', got 'int'", + JsonType::match(['a' => 1], ['a' => 123]) + ); + } + + public function testScalarTypes(): void + { + $this->assertNull(JsonType::match(['a' => true], ['a' => 'boolean'])); + $this->assertNull(JsonType::match(['a' => [1, 2]], ['a' => 'array'])); + $this->assertNull(JsonType::match(['a' => null], ['a' => 'null'])); + $this->assertNull(JsonType::match(['a' => 'x'], ['a' => 'string'])); + $this->assertNull(JsonType::match(['a' => 1], ['a' => 'integer'])); + } + + public function testTopLevelNonArrayFails(): void + { + $this->assertSame( + "Key '' expected an object, got 'string'", + JsonType::match('scalar', ['a' => 'string']) + ); + } + + public function testUnionAcceptsEitherAlternative(): void + { + $this->assertNull(JsonType::match(['a' => null], ['a' => 'string|null'])); + $this->assertNull(JsonType::match(['a' => 'x'], ['a' => 'string|null'])); + $this->assertNotNull(JsonType::match(['a' => 1], ['a' => 'string|null'])); + } + + public function testUnknownBaseTypeNeverMatches(): void + { + $this->assertSame( + "Key 'a' expected 'bogus', got 'int'", + JsonType::match(['a' => 1], ['a' => 'bogus']) + ); + } + + public function testUnknownFilterNeverMatches(): void + { + $this->assertSame( + "Key 't' expected 'string:bogus', got 'string'", + JsonType::match(['t' => 'anything'], ['t' => 'string:bogus']) + ); + } + + public function testUnlistedKeysAreIgnored(): void + { + $this->assertNull(JsonType::match(['a' => 1, 'b' => 'x'], ['a' => 'integer'])); + } + + public function testWrongTypeReportsPath(): void + { + $this->assertSame( + "Key 'meta.hash' expected 'string', got 'int'", + JsonType::match(['meta' => ['hash' => 1]], ['meta' => ['hash' => 'string']]) + ); + } +} diff --git a/tests/Unit/PHPUnit/AbstractBrowserTestCaseTest.php b/tests/Unit/PHPUnit/AbstractBrowserTestCaseTest.php index ad8a15f..519566e 100644 --- a/tests/Unit/PHPUnit/AbstractBrowserTestCaseTest.php +++ b/tests/Unit/PHPUnit/AbstractBrowserTestCaseTest.php @@ -19,11 +19,6 @@ final class AbstractBrowserTestCaseTest extends AbstractBrowserTestCase { - protected function appFactory(): callable - { - return static fn () => require __DIR__ . '/../../Fakes/Browser/app.php'; - } - public function testSetUpResetsTheDefaultDiAndClearsSession(): void { Di::setDefault(new FactoryDefault()); @@ -34,4 +29,8 @@ public function testSetUpResetsTheDefaultDiAndClearsSession(): void $this->assertNull(Di::getDefault()); $this->assertSame([], $_SESSION); } + protected function appFactory(): callable + { + return static fn () => require __DIR__ . '/../../Fakes/Browser/app.php'; + } } diff --git a/tests/Unit/PHPUnit/AbstractFunctionalTestCaseTest.php b/tests/Unit/PHPUnit/AbstractFunctionalTestCaseTest.php index 48fb86e..a0d04eb 100644 --- a/tests/Unit/PHPUnit/AbstractFunctionalTestCaseTest.php +++ b/tests/Unit/PHPUnit/AbstractFunctionalTestCaseTest.php @@ -17,11 +17,6 @@ final class AbstractFunctionalTestCaseTest extends AbstractFunctionalTestCase { - protected function appFactory(): callable - { - return static fn () => require __DIR__ . '/../../Fakes/App/app.php'; - } - public function testDispatch(): void { $this->dispatch('/test/hello'); @@ -29,4 +24,8 @@ public function testDispatch(): void $this->assertController('test'); $this->assertResponseContentContains('Operator'); } + protected function appFactory(): callable + { + return static fn () => require __DIR__ . '/../../Fakes/App/app.php'; + } } diff --git a/tests/Unit/PHPUnit/AbstractRestTestCaseTest.php b/tests/Unit/PHPUnit/AbstractRestTestCaseTest.php new file mode 100644 index 0000000..a7aeb5a --- /dev/null +++ b/tests/Unit/PHPUnit/AbstractRestTestCaseTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Unit\PHPUnit; + +use Phalcon\Talon\PHPUnit\AbstractRestTestCase; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Talon\Traits\RestAssertionsTrait; +use Phalcon\Talon\Traits\RestTrait; + +use function class_parents; +use function class_uses; +use function in_array; + +final class AbstractRestTestCaseTest extends AbstractUnitTestCase +{ + public function testExtendsAbstractUnitTestCase(): void + { + $this->assertContains( + AbstractUnitTestCase::class, + (array) class_parents(AbstractRestTestCase::class) + ); + } + + public function testUsesBothRestTraits(): void + { + $uses = (array) class_uses(AbstractRestTestCase::class); + + $this->assertTrue(in_array(RestTrait::class, $uses, true)); + $this->assertTrue(in_array(RestAssertionsTrait::class, $uses, true)); + } +} diff --git a/tests/Unit/PHPUnit/AbstractUnitTestCaseTest.php b/tests/Unit/PHPUnit/AbstractUnitTestCaseTest.php index f2adad7..ef92ade 100644 --- a/tests/Unit/PHPUnit/AbstractUnitTestCaseTest.php +++ b/tests/Unit/PHPUnit/AbstractUnitTestCaseTest.php @@ -22,14 +22,6 @@ final class AbstractUnitTestCaseTest extends AbstractUnitTestCase { - public function testInheritsTraitHelpers(): void - { - $this->assertSame('/x/', $this->getDirSeparator('/x')); - - $this->checkPhalconAvailable(); - $this->addToAssertionCount(1); - } - public function testCheckExtensionIsLoadedPassesForLoadedExtension(): void { $this->checkExtensionIsLoaded('json'); @@ -47,30 +39,36 @@ public function testCheckExtensionIsLoadedThrowsForMissingExtension(): void } } - public function testMockWithoutConstructorSkipsConstructor(): void + public function testHelperMethodVisibility(): void { - $subject = $this->mockWithoutConstructor(MockSubject::class); + // Execute each helper so this test covers the mutated method bodies + // (infection only pairs tests with mutants via line coverage). + $this->checkExtensionIsLoaded('json'); + $this->checkPhalconAvailable(); + $this->mockWithConstructor(MockSubject::class, ['custom']); + $this->mockWithoutConstructor(MockSubject::class); - $this->assertInstanceOf(MockSubject::class, $subject); - $this->assertSame('default', $subject->tag); - $this->assertFalse($subject->booted); + $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'checkExtensionIsLoaded'))->isPublic()); + $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'checkPhalconAvailable'))->isPublic()); + $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'mockWithConstructor'))->isPublic()); + $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'mockWithoutConstructor'))->isPublic()); + $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'phalconAvailable'))->isProtected()); } - - public function testMockWithConstructorRunsConstructor(): void + public function testInheritsTraitHelpers(): void { - $subject = $this->mockWithConstructor(MockSubject::class, ['custom']); + $this->assertSame('/x/', $this->getDirSeparator('/x')); - $this->assertInstanceOf(MockSubject::class, $subject); - $this->assertSame('custom', $subject->tag); - $this->assertTrue($subject->booted); + $this->checkPhalconAvailable(); + $this->addToAssertionCount(1); } - public function testMockWithConstructorStubsMethodsDuringConstruction(): void + public function testMockMethodOverrideAcceptsClosure(): void { - $subject = $this->mockWithConstructor(MockSubject::class, ['custom'], ['boot' => null]); + $subject = $this->mockWithoutConstructor(MockSubject::class, [ + 'greeting' => static fn (): string => 'stubbed', + ]); - $this->assertSame('custom', $subject->tag); - $this->assertFalse($subject->booted); + $this->assertSame('stubbed', $subject->greeting()); } public function testMockMethodOverrideReturnsValue(): void @@ -80,20 +78,58 @@ public function testMockMethodOverrideReturnsValue(): void $this->assertSame(99, $subject->value()); } - public function testMockMethodOverrideAcceptsClosure(): void + public function testMockPropertyOverride(): void + { + $subject = $this->mockWithoutConstructor(MockSubject::class, ['tag' => 'overridden']); + + $this->assertSame('overridden', $subject->tag); + } + + public function testMockStubsMultipleMethodOverrides(): void { $subject = $this->mockWithoutConstructor(MockSubject::class, [ 'greeting' => static fn (): string => 'stubbed', + 'value' => 7, ]); $this->assertSame('stubbed', $subject->greeting()); + $this->assertSame(7, $subject->value()); } - public function testMockPropertyOverride(): void + public function testMockWithConstructorNormalizesCtorArgKeys(): void { - $subject = $this->mockWithoutConstructor(MockSubject::class, ['tag' => 'overridden']); + $plain = $this->mockWithConstructor(MockSubject::class, ['label' => 'custom']); + $this->assertSame('custom', $plain->tag); - $this->assertSame('overridden', $subject->tag); + $stubbed = $this->mockWithConstructor(MockSubject::class, ['label' => 'custom'], ['boot' => null]); + $this->assertSame('custom', $stubbed->tag); + $this->assertFalse($stubbed->booted); + } + + public function testMockWithConstructorRunsConstructor(): void + { + $subject = $this->mockWithConstructor(MockSubject::class, ['custom']); + + $this->assertInstanceOf(MockSubject::class, $subject); + $this->assertSame('custom', $subject->tag); + $this->assertTrue($subject->booted); + } + + public function testMockWithConstructorStubsMethodsDuringConstruction(): void + { + $subject = $this->mockWithConstructor(MockSubject::class, ['custom'], ['boot' => null]); + + $this->assertSame('custom', $subject->tag); + $this->assertFalse($subject->booted); + } + + public function testMockWithoutConstructorSkipsConstructor(): void + { + $subject = $this->mockWithoutConstructor(MockSubject::class); + + $this->assertInstanceOf(MockSubject::class, $subject); + $this->assertSame('default', $subject->tag); + $this->assertFalse($subject->booted); } public function testSetUpResetsTheDefaultDi(): void @@ -131,41 +167,4 @@ protected function phalconAvailable(): bool Di::reset(); } - - public function testHelperMethodVisibility(): void - { - // Execute each helper so this test covers the mutated method bodies - // (infection only pairs tests with mutants via line coverage). - $this->checkExtensionIsLoaded('json'); - $this->checkPhalconAvailable(); - $this->mockWithConstructor(MockSubject::class, ['custom']); - $this->mockWithoutConstructor(MockSubject::class); - - $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'checkExtensionIsLoaded'))->isPublic()); - $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'checkPhalconAvailable'))->isPublic()); - $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'mockWithConstructor'))->isPublic()); - $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'mockWithoutConstructor'))->isPublic()); - $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'phalconAvailable'))->isProtected()); - } - - public function testMockStubsMultipleMethodOverrides(): void - { - $subject = $this->mockWithoutConstructor(MockSubject::class, [ - 'greeting' => static fn (): string => 'stubbed', - 'value' => 7, - ]); - - $this->assertSame('stubbed', $subject->greeting()); - $this->assertSame(7, $subject->value()); - } - - public function testMockWithConstructorNormalizesCtorArgKeys(): void - { - $plain = $this->mockWithConstructor(MockSubject::class, ['label' => 'custom']); - $this->assertSame('custom', $plain->tag); - - $stubbed = $this->mockWithConstructor(MockSubject::class, ['label' => 'custom'], ['boot' => null]); - $this->assertSame('custom', $stubbed->tag); - $this->assertFalse($stubbed->booted); - } } diff --git a/tests/Unit/SettingsTest.php b/tests/Unit/SettingsTest.php index edf0555..709170a 100644 --- a/tests/Unit/SettingsTest.php +++ b/tests/Unit/SettingsTest.php @@ -22,13 +22,15 @@ final class SettingsTest extends TestCase { - public function testFromArrayImplementsContractAndResolvesPaths(): void + public function testDirectoryAccessorsHonorOverrides(): void { - $settings = Settings::fromArray(['root' => '/app']); + $settings = Settings::fromArray([ + 'root' => '/app', + 'paths' => ['output' => 'build/out'], + ]); - $this->assertInstanceOf(SettingsContract::class, $settings); - $this->assertSame('/app', $settings->rootPath()); - $this->assertSame('/app/sub/file.txt', $settings->rootPath('sub/file.txt')); + $this->assertSame('/app/build/out', $settings->outputPath()); + $this->assertSame('/app/tests/_data', $settings->dataPath()); } public function testDirectoryAccessorsUseDefaults(): void @@ -44,97 +46,180 @@ public function testDirectoryAccessorsUseDefaults(): void $this->assertSame('/app/tests/_output/run.log', $settings->outputPath('run.log')); } - public function testDirectoryAccessorsHonorOverrides(): void + public function testDirJoinsOverrideAndRelativeWithoutDuplicateSlashes(): void { $settings = Settings::fromArray([ 'root' => '/app', - 'paths' => ['output' => 'build/out'], + 'paths' => ['output' => 'build/out/'], ]); - $this->assertSame('/app/build/out', $settings->outputPath()); - $this->assertSame('/app/tests/_data', $settings->dataPath()); + $this->assertSame('/app/build/out/run.log', $settings->outputPath('/run.log')); } - public function testNonArrayPathsSectionFallsBackToDefaults(): void + public function testDiscoverRootFallsBackWhenNoComposerJson(): void { - $settings = Settings::fromArray(['root' => '/app', 'paths' => 'not-an-array']); + $cwd = getcwd(); - $this->assertSame('/app/tests/_output', $settings->outputPath()); + try { + chdir('/'); + $this->assertSame('/', Settings::fromEnv()->rootPath()); + } finally { + chdir($cwd ?: '/srv'); + } } - public function testFromEnvDiscoversRootFromComposerJson(): void + public function testDiscoverRootWalksUpToComposerJson(): void { - // The package ships a composer.json at its root; discovery must find it. - $settings = Settings::fromEnv(); + $cwd = getcwd(); - $this->assertFileExists($settings->rootPath('composer.json')); + try { + // A nested directory with no composer.json of its own. + chdir(__DIR__); + $this->assertFileExists(Settings::fromEnv()->rootPath('composer.json')); + } finally { + chdir($cwd ?: '/srv'); + } } - public function testFromArrayRequiresRoot(): void + public function testFromArrayAcceptsServiceOptionsInstanceDirectly(): void { - $this->expectException(InvalidConfiguration::class); - Settings::fromArray([]); + $settings = Settings::fromArray([ + 'root' => '/app', + 'services' => [ + 'redis' => new ServiceOptions('redis', ['host' => 'direct-host']), + ], + ]); + + $this->assertSame(['host' => 'direct-host'], $settings->getServiceOptions('redis')); } - public function testSqliteDsn(): void + public function testFromArrayCastsIntegerServiceKeys(): void + { + $settings = Settings::fromArray([ + 'root' => '/app', + 'services' => [0 => ['host' => 'indexed-host']], + ]); + + $this->assertSame(['host' => 'indexed-host'], $settings->getServiceOptions('0')); + } + public function testFromArrayImplementsContractAndResolvesPaths(): void + { + $settings = Settings::fromArray(['root' => '/app']); + + $this->assertInstanceOf(SettingsContract::class, $settings); + $this->assertSame('/app', $settings->rootPath()); + $this->assertSame('/app/sub/file.txt', $settings->rootPath('sub/file.txt')); + } + + public function testFromArrayKeepsAllDbDrivers(): void { $settings = Settings::fromArray([ 'root' => '/app', - 'db' => ['sqlite' => ['dbname' => ':memory:']], + 'db' => [ + 'mysql' => ['host' => '127.0.0.1', 'port' => 3306, 'dbname' => 'talon'], + 'sqlite' => ['dbname' => '/data/db.sqlite'], + ], ]); - $this->assertSame('sqlite::memory:', $settings->getDatabaseDsn('sqlite')); + $this->assertSame('sqlite:/data/db.sqlite', $settings->getDatabaseDsn('sqlite')); } - public function testMysqlDsnAndOptions(): void + public function testFromArrayKeepsAllServices(): void { - $settings = Settings::fromArray( - [ - 'root' => '/app', - 'db' => [ - 'mysql' => [ - 'host' => '127.0.0.1', - 'port' => 3306, - 'dbname' => 'talon', - 'username' => 'root', - 'password' => 'secret', - 'charset' => 'utf8mb4', - ] - ], - ] - ); + $settings = Settings::fromArray([ + 'root' => '/app', + 'services' => [ + 'first' => ['host' => 'one'], + 'second' => ['host' => 'two'], + ], + ]); - $this->assertSame( - 'mysql:host=127.0.0.1;port=3306;dbname=talon;charset=utf8mb4', - $settings->getDatabaseDsn('mysql') - ); - $this->assertSame('root', $settings->getDatabaseOptions('mysql')['username']); + $this->assertSame(['host' => 'one'], $settings->getServiceOptions('first')); + $this->assertSame(['host' => 'two'], $settings->getServiceOptions('second')); } - public function testUnknownDriverThrows(): void + public function testFromArrayRequiresRoot(): void { - $this->expectException(UnknownDriver::class); - Settings::fromArray(['root' => '/app'])->getDatabaseDsn('oracle'); + $this->expectException(InvalidConfiguration::class); + Settings::fromArray([]); } - public function testFromEnvReadsOverrides(): void + public function testFromEnvBuildsDbServiceOptionsFromOverrides(): void { $settings = Settings::fromEnv([ - 'root' => '/app', - 'DATA_REDIS_HOST' => 'redis-host', + 'root' => '/app', + 'DATA_MYSQL_HOST' => 'mysql-host', + 'DATA_MYSQL_PORT' => 3307, + 'DATA_MYSQL_NAME' => 'mysql-db', + 'DATA_MYSQL_USER' => 'mysql-user', + 'DATA_MYSQL_PASS' => 'mysql-pass', + 'DATA_MYSQL_CHARSET' => 'utf8', + 'DATA_POSTGRES_HOST' => 'pgsql-host', + 'DATA_POSTGRES_PORT' => '5433', + 'DATA_POSTGRES_NAME' => 'pgsql-db', + 'DATA_POSTGRES_USER' => 'pgsql-user', + 'DATA_POSTGRES_PASS' => 'pgsql-pass', + 'DATA_POSTGRES_SCHEMA' => 'pgsql-schema', + 'DATA_SQLITE_NAME' => '/data/db.sqlite', ]); - $this->assertSame('redis-host', $settings->getServiceOptions('redis')['host']); + $this->assertSame( + [ + 'host' => 'mysql-host', + 'port' => 3307, + 'dbname' => 'mysql-db', + 'username' => 'mysql-user', + 'password' => 'mysql-pass', + 'charset' => 'utf8', + ], + $settings->getServiceOptions('mysql') + ); + $this->assertSame( + [ + 'host' => 'pgsql-host', + 'port' => 5433, + 'dbname' => 'pgsql-db', + 'username' => 'pgsql-user', + 'password' => 'pgsql-pass', + 'schema' => 'pgsql-schema', + ], + $settings->getServiceOptions('pgsql') + ); + $this->assertSame(['dbname' => '/data/db.sqlite'], $settings->getServiceOptions('sqlite')); } - public function testFromEnvReadsMemcachedOverrides(): void + public function testFromEnvDiscoversRootFromComposerJson(): void { - $settings = Settings::fromEnv([ - 'root' => '/app', - 'DATA_MEMCACHED_HOST' => 'memcached-host', - ]); + // The package ships a composer.json at its root; discovery must find it. + $settings = Settings::fromEnv(); - $this->assertSame('memcached-host', $settings->getServiceOptions('memcached')['host']); + $this->assertFileExists($settings->rootPath('composer.json')); + } + + public function testFromEnvFallsBackToEnvSuperglobal(): void + { + $originalGetenv = getenv('DATA_MYSQL_NAME'); + $originalEnv = $_ENV['DATA_MYSQL_NAME'] ?? null; + + try { + putenv('DATA_MYSQL_NAME'); + $_ENV['DATA_MYSQL_NAME'] = 'env-db'; + + $settings = Settings::fromEnv(['root' => '/app']); + + $this->assertSame('env-db', $settings->getServiceOptions('mysql')['dbname']); + } finally { + putenv( + $originalGetenv === false + ? 'DATA_MYSQL_NAME' + : 'DATA_MYSQL_NAME=' . $originalGetenv + ); + if ($originalEnv === null) { + unset($_ENV['DATA_MYSQL_NAME']); + } else { + $_ENV['DATA_MYSQL_NAME'] = $originalEnv; + } + } } public function testFromEnvReadsBeanstalkServiceOptions(): void @@ -163,6 +248,26 @@ public function testFromEnvReadsDumpFileAndInitialQueries(): void $this->assertSame('SET NAMES utf8;', $settings->get('initial_queries')); } + public function testFromEnvReadsMemcachedOverrides(): void + { + $settings = Settings::fromEnv([ + 'root' => '/app', + 'DATA_MEMCACHED_HOST' => 'memcached-host', + ]); + + $this->assertSame('memcached-host', $settings->getServiceOptions('memcached')['host']); + } + + public function testFromEnvReadsOverrides(): void + { + $settings = Settings::fromEnv([ + 'root' => '/app', + 'DATA_REDIS_HOST' => 'redis-host', + ]); + + $this->assertSame('redis-host', $settings->getServiceOptions('redis')['host']); + } + public function testFromEnvReadsRedisClusterOverrides(): void { $settings = Settings::fromEnv([ @@ -184,74 +289,32 @@ public function testFromEnvRedisClusterHostsEmptyWhenUnset(): void $this->assertSame([], $settings->getServiceOptions('redisCluster')['hosts']); } - public function testGetServiceOptionsFromArrayForRedisCluster(): void + public function testFromEnvRestUrlDefault(): void { - $settings = Settings::fromArray([ - 'root' => '/app', - 'services' => [ - 'redisCluster' => [ - 'hosts' => ['10.0.0.1:6379', '10.0.0.2:6379'], - 'auth' => 'secret', - ], - ], - ]); + $settings = Settings::fromEnv(['root' => '/app']); - $this->assertSame( - ['hosts' => ['10.0.0.1:6379', '10.0.0.2:6379'], 'auth' => 'secret'], - $settings->getServiceOptions('redisCluster') - ); + $this->assertSame('http://127.0.0.1:8080', $settings->get('rest_url')); } - public function testFromArrayAcceptsServiceOptionsInstanceDirectly(): void + public function testFromEnvRestUrlOverride(): void { - $settings = Settings::fromArray([ - 'root' => '/app', - 'services' => [ - 'redis' => new ServiceOptions('redis', ['host' => 'direct-host']), - ], + $settings = Settings::fromEnv([ + 'root' => '/app', + 'TALON_REST_URL' => 'http://api.test:9501', ]); - $this->assertSame(['host' => 'direct-host'], $settings->getServiceOptions('redis')); + $this->assertSame('http://api.test:9501', $settings->get('rest_url')); } - public function testPgsqlDsn(): void + public function testFromEnvUsesRootOverride(): void { - $settings = Settings::fromArray( - [ - 'root' => '/app', - 'db' => [ - 'pgsql' => [ - 'host' => '127.0.0.1', - 'port' => 5432, - 'dbname' => 'talon' - ] - ], - ] - ); - - $this->assertSame( - 'pgsql:host=127.0.0.1;port=5432;dbname=talon', - $settings->getDatabaseDsn('pgsql') - ); + $this->assertSame('/app', Settings::fromEnv(['root' => '/app'])->rootPath()); } - public function testPgsqlOptionsIncludeSchema(): void + public function testGetDatabaseOptionsRejectsUnknownDriver(): void { - $settings = Settings::fromArray( - [ - 'root' => '/app', - 'db' => [ - 'pgsql' => [ - 'host' => '127.0.0.1', - 'port' => 5432, - 'dbname' => 'talon', - 'schema' => 'public', - ] - ], - ] - ); - - $this->assertSame('public', $settings->getDatabaseOptions('pgsql')['schema']); + $this->expectException(UnknownDriver::class); + Settings::fromArray(['root' => '/app'])->getDatabaseOptions('oracle'); } public function testGetReturnsExtraConfig(): void @@ -277,6 +340,24 @@ public function testGetServiceOptionsFromArray(): void ); } + public function testGetServiceOptionsFromArrayForRedisCluster(): void + { + $settings = Settings::fromArray([ + 'root' => '/app', + 'services' => [ + 'redisCluster' => [ + 'hosts' => ['10.0.0.1:6379', '10.0.0.2:6379'], + 'auth' => 'secret', + ], + ], + ]); + + $this->assertSame( + ['hosts' => ['10.0.0.1:6379', '10.0.0.2:6379'], 'auth' => 'secret'], + $settings->getServiceOptions('redisCluster') + ); + } + public function testGetServiceOptionsUnknownNameReturnsEmptyArray(): void { $settings = Settings::fromArray(['root' => '/app']); @@ -284,29 +365,36 @@ public function testGetServiceOptionsUnknownNameReturnsEmptyArray(): void $this->assertSame([], $settings->getServiceOptions('unknown')); } - public function testDiscoverRootFallsBackWhenNoComposerJson(): void + public function testMysqlDsnAndOptions(): void { - $cwd = getcwd(); + $settings = Settings::fromArray( + [ + 'root' => '/app', + 'db' => [ + 'mysql' => [ + 'host' => '127.0.0.1', + 'port' => 3306, + 'dbname' => 'talon', + 'username' => 'root', + 'password' => 'secret', + 'charset' => 'utf8mb4', + ] + ], + ] + ); - try { - chdir('/'); - $this->assertSame('/', Settings::fromEnv()->rootPath()); - } finally { - chdir($cwd ?: '/srv'); - } + $this->assertSame( + 'mysql:host=127.0.0.1;port=3306;dbname=talon;charset=utf8mb4', + $settings->getDatabaseDsn('mysql') + ); + $this->assertSame('root', $settings->getDatabaseOptions('mysql')['username']); } - public function testDiscoverRootWalksUpToComposerJson(): void + public function testNonArrayPathsSectionFallsBackToDefaults(): void { - $cwd = getcwd(); + $settings = Settings::fromArray(['root' => '/app', 'paths' => 'not-an-array']); - try { - // A nested directory with no composer.json of its own. - chdir(__DIR__); - $this->assertFileExists(Settings::fromEnv()->rootPath('composer.json')); - } finally { - chdir($cwd ?: '/srv'); - } + $this->assertSame('/app/tests/_output', $settings->outputPath()); } public function testNonArraySectionIsIgnored(): void @@ -319,139 +407,67 @@ public function testNonArraySectionIsIgnored(): void $this->assertSame([], $settings->getServiceOptions('redis')); } - public function testDirJoinsOverrideAndRelativeWithoutDuplicateSlashes(): void + public function testPgsqlDsn(): void { - $settings = Settings::fromArray([ - 'root' => '/app', - 'paths' => ['output' => 'build/out/'], - ]); + $settings = Settings::fromArray( + [ + 'root' => '/app', + 'db' => [ + 'pgsql' => [ + 'host' => '127.0.0.1', + 'port' => 5432, + 'dbname' => 'talon' + ] + ], + ] + ); - $this->assertSame('/app/build/out/run.log', $settings->outputPath('/run.log')); + $this->assertSame( + 'pgsql:host=127.0.0.1;port=5432;dbname=talon', + $settings->getDatabaseDsn('pgsql') + ); } - public function testFromArrayCastsIntegerServiceKeys(): void + public function testPgsqlOptionsIncludeSchema(): void { - $settings = Settings::fromArray([ - 'root' => '/app', - 'services' => [0 => ['host' => 'indexed-host']], - ]); + $settings = Settings::fromArray( + [ + 'root' => '/app', + 'db' => [ + 'pgsql' => [ + 'host' => '127.0.0.1', + 'port' => 5432, + 'dbname' => 'talon', + 'schema' => 'public', + ] + ], + ] + ); - $this->assertSame(['host' => 'indexed-host'], $settings->getServiceOptions('0')); + $this->assertSame('public', $settings->getDatabaseOptions('pgsql')['schema']); } - public function testFromArrayKeepsAllDbDrivers(): void + public function testRootPathTrimsSlashes(): void { - $settings = Settings::fromArray([ - 'root' => '/app', - 'db' => [ - 'mysql' => ['host' => '127.0.0.1', 'port' => 3306, 'dbname' => 'talon'], - 'sqlite' => ['dbname' => '/data/db.sqlite'], - ], - ]); + $settings = Settings::fromArray(['root' => '/app/']); - $this->assertSame('sqlite:/data/db.sqlite', $settings->getDatabaseDsn('sqlite')); + $this->assertSame('/app', $settings->rootPath()); + $this->assertSame('/app/sub', $settings->rootPath('/sub')); } - public function testFromArrayKeepsAllServices(): void + public function testSqliteDsn(): void { $settings = Settings::fromArray([ - 'root' => '/app', - 'services' => [ - 'first' => ['host' => 'one'], - 'second' => ['host' => 'two'], - ], - ]); - - $this->assertSame(['host' => 'one'], $settings->getServiceOptions('first')); - $this->assertSame(['host' => 'two'], $settings->getServiceOptions('second')); - } - - public function testFromEnvBuildsDbServiceOptionsFromOverrides(): void - { - $settings = Settings::fromEnv([ - 'root' => '/app', - 'DATA_MYSQL_HOST' => 'mysql-host', - 'DATA_MYSQL_PORT' => 3307, - 'DATA_MYSQL_NAME' => 'mysql-db', - 'DATA_MYSQL_USER' => 'mysql-user', - 'DATA_MYSQL_PASS' => 'mysql-pass', - 'DATA_MYSQL_CHARSET' => 'utf8', - 'DATA_POSTGRES_HOST' => 'pgsql-host', - 'DATA_POSTGRES_PORT' => '5433', - 'DATA_POSTGRES_NAME' => 'pgsql-db', - 'DATA_POSTGRES_USER' => 'pgsql-user', - 'DATA_POSTGRES_PASS' => 'pgsql-pass', - 'DATA_POSTGRES_SCHEMA' => 'pgsql-schema', - 'DATA_SQLITE_NAME' => '/data/db.sqlite', + 'root' => '/app', + 'db' => ['sqlite' => ['dbname' => ':memory:']], ]); - $this->assertSame( - [ - 'host' => 'mysql-host', - 'port' => 3307, - 'dbname' => 'mysql-db', - 'username' => 'mysql-user', - 'password' => 'mysql-pass', - 'charset' => 'utf8', - ], - $settings->getServiceOptions('mysql') - ); - $this->assertSame( - [ - 'host' => 'pgsql-host', - 'port' => 5433, - 'dbname' => 'pgsql-db', - 'username' => 'pgsql-user', - 'password' => 'pgsql-pass', - 'schema' => 'pgsql-schema', - ], - $settings->getServiceOptions('pgsql') - ); - $this->assertSame(['dbname' => '/data/db.sqlite'], $settings->getServiceOptions('sqlite')); - } - - public function testFromEnvFallsBackToEnvSuperglobal(): void - { - $originalGetenv = getenv('DATA_MYSQL_NAME'); - $originalEnv = $_ENV['DATA_MYSQL_NAME'] ?? null; - - try { - putenv('DATA_MYSQL_NAME'); - $_ENV['DATA_MYSQL_NAME'] = 'env-db'; - - $settings = Settings::fromEnv(['root' => '/app']); - - $this->assertSame('env-db', $settings->getServiceOptions('mysql')['dbname']); - } finally { - putenv( - $originalGetenv === false - ? 'DATA_MYSQL_NAME' - : 'DATA_MYSQL_NAME=' . $originalGetenv - ); - if ($originalEnv === null) { - unset($_ENV['DATA_MYSQL_NAME']); - } else { - $_ENV['DATA_MYSQL_NAME'] = $originalEnv; - } - } - } - - public function testFromEnvUsesRootOverride(): void - { - $this->assertSame('/app', Settings::fromEnv(['root' => '/app'])->rootPath()); + $this->assertSame('sqlite::memory:', $settings->getDatabaseDsn('sqlite')); } - public function testGetDatabaseOptionsRejectsUnknownDriver(): void + public function testUnknownDriverThrows(): void { $this->expectException(UnknownDriver::class); - Settings::fromArray(['root' => '/app'])->getDatabaseOptions('oracle'); - } - - public function testRootPathTrimsSlashes(): void - { - $settings = Settings::fromArray(['root' => '/app/']); - - $this->assertSame('/app', $settings->rootPath()); - $this->assertSame('/app/sub', $settings->rootPath('/sub')); + Settings::fromArray(['root' => '/app'])->getDatabaseDsn('oracle'); } } diff --git a/tests/Unit/TalonTest.php b/tests/Unit/TalonTest.php index 8c6cd52..a27e44a 100644 --- a/tests/Unit/TalonTest.php +++ b/tests/Unit/TalonTest.php @@ -26,20 +26,6 @@ protected function tearDown(): void parent::tearDown(); } - public function testSettingsLazilyFallsBackToEnv(): void - { - Talon::reset(); - $this->assertInstanceOf(SettingsContract::class, Talon::settings()); - } - - public function testUseSettingsRegistersTheSlot(): void - { - $settings = Settings::fromArray(['root' => '/app']); - Talon::useSettings($settings); - - $this->assertSame($settings, Talon::settings()); - } - public function testBootReturnsAndRegistersSettings(): void { $settings = Settings::fromArray(['root' => dirname(__DIR__, 2)]); @@ -56,4 +42,18 @@ public function testResetClearsTheSlot(): void $this->assertNotSame($settings, Talon::settings()); } + + public function testSettingsLazilyFallsBackToEnv(): void + { + Talon::reset(); + $this->assertInstanceOf(SettingsContract::class, Talon::settings()); + } + + public function testUseSettingsRegistersTheSlot(): void + { + $settings = Settings::fromArray(['root' => '/app']); + Talon::useSettings($settings); + + $this->assertSame($settings, Talon::settings()); + } }