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);
+}