From d704be5bff72b24716d5159377ea43de64203045 Mon Sep 17 00:00:00 2001 From: Lang <7system7@gmail.com> Date: Tue, 21 Apr 2026 17:56:04 +0200 Subject: [PATCH] new: pkg: add test cases to back-end w/ real database connection in it #10 --- .env.test | 3 + .gitignore | 5 + Makefile | 23 +- README.md | 4 +- bin/phpunit | 4 + composer.json | 7 +- composer.lock | 2473 ++++++++++++++++++- config/bundles.php | 2 + config/packages/test/framework.yaml | 2 +- config/packages/zenstruck_foundry.yaml | 16 + docs/README.md | 97 +- docs/testing/FACTORIES.md | 265 ++ docs/testing/TESTING.md | 317 +++ phpunit.dist.xml | 48 + src/Story/AppStory.php | 33 + src/Validator/RecaptchaValidator.php | 13 +- symfony.lock | 37 + tests/Controller/GameControllerTest.php | 145 ++ tests/Controller/ProfileControllerTest.php | 146 ++ tests/Controller/SecurityControllerTest.php | 148 ++ tests/Dto/ProfileChartDataDtoTest.php | 89 + tests/Dto/ProfileGameDtoTest.php | 126 + tests/Dto/ProfileStatsDtoTest.php | 134 + tests/Entity/UserStatsTest.php | 142 ++ tests/Factory/ContactMessageFactory.php | 55 + tests/Factory/GamerFactory.php | 51 + tests/Factory/GridFactory.php | 53 + tests/Factory/GridRowFactory.php | 52 + tests/Factory/PlayedGameFactory.php | 108 + tests/Factory/StepFactory.php | 68 + tests/Factory/UserFactory.php | 60 + tests/Factory/WebAuthnCredentialFactory.php | 62 + tests/Integration/FactoryExampleTest.php | 211 ++ tests/README.md | 108 + tests/Service/MercureJwtServiceTest.php | 109 + tests/Service/RecaptchaServiceTest.php | 188 ++ tests/WebTestCase.php | 21 + tests/bootstrap.php | 21 + 38 files changed, 5429 insertions(+), 17 deletions(-) create mode 100644 .env.test create mode 100755 bin/phpunit create mode 100644 config/packages/zenstruck_foundry.yaml create mode 100644 docs/testing/FACTORIES.md create mode 100644 docs/testing/TESTING.md create mode 100644 phpunit.dist.xml create mode 100644 src/Story/AppStory.php create mode 100644 tests/Controller/GameControllerTest.php create mode 100644 tests/Controller/ProfileControllerTest.php create mode 100644 tests/Controller/SecurityControllerTest.php create mode 100644 tests/Dto/ProfileChartDataDtoTest.php create mode 100644 tests/Dto/ProfileGameDtoTest.php create mode 100644 tests/Dto/ProfileStatsDtoTest.php create mode 100644 tests/Entity/UserStatsTest.php create mode 100644 tests/Factory/ContactMessageFactory.php create mode 100644 tests/Factory/GamerFactory.php create mode 100644 tests/Factory/GridFactory.php create mode 100644 tests/Factory/GridRowFactory.php create mode 100644 tests/Factory/PlayedGameFactory.php create mode 100644 tests/Factory/StepFactory.php create mode 100644 tests/Factory/UserFactory.php create mode 100644 tests/Factory/WebAuthnCredentialFactory.php create mode 100644 tests/Integration/FactoryExampleTest.php create mode 100644 tests/README.md create mode 100644 tests/Service/MercureJwtServiceTest.php create mode 100644 tests/Service/RecaptchaServiceTest.php create mode 100644 tests/WebTestCase.php create mode 100644 tests/bootstrap.php diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..64bd111 --- /dev/null +++ b/.env.test @@ -0,0 +1,3 @@ +# define your env variables for the test env here +KERNEL_CLASS='App\Kernel' +APP_SECRET='$ecretf0rt3st' diff --git a/.gitignore b/.gitignore index b95710b..d6dcc7a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ nohup.out /var/ /vendor/ ###< symfony/framework-bundle ### + +###> phpunit/phpunit ### +/phpunit.xml +/.phpunit.cache/ +###< phpunit/phpunit ### diff --git a/Makefile b/Makefile index 515f36e..36f9231 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear +.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear test-db-setup test-db-reset test .DEFAULT_GOAL := help @@ -14,6 +14,9 @@ help: @echo " make ccp - Clear the production cache" @echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)" @echo " make og-cache-clear - Clear Open Graph cache only" + @echo " make test-db-setup - One-time setup: Create test database and run migrations" + @echo " make test-db-reset - Reset test database (drop, create, migrate)" + @echo " make test - Run PHPUnit tests" start: docker compose up -d @@ -80,5 +83,23 @@ og-cache-clear: @echo "✓ OG cache cleared!" @echo " Battle card images will be regenerated on next access" +test-db-setup: + @echo "Setting up test database..." + @bin/console dbal:run-sql "SELECT 1 FROM pg_database WHERE datname='mineseeker_test'" 2>/dev/null | grep -q 1 || \ + (bin/console dbal:run-sql "CREATE DATABASE mineseeker_test" && echo "✓ Database 'mineseeker_test' created") + @bin/console doctrine:migrations:migrate --env=test --no-interaction --allow-no-migration 2>&1 | grep -v "WARNING" || true + @echo "✓ Test database setup complete!" + @echo " Database: mineseeker_test" + @echo " Run tests with: make test" +test-db-reset: + @echo "Resetting test database..." + @bin/console dbal:run-sql "DROP DATABASE IF EXISTS mineseeker_test" --quiet + @bin/console dbal:run-sql "CREATE DATABASE mineseeker_test" --quiet + @bin/console doctrine:migrations:migrate --env=test --no-interaction --quiet + @echo "✓ Test database reset complete!" + @echo " Database: mineseeker_test" + @echo " Run tests with: make test" +test: + @php -d memory_limit=512M bin/phpunit --testdox --colors=always diff --git a/README.md b/README.md index 8ce8832..9d74e22 100644 --- a/README.md +++ b/README.md @@ -289,11 +289,13 @@ git push origin v2026.01 ## Documentation -For detailed information about game mechanics, bonus systems, fonts, and other technical details, see the [docs](./docs/) directory: +For detailed information about game mechanics, bonus systems, fonts, testing, and other technical details, see the [docs](./docs/) directory: - **[AI Agent Guidelines](./AGENTS.md)** — Comprehensive guide for AI coding agents working on this project - **[Bonus Points System](./docs/game-mechanics/BONUS_POINTS_SYSTEM.md)** — Complete reference for all bonus point types, calculation rules, and implementation details - **[Fonts](./docs/FONTS.md)** — TrueType fonts used for server-side image generation +- **[Testing Guide](./docs/testing/TESTING.md)** — Complete testing setup with Foundry factories, database isolation, and best practices +- **[Factory Documentation](./docs/testing/FACTORIES.md)** — Detailed API reference for all test data factories --- diff --git a/bin/phpunit b/bin/phpunit new file mode 100755 index 0000000..ac5eef1 --- /dev/null +++ b/bin/phpunit @@ -0,0 +1,4 @@ +#!/usr/bin/env php += 8.2", + "psr/cache": "^2.0 || ^3.0", + "symfony/cache": "^6.4 || ^7.3 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.3 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<11.0" + }, + "require-dev": { + "behat/behat": "^3.0", + "friendsofphp/php-cs-fixer": "^3.27", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.41|| ^12.3.14", + "symfony/dotenv": "^6.4 || ^7.3 || ^8.0", + "symfony/process": "^6.4 || ^7.3 || ^8.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "DAMA\\DoctrineTestBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "David Maicher", + "email": "mail@dmaicher.de" + } + ], + "description": "Symfony bundle to isolate doctrine database tests and improve test performance", + "keywords": [ + "doctrine", + "isolation", + "performance", + "symfony", + "testing", + "tests" + ], + "support": { + "issues": "https://github.com/dmaicher/doctrine-test-bundle/issues", + "source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.6.0" + }, + "time": "2026-01-21T07:39:44+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, { "name": "nikic/php-parser", "version": "v5.7.0", @@ -9763,6 +10022,597 @@ }, "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": "14.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "24dc6fcf9f2a983de5b3f1199fb01e88d68e7474" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/24dc6fcf9f2a983de5b3f1199fb01e88d68e7474", + "reference": "24dc6fcf9f2a983de5b3f1199fb01e88d68e7474", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.1" + }, + "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": "14.1.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/14.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/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-04-18T05:41:54+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.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": "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/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "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": "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/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "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/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "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/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.1.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddd6401641861cdef94b922ef10d484f436e8dcd", + "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd", + "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.4.1", + "phpunit/php-code-coverage": "^14.1.3", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.1.2", + "sebastian/diff": "^8.1.0", + "sebastian/environment": "^9.3.0", + "sebastian/exporter": "^8.0.2", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.0", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.0", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.1-dev" + } + }, + "autoload": { + "files": [ + "src/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/13.1.7" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-04-18T06:14:52+00:00" + }, { "name": "roave/security-advisories", "version": "dev-master", @@ -10818,6 +11668,1310 @@ ], "time": "2026-04-18T09:19:46+00:00" }, + { + "name": "sebastian/cli-parser", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:39:44+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1", + "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^8.1", + "sebastian/exporter": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-04-14T08:24:42+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "8.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "9c957d730257f49c873f3761674559bd90098a7d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/9c957d730257f49c873f3761674559bd90098a7d", + "reference": "9c957d730257f49c873f3761674559bd90098a7d", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/8.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-04-05T12:02:33+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6767059a30e4277ac95ee034809e793528464768" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6767059a30e4277ac95ee034809e793528464768", + "reference": "6767059a30e4277ac95ee034809e793528464768", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-04-15T12:14:03+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "9cee180ebe62259e3ed48df2212d1fc8cfd971bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/9cee180ebe62259e3ed48df2212d1fc8cfd971bb", + "reference": "9cee180ebe62259e3ed48df2212d1fc8cfd971bb", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-04-15T12:38:05+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:45:13+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:45:54+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:46:36+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:47:13+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "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/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:51:28+00:00" + }, + { + "name": "sebastian/type", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "42412224607bd3931241bbd17f38e0f972f5a916" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/42412224607bd3931241bbd17f38e0f972f5a916", + "reference": "42412224607bd3931241bbd17f38e0f972f5a916", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.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": "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/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:09+00:00" + }, + { + "name": "sebastian/version", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "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": "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/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:52+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/browser-kit", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/41850d8f8ddef9a9cd7314fa9f4902cf48885521", + "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/dom-crawler": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v7.4.8" + }, + "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-03-24T13:12:05+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "b055f228a4178a1d6774909903905e3475f3eac8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b055f228a4178a1d6774909903905e3475f3eac8", + "reference": "b055f228a4178a1d6774909903905e3475f3eac8", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.8" + }, + "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-03-24T13:12:05+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/2918e7c2ba964defca1f5b69c6f74886529e2dc8", + "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.8" + }, + "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-03-24T13:12:05+00:00" + }, { "name": "symfony/dotenv", "version": "v7.4.8", @@ -10995,6 +13149,86 @@ ], "time": "2026-03-18T13:39:06+00:00" }, + { + "name": "symfony/polyfill-php81", + "version": "v1.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "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 backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.36.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": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/web-profiler-bundle", "version": "v7.4.8", @@ -11084,6 +13318,243 @@ } ], "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "zenstruck/assert", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/zenstruck/assert.git", + "reference": "1e32d48847d4e82c345112ca226b21a1a792af0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zenstruck/assert/zipball/1e32d48847d4e82c345112ca226b21a1a792af0a", + "reference": "1e32d48847d4e82c345112ca226b21a1a792af0a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9.5.21", + "symfony/phpunit-bridge": "^6.3|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Zenstruck\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + } + ], + "description": "Standalone, lightweight, framework agnostic, test assertion library.", + "homepage": "https://github.com/zenstruck/assert", + "keywords": [ + "assertion", + "phpunit", + "test" + ], + "support": { + "issues": "https://github.com/zenstruck/assert/issues", + "source": "https://github.com/zenstruck/assert/tree/v1.7.0" + }, + "funding": [ + { + "url": "https://github.com/kbond", + "type": "github" + }, + { + "url": "https://github.com/nikophil", + "type": "github" + } + ], + "time": "2025-12-07T01:59:12+00:00" + }, + { + "name": "zenstruck/foundry", + "version": "v2.9.2", + "source": { + "type": "git", + "url": "https://github.com/zenstruck/foundry.git", + "reference": "9f3fe969d37fd5a0799ca455af9990a88036b6a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zenstruck/foundry/zipball/9f3fe969d37fd5a0799ca455af9990a88036b6a0", + "reference": "9f3fe969d37fd5a0799ca455af9990a88036b6a0", + "shasum": "" + }, + "require": { + "fakerphp/faker": "^1.24", + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/polyfill-php84": "^1.32", + "symfony/polyfill-php85": "^1.33", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4.9|~7.0.9|^7.1.2|^8.0", + "zenstruck/assert": "^1.4" + }, + "conflict": { + "doctrine/cache": "<1.12.1", + "doctrine/persistence": "<2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8", + "dama/doctrine-test-bundle": "^8.0", + "doctrine/collections": "^1.7|^2.0", + "doctrine/common": "^3.2.2", + "doctrine/doctrine-bundle": "^2.10|^3.0", + "doctrine/doctrine-migrations-bundle": "^2.2|^3.0", + "doctrine/mongodb-odm": "^2.4", + "doctrine/mongodb-odm-bundle": "^4.6|^5.0", + "doctrine/orm": "^2.16|^3.0", + "doctrine/persistence": "^2.0|^3.0|^4.0", + "phpunit/phpunit": "^9.5.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/flex": "^2.10", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/maker-bundle": "^1.55", + "symfony/phpunit-bridge": "^6.4.26|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^3.4", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "webmozart/assert": "^1.11" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Zenstruck\\Foundry\\Psalm\\FoundryPlugin" + }, + "symfony": { + "allow-contrib": false + }, + "bamarni-bin": { + "bin-links": true, + "forward-command": false, + "target-directory": "bin/tools" + } + }, + "autoload": { + "files": [ + "src/functions.php", + "src/Persistence/functions.php", + "src/symfony_console.php" + ], + "psr-4": { + "Zenstruck\\Foundry\\": "src/", + "Zenstruck\\Foundry\\Psalm\\": "utils/psalm", + "Zenstruck\\Foundry\\Utils\\Rector\\": "utils/rector/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + }, + { + "name": "Nicolas PHILIPPE", + "email": "nikophil@gmail.com" + } + ], + "description": "A model factory library for creating expressive, auto-completable, on-demand dev/test fixtures with Symfony and Doctrine.", + "homepage": "https://github.com/zenstruck/foundry", + "keywords": [ + "Fixture", + "dev", + "doctrine", + "factory", + "faker", + "symfony", + "test" + ], + "support": { + "issues": "https://github.com/zenstruck/foundry/issues", + "source": "https://github.com/zenstruck/foundry/tree/v2.9.2" + }, + "funding": [ + { + "url": "https://github.com/kbond", + "type": "github" + }, + { + "url": "https://github.com/nikophil", + "type": "github" + } + ], + "time": "2026-02-17T15:48:50+00:00" } ], "aliases": [], diff --git a/config/bundles.php b/config/bundles.php index 43a1e93..bf64691 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -16,4 +16,6 @@ return [ Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], League\FlysystemBundle\FlysystemBundle::class => ['all' => true], Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true], + Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true], + DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], ]; diff --git a/config/packages/test/framework.yaml b/config/packages/test/framework.yaml index d153e0d..54824ee 100644 --- a/config/packages/test/framework.yaml +++ b/config/packages/test/framework.yaml @@ -1,4 +1,4 @@ framework: test: true session: - storage_id: session.storage.mock_file + storage_factory_id: session.storage.factory.mock_file diff --git a/config/packages/zenstruck_foundry.yaml b/config/packages/zenstruck_foundry.yaml new file mode 100644 index 0000000..2f60dd0 --- /dev/null +++ b/config/packages/zenstruck_foundry.yaml @@ -0,0 +1,16 @@ +when@dev: &dev + # See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration + zenstruck_foundry: + persistence: + # Flush only once per call of `PersistentObjectFactory::create()` + flush_once: true + + # If you use the `make:factory --test` command, you may need to uncomment the following. + # See https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#generate + #services: + # App\Tests\Factory\: + # resource: '%kernel.project_dir%/tests/Factory/' + # autowire: true + # autoconfigure: true + +when@test: *dev diff --git a/docs/README.md b/docs/README.md index f66765a..be8d4cf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # Mine-Seeker Game Documentation -This directory contains comprehensive documentation about the Mine-Seeker game mechanics and implementation. +This directory contains comprehensive documentation about the Mine-Seeker game mechanics, testing, and implementation. ## Game Mechanics @@ -18,6 +18,46 @@ Complete reference for the bonus points system including: --- +## Testing + +### [Testing Guide](./testing/TESTING.md) +Complete testing setup and best practices including: +- Test environment configuration +- Database isolation with DAMA Doctrine Test Bundle +- Test database setup (`mineseeker_test`) +- Writing effective tests +- Running tests (all, specific, with coverage) +- Best practices (AAA pattern, factories, type hints) +- CI/CD integration examples +- Troubleshooting guide + +**Recommended for**: All developers, understanding test infrastructure, writing new tests, debugging test failures. + +### [Factory Documentation](./testing/FACTORIES.md) +Detailed API reference for Zenstruck Foundry factories: +- All available factories (User, Gamer, PlayedGame, Step, Grid, etc.) +- Factory usage examples +- Creating related entities +- Repository access +- Database isolation explanation +- Best practices and patterns + +**Recommended for**: Writing tests with test data, understanding factory patterns, creating complex test scenarios. + +--- + +## Technical Documentation + +### [Fonts](./FONTS.md) +TrueType fonts used for server-side image generation: +- Font file locations +- Usage in battle card generation +- Web vs. server-side font formats + +**Recommended for**: Working on OG image generation, understanding font rendering. + +--- + ## Quick Reference ### Bonus Points at a Glance @@ -30,18 +70,63 @@ Complete reference for the bonus points system including: | Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) | | Biggest Reveal | Tracked | Largest number of safe cells revealed | -### Key Rules -- Safe cell bonus only awarded for ≥2 cells minimum -- Chain counter resets on any safe cell click -- Endgame threshold: 51 - (redPoints + bluePoints) ≤ 10 -- Bonus stats are per-player and persist in database +### Available Test Factories +| Factory | Entity | Purpose | +|---------|--------|---------| +| `UserFactory` | `User` | Registered users | +| `GamerFactory` | `Gamer` | Anonymous/guest players | +| `PlayedGameFactory` | `PlayedGame` | Game records | +| `StepFactory` | `Step` | Game moves | +| `GridFactory` | `Grid` | Game grids (16×16) | +| `GridRowFactory` | `GridRow` | Grid rows | +| `WebAuthnCredentialFactory` | `WebAuthnCredential` | Passkey credentials | +| `ContactMessageFactory` | `ContactMessage` | Contact messages | + +### Running Tests +```bash +# Recommended: Use Makefile +make test-db-setup + +# Or manually: +# All tests +vendor/bin/phpunit + +# Specific file +vendor/bin/phpunit tests/Controller/ProfileControllerTest.php + +# Specific method +vendor/bin/phpunit --filter testProfilePageRequiresAuthentication + +# With coverage (requires Xdebug/PCOV) +XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html coverage/ + +# With increased memory +php -d memory_limit=512M vendor/bin/phpunit +``` --- ## Files Using This Information + +### Game Mechanics - Backend: `/src/Util/TopicManager.php` - Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx` - UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx` - Constants: `/assets/js/mine-seeker/utils/constants.jsx` +### Testing +- Test Base Class: `/tests/WebTestCase.php` +- Factories: `/tests/Factory/` +- Example Tests: `/tests/Integration/FactoryExampleTest.php` +- PHPUnit Config: `/phpunit.dist.xml` +- Bundle Config: `/config/bundles.php` +--- + +## Additional Resources + +- **[Main README](../README.md)** - Project overview, installation, deployment +- **[AI Agent Guidelines](../AGENTS.md)** - Comprehensive guide for AI coding agents +- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Official Foundry documentation +- **[PHPUnit](https://phpunit.de/)** - PHPUnit documentation +- **[Symfony Testing](https://symfony.com/doc/current/testing.html)** - Symfony testing guide diff --git a/docs/testing/FACTORIES.md b/docs/testing/FACTORIES.md new file mode 100644 index 0000000..cacff59 --- /dev/null +++ b/docs/testing/FACTORIES.md @@ -0,0 +1,265 @@ +# Foundry Factories for MineSeeker + +Factory classes for creating test data. For general Foundry usage, see [Zenstruck Foundry Docs](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html). + +--- + +## Available Factories + +All factories are in `tests/Factory/`: + +| Factory | Entity | Use For | +|---------|--------|---------| +| `UserFactory` | `User` | Registered users with auth | +| `GamerFactory` | `Gamer` | Anonymous/guest players | +| `PlayedGameFactory` | `PlayedGame` | Game records | +| `StepFactory` | `Step` | Individual game moves | +| `GridFactory` | `Grid` | 16×16 game grids | +| `GridRowFactory` | `GridRow` | Grid row data | +| `WebAuthnCredentialFactory` | `WebAuthnCredential` | Passkey credentials | +| `ContactMessageFactory` | `ContactMessage` | Contact form messages | + +--- + +## Quick Examples + +### UserFactory + +```php +// Basic user +$user = UserFactory::createOne(); + +// Unverified user +$user = UserFactory::createOne(['isVerified' => false]); + +// Admin user +$user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]); + +// Multiple users +UserFactory::createMany(5); +``` + +### GamerFactory + +```php +// Anonymous gamer +$gamer = GamerFactory::new()->anonymous()->create(); +``` + +### PlayedGameFactory + +```php +// Game with registered players +$game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->create(); + +// Game with anonymous players +$game = PlayedGameFactory::new() + ->withAnonymousPlayers() + ->create(); + +// Red wins +$game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->redWins() + ->create(); + +// Blue wins +$game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->blueWins() + ->create(); + +// Resigned game +$game = PlayedGameFactory::new() + ->resigned('red') + ->create(); +``` + +### StepFactory + +```php +// Mine hit +$step = StepFactory::new() + ->forPlayer('red') + ->mine() + ->create(['playedGame' => $game]); + +// Safe cell +$step = StepFactory::new() + ->forPlayer('blue') + ->safe() + ->create(['playedGame' => $game]); + +// With revealed cells +$step = StepFactory::new() + ->withRevealedCells([ + ['row' => 5, 'col' => 5], + ['row' => 5, 'col' => 6], + ]) + ->create(['playedGame' => $game]); +``` + +### GridFactory + +```php +// Full 16×16 grid +$grid = GridFactory::createOne(['playedGame' => $game]); + +// Automatically creates 16 rows +self::assertCount(16, $grid->gridRow); +``` + +### WebAuthnCredentialFactory + +```php +// Basic credential +$credential = WebAuthnCredentialFactory::createOne(['user' => $user]); + +// Named credential, recently used +$credential = WebAuthnCredentialFactory::new() + ->withName('YubiKey 5C') + ->recentlyUsed() + ->create(['user' => $user]); +``` + +### ContactMessageFactory + +```php +// Basic message +$message = ContactMessageFactory::createOne(); + +// Without consent +$message = ContactMessageFactory::new() + ->withoutConsent() + ->create(); +``` + +--- + +## Factory API Reference + +### Common Methods (All Factories) + +```php +// Create single entity +Factory::createOne([ + 'property' => 'value', +]); + +// Create multiple +Factory::createMany(5); + +// Create with factory methods +Factory::new() + ->customMethod() + ->create(['property' => 'value']); + +// Access repository +Factory::repository()->findAll(); +Factory::repository()->count([]); +Factory::repository()->findBy(['field' => 'value']); +``` + +### PlayedGameFactory Methods + +| Method | Description | +|--------|-------------| +| `withRegisteredPlayers()` | Creates game with 2 registered users | +| `withAnonymousPlayers()` | Creates game with 2 anonymous gamers | +| `withMixedPlayers()` | One registered, one anonymous | +| `redWins()` | Red has 26 points | +| `blueWins()` | Blue has 26 points | +| `resigned(string $player)` | Set resignation ('red' or 'blue') | + +### StepFactory Methods + +| Method | Description | +|--------|-------------| +| `forPlayer(string $player)` | Set player ('red' or 'blue') | +| `mine()` | Step hits a mine | +| `safe()` | Step reveals safe cell | +| `withRevealedCells(array $cells)` | Set revealed cells array | + +### GamerFactory Methods + +| Method | Description | +|--------|-------------| +| `anonymous()` | Set username as "Guest_12345" | + +### WebAuthnCredentialFactory Methods + +| Method | Description | +|--------|-------------| +| `withName(string $name)` | Set credential name | +| `recentlyUsed()` | Set lastUsedAt to now | + +### ContactMessageFactory Methods + +| Method | Description | +|--------|-------------| +| `withoutConsent()` | Set consent to false | +| `anonymous()` | Remove IP address | + +--- + +## MineSeeker-Specific Patterns + +### Complete Game Setup + +```php +// Create full game with steps and grid +$game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->create(); + +// Add steps +StepFactory::createMany(10, [ + 'playedGame' => $game, + 'player' => 'red', +]); + +// Add grid +$grid = GridFactory::createOne(['playedGame' => $game]); +``` + +### Testing Battle History + +```php +// Create multiple finished games for a user +$user = UserFactory::createOne(); + +PlayedGameFactory::new() + ->redWins() + ->create(['red' => $user]); + +PlayedGameFactory::new() + ->blueWins() + ->create(['blue' => $user]); +``` + +--- + +## Database Isolation + +Tests automatically run in isolated transactions: + +```php +public function testDatabaseIsolation(): void +{ + UserFactory::createMany(5); + self::assertCount(5, UserFactory::repository()->findAll()); + + // Automatically rolled back after test +} +``` + +No manual cleanup needed. Each test starts with a clean database. + +--- + +## External Resources + +- **[Zenstruck Foundry Documentation](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Complete Foundry guide +- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation details diff --git a/docs/testing/TESTING.md b/docs/testing/TESTING.md new file mode 100644 index 0000000..31d3ceb --- /dev/null +++ b/docs/testing/TESTING.md @@ -0,0 +1,317 @@ +# Testing Guide for MineSeeker + +MineSeeker-specific testing setup and workflows. For general PHPUnit/Symfony testing, see [Symfony Testing Docs](https://symfony.com/doc/current/testing.html). + +## Quick Start + +```bash +# One-time setup +make test-db-setup + +# Run tests +make test +``` + +--- + +## Test Database Configuration + +MineSeeker uses a **separate test database** (`mineseeker_test`) with automatic transaction rollback for isolated tests. + +### Stack + +- **PHPUnit 13** - Testing framework +- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Factory library for test fixtures +- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation (rollback after each test) + +### Configuration + +**`phpunit.dist.xml`**: +```xml + + +``` + +**`config/bundles.php`**: +```php +Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true], +DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], +``` + +--- + +## Setup + +### One-time Setup + +```bash +make test-db-setup +``` + +Creates `mineseeker_test` database and runs migrations. + +### Reset Test Database + +```bash +make test-db-reset +``` + +Drops and recreates test database (useful after schema changes). + +--- + +## Running Tests + +```bash +# All tests +make test + +# Specific file +vendor/bin/phpunit tests/Controller/ProfileControllerTest.php + +# Specific method +vendor/bin/phpunit --filter testCreateUser + +# With test names +vendor/bin/phpunit --testdox +``` + +--- + +## MineSeeker Factories + +See **[FACTORIES.md](./FACTORIES.md)** for complete factory API. + +### Available Factories + +| Factory | Entity | Location | +|---------|--------|----------| +| `UserFactory` | Registered users | `tests/Factory/UserFactory.php` | +| `GamerFactory` | Anonymous players | `tests/Factory/GamerFactory.php` | +| `PlayedGameFactory` | Game records | `tests/Factory/PlayedGameFactory.php` | +| `StepFactory` | Game moves | `tests/Factory/StepFactory.php` | +| `GridFactory` | 16×16 game grids | `tests/Factory/GridFactory.php` | +| `GridRowFactory` | Grid rows | `tests/Factory/GridRowFactory.php` | +| `WebAuthnCredentialFactory` | Passkey credentials | `tests/Factory/WebAuthnCredentialFactory.php` | +| `ContactMessageFactory` | Contact messages | `tests/Factory/ContactMessageFactory.php` | + +### Example + +```php +use App\Tests\Factory\PlayedGameFactory; +use App\Tests\WebTestCase; + +class GameTest extends WebTestCase +{ + public function testGameCreation(): void + { + $game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->redWins() + ->create(); + + self::assertEquals(26, $game->redPoints); + } +} +``` + +--- + +## Test Structure + +All tests extend `App\Tests\WebTestCase`: + +```php +request('GET', '/profile'); + + self::assertResponseRedirects('/login'); + } +} +``` + +**Important**: Always extend `App\Tests\WebTestCase`, not `Symfony\Bundle\FrameworkBundle\Test\WebTestCase`. + +--- + +## Directory Structure + +``` +tests/ +├── Controller/ # HTTP endpoint tests +├── Dto/ # Data Transfer Object tests +├── Entity/ # Entity logic tests +├── Service/ # Service layer tests +├── Integration/ # Integration tests +├── Factory/ # Foundry factories +├── WebTestCase.php # Base test class +└── bootstrap.php # PHPUnit bootstrap +``` + +--- + +## MineSeeker-Specific Patterns + +### Testing Game Flow + +```php +public function testRedPlayerWinsGame(): void +{ + $game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->create(); + + // Create steps to simulate gameplay + for ($i = 0; $i < 26; $i++) { + StepFactory::new() + ->forPlayer('red') + ->mine() + ->create(['playedGame' => $game]); + } + + self::assertEquals(26, $game->redPoints); +} +``` + +### Testing Battle Sharing + +```php +public function testBattleSharePageReturnsValidGame(): void +{ + $game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->redWins() + ->create(); + + $client = static::createClient(); + $client->request('GET', '/battle/' . $game->uuid); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Battle Report'); +} +``` + +### Testing Authentication + +```php +public function testProfileRequiresAuthentication(): void +{ + $client = static::createClient(); + $client->request('GET', '/profile'); + + self::assertResponseRedirects('/login'); +} + +public function testAuthenticatedUserCanAccessProfile(): void +{ + $user = UserFactory::createOne(); + $client = static::createClient(); + $client->loginUser($user->_real()); + + $client->request('GET', '/profile'); + + self::assertResponseIsSuccessful(); +} +``` + +--- + +## Troubleshooting + +### Tests interfering with each other? + +Ensure you extend `App\Tests\WebTestCase`, not the base Symfony class. + +### Schema out of sync? + +```bash +make test-db-reset +``` + +### Memory limit errors? + +```bash +php -d memory_limit=1024M vendor/bin/phpunit +``` + +Or increase in `phpunit.dist.xml`: +```xml + +``` + +### Test database doesn't exist? + +```bash +make test-db-setup +``` + +--- + +## External Resources + +- **[Symfony Testing](https://symfony.com/doc/current/testing.html)** - Symfony testing guide +- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Factory documentation +- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation +- **[PHPUnit](https://docs.phpunit.de/)** - PHPUnit documentation + +--- + +## Modern PHPUnit Attributes + +MineSeeker tests use modern PHP 8 attributes instead of method name prefixes: + +```php +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; + +#[TestDox('Security Controller')] +class SecurityControllerTest extends WebTestCase +{ + #[Test] + #[TestDox('Login page loads successfully with form fields')] + public function loginPageLoadsSuccessfully(): void + { + // Test implementation + } +} +``` + +**Benefits:** +- ✅ More readable method names (no `test` prefix required) +- ✅ Self-documenting with `TestDox` descriptions +- ✅ Better IDE support and refactoring +- ✅ Cleaner `--testdox` output + +**Run with documentation:** +```bash +vendor/bin/phpunit --testdox +``` + +Output: +``` +Security Controller + ✔ Login page loads successfully with form fields + ✔ Login page has links to register and forgot password + ✔ Register page loads successfully with form +``` diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..3ad4932 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + tests + + + + + + src + + + + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend + trigger_deprecation + + + + + + + diff --git a/src/Story/AppStory.php b/src/Story/AppStory.php new file mode 100644 index 0000000..b28fbf3 --- /dev/null +++ b/src/Story/AppStory.php @@ -0,0 +1,33 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + */ +#[AsFixture(name: 'main')] +final class AppStory extends Story +{ + public function build(): void + { + // SomeFactory::createOne(); + } +} diff --git a/src/Validator/RecaptchaValidator.php b/src/Validator/RecaptchaValidator.php index e7f02a1..33f9b3b 100644 --- a/src/Validator/RecaptchaValidator.php +++ b/src/Validator/RecaptchaValidator.php @@ -26,12 +26,13 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException; * @link www.splendidbear.org * @since 2026. 04. 12. */ -class RecaptchaValidator extends ConstraintValidator +final class RecaptchaValidator extends ConstraintValidator { public function __construct( private readonly RecaptchaService $recaptcha, - private readonly RequestStack $requestStack, - ) {} + private readonly RequestStack $requestStack, + ) { + } public function validate(mixed $value, Constraint $constraint): void { @@ -39,10 +40,10 @@ class RecaptchaValidator extends ConstraintValidator throw new UnexpectedTypeException($constraint, Recaptcha::class); } - $request = $this->requestStack->getCurrentRequest(); - $remoteIp = $request !== null ? ((string) $request->getClientIp()) : ''; + $request = $this->requestStack->getCurrentRequest(); + $remoteIp = $request !== null ? ((string)$request->getClientIp()) : ''; - if ($this->recaptcha->verify((string) $value, $remoteIp)) { + if ($this->recaptcha->verify((string)$value, $remoteIp)) { return; } diff --git a/symfony.lock b/symfony.lock index 90dacac..dd9a62e 100644 --- a/symfony.lock +++ b/symfony.lock @@ -2,6 +2,15 @@ "cocur/slugify": { "version": "v3.1" }, + "dama/doctrine-test-bundle": { + "version": "8.6", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "8.3", + "ref": "dfc51177476fb39d014ed89944cde53dc3326d23" + } + }, "doctrine/collections": { "version": "v1.5.0" }, @@ -120,6 +129,21 @@ "ref": "3a6673f248f8fc1dd364dadfef4c5b381d1efab6" } }, + "phpunit/phpunit": { + "version": "13.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "11.1", + "ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314" + }, + "files": [ + ".env.test", + "phpunit.dist.xml", + "tests/bootstrap.php", + "bin/phpunit" + ] + }, "psr/cache": { "version": "1.0.1" }, @@ -469,5 +493,18 @@ }, "zendframework/zend-eventmanager": { "version": "3.2.1" + }, + "zenstruck/foundry": { + "version": "2.9", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.7", + "ref": "9bd824886195ebe64a24438db1b6d17eb833ac56" + }, + "files": [ + "config/packages/zenstruck_foundry.yaml", + "src/Story/AppStory.php" + ] } } diff --git a/tests/Controller/GameControllerTest.php b/tests/Controller/GameControllerTest.php new file mode 100644 index 0000000..a3005b7 --- /dev/null +++ b/tests/Controller/GameControllerTest.php @@ -0,0 +1,145 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + */ +#[TestDox('Game Controller')] +class GameControllerTest extends WebTestCase +{ + private KernelBrowser $client; + + protected function setUp(): void + { + parent::setUp(); + $this->client = static::createClient(); + } + + #[Test] + #[TestDox('Homepage loads successfully with navigation links')] + public function homepageLoadsSuccessfully(): void + { + $crawler = $this->client->request('GET', '/'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertSelectorExists('h1'); + self::assertSelectorExists('a[href="/play"]'); + } + + #[Test] + #[TestDox('Play page loads successfully')] + public function playPageLoadsSuccessfully(): void + { + $crawler = $this->client->request('GET', '/play'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertResponseHasHeader('Content-Type'); + } + + #[Test] + #[TestDox('Play page with game association loads successfully')] + public function playPageWithGameAssocLoadsSuccessfully(): void + { + $crawler = $this->client->request('GET', '/play/testgame123'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertResponseHasHeader('Content-Type'); + } + + #[Test] + #[TestDox('Privacy policy page loads successfully')] + public function privacyPolicyPageLoadsSuccessfully(): void + { + $crawler = $this->client->request('GET', '/privacy-policy'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertSelectorExists('h1'); + } + + #[Test] + #[TestDox('Terms of service page loads successfully')] + public function termsOfServicePageLoadsSuccessfully(): void + { + $crawler = $this->client->request('GET', '/terms-of-service'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertSelectorExists('h1'); + } + + #[Test] + #[TestDox('Contact page loads successfully with form')] + public function contactPageLoadsSuccessfully(): void + { + $crawler = $this->client->request('GET', '/contact'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertSelectorExists('form'); + self::assertSelectorExists('button[type="submit"]'); + } + + #[Test] + #[TestDox('Landing page loads successfully with play link')] + public function landingPageLoadsSuccessfully(): void + { + $crawler = $this->client->request('GET', '/landing-page'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertSelectorExists('h1'); + self::assertSelectorExists('a[href="/play"]'); + } + + #[Test] + #[TestDox('Rules page loads successfully')] + public function rulesPageLoadsSuccessfully(): void + { + $crawler = $this->client->request('GET', '/rules'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertSelectorExists('h1'); + } + + #[Test] + #[TestDox('Homepage contains navigation links to play, login, and register')] + public function homepageContainsNavigationLinks(): void + { + $crawler = $this->client->request('GET', '/'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('a[href="/play"]'); + self::assertSelectorExists('a[href="/login"]'); + self::assertSelectorExists('a[href="/register"]'); + } + + #[Test] + #[TestDox('Play page has correct meta tags for SEO and social sharing')] + public function playPageHasCorrectMetaTags(): void + { + $this->client->request('GET', '/play'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('meta[name="description"]'); + self::assertSelectorExists('meta[property="og:title"]'); + } +} diff --git a/tests/Controller/ProfileControllerTest.php b/tests/Controller/ProfileControllerTest.php new file mode 100644 index 0000000..80d4bd2 --- /dev/null +++ b/tests/Controller/ProfileControllerTest.php @@ -0,0 +1,146 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + */ +#[TestDox('Profile Controller')] +class ProfileControllerTest extends WebTestCase +{ + private KernelBrowser $client; + + protected function setUp(): void + { + parent::setUp(); + $this->client = static::createClient(); + } + + #[Test] + #[TestDox('Profile page requires authentication')] + public function profilePageRequiresAuthentication(): void + { + $this->client->request('GET', '/profile'); + + self::assertResponseRedirects('/login'); + } + + #[Test] + #[TestDox('Profile security page requires authentication')] + public function profileSecurityPageRequiresAuthentication(): void + { + $this->client->request('GET', '/profile/security'); + + self::assertResponseRedirects('/login'); + } + + #[Test] + #[TestDox('Profile avatar upload requires authentication')] + public function profileAvatarPageRequiresAuthentication(): void + { + $this->client->request('POST', '/profile/avatar'); + + self::assertResponseRedirects('/login'); + } + + #[Test] + #[TestDox('Battle share page returns 404 for non-existent game')] + public function battleSharePageReturns404ForNonExistentGame(): void + { + $uuid = '00000000-0000-4000-a000-000000000000'; + $this->client->request('GET', '/battle/' . $uuid); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + #[Test] + #[TestDox('Battle share page displays valid game details')] + public function battleSharePageShowsValidGame(): void + { + $game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->redWins() + ->create(); + + $crawler = $this->client->request('GET', '/battle/' . $game->uuid); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('h1'); + self::assertGreaterThan(0, $crawler->filter('body')->count()); + } + + #[Test] + #[TestDox('Battle share page has Open Graph meta tags for social sharing')] + public function battleSharePageHasOgMetaTags(): void + { + $game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->redWins() + ->create(); + + $this->client->request('GET', '/battle/' . $game->uuid); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('meta[property="og:title"]'); + self::assertSelectorExists('meta[property="og:image"]'); + self::assertSelectorExists('meta[property="og:description"]'); + self::assertSelectorExists('meta[property="og:type"]'); + } + + #[Test] + #[TestDox('OG battle image returns 404 for non-existent game')] + public function ogBattleImageReturns404ForNonExistentGame(): void + { + $uuid = '00000000-0000-4000-a000-000000000000'; + $this->client->request('GET', '/og/battle/' . $uuid . '.png'); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + #[Test] + #[TestDox('OG battle image generates valid PNG for existing game')] + public function ogBattleImageReturnsImageForValidGame(): void + { + $game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->redWins() + ->create(); + + $this->client->request('GET', '/og/battle/' . $game->uuid . '.png'); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'image/png'); + } + + #[Test] + #[TestDox('Battle share page returns 404 for invalid UUID format')] + public function battleSharePageWithInvalidUuidFormat(): void + { + $this->client->request('GET', '/battle/invalid-uuid'); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } +} diff --git a/tests/Controller/SecurityControllerTest.php b/tests/Controller/SecurityControllerTest.php new file mode 100644 index 0000000..427ecd5 --- /dev/null +++ b/tests/Controller/SecurityControllerTest.php @@ -0,0 +1,148 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + */ +#[TestDox('Security Controller')] +class SecurityControllerTest extends WebTestCase +{ + private KernelBrowser $client; + + protected function setUp(): void + { + parent::setUp(); + $this->client = static::createClient(); + } + + #[Test] + #[TestDox('Login page loads successfully with form fields')] + public function loginPageLoadsSuccessfully(): void + { + $crawler = $this->client->request('GET', '/login'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertSelectorExists('form'); + self::assertSelectorExists('input[name="_username"]'); + self::assertSelectorExists('input[name="_password"]'); + self::assertSelectorExists('button[type="submit"]'); + } + + #[Test] + #[TestDox('Login page has links to register and forgot password')] + public function loginPageHasRegisterLink(): void + { + $crawler = $this->client->request('GET', '/login'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('a[href="/register"]'); + self::assertSelectorExists('a[href="/forgot-password"]'); + } + + #[Test] + #[TestDox('Register page loads successfully with form')] + public function registerPageLoadsSuccessfully(): void + { + $crawler = $this->client->request('GET', '/register'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertSelectorExists('form'); + self::assertSelectorExists('button[type="submit"]'); + } + + #[Test] + #[TestDox('Register page has link to login')] + public function registerPageHasLoginLink(): void + { + $crawler = $this->client->request('GET', '/register'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('a[href="/login"]'); + } + + #[Test] + #[TestDox('Forgot password page loads successfully with form')] + public function forgotPasswordPageLoadsSuccessfully(): void + { + $crawler = $this->client->request('GET', '/forgot-password'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertSelectorExists('form'); + self::assertSelectorExists('button[type="submit"]'); + } + + #[Test] + #[TestDox('Account activation with invalid token redirects to login')] + public function activateWithInvalidTokenShowsError(): void + { + $this->client->request('GET', '/activate/invalid-token'); + + self::assertResponseRedirects('/login'); + } + + #[Test] + #[TestDox('Password reset with invalid token redirects to forgot password')] + public function resetPasswordWithInvalidTokenShowsError(): void + { + $this->client->request('GET', '/reset-password/invalid-token'); + + self::assertResponseRedirects('/forgot-password'); + } + + #[Test] + #[TestDox('Authenticated user is redirected from login page to homepage')] + public function authenticatedUserRedirectsFromLogin(): void + { + $user = UserFactory::createOne(); + $this->client->loginUser($user->_real()); + + $this->client->request('GET', '/login'); + + self::assertResponseRedirects('/'); + } + + #[Test] + #[TestDox('Authenticated user is redirected from register page to homepage')] + public function authenticatedUserRedirectsFromRegister(): void + { + $user = UserFactory::createOne(); + $this->client->loginUser($user->_real()); + + $this->client->request('GET', '/register'); + + self::assertResponseRedirects('/'); + } + + #[Test] + #[TestDox('Login form has remember me checkbox')] + public function loginFormHasRememberMeCheckbox(): void + { + $crawler = $this->client->request('GET', '/login'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('input[name="_remember_me"]'); + } +} diff --git a/tests/Dto/ProfileChartDataDtoTest.php b/tests/Dto/ProfileChartDataDtoTest.php new file mode 100644 index 0000000..6bf9074 --- /dev/null +++ b/tests/Dto/ProfileChartDataDtoTest.php @@ -0,0 +1,89 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + */ +#[TestDox('Profile Chart Data Dto')] +class ProfileChartDataDtoTest extends TestCase +{ + #[Test] + #[TestDox('Json serialize returns all properties')] + public function jsonSerializeReturnsAllProperties(): void + { + $months = ['Jan', 'Feb', 'Mar']; + $wins = [5, 8, 10]; + $losses = [2, 3, 4]; + $draws = [1, 0, 1]; + $recentGames = [ + ['id' => 1, 'result' => 'win'], + ['id' => 2, 'result' => 'loss'], + ]; + + $dto = new ProfileChartDataDto( + months: $months, + wins: $wins, + losses: $losses, + draws: $draws, + pieWins: 23, + pieLosses: 9, + pieDraws: 2, + recentGames: $recentGames, + ); + + $json = $dto->jsonSerialize(); + + $this->assertSame($months, $json['months']); + $this->assertSame($wins, $json['wins']); + $this->assertSame($losses, $json['losses']); + $this->assertSame($draws, $json['draws']); + $this->assertSame(23, $json['pieWins']); + $this->assertSame(9, $json['pieLosses']); + $this->assertSame(2, $json['pieDraws']); + $this->assertSame($recentGames, $json['recentGames']); + } + + #[Test] + #[TestDox('Constructor with empty arrays')] + public function constructorWithEmptyArrays(): void + { + $dto = new ProfileChartDataDto( + months: [], + wins: [], + losses: [], + draws: [], + pieWins: 0, + pieLosses: 0, + pieDraws: 0, + recentGames: [], + ); + + $json = $dto->jsonSerialize(); + + $this->assertSame([], $json['months']); + $this->assertSame([], $json['wins']); + $this->assertSame([], $json['recentGames']); + $this->assertSame(0, $json['pieWins']); + } +} diff --git a/tests/Dto/ProfileGameDtoTest.php b/tests/Dto/ProfileGameDtoTest.php new file mode 100644 index 0000000..2577fcb --- /dev/null +++ b/tests/Dto/ProfileGameDtoTest.php @@ -0,0 +1,126 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + */ +#[TestDox('Profile Game Dto')] +class ProfileGameDtoTest extends TestCase +{ + #[Test] + #[TestDox('Json serialize returns all properties')] + public function jsonSerializeReturnsAllProperties(): void + { + $dto = new ProfileGameDto( + id: 1, + uuid: '550e8400-e29b-41d4-a716-446655440000', + redName: 'RedPlayer', + blueName: 'BluePlayer', + redAvatar: '/uploads/avatars/red.png', + blueAvatar: '/uploads/avatars/blue.png', + redPoints: 10, + bluePoints: 8, + redExplodedBomb: false, + blueExplodedBomb: true, + resign: null, + created: '2026-04-20 10:00', + date: '2026-04-20 10:30', + isRed: true, + result: 'win', + myPoints: 10, + oppPoints: 8, + redBonusPoints: 5.5, + blueBonusPoints: 2.0, + redBonusStats: ['blindHits' => 2, 'chainBest' => 3], + blueBonusStats: ['blindHits' => 1, 'chainBest' => 2], + bothRegistered: true, + ); + + $json = $dto->jsonSerialize(); + + $this->assertSame(1, $json['id']); + $this->assertSame('550e8400-e29b-41d4-a716-446655440000', $json['uuid']); + $this->assertSame('RedPlayer', $json['redName']); + $this->assertSame('BluePlayer', $json['blueName']); + $this->assertSame('/uploads/avatars/red.png', $json['redAvatar']); + $this->assertSame('/uploads/avatars/blue.png', $json['blueAvatar']); + $this->assertSame(10, $json['redPoints']); + $this->assertSame(8, $json['bluePoints']); + $this->assertFalse($json['redExplodedBomb']); + $this->assertTrue($json['blueExplodedBomb']); + $this->assertNull($json['resign']); + $this->assertSame('2026-04-20 10:00', $json['created']); + $this->assertSame('2026-04-20 10:30', $json['date']); + $this->assertTrue($json['isRed']); + $this->assertSame('win', $json['result']); + $this->assertSame(10, $json['myPoints']); + $this->assertSame(8, $json['oppPoints']); + $this->assertSame(5.5, $json['redBonusPoints']); + $this->assertSame(2.0, $json['blueBonusPoints']); + $this->assertSame(['blindHits' => 2, 'chainBest' => 3], $json['redBonusStats']); + $this->assertSame(['blindHits' => 1, 'chainBest' => 2], $json['blueBonusStats']); + $this->assertTrue($json['bothRegistered']); + } + + #[Test] + #[TestDox('Json serialize with null values')] + public function jsonSerializeWithNullValues(): void + { + $dto = new ProfileGameDto( + id: null, + uuid: null, + redName: 'Guest', + blueName: 'Guest', + redAvatar: null, + blueAvatar: null, + redPoints: null, + bluePoints: null, + redExplodedBomb: null, + blueExplodedBomb: null, + resign: null, + created: null, + date: null, + isRed: false, + result: 'draw', + myPoints: null, + oppPoints: null, + redBonusPoints: 0.0, + blueBonusPoints: 0.0, + redBonusStats: [], + blueBonusStats: [], + bothRegistered: false, + ); + + $json = $dto->jsonSerialize(); + + $this->assertNull($json['id']); + $this->assertNull($json['uuid']); + $this->assertNull($json['redAvatar']); + $this->assertNull($json['blueAvatar']); + $this->assertNull($json['redPoints']); + $this->assertNull($json['bluePoints']); + $this->assertSame('draw', $json['result']); + $this->assertFalse($json['bothRegistered']); + } +} diff --git a/tests/Dto/ProfileStatsDtoTest.php b/tests/Dto/ProfileStatsDtoTest.php new file mode 100644 index 0000000..032afd2 --- /dev/null +++ b/tests/Dto/ProfileStatsDtoTest.php @@ -0,0 +1,134 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + */ +#[TestDox('Profile Stats Dto')] +class ProfileStatsDtoTest extends TestCase +{ + #[Test] + #[TestDox('From user stats with valid stats')] + public function fromUserStatsWithValidStats(): void + { + $userStats = new UserStats(); + $userStats->userId = 1; + $userStats->totalGames = 100; + $userStats->wins = 60; + $userStats->losses = 30; + $userStats->draws = 10; + $userStats->totalMines = 500; + $userStats->totalBonusPoints = '150.5'; + $userStats->avgBonus = '2.5'; + $userStats->bestChain = 5; + $userStats->blindHits = 20; + $userStats->edgeMines = 15; + $userStats->gamesWithScores = 90; + + $dto = ProfileStatsDto::fromUserStats($userStats); + + $this->assertSame(100, $dto->total); + $this->assertSame(60, $dto->wins); + $this->assertSame(30, $dto->losses); + $this->assertSame(10, $dto->draws); + $this->assertSame(500, $dto->minesHit); + $this->assertSame(67, $dto->winRate); // 60/90 * 100 = 67 + $this->assertSame(6, $dto->avgScore); // 500/90 = 5.56 -> 6 + $this->assertSame(150.5, $dto->bonusPoints); + $this->assertSame(2.5, $dto->avgBonus); + $this->assertSame(5, $dto->bestChain); + $this->assertSame(20, $dto->blindHits); + $this->assertSame(15, $dto->edgeMines); + } + + #[Test] + #[TestDox('From user stats with null returns empty')] + public function fromUserStatsWithNullReturnsEmpty(): void + { + $dto = ProfileStatsDto::fromUserStats(null); + + $this->assertSame(0, $dto->total); + $this->assertSame(0, $dto->wins); + $this->assertSame(0, $dto->losses); + $this->assertSame(0, $dto->draws); + $this->assertSame(0, $dto->minesHit); + $this->assertSame(0, $dto->winRate); + $this->assertSame(0, $dto->avgScore); + $this->assertSame(0.0, $dto->bonusPoints); + $this->assertSame(0.0, $dto->avgBonus); + $this->assertSame(0, $dto->bestChain); + $this->assertSame(0, $dto->blindHits); + $this->assertSame(0, $dto->edgeMines); + } + + #[Test] + #[TestDox('Empty returns default values')] + public function emptyReturnsDefaultValues(): void + { + $dto = ProfileStatsDto::empty(); + + $this->assertSame(0, $dto->total); + $this->assertSame(0, $dto->wins); + $this->assertSame(0, $dto->losses); + $this->assertSame(0, $dto->draws); + $this->assertSame(0, $dto->minesHit); + $this->assertSame(0, $dto->winRate); + $this->assertSame(0, $dto->avgScore); + $this->assertSame(0.0, $dto->bonusPoints); + $this->assertSame(0.0, $dto->avgBonus); + $this->assertSame(0, $dto->bestChain); + $this->assertSame(0, $dto->blindHits); + $this->assertSame(0, $dto->edgeMines); + } + + #[Test] + #[TestDox('Win rate calculation with no games with scores')] + public function winRateCalculationWithNoGamesWithScores(): void + { + $userStats = new UserStats(); + $userStats->totalGames = 10; + $userStats->wins = 5; + $userStats->losses = 5; + $userStats->draws = 0; + $userStats->gamesWithScores = 0; + + $dto = ProfileStatsDto::fromUserStats($userStats); + + $this->assertSame(0, $dto->winRate); + } + + #[Test] + #[TestDox('Avg score calculation with no games with scores')] + public function avgScoreCalculationWithNoGamesWithScores(): void + { + $userStats = new UserStats(); + $userStats->totalMines = 100; + $userStats->gamesWithScores = 0; + + $dto = ProfileStatsDto::fromUserStats($userStats); + + $this->assertSame(0, $dto->avgScore); + } +} diff --git a/tests/Entity/UserStatsTest.php b/tests/Entity/UserStatsTest.php new file mode 100644 index 0000000..8c429d3 --- /dev/null +++ b/tests/Entity/UserStatsTest.php @@ -0,0 +1,142 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + */ +#[TestDox('User Stats')] +class UserStatsTest extends TestCase +{ + #[Test] + #[TestDox('Get win rate calculates correctly')] + public function getWinRateCalculatesCorrectly(): void + { + $stats = new UserStats(); + $stats->wins = 60; + $stats->gamesWithScores = 100; + + $result = $stats->getWinRate(); + + $this->assertSame(60, $result); + } + + #[Test] + #[TestDox('Get win rate with zero games returns zero')] + public function getWinRateWithZeroGamesReturnsZero(): void + { + $stats = new UserStats(); + $stats->wins = 10; + $stats->gamesWithScores = 0; + + $result = $stats->getWinRate(); + + $this->assertSame(0, $result); + } + + #[Test] + #[TestDox('Get win rate rounds correctly')] + public function getWinRateRoundsCorrectly(): void + { + $stats = new UserStats(); + $stats->wins = 33; + $stats->gamesWithScores = 100; + + $result = $stats->getWinRate(); + + $this->assertSame(33, $result); + } + + #[Test] + #[TestDox('Get avg score calculates correctly')] + public function getAvgScoreCalculatesCorrectly(): void + { + $stats = new UserStats(); + $stats->totalMines = 550; + $stats->gamesWithScores = 100; + + $result = $stats->getAvgScore(); + + $this->assertSame(6, $result); // 550/100 = 5.5 -> 6 + } + + #[Test] + #[TestDox('Get avg score with zero games returns zero')] + public function getAvgScoreWithZeroGamesReturnsZero(): void + { + $stats = new UserStats(); + $stats->totalMines = 100; + $stats->gamesWithScores = 0; + + $result = $stats->getAvgScore(); + + $this->assertSame(0, $result); + } + + #[Test] + #[TestDox('Get avg score rounds down')] + public function getAvgScoreRoundsDown(): void + { + $stats = new UserStats(); + $stats->totalMines = 101; + $stats->gamesWithScores = 100; + + $result = $stats->getAvgScore(); + + $this->assertSame(1, $result); // 101/100 = 1.01 -> 1 + } + + #[Test] + #[TestDox('Get avg score rounds up')] + public function getAvgScoreRoundsUp(): void + { + $stats = new UserStats(); + $stats->totalMines = 151; + $stats->gamesWithScores = 100; + + $result = $stats->getAvgScore(); + + $this->assertSame(2, $result); // 151/100 = 1.51 -> 2 + } + + #[Test] + #[TestDox('Default values')] + public function defaultValues(): void + { + $stats = new UserStats(); + + $this->assertSame(0, $stats->userId); + $this->assertSame(0, $stats->totalGames); + $this->assertSame(0, $stats->wins); + $this->assertSame(0, $stats->losses); + $this->assertSame(0, $stats->draws); + $this->assertSame(0, $stats->totalMines); + $this->assertSame('0.0', $stats->totalBonusPoints); + $this->assertSame('0.0', $stats->avgBonus); + $this->assertSame(0, $stats->bestChain); + $this->assertSame(0, $stats->blindHits); + $this->assertSame(0, $stats->edgeMines); + $this->assertSame(0, $stats->gamesWithScores); + $this->assertNull($stats->lastGameAt); + } +} diff --git a/tests/Factory/ContactMessageFactory.php b/tests/Factory/ContactMessageFactory.php new file mode 100644 index 0000000..627e723 --- /dev/null +++ b/tests/Factory/ContactMessageFactory.php @@ -0,0 +1,55 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + * + * @extends PersistentProxyObjectFactory + */ +class ContactMessageFactory extends PersistentProxyObjectFactory +{ + protected function defaults(): array + { + return [ + 'name' => self::faker()->name(), + 'email' => self::faker()->safeEmail(), + 'content' => self::faker()->paragraph(3), + 'consent' => true, + 'ipAddress' => self::faker()->ipv4(), + ]; + } + + public static function class(): string + { + return ContactMessage::class; + } + + public function withoutConsent(): self + { + return $this->with(['consent' => false]); + } + + public function anonymous(): self + { + return $this->with(['ipAddress' => null]); + } +} diff --git a/tests/Factory/GamerFactory.php b/tests/Factory/GamerFactory.php new file mode 100644 index 0000000..903a0e5 --- /dev/null +++ b/tests/Factory/GamerFactory.php @@ -0,0 +1,51 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + * + * @extends PersistentProxyObjectFactory + */ +class GamerFactory extends PersistentProxyObjectFactory +{ + protected function defaults(): array + { + return [ + 'userName' => self::faker()->userName(), + 'ip' => self::faker()->ipv4(), + 'country' => self::faker()->countryCode(), + 'userAgent' => self::faker()->userAgent(), + 'connTimestamp' => new DateTime(), + ]; + } + + public static function class(): string + { + return Gamer::class; + } + + public function anonymous(): self + { + return $this->with(['userName' => sprintf('Guest_%d', self::faker()->randomNumber(5))]); + } +} diff --git a/tests/Factory/GridFactory.php b/tests/Factory/GridFactory.php new file mode 100644 index 0000000..0385dd9 --- /dev/null +++ b/tests/Factory/GridFactory.php @@ -0,0 +1,53 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + * + * @extends PersistentProxyObjectFactory + */ +class GridFactory extends PersistentProxyObjectFactory +{ + protected function defaults(): array + { + return [ + 'playedGame' => PlayedGameFactory::new(), + ]; + } + + public static function class(): string + { + return Grid::class; + } + + protected function initialize(): static + { + return $this->afterInstantiate(function (Grid $grid): void { + for ($i = 0; $i < 16; $i++) { + /** @var GridRow $factory */ + $factory = GridRowFactory::new()->create()->_real(); + $grid->addGridRow($factory); + } + }); + } +} diff --git a/tests/Factory/GridRowFactory.php b/tests/Factory/GridRowFactory.php new file mode 100644 index 0000000..88bbce9 --- /dev/null +++ b/tests/Factory/GridRowFactory.php @@ -0,0 +1,52 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + * + * @extends PersistentProxyObjectFactory + */ +class GridRowFactory extends PersistentProxyObjectFactory +{ + protected function defaults(): array + { + $columns = []; + + for ($i = 0; $i < 16; $i++) { + $columns[] = self::faker()->numberBetween(0, 8); + } + + return [ + 'gridCol' => $columns, + ]; + } + + public static function class(): string + { + return GridRow::class; + } + + public function withColumns(array $columns): self + { + return $this->with(['gridCol' => $columns]); + } +} diff --git a/tests/Factory/PlayedGameFactory.php b/tests/Factory/PlayedGameFactory.php new file mode 100644 index 0000000..30e6ba5 --- /dev/null +++ b/tests/Factory/PlayedGameFactory.php @@ -0,0 +1,108 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + * + * @extends PersistentProxyObjectFactory + */ +class PlayedGameFactory extends PersistentProxyObjectFactory +{ + protected function defaults(): array + { + return [ + 'uuid' => Uuid::v4(), + 'gameAssoc' => self::faker()->uuid(), + 'redPoints' => self::faker()->numberBetween(0, 51), + 'bluePoints' => self::faker()->numberBetween(0, 51), + 'redExplodedBomb' => false, + 'blueExplodedBomb' => false, + 'resign' => null, + 'redBonusPoints' => self::faker()->randomFloat(2, 0, 100), + 'blueBonusPoints' => self::faker()->randomFloat(2, 0, 100), + 'redBonusStats' => null, + 'blueBonusStats' => null, + 'created' => new DateTime(), + 'updated' => new DateTime(), + ]; + } + + public static function class(): string + { + return PlayedGame::class; + } + + public function withRegisteredPlayers(): self + { + return $this->with([ + 'red' => UserFactory::new(), + 'blue' => UserFactory::new(), + ]); + } + + public function withAnonymousPlayers(): self + { + return $this->with([ + 'redAnon' => GamerFactory::new(), + 'blueAnon' => GamerFactory::new(), + ]); + } + + public function withMixedPlayers(): self + { + return $this->with([ + 'red' => UserFactory::new(), + 'blueAnon' => GamerFactory::new(), + ]); + } + + public function finished(): self + { + return $this->with([ + 'redPoints' => 26, + 'bluePoints' => 25, + ]); + } + + public function redWins(): self + { + return $this->with([ + 'redPoints' => 26, + 'bluePoints' => self::faker()->numberBetween(0, 25), + ]); + } + + public function blueWins(): self + { + return $this->with([ + 'redPoints' => self::faker()->numberBetween(0, 25), + 'bluePoints' => 26, + ]); + } + + public function resigned(string $player): self + { + return $this->with(['resign' => $player]); + } +} diff --git a/tests/Factory/StepFactory.php b/tests/Factory/StepFactory.php new file mode 100644 index 0000000..8c7351c --- /dev/null +++ b/tests/Factory/StepFactory.php @@ -0,0 +1,68 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + * + * @extends PersistentProxyObjectFactory + */ +class StepFactory extends PersistentProxyObjectFactory +{ + protected function defaults(): array + { + return [ + 'row' => self::faker()->numberBetween(0, 15), + 'col' => self::faker()->numberBetween(0, 15), + 'wBomb' => self::faker()->boolean(), + 'player' => self::faker()->randomElement(['red', 'blue']), + 'revealedCells' => null, + 'playedGame' => PlayedGameFactory::new(), + 'created' => new DateTime(), + ]; + } + + public static function class(): string + { + return Step::class; + } + + public function mine(): self + { + return $this->with(['wBomb' => true]); + } + + public function safe(): self + { + return $this->with(['wBomb' => false]); + } + + public function forPlayer(string $player): self + { + return $this->with(['player' => $player]); + } + + public function withRevealedCells(array $cells): self + { + return $this->with(['revealedCells' => $cells]); + } +} diff --git a/tests/Factory/UserFactory.php b/tests/Factory/UserFactory.php new file mode 100644 index 0000000..d9f8ee9 --- /dev/null +++ b/tests/Factory/UserFactory.php @@ -0,0 +1,60 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + * + * @extends PersistentProxyObjectFactory + */ +class UserFactory extends PersistentProxyObjectFactory +{ + protected function defaults(): array + { + return [ + 'username' => self::faker()->unique()->userName(), + 'email' => self::faker()->unique()->safeEmail(), + 'password' => 'hashedpassword', + 'isVerified' => true, + 'roles' => [], + ]; + } + + public static function class(): string + { + return User::class; + } + + public function withPassword(string $hashedPassword): self + { + return $this->with(['password' => $hashedPassword]); + } + + public function unverified(): self + { + return $this->with(['isVerified' => false]); + } + + public function withRoles(array $roles): self + { + return $this->with(['roles' => $roles]); + } +} diff --git a/tests/Factory/WebAuthnCredentialFactory.php b/tests/Factory/WebAuthnCredentialFactory.php new file mode 100644 index 0000000..0246841 --- /dev/null +++ b/tests/Factory/WebAuthnCredentialFactory.php @@ -0,0 +1,62 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + * + * @extends PersistentProxyObjectFactory + */ +class WebAuthnCredentialFactory extends PersistentProxyObjectFactory +{ + protected function defaults(): array + { + return [ + 'user' => UserFactory::new(), + 'credentialData' => json_encode([ + 'type' => 'public-key', + 'id' => base64_encode(self::faker()->uuid()), + 'transports' => ['usb', 'nfc'], + ], JSON_THROW_ON_ERROR), + 'credentialName' => self::faker()->words(3, true), + 'createdAt' => new DateTime(), + 'lastUsedAt' => null, + 'isBackupEligible' => self::faker()->boolean(), + 'isBackupAuthenticated' => self::faker()->boolean(), + ]; + } + + public static function class(): string + { + return WebAuthnCredential::class; + } + + public function withName(string $name): self + { + return $this->with(['credentialName' => $name]); + } + + public function recentlyUsed(): self + { + return $this->with(['lastUsedAt' => new DateTime()]); + } +} diff --git a/tests/Integration/FactoryExampleTest.php b/tests/Integration/FactoryExampleTest.php new file mode 100644 index 0000000..635d640 --- /dev/null +++ b/tests/Integration/FactoryExampleTest.php @@ -0,0 +1,211 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + */ +#[TestDox('Factory Examples')] +class FactoryExampleTest extends WebTestCase +{ + #[Test] + #[TestDox('Creates a verified user with UserFactory')] + public function createUser(): void + { + $user = UserFactory::createOne(); + + self::assertInstanceOf(User::class, $user->_real()); + self::assertNotNull($user->username); + self::assertNotNull($user->email); + self::assertTrue($user->isVerified); + } + + #[Test] + #[TestDox('Creates an unverified user')] + public function createUnverifiedUser(): void + { + $user = UserFactory::createOne(['isVerified' => false]); + + self::assertFalse($user->isVerified); + } + + #[Test] + #[TestDox('Creates multiple users at once')] + public function createMultipleUsers(): void + { + UserFactory::createMany(5); + + self::assertCount(5, UserFactory::repository()->findAll()); + } + + #[Test] + #[TestDox('Creates an anonymous gamer with guest username')] + public function createAnonymousGamer(): void + { + $gamer = GamerFactory::new()->anonymous()->create(); + + self::assertStringStartsWith('Guest_', $gamer->userName); + self::assertNotNull($gamer->ip); + self::assertNotNull($gamer->connTimestamp); + } + + #[Test] + #[TestDox('Creates a game with two registered players')] + public function createGameWithRegisteredPlayers(): void + { + $game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->create(); + + self::assertInstanceOf(PlayedGame::class, $game->_real()); + self::assertNotNull($game->red); + self::assertNotNull($game->blue); + self::assertNull($game->redAnon); + self::assertNull($game->blueAnon); + } + + #[Test] + #[TestDox('Creates a game with two anonymous players')] + public function createGameWithAnonymousPlayers(): void + { + $game = PlayedGameFactory::new() + ->withAnonymousPlayers() + ->create(); + + self::assertNull($game->red); + self::assertNull($game->blue); + self::assertNotNull($game->redAnon); + self::assertNotNull($game->blueAnon); + } + + #[Test] + #[TestDox('Creates a finished game where red player wins')] + public function createFinishedGame(): void + { + $game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->redWins() + ->create(); + + self::assertEquals(26, $game->redPoints); + self::assertLessThan(26, $game->bluePoints); + } + + #[Test] + #[TestDox('Creates a game with multiple steps from both players')] + public function createGameWithSteps(): void + { + $game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->create(); + + StepFactory::new() + ->forPlayer('red') + ->mine() + ->create(['playedGame' => $game]); + + StepFactory::new() + ->forPlayer('red') + ->mine() + ->create(['playedGame' => $game]); + + StepFactory::new() + ->forPlayer('red') + ->mine() + ->create(['playedGame' => $game]); + + StepFactory::new() + ->forPlayer('blue') + ->safe() + ->create(['playedGame' => $game]); + + StepFactory::new() + ->forPlayer('blue') + ->safe() + ->create(['playedGame' => $game]); + + self::assertCount(5, StepFactory::repository()->findAll()); + } + + #[Test] + #[TestDox('Creates a game with a 16x16 grid')] + public function createGameWithGrid(): void + { + $game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->create(); + + $grid = GridFactory::createOne(['playedGame' => $game]); + + self::assertCount(16, $grid->gridRow); + } + + #[Test] + #[TestDox('Creates a WebAuthn credential for a user')] + public function createWebAuthnCredential(): void + { + $user = UserFactory::createOne(); + + $credential = WebAuthnCredentialFactory::new() + ->withName('YubiKey 5C') + ->recentlyUsed() + ->create(['user' => $user]); + + self::assertEquals($user->_real(), $credential->user); + self::assertEquals('YubiKey 5C', $credential->credentialName); + self::assertNotNull($credential->lastUsedAt); + } + + #[Test] + #[TestDox('Creates a contact message with consent')] + public function createContactMessage(): void + { + $message = ContactMessageFactory::createOne(); + + self::assertNotNull($message->name); + self::assertNotNull($message->email); + self::assertNotNull($message->content); + self::assertTrue($message->consent); + self::assertNotNull($message->createdAt); + } + + #[Test] + #[TestDox('Tests are isolated - database is reset between tests')] + public function databaseIsolation(): void + { + UserFactory::createMany(3); + self::assertCount(3, UserFactory::repository()->findAll()); + + /** Database will be reset before next test due to ResetDatabase trait */ + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..db767c9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,108 @@ +# Tests Directory + +This directory contains all test files for the MineSeeker project. + +## Quick Start + +```bash +# Run all tests (recommended) +make test + +# Or with PHPUnit directly: +vendor/bin/phpunit + +# Run specific test file +vendor/bin/phpunit tests/Controller/ProfileControllerTest.php + +# Run with filter +vendor/bin/phpunit --filter testCreateUser +``` + +## Documentation + +For comprehensive testing documentation, see: + +- **[Testing Guide](../docs/testing/TESTING.md)** - Complete testing setup, best practices, troubleshooting +- **[Factory Documentation](../docs/testing/FACTORIES.md)** - Detailed factory API reference + +## Directory Structure + +``` +tests/ +├── Controller/ # HTTP endpoint tests +├── Dto/ # Data Transfer Object tests +├── Entity/ # Entity logic tests +├── Service/ # Service layer tests +├── Integration/ # Integration tests (example: FactoryExampleTest.php) +├── Factory/ # Foundry factory classes +│ ├── UserFactory.php +│ ├── GamerFactory.php +│ ├── PlayedGameFactory.php +│ ├── StepFactory.php +│ ├── GridFactory.php +│ ├── GridRowFactory.php +│ ├── WebAuthnCredentialFactory.php +│ └── ContactMessageFactory.php +├── WebTestCase.php # Base test class (extends this!) +├── bootstrap.php # PHPUnit bootstrap +└── README.md # This file +``` + +## Quick Factory Examples + +```php +use App\Tests\Factory\UserFactory; +use App\Tests\Factory\PlayedGameFactory; +use App\Tests\WebTestCase; + +class MyTest extends WebTestCase +{ + public function testExample(): void + { + /** Create user */ + $user = UserFactory::createOne(); + + /** Create game with registered players */ + $game = PlayedGameFactory::new() + ->withRegisteredPlayers() + ->redWins() + ->create(); + + /** Create multiple entities */ + UserFactory::createMany(5); + + /** Access repository */ + $users = UserFactory::repository()->findAll(); + } +} +``` + +## Important Notes + +- **Always extend `App\Tests\WebTestCase`** - provides database isolation +- **Use factories** - don't manually create entities with `new Entity()` +- **Test database** - Tests run on `mineseeker_test`, never production +- **Automatic rollback** - Each test is wrapped in a transaction + +## Test Database Setup (One-time) + +```bash +# Create test database +bin/console dbal:run-sql "CREATE DATABASE mineseeker_test" + +# Run migrations +bin/console doctrine:migrations:migrate --env=test --no-interaction +``` + +## Troubleshooting + +### Tests interfering with each other? +→ Make sure your test extends `App\Tests\WebTestCase` + +### Database schema out of sync? +→ Run `bin/console doctrine:migrations:migrate --env=test` + +### Memory limit errors? +→ Run `php -d memory_limit=512M vendor/bin/phpunit` + +For more troubleshooting, see [Testing Guide](../docs/testing/TESTING.md#troubleshooting). diff --git a/tests/Service/MercureJwtServiceTest.php b/tests/Service/MercureJwtServiceTest.php new file mode 100644 index 0000000..5e5b5c9 --- /dev/null +++ b/tests/Service/MercureJwtServiceTest.php @@ -0,0 +1,109 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + */ +#[TestDox('Mercure Jwt Service')] +class MercureJwtServiceTest extends TestCase +{ + /** JWT HS256 requires at least 32 characters for the secret key */ + private const SECRET = 'test-mercure-secret-key-12345678901234567890'; + + private MercureJwtService $service; + + protected function setUp(): void + { + parent::setUp(); + $this->service = new MercureJwtService(self::SECRET); + } + + #[Test] + #[TestDox('Mint subscriber token returns valid jwt')] + public function mintSubscriberTokenReturnsValidJwt(): void + { + $token = $this->service->mintSubscriberToken('game123', 'player1'); + + $this->assertIsString($token); + $this->assertNotEmpty($token); + + /** Token should have 3 parts (header.payload.signature) */ + $parts = explode('.', $token); + $this->assertCount(3, $parts); + } + + #[Test] + #[TestDox('Mint subscriber token with different game assoc')] + public function mintSubscriberTokenWithDifferentGameAssoc(): void + { + $token1 = $this->service->mintSubscriberToken('gameA', 'player1'); + $token2 = $this->service->mintSubscriberToken('gameB', 'player1'); + + /** Different gameAssoc should result in different tokens */ + $this->assertNotSame($token1, $token2); + } + + #[Test] + #[TestDox('Mint subscriber token with different user names')] + public function mintSubscriberTokenWithDifferentUserNames(): void + { + $token1 = $this->service->mintSubscriberToken('game1', 'playerA'); + $token2 = $this->service->mintSubscriberToken('game1', 'playerB'); + + /** Different usernames should result in different tokens */ + $this->assertNotSame($token1, $token2); + } + + #[Test] + #[TestDox('Mint subscriber token contains proper structure')] + public function mintSubscriberTokenContainsProperStructure(): void + { + $token = $this->service->mintSubscriberToken('game123', 'testplayer'); + + /** Decode without verification to check structure */ + $parts = explode('.', $token); + + /** Decode payload (middle part) */ + $payload = json_decode(base64_decode($parts[1] . str_repeat('=', (4 - strlen($parts[1]) % 4))), true); + + $this->assertIsArray($payload); + $this->assertArrayHasKey('mercure', $payload); + $this->assertArrayHasKey('subscribe', $payload['mercure']); + $this->assertArrayHasKey('payload', $payload['mercure']); + } + + #[Test] + #[TestDox('Mint subscriber token payload contains correct data')] + public function mintSubscriberTokenPayloadContainsCorrectData(): void + { + $token = $this->service->mintSubscriberToken('test-game', 'test-user'); + + $parts = explode('.', $token); + $payload = json_decode(base64_decode($parts[1] . str_repeat('=', (4 - strlen($parts[1]) % 4))), true); + + $this->assertSame('test-user', $payload['mercure']['payload']['username']); + $this->assertSame('test-game', $payload['mercure']['payload']['gameAssoc']); + $this->assertContains('*', $payload['mercure']['subscribe']); + } +} diff --git a/tests/Service/RecaptchaServiceTest.php b/tests/Service/RecaptchaServiceTest.php new file mode 100644 index 0000000..4a4db9e --- /dev/null +++ b/tests/Service/RecaptchaServiceTest.php @@ -0,0 +1,188 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 21. + */ +#[AllowMockObjectsWithoutExpectations] +#[TestDox('Recaptcha Service')] +class RecaptchaServiceTest extends TestCase +{ + private const SECRET_KEY = 'test-secret-key'; + + #[Test] + #[TestDox('Verify returns false for empty token')] + public function verifyReturnsFalseForEmptyToken(): void + { + $httpClient = $this->createMock(HttpClientInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); + + $result = $service->verify(''); + + $this->assertFalse($result); + } + + #[Test] + #[TestDox('Verify returns false when api returns failure')] + public function verifyReturnsFalseWhenApiReturnsFailure(): void + { + $mockResponse = new MockResponse(json_encode([ + 'success' => false, + 'error-codes' => ['invalid-input-secret'], + ], JSON_THROW_ON_ERROR)); + + $httpClient = new MockHttpClient($mockResponse); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('info'); + + $service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); + + $result = $service->verify('invalid-token'); + + $this->assertFalse($result); + } + + #[Test] + #[TestDox('Verify returns true when api returns success and high score')] + public function verifyReturnsTrueWhenApiReturnsSuccessAndHighScore(): void + { + $mockResponse = new MockResponse(json_encode([ + 'success' => true, + 'score' => 0.8, + 'hostname' => 'test.com', + ], JSON_THROW_ON_ERROR)); + + $httpClient = new MockHttpClient($mockResponse); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('info'); + + $service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); + + $result = $service->verify('valid-token'); + + $this->assertTrue($result); + } + + #[Test] + #[TestDox('Verify returns false when score below threshold')] + public function verifyReturnsFalseWhenScoreBelowThreshold(): void + { + $mockResponse = new MockResponse(json_encode([ + 'success' => true, + 'score' => 0.3, + 'hostname' => 'test.com', + ], JSON_THROW_ON_ERROR)); + + $httpClient = new MockHttpClient($mockResponse); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('info'); + + $service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); + + $result = $service->verify('low-score-token'); + + $this->assertFalse($result); + } + + #[Test] + #[TestDox('Verify returns false when api throws exception')] + public function verifyReturnsFalseWhenApiThrowsException(): void + { + $mockResponse = new MockResponse('', ['http_code' => 500]); + + $httpClient = new MockHttpClient($mockResponse); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error'); + + $service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); + + $result = $service->verify('test-token'); + + $this->assertFalse($result); + } + + #[Test] + #[TestDox('Verify includes remote ip when provided')] + public function verifyIncludesRemoteIpWhenProvided(): void + { + $mockResponse = new MockResponse(json_encode([ + 'success' => true, + 'score' => 0.9, + ], JSON_THROW_ON_ERROR)); + + $httpClient = new MockHttpClient($mockResponse); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('info'); + + $service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); + + $result = $service->verify('test-token', '192.168.1.1'); + + $this->assertTrue($result); + } + + #[Test] + #[TestDox('Verify with score at threshold')] + public function verifyWithScoreAtThreshold(): void + { + $mockResponse = new MockResponse(json_encode([ + 'success' => true, + 'score' => 0.5, + ], JSON_THROW_ON_ERROR)); + + $httpClient = new MockHttpClient($mockResponse); + $logger = $this->createMock(LoggerInterface::class); + + $service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); + + $result = $service->verify('threshold-token'); + + $this->assertTrue($result); + } + + #[Test] + #[TestDox('Verify with no score fails')] + public function verifyWithNoScoreFails(): void + { + $mockResponse = new MockResponse(json_encode([ + 'success' => true, + ], JSON_THROW_ON_ERROR)); + + $httpClient = new MockHttpClient($mockResponse); + $logger = $this->createMock(LoggerInterface::class); + + $service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); + + $result = $service->verify('no-score-token'); + + $this->assertFalse($result); + } +} diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php new file mode 100644 index 0000000..78a82d7 --- /dev/null +++ b/tests/WebTestCase.php @@ -0,0 +1,21 @@ +bootEnv(dirname(__DIR__).'/.env'); +} + +if ($_SERVER['APP_DEBUG']) { + umask(0000); +}