From 8a8a96fe0625b788fd7276e5060e581b06a96d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Le=CC=81o?= Date: Mon, 11 May 2026 16:02:33 +0200 Subject: [PATCH 1/2] Add test --- .github/workflows/php-prod.yml | 3 + .github/workflows/php-sandbox.yml | 3 + README.md | 47 + composer.json | 8 + composer.lock | 1788 ++++++++++++++++- phpunit.xml | 23 + .../Unit/Controllers/AdminControllerTest.php | 229 +++ .../Unit/Controllers/WidgetControllerTest.php | 406 ++++ .../AccessTokenRepositoryTest.php | 186 ++ tests/Unit/Services/ApiWrapperTest.php | 357 ++++ tests/bootstrap.php | 6 + 11 files changed, 3053 insertions(+), 3 deletions(-) create mode 100644 phpunit.xml create mode 100644 tests/Unit/Controllers/AdminControllerTest.php create mode 100644 tests/Unit/Controllers/WidgetControllerTest.php create mode 100644 tests/Unit/Repositories/AccessTokenRepositoryTest.php create mode 100644 tests/Unit/Services/ApiWrapperTest.php create mode 100644 tests/bootstrap.php diff --git a/.github/workflows/php-prod.yml b/.github/workflows/php-prod.yml index 05919d7..e67696f 100644 --- a/.github/workflows/php-prod.yml +++ b/.github/workflows/php-prod.yml @@ -38,6 +38,9 @@ jobs: run: | npm install + - name: Run PHPUnit tests + run: ./vendor/bin/phpunit --testdox + - name: Make envfile uses: SpicyPizza/create-envfile@v2.0 with: diff --git a/.github/workflows/php-sandbox.yml b/.github/workflows/php-sandbox.yml index ac5a9f8..0ac0cc3 100644 --- a/.github/workflows/php-sandbox.yml +++ b/.github/workflows/php-sandbox.yml @@ -39,6 +39,9 @@ jobs: run: | npm install + - name: Run PHPUnit tests + run: ./vendor/bin/phpunit --testdox + - name: Make envfile uses: SpicyPizza/create-envfile@v2.0 with: diff --git a/README.md b/README.md index ead25ab..657d4a0 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,53 @@ ngrok http 8000 This command will create a secure tunnel to your localhost server running on port 8000, allowing you to safely test your application's HTTPS functionality. Ensure that your application is running on port 8000 or adjust the port number in the command accordingly. Also update WEBSITE_DOMAIN from you env file +## Tests + +The project includes a PHPUnit test suite covering token management, widget rendering and dashboard display. Tests are fully isolated (SQLite in-memory, mocked dependencies) and require no running database or external API. + +### Run the tests + +```bash +./vendor/bin/phpunit --testdox +``` + +### Test structure + +``` +tests/ +├── bootstrap.php +└── Unit/ + ├── Services/ + │ └── ApiWrapperTest.php # Token generation & refresh (OAuth) + ├── Repositories/ + │ └── AccessTokenRepositoryTest.php # Token storage & retrieval (SQLite) + └── Controllers/ + ├── AdminControllerTest.php # Dashboard display (admin / user roles) + └── WidgetControllerTest.php # Alert & donation widgets (render + JSON fetch) +``` + +### Coverage areas + +| Suite | Tests | What is verified | +|---|---|---| +| **Token generation** | 6 | Valid token returned, generation when absent, regeneration when expired, API errors | +| **Token refresh** | 6 | Successful refresh, DB update, API errors, expired refresh token | +| **Token storage** | 10 | Insert, update, select by slug, null slug (global token), unknown org | +| **Dashboard** | 4 | Admin vs user template, flash messages forwarded, query isolation | +| **Widget Alert** | 8 | 200 render, 400/404/500 errors, JSON fetch, cache update | +| **Widget Donation** | 9 | 200 render, 400/404 errors, JSON fetch, cache update on amount change, multi-stream events | + +### CI integration + +Tests run automatically on every push via GitHub Actions, **before** deployment, on both environments: + +| Workflow | Branch | Trigger | +|---|---|---| +| `php-sandbox.yml` | `develop` | Push on develop | +| `php-prod.yml` | `main` | Push on main | + +A test failure blocks the pipeline immediately — the artifact is never built or deployed. + ## Architecture & Code Organization ### Framework & Technologies diff --git a/composer.json b/composer.json index df7a349..46b6138 100644 --- a/composer.json +++ b/composer.json @@ -17,9 +17,17 @@ "slim/flash": "^0.4.0", "filp/whoops": "^2.18" }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, "autoload": { "psr-4": { "App\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } } } diff --git a/composer.lock b/composer.lock index 9105a79..2fa5aaf 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": "c0b8ae210ac80fcafb5156524bf848a1", + "content-hash": "ece681001b4a03dd54f30486e2f12178", "packages": [ { "name": "d4h/pkce", @@ -2632,7 +2632,1789 @@ "time": "2024-07-20T21:52:34+00:00" } ], - "packages-dev": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": {}, @@ -2642,5 +4424,5 @@ "php": "^8.3" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..5a7317f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ + + + + + + tests/Unit + + + + + + src + + + + + diff --git a/tests/Unit/Controllers/AdminControllerTest.php b/tests/Unit/Controllers/AdminControllerTest.php new file mode 100644 index 0000000..d74f6a8 --- /dev/null +++ b/tests/Unit/Controllers/AdminControllerTest.php @@ -0,0 +1,229 @@ +view = $this->createMock(Twig::class); + $this->fileManager = $this->createMock(FileManager::class); + $this->eventRepository = $this->createMock(EventRepository::class); + $this->streamRepository = $this->createMock(StreamRepository::class); + $this->userRepository = $this->createMock(UserRepository::class); + $this->widgetRepository = $this->createMock(WidgetRepository::class); + $this->messages = $this->createMock(Messages::class); + $this->apiWrapper = $this->createMock(ApiWrapper::class); + $this->accessTokenRepository = $this->createMock(AccessTokenRepository::class); + $this->authorizationCodeRepository = $this->createMock(AuthorizationCodeRepository::class); + + $this->controller = new AdminController( + $this->view, + $this->fileManager, + $this->eventRepository, + $this->streamRepository, + $this->userRepository, + $this->widgetRepository, + $this->messages, + $this->apiWrapper, + $this->accessTokenRepository, + $this->authorizationCodeRepository, + ); + } + + // ===================================================================== + // Tests d'affichage du dashboard (index) + // ===================================================================== + + public function testIndexRendersAdminTemplateForAdminUser(): void + { + $adminUser = $this->buildUser('ADMIN'); + $streams = [$this->buildStream('stream-1'), $this->buildStream('stream-2')]; + $events = [$this->buildEvent('event-1')]; + + $this->streamRepository->expects($this->once())->method('selectList')->willReturn($streams); + $this->eventRepository->expects($this->once())->method('selectList')->willReturn($events); + $this->messages->method('getMessages')->willReturn([]); + + $capturedTemplate = null; + $capturedData = null; + + $this->view->expects($this->once()) + ->method('render') + ->willReturnCallback( + function (ResponseInterface $response, string $template, array $data) use (&$capturedTemplate, &$capturedData) { + $capturedTemplate = $template; + $capturedData = $data; + $response->getBody()->write('admin'); + return $response; + } + ); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/admin') + ->withAttribute('user', $adminUser); + $response = new Response(); + + $result = $this->controller->index($request, $response); + + $this->assertEquals('stream/index-admin.html.twig', $capturedTemplate); + $this->assertCount(2, $capturedData['streams']); + $this->assertCount(1, $capturedData['events']); + $this->assertEquals(200, $result->getStatusCode()); + } + + public function testIndexRendersUserTemplateForRegularUser(): void + { + $regularUser = $this->buildUser('USER'); + + $this->streamRepository->expects($this->once()) + ->method('selectListByUser') + ->with($regularUser) + ->willReturn([$this->buildStream('my-stream')]); + + $this->eventRepository->expects($this->once()) + ->method('selectListByUser') + ->with($regularUser) + ->willReturn([]); + + $this->messages->method('getMessages')->willReturn([]); + + $capturedTemplate = null; + $this->view->expects($this->once()) + ->method('render') + ->willReturnCallback( + function (ResponseInterface $response, string $template, array $data) use (&$capturedTemplate) { + $capturedTemplate = $template; + $response->getBody()->write('user'); + return $response; + } + ); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/admin') + ->withAttribute('user', $regularUser); + $response = new Response(); + + $result = $this->controller->index($request, $response); + + $this->assertEquals('stream/index.html.twig', $capturedTemplate); + $this->assertEquals(200, $result->getStatusCode()); + } + + public function testIndexPassesFlashMessagesToTemplate(): void + { + $adminUser = $this->buildUser('ADMIN'); + $flashMessages = ['success' => ['Opération réussie']]; + + $this->streamRepository->method('selectList')->willReturn([]); + $this->eventRepository->method('selectList')->willReturn([]); + $this->messages->method('getMessages')->willReturn($flashMessages); + + $capturedData = null; + $this->view->method('render') + ->willReturnCallback( + function (ResponseInterface $response, string $template, array $data) use (&$capturedData) { + $capturedData = $data; + $response->getBody()->write(''); + return $response; + } + ); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/admin') + ->withAttribute('user', $adminUser); + $response = new Response(); + + $this->controller->index($request, $response); + + $this->assertEquals($flashMessages, $capturedData['messages']); + } + + public function testIndexAdminDoesNotCallSelectListByUser(): void + { + $adminUser = $this->buildUser('ADMIN'); + + $this->streamRepository->expects($this->once())->method('selectList')->willReturn([]); + $this->streamRepository->expects($this->never())->method('selectListByUser'); + $this->eventRepository->expects($this->once())->method('selectList')->willReturn([]); + $this->eventRepository->expects($this->never())->method('selectListByUser'); + $this->messages->method('getMessages')->willReturn([]); + + $this->view->method('render')->willReturnCallback( + fn(ResponseInterface $r) => $r + ); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/admin') + ->withAttribute('user', $adminUser); + + $this->controller->index($request, new Response()); + } + + // ===================================================================== + // Helpers + // ===================================================================== + + private function buildUser(string $role): User + { + $user = new User(); + $user->id = 1; + $user->email = 'test@example.com'; + $user->role = $role; + return $user; + } + + private function buildStream(string $guid): Stream + { + $stream = new Stream(); + $stream->id = rand(1, 1000); + $stream->guid = $guid; + $stream->title = 'Test Stream ' . $guid; + $stream->organization_slug = 'test-org'; + $stream->form_slug = 'test-form'; + return $stream; + } + + private function buildEvent(string $guid): Event + { + $event = new Event(); + $event->id = rand(1, 1000); + $event->guid = $guid; + $event->title = 'Test Event ' . $guid; + return $event; + } +} + diff --git a/tests/Unit/Controllers/WidgetControllerTest.php b/tests/Unit/Controllers/WidgetControllerTest.php new file mode 100644 index 0000000..267def7 --- /dev/null +++ b/tests/Unit/Controllers/WidgetControllerTest.php @@ -0,0 +1,406 @@ +view = $this->createMock(Twig::class); + $this->apiWrapper = $this->createMock(ApiWrapper::class); + $this->fileManager = $this->createMock(FileManager::class); + $this->eventRepository = $this->createMock(EventRepository::class); + $this->streamRepository = $this->createMock(StreamRepository::class); + $this->widgetRepository = $this->createMock(WidgetRepository::class); + + $this->controller = new WidgetController( + $this->view, + $this->apiWrapper, + $this->fileManager, + $this->eventRepository, + $this->streamRepository, + $this->widgetRepository, + ); + } + + // ===================================================================== + // Widget Alert — rendu (affichage) + // ===================================================================== + + public function testWidgetAlertRendersWithoutError(): void + { + $streamGuid = 'alert-stream-guid'; + $stream = $this->buildStream($streamGuid); + $alertWidget = $this->buildAlertWidget($streamGuid); + + $this->widgetRepository->method('selectAlertWidgetByGuid')->willReturn($alertWidget); + $this->streamRepository->method('selectByGuid')->willReturn($stream); + $this->widgetRepository->method('selectAlertWidgetCacheData')->willReturn(null); + $this->apiWrapper->method('getAllOrders')->willReturn([ + 'amount' => 150, + 'donations' => [], + 'continuation_token' => 'token123', + ]); + $this->fileManager->method('getPictureUrl')->willReturn('https://cdn.example.com/img.png'); + $this->fileManager->method('getSoundUrl')->willReturn('https://cdn.example.com/sound.mp3'); + + $capturedTemplate = null; + $capturedData = null; + $this->view->method('render') + ->willReturnCallback( + function (ResponseInterface $response, string $template, array $data) use (&$capturedTemplate, &$capturedData) { + $capturedTemplate = $template; + $capturedData = $data; + $response->getBody()->write('alert widget'); + return $response; + } + ); + + $request = (new ServerRequestFactory())->createServerRequest('GET', "/widget-stream-alert/{$streamGuid}"); + $result = $this->controller->widgetAlert($request, new Response(), ['id' => $streamGuid]); + + $this->assertEquals(200, $result->getStatusCode()); + $this->assertEquals('widget/alert.html.twig', $capturedTemplate); + $this->assertArrayHasKey('alertBoxWidget', $capturedData); + $this->assertArrayHasKey('alertBoxWidgetPictureUrl', $capturedData); + $this->assertArrayHasKey('alertBoxWidgetSoundUrl', $capturedData); + } + + public function testWidgetAlertThrowsWhenNoIdProvided(): void + { + $this->expectException(Exception::class); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/widget-stream-alert/'); + $this->controller->widgetAlert($request, new Response(), ['id' => '']); + } + + public function testWidgetAlertThrowsWhenWidgetNotFound(): void + { + $this->widgetRepository->method('selectAlertWidgetByGuid')->willReturn(null); + + $this->expectException(Exception::class); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/widget-stream-alert/unknown'); + $this->controller->widgetAlert($request, new Response(), ['id' => 'unknown-guid']); + } + + public function testWidgetAlertThrowsWhenStreamNotFound(): void + { + $this->widgetRepository->method('selectAlertWidgetByGuid')->willReturn($this->buildAlertWidget('g')); + $this->streamRepository->method('selectByGuid')->willReturn(null); + + $this->expectException(Exception::class); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/widget-stream-alert/g'); + $this->controller->widgetAlert($request, new Response(), ['id' => 'g']); + } + + // ===================================================================== + // Widget Alert — endpoint JSON (fetch) + // ===================================================================== + + public function testWidgetAlertFetchReturnsJsonOnSuccess(): void + { + $streamGuid = 'fetch-stream-guid'; + $stream = $this->buildStream($streamGuid); + $ordersResult = [ + 'amount' => 0, + 'donations' => [['pseudo' => 'Alice', 'message' => 'Bravo!', 'amount' => 500]], + 'continuation_token' => 'newToken123', + ]; + + $this->streamRepository->method('selectByGuid')->willReturn($stream); + $this->widgetRepository->method('selectAlertWidgetCacheData')->willReturn(['continuation_token' => '']); + $this->apiWrapper->method('getAllOrders')->willReturn($ordersResult); + $this->widgetRepository->method('updateAlertWidgetCacheData'); + + $request = (new ServerRequestFactory())->createServerRequest('GET', "/widget-stream-alert/{$streamGuid}/fetch"); + $result = $this->controller->widgetAlertFetch($request, new Response(), ['id' => $streamGuid]); + + $this->assertEquals(200, $result->getStatusCode()); + $this->assertStringContainsString('application/json', $result->getHeaderLine('Content-Type')); + + $body = json_decode((string) $result->getBody(), true); + $this->assertIsArray($body); + $this->assertArrayHasKey('donations', $body); + } + + public function testWidgetAlertFetchReturns400WhenIdMissing(): void + { + $request = (new ServerRequestFactory())->createServerRequest('GET', '/widget-stream-alert//fetch'); + $result = $this->controller->widgetAlertFetch($request, new Response(), ['id' => '']); + + $this->assertEquals(400, $result->getStatusCode()); + $body = json_decode((string) $result->getBody(), true); + $this->assertArrayHasKey('error', $body); + } + + public function testWidgetAlertFetchReturns404WhenStreamNotFound(): void + { + $this->streamRepository->method('selectByGuid')->willReturn(null); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/widget-stream-alert/ghost/fetch'); + $result = $this->controller->widgetAlertFetch($request, new Response(), ['id' => 'ghost-guid']); + + $this->assertEquals(404, $result->getStatusCode()); + $body = json_decode((string) $result->getBody(), true); + $this->assertArrayHasKey('error', $body); + } + + public function testWidgetAlertFetchReturns500OnApiException(): void + { + $stream = $this->buildStream('error-stream'); + $this->streamRepository->method('selectByGuid')->willReturn($stream); + $this->widgetRepository->method('selectAlertWidgetCacheData')->willReturn(null); + $this->apiWrapper->method('getAllOrders')->willThrowException(new Exception('API error')); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/widget-stream-alert/error-stream/fetch'); + $result = $this->controller->widgetAlertFetch($request, new Response(), ['id' => 'error-stream']); + + $this->assertEquals(500, $result->getStatusCode()); + } + + // ===================================================================== + // Widget Donation (stream) — rendu (affichage) + // ===================================================================== + + public function testWidgetDonationRendersWithoutError(): void + { + $streamGuid = 'donation-stream-guid'; + $stream = $this->buildStream($streamGuid); + $donationWidget = $this->buildDonationWidget($streamGuid, null); + + $this->widgetRepository->method('selectDonationWidgetByGuid')->willReturn($donationWidget); + $this->streamRepository->method('selectByGuid')->willReturn($stream); + $this->widgetRepository->method('selectStreamDonationWidgetCacheData')->willReturn(null); + $this->apiWrapper->method('getAllOrders')->willReturn([ + 'amount' => 3000, + 'donations' => [], + 'continuation_token' => null, + ]); + + $capturedTemplate = null; + $capturedData = null; + $this->view->method('render') + ->willReturnCallback( + function (ResponseInterface $response, string $template, array $data) use (&$capturedTemplate, &$capturedData) { + $capturedTemplate = $template; + $capturedData = $data; + $response->getBody()->write('donation widget'); + return $response; + } + ); + + $request = (new ServerRequestFactory())->createServerRequest('GET', "/widget-stream-donation/{$streamGuid}"); + $result = $this->controller->widgetDonation($request, new Response(), ['id' => $streamGuid]); + + $this->assertEquals(200, $result->getStatusCode()); + $this->assertEquals('widget/donation.html.twig', $capturedTemplate); + $this->assertEquals(3000, $capturedData['currentAmount']); + $this->assertArrayHasKey('donationGoalWidget', $capturedData); + } + + public function testWidgetDonationThrowsWhenNoIdProvided(): void + { + $this->expectException(Exception::class); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/widget-stream-donation/'); + $this->controller->widgetDonation($request, new Response(), ['id' => '']); + } + + public function testWidgetDonationThrowsWhenWidgetNotFound(): void + { + $this->widgetRepository->method('selectDonationWidgetByGuid')->willReturn(null); + + $this->expectException(Exception::class); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/widget-stream-donation/x'); + $this->controller->widgetDonation($request, new Response(), ['id' => 'x']); + } + + // ===================================================================== + // Widget Donation (stream) — endpoint JSON (fetch) + // ===================================================================== + + public function testWidgetDonationFetchReturnsJsonOnSuccess(): void + { + $streamGuid = 'fetch-donation-guid'; + $stream = $this->buildStream($streamGuid); + + $this->streamRepository->method('selectByGuid')->willReturn($stream); + $this->widgetRepository->method('selectStreamDonationWidgetCacheData') + ->willReturn(['amount' => 1000, 'continuation_token' => 'prev_token']); + $this->apiWrapper->method('getAllOrders')->willReturn([ + 'amount' => 1500, + 'donations' => [], + 'continuation_token' => 'new_token', + ]); + $this->widgetRepository->method('updateStreamDonationWidgetCacheData'); + + $request = (new ServerRequestFactory())->createServerRequest('GET', "/widget-stream-donation/{$streamGuid}/fetch"); + $result = $this->controller->widgetDonationFetch($request, new Response(), ['id' => $streamGuid]); + + $this->assertEquals(200, $result->getStatusCode()); + $this->assertStringContainsString('application/json', $result->getHeaderLine('Content-Type')); + + $body = json_decode((string) $result->getBody(), true); + $this->assertEquals(1500, $body['amount']); + } + + public function testWidgetDonationFetchReturns400WhenIdMissing(): void + { + $request = (new ServerRequestFactory())->createServerRequest('GET', '/widget-stream-donation//fetch'); + $result = $this->controller->widgetDonationFetch($request, new Response(), ['id' => '']); + + $this->assertEquals(400, $result->getStatusCode()); + } + + public function testWidgetDonationFetchReturns404WhenStreamNotFound(): void + { + $this->streamRepository->method('selectByGuid')->willReturn(null); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/widget-stream-donation/ghost/fetch'); + $result = $this->controller->widgetDonationFetch($request, new Response(), ['id' => 'ghost-guid']); + + $this->assertEquals(404, $result->getStatusCode()); + } + + public function testWidgetDonationFetchUpdatesCacheWhenAmountChanged(): void + { + $streamGuid = 'cache-update-guid'; + $stream = $this->buildStream($streamGuid); + + $this->streamRepository->method('selectByGuid')->willReturn($stream); + $this->widgetRepository->method('selectStreamDonationWidgetCacheData') + ->willReturn(['amount' => 500, 'continuation_token' => null]); + $this->apiWrapper->method('getAllOrders')->willReturn([ + 'amount' => 800, + 'donations' => [], + 'continuation_token' => 'ct_new', + ]); + + // Vérifie que le cache est mis à jour + $this->widgetRepository->expects($this->once()) + ->method('updateStreamDonationWidgetCacheData') + ->with($streamGuid, ['amount' => 800, 'continuation_token' => 'ct_new']); + + $request = (new ServerRequestFactory())->createServerRequest('GET', "/widget-stream-donation/{$streamGuid}/fetch"); + $this->controller->widgetDonationFetch($request, new Response(), ['id' => $streamGuid]); + } + + // ===================================================================== + // Widget Donation (event) — endpoint JSON (fetch) + // ===================================================================== + + public function testWidgetEventDonationFetchReturnsJsonOnSuccess(): void + { + $eventGuid = 'event-donation-guid'; + $event = $this->buildEvent($eventGuid); + $streams = [$this->buildStream('s1'), $this->buildStream('s2')]; + + $this->eventRepository->method('selectByGuid')->willReturn($event); + $this->widgetRepository->method('selectEventDonationWidgetCacheData')->willReturn(null); + $this->streamRepository->method('selectListByEvent')->willReturn($streams); + $this->apiWrapper->method('getAllOrders')->willReturn([ + 'amount' => 2000, + 'donations' => [], + 'continuation_token' => null, + ]); + + $request = (new ServerRequestFactory())->createServerRequest('GET', "/widget-event-donation/{$eventGuid}/fetch"); + $result = $this->controller->widgetEventDonationFetch($request, new Response(), ['id' => $eventGuid]); + + $this->assertEquals(200, $result->getStatusCode()); + $body = json_decode((string) $result->getBody(), true); + $this->assertArrayHasKey('amount', $body); + } + + public function testWidgetEventDonationFetchReturns404WhenEventNotFound(): void + { + $this->eventRepository->method('selectByGuid')->willReturn(null); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/widget-event-donation/ghost/fetch'); + $result = $this->controller->widgetEventDonationFetch($request, new Response(), ['id' => 'ghost-event']); + + $this->assertEquals(404, $result->getStatusCode()); + } + + // ===================================================================== + // Helpers + // ===================================================================== + + private function buildStream(string $guid): Stream + { + $stream = new Stream(); + $stream->id = rand(1, 9999); + $stream->guid = $guid; + $stream->title = 'Stream ' . $guid; + $stream->organization_slug = 'test-org'; + $stream->form_slug = 'test-form'; + return $stream; + } + + private function buildEvent(string $guid): Event + { + $event = new Event(); + $event->id = rand(1, 9999); + $event->guid = $guid; + $event->title = 'Event ' . $guid; + return $event; + } + + private function buildAlertWidget(string $streamGuid): WidgetAlert + { + $widget = new WidgetAlert(); + $widget->id = 1; + $widget->charity_stream_guid = $streamGuid; + $widget->alert_duration = 5; + $widget->message_template = '{pseudo} a donné {amount}'; + $widget->sound_volume = 50; + $widget->image = 'image.png'; + $widget->sound = 'sound.mp3'; + return $widget; + } + + private function buildDonationWidget(?string $streamGuid, ?string $eventGuid): WidgetDonation + { + $widget = new WidgetDonation(); + $widget->id = 1; + $widget->charity_stream_guid = $streamGuid; + $widget->charity_event_guid = $eventGuid; + $widget->goal = 5000; + $widget->text_content = 'Objectif : {goal}€'; + $widget->bar_color = '#00ff00'; + $widget->background_color = '#000000'; + $widget->text_color_main = '#ffffff'; + $widget->text_color_alt = '#cccccc'; + return $widget; + } +} + diff --git a/tests/Unit/Repositories/AccessTokenRepositoryTest.php b/tests/Unit/Repositories/AccessTokenRepositoryTest.php new file mode 100644 index 0000000..a8c1bbe --- /dev/null +++ b/tests/Unit/Repositories/AccessTokenRepositoryTest.php @@ -0,0 +1,186 @@ +pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->pdo->exec("CREATE TABLE IF NOT EXISTS `{$this->prefix}access_token_partner_organization` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + organization_slug TEXT DEFAULT NULL, + access_token_expires_at DATETIME, + refresh_token_expires_at DATETIME, + creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, + last_update DATETIME DEFAULT CURRENT_TIMESTAMP + )"); + + $this->repository = new AccessTokenRepository($this->pdo, $this->prefix); + } + + // ===================================================================== + // Insertion d'un nouveau token + // ===================================================================== + + public function testInsertTokenAssignsId(): void + { + $token = $this->buildToken('access_1', 'refresh_1', 'org-a'); + + $result = $this->repository->insert($token); + + $this->assertNotNull($result->id); + $this->assertGreaterThan(0, (int) $result->id); + } + + public function testInsertTokenPersistsAllFields(): void + { + $token = $this->buildToken('access_abc', 'refresh_abc', 'org-b'); + + $this->repository->insert($token); + $fetched = $this->repository->selectBySlug('org-b'); + + $this->assertNotNull($fetched); + $this->assertEquals('access_abc', $fetched->access_token); + $this->assertEquals('refresh_abc', $fetched->refresh_token); + $this->assertEquals('org-b', $fetched->organization_slug); + } + + public function testInsertGlobalTokenWithNullSlug(): void + { + $token = $this->buildToken('global_access', 'global_refresh', null); + + $this->repository->insert($token); + $fetched = $this->repository->selectBySlug(null); + + $this->assertNotNull($fetched); + $this->assertEquals('global_access', $fetched->access_token); + $this->assertNull($fetched->organization_slug); + } + + public function testInsertMultipleTokensAreSeparated(): void + { + $this->repository->insert($this->buildToken('token_org1', 'refresh_org1', 'org-1')); + $this->repository->insert($this->buildToken('token_org2', 'refresh_org2', 'org-2')); + + $r1 = $this->repository->selectBySlug('org-1'); + $r2 = $this->repository->selectBySlug('org-2'); + + $this->assertEquals('token_org1', $r1->access_token); + $this->assertEquals('token_org2', $r2->access_token); + } + + // ===================================================================== + // Sélection par slug + // ===================================================================== + + public function testSelectBySlugReturnsNullForUnknownOrg(): void + { + $result = $this->repository->selectBySlug('unknown-org'); + $this->assertNull($result); + } + + public function testSelectBySlugReturnsCorrectModel(): void + { + $this->repository->insert($this->buildToken('found_token', 'found_refresh', 'found-org')); + + $result = $this->repository->selectBySlug('found-org'); + + $this->assertInstanceOf(AccessToken::class, $result); + $this->assertEquals('found_token', $result->access_token); + } + + // ===================================================================== + // Mise à jour d'un token + // ===================================================================== + + public function testUpdateTokenReplacesValues(): void + { + $this->repository->insert($this->buildToken('old_access', 'old_refresh', 'update-org')); + + $updated = $this->buildToken('new_access', 'new_refresh', 'update-org'); + $result = $this->repository->update($updated); + + $this->assertNotNull($result); + $this->assertEquals('new_access', $result->access_token); + $this->assertEquals('new_refresh', $result->refresh_token); + + // Vérification en base + $fetched = $this->repository->selectBySlug('update-org'); + $this->assertEquals('new_access', $fetched->access_token); + $this->assertEquals('new_refresh', $fetched->refresh_token); + } + + public function testUpdateTokenAssignsId(): void + { + $this->repository->insert($this->buildToken('old', 'old_r', 'id-org')); + $updated = $this->buildToken('new', 'new_r', 'id-org'); + + $result = $this->repository->update($updated); + + $this->assertNotNull($result->id); + $this->assertGreaterThan(0, (int) $result->id); + } + + public function testUpdateNonExistentTokenReturnsNull(): void + { + $token = $this->buildToken('some_access', 'some_refresh', 'does-not-exist'); + + $result = $this->repository->update($token); + + $this->assertNull($result); + } + + public function testUpdateGlobalToken(): void + { + $this->repository->insert($this->buildToken('global_old', 'global_r_old', null)); + + $updated = $this->buildToken('global_new', 'global_r_new', null); + $result = $this->repository->update($updated); + + $this->assertNotNull($result); + $this->assertEquals('global_new', $result->access_token); + } + + // ===================================================================== + // Helpers + // ===================================================================== + + private function buildToken( + string $access, + string $refresh, + ?string $slug, + int $accessMinutes = 28, + int $refreshDays = 28 + ): AccessToken { + $token = new AccessToken(); + $token->access_token = $access; + $token->refresh_token = $refresh; + $token->organization_slug = $slug; + $token->access_token_expires_at = (new DateTime())->add(new DateInterval("PT{$accessMinutes}M")); + $token->refresh_token_expires_at = (new DateTime())->add(new DateInterval("P{$refreshDays}D")); + return $token; + } +} + diff --git a/tests/Unit/Services/ApiWrapperTest.php b/tests/Unit/Services/ApiWrapperTest.php new file mode 100644 index 0000000..caf4577 --- /dev/null +++ b/tests/Unit/Services/ApiWrapperTest.php @@ -0,0 +1,357 @@ +accessTokenRepository = $this->createMock(AccessTokenRepository::class); + $this->authorizationCodeRepository = $this->createMock(AuthorizationCodeRepository::class); + + $this->logger = new Logger('test'); + $this->logger->pushHandler(new NullHandler()); + + $this->apiWrapper = new ApiWrapper( + $this->accessTokenRepository, + $this->authorizationCodeRepository, + 'https://auth.helloasso.com', + 'https://api.helloasso.com', + 'https://api.helloasso.com/oauth2/token', + 'test_client_id', + 'test_client_secret', + 'https://my-widget.test', + $this->logger + ); + } + + /** + * Injecte un client Guzzle mockée dans ApiWrapper via Reflection. + */ + private function injectMockHttpClient(array $responses): void + { + $mockHandler = new MockHandler($responses); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + + $reflection = new ReflectionClass($this->apiWrapper); + $clientProperty = $reflection->getProperty('client'); + $clientProperty->setValue($this->apiWrapper, $mockClient); + } + + // ===================================================================== + // Génération du token global (client_credentials) + // ===================================================================== + + public function testGetGlobalAccessTokenReturnsExistingValidToken(): void + { + $token = new AccessToken(); + $token->access_token = 'existing_valid_token'; + $token->access_token_expires_at = (new DateTime())->add(new DateInterval('PT30M')); + $token->refresh_token_expires_at = (new DateTime())->add(new DateInterval('P28D')); + + $this->accessTokenRepository + ->expects($this->once()) + ->method('selectBySlug') + ->with(null) + ->willReturn($token); + + $result = $this->apiWrapper->getGlobalAccessToken(); + + $this->assertSame($token, $result); + $this->assertEquals('existing_valid_token', $result->access_token); + } + + public function testGetGlobalAccessTokenGeneratesNewWhenNoneExists(): void + { + $newToken = new AccessToken(); + $newToken->access_token = 'brand_new_token'; + $newToken->refresh_token = 'brand_new_refresh'; + $newToken->access_token_expires_at = (new DateTime())->add(new DateInterval('PT28M')); + $newToken->refresh_token_expires_at = (new DateTime())->add(new DateInterval('P28D')); + + $this->accessTokenRepository + ->method('selectBySlug') + ->with(null) + ->willReturn(null); + + $this->accessTokenRepository + ->expects($this->once()) + ->method('insert') + ->willReturn($newToken); + + $this->injectMockHttpClient([ + new Response(200, [], json_encode([ + 'access_token' => 'brand_new_token', + 'refresh_token' => 'brand_new_refresh', + 'expires_in' => 1700, + ])), + ]); + + $result = $this->apiWrapper->getGlobalAccessToken(); + + $this->assertEquals('brand_new_token', $result->access_token); + $this->assertEquals('brand_new_refresh', $result->refresh_token); + } + + public function testGetGlobalAccessTokenRegeneratesWhenExpired(): void + { + $expiredToken = new AccessToken(); + $expiredToken->access_token = 'expired_token'; + $expiredToken->access_token_expires_at = (new DateTime())->sub(new DateInterval('PT10M')); + $expiredToken->refresh_token_expires_at = (new DateTime())->sub(new DateInterval('P1D')); + + $refreshedToken = new AccessToken(); + $refreshedToken->access_token = 'refreshed_token'; + $refreshedToken->refresh_token = 'refreshed_refresh'; + $refreshedToken->access_token_expires_at = (new DateTime())->add(new DateInterval('PT28M')); + $refreshedToken->refresh_token_expires_at = (new DateTime())->add(new DateInterval('P28D')); + + $this->accessTokenRepository + ->method('selectBySlug') + ->willReturn($expiredToken); + + $this->accessTokenRepository + ->expects($this->once()) + ->method('update') + ->willReturn($refreshedToken); + + $this->injectMockHttpClient([ + new Response(200, [], json_encode([ + 'access_token' => 'refreshed_token', + 'refresh_token' => 'refreshed_refresh', + 'expires_in' => 1700, + ])), + ]); + + $result = $this->apiWrapper->getGlobalAccessToken(); + + $this->assertEquals('refreshed_token', $result->access_token); + } + + public function testGetGlobalAccessTokenThrowsOnMissingTokensInResponse(): void + { + $this->accessTokenRepository + ->method('selectBySlug') + ->willReturn(null); + + $this->injectMockHttpClient([ + new Response(200, [], json_encode(['error' => 'invalid_client'])), + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/Les tokens ne sont pas présents/'); + + $this->apiWrapper->getGlobalAccessToken(); + } + + public function testGetGlobalAccessTokenThrowsOnApiConnectionError(): void + { + $this->accessTokenRepository + ->method('selectBySlug') + ->willReturn(null); + + $request = new GuzzleRequest('POST', 'https://api.helloasso.com/oauth2/token'); + $this->injectMockHttpClient([ + new RequestException('Connection refused', $request), + ]); + + $this->expectException(Exception::class); + + $this->apiWrapper->getGlobalAccessToken(); + } + + public function testGetGlobalAccessTokenThrowsOnInvalidJson(): void + { + $this->accessTokenRepository + ->method('selectBySlug') + ->willReturn(null); + + $this->injectMockHttpClient([ + new Response(200, [], 'not_valid_json{{{'), + ]); + + $this->expectException(Exception::class); + + $this->apiWrapper->getGlobalAccessToken(); + } + + // ===================================================================== + // Rafraîchissement du token organisation (refresh_token) + // ===================================================================== + + public function testRefreshTokenSuccess(): void + { + $updatedToken = new AccessToken(); + $updatedToken->access_token = 'new_access_token'; + $updatedToken->refresh_token = 'new_refresh_token'; + $updatedToken->organization_slug = 'test-org'; + $updatedToken->access_token_expires_at = (new DateTime())->add(new DateInterval('PT28M')); + $updatedToken->refresh_token_expires_at = (new DateTime())->add(new DateInterval('P28D')); + + // Simule un token existant pour la mise à jour en base + $existingToken = new AccessToken(); + $existingToken->id = 1; + $existingToken->organization_slug = 'test-org'; + + $this->accessTokenRepository + ->method('selectBySlug') + ->with('test-org') + ->willReturn($existingToken); + + $this->accessTokenRepository + ->expects($this->once()) + ->method('update') + ->willReturn($updatedToken); + + $this->injectMockHttpClient([ + new Response(200, [], json_encode([ + 'access_token' => 'new_access_token', + 'refresh_token' => 'new_refresh_token', + ])), + ]); + + $result = $this->apiWrapper->refreshToken('old_refresh_token', 'test-org'); + + $this->assertNotNull($result); + $this->assertEquals('new_access_token', $result->access_token); + $this->assertEquals('new_refresh_token', $result->refresh_token); + } + + public function testRefreshTokenThrowsOnInvalidApiResponse(): void + { + $this->injectMockHttpClient([ + new Response(200, [], json_encode(['error' => 'invalid_grant'])), + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/Les tokens ne sont pas présents/'); + + $this->apiWrapper->refreshToken('invalid_refresh', 'test-org'); + } + + public function testRefreshTokenThrowsOnApiError(): void + { + $request = new GuzzleRequest('POST', 'https://api.helloasso.com/oauth2/token'); + $this->injectMockHttpClient([ + new RequestException('Unauthorized', $request, new Response(401)), + ]); + + $this->expectException(Exception::class); + + $this->apiWrapper->refreshToken('expired_token', 'test-org'); + } + + // ===================================================================== + // Récupération du token organisation avec gestion d'expiration + // ===================================================================== + + public function testGetOrganizationAccessTokenReturnsValidToken(): void + { + $token = new AccessToken(); + $token->organization_slug = 'my-org'; + $token->access_token = 'valid_org_token'; + $token->refresh_token = 'valid_refresh'; + $token->access_token_expires_at = (new DateTime())->add(new DateInterval('PT20M')); + $token->refresh_token_expires_at = (new DateTime())->add(new DateInterval('P20D')); + + $this->accessTokenRepository + ->expects($this->once()) + ->method('selectBySlug') + ->with('my-org') + ->willReturn($token); + + $result = $this->apiWrapper->getOrganizationAccessToken('my-org'); + + $this->assertEquals('valid_org_token', $result->access_token); + $this->assertEquals('my-org', $result->organization_slug); + } + + public function testGetOrganizationAccessTokenRefreshesWhenAccessTokenExpired(): void + { + $expiredToken = new AccessToken(); + $expiredToken->organization_slug = 'my-org'; + $expiredToken->access_token = 'expired_access'; + $expiredToken->refresh_token = 'valid_refresh'; + $expiredToken->access_token_expires_at = (new DateTime())->sub(new DateInterval('PT5M')); + $expiredToken->refresh_token_expires_at = (new DateTime())->add(new DateInterval('P20D')); + + $refreshedToken = new AccessToken(); + $refreshedToken->organization_slug = 'my-org'; + $refreshedToken->access_token = 'new_access_token'; + $refreshedToken->refresh_token = 'new_refresh_token'; + $refreshedToken->access_token_expires_at = (new DateTime())->add(new DateInterval('PT28M')); + $refreshedToken->refresh_token_expires_at = (new DateTime())->add(new DateInterval('P28D')); + + $this->accessTokenRepository + ->method('selectBySlug') + ->with('my-org') + ->willReturn($expiredToken); + + $this->accessTokenRepository + ->expects($this->once()) + ->method('update') + ->willReturn($refreshedToken); + + $this->injectMockHttpClient([ + new Response(200, [], json_encode([ + 'access_token' => 'new_access_token', + 'refresh_token' => 'new_refresh_token', + ])), + ]); + + $result = $this->apiWrapper->getOrganizationAccessToken('my-org'); + + $this->assertEquals('new_access_token', $result->access_token); + } + + public function testGetOrganizationAccessTokenThrowsWhenRefreshTokenIsExpired(): void + { + $tokenWithExpiredRefresh = new AccessToken(); + $tokenWithExpiredRefresh->organization_slug = 'my-org'; + $tokenWithExpiredRefresh->access_token = 'still_valid_access'; + $tokenWithExpiredRefresh->refresh_token = 'expired_refresh'; + $tokenWithExpiredRefresh->access_token_expires_at = (new DateTime())->add(new DateInterval('PT20M')); + $tokenWithExpiredRefresh->refresh_token_expires_at = (new DateTime())->sub(new DateInterval('P1D')); + + $this->accessTokenRepository + ->method('selectBySlug') + ->willReturn($tokenWithExpiredRefresh); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/refresh_token is expired/'); + + $this->apiWrapper->getOrganizationAccessToken('my-org'); + } +} + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..f09f7df --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,6 @@ + Date: Mon, 11 May 2026 16:08:15 +0200 Subject: [PATCH 2/2] Run test in CI --- .github/workflows/php-tests.yml | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/php-tests.yml diff --git a/.github/workflows/php-tests.yml b/.github/workflows/php-tests.yml new file mode 100644 index 0000000..cfc4210 --- /dev/null +++ b/.github/workflows/php-tests.yml @@ -0,0 +1,49 @@ +name: Run tests + +on: + push: + branches: + - '**' + - '!main' + - '!develop' + pull_request: + branches: + - main + - develop + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + + - name: Check if composer.json exists + id: check_files + uses: andstor/file-existence-action@v1 + with: + files: 'composer.json' + + - name: Run composer install if composer.json exists + if: steps.check_files.outputs.files_exists == 'true' + run: composer validate --no-check-publish && composer install --prefer-dist --no-progress + + - name: Set up Node.js version + uses: actions/setup-node@v3 + with: + node-version: '20.x' + + - name: npm install + run: npm install + + - name: Run PHPUnit tests + run: ./vendor/bin/phpunit --testdox +