From 0f254c27704fe3670a670a478d162f8812f94023 Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 12:27:35 +0200 Subject: [PATCH 01/12] chore(deps): remove unused stimulus-bundle and ux-turbo, refresh deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project doesn't use Stimulus or Turbo (no controllers.json, no , no data-controller — frontend is React/Mantine + Twig/Bootstrap). Both bundles were Flex-recipe leftovers that Dependabot kept churning major-bump PRs against (#488, #489 closed). Drops the composer packages, the four orphaned npm deps (@hotwired/{stimulus,turbo}, @symfony/{stimulus-bridge,ux-turbo}), config/packages/ux_turbo.yaml, the bundles.php entries, and the turbo-track comment boilerplate in webpack_encore.yaml. The subsequent `composer update` also picked up routine patch bumps: Symfony 8.0.8 -> 8.0.9 across components, doctrine/persistence 4.1.1 -> 4.2.0, async-aws/sqs 2.8.1 -> 2.9.0, phpstan 2.1.54, phpunit 13.1.8, aws-sdk-php 3.379.11, polyfills 1.37. No majors, no security advisories. --- composer.json | 30 +- composer.lock | 570 +++++++++--------------- config/bundles.php | 13 - config/packages/ux_turbo.yaml | 4 - config/packages/webpack_encore.yaml | 6 - package-lock.json | 665 +--------------------------- package.json | 4 - symfony.lock | 27 -- 8 files changed, 233 insertions(+), 1086 deletions(-) delete mode 100644 config/packages/ux_turbo.yaml diff --git a/composer.json b/composer.json index 34583a0..37f5ab2 100644 --- a/composer.json +++ b/composer.json @@ -25,18 +25,18 @@ "sentry/sentry-symfony": "^5.10", "symfony/amazon-sqs-messenger": "^8.0.6", "symfony/asset": "^8.0.8", - "symfony/asset-mapper": "^8.0.8", - "symfony/console": "^8.0.8", + "symfony/asset-mapper": "^8.0.9", + "symfony/console": "^8.0.9", "symfony/doctrine-messenger": "^8.0.6", - "symfony/dotenv": "^8.0.8", + "symfony/dotenv": "^8.0.9", "symfony/expression-language": "^8.0.8", "symfony/flex": "^2.10", - "symfony/form": "^8.0.8", - "symfony/framework-bundle": "^8.0.8", - "symfony/http-client": "^8.0.8", + "symfony/form": "^8.0.9", + "symfony/framework-bundle": "^8.0.9", + "symfony/http-client": "^8.0.9", "symfony/intl": "^8.0.8", "symfony/mailer": "^8.0.8", - "symfony/mime": "^8.0.8", + "symfony/mime": "^8.0.9", "symfony/monolog-bundle": "^3.0|^4.0.2", "symfony/notifier": "^8.0.8", "symfony/process": "^8.0.8", @@ -45,16 +45,14 @@ "symfony/runtime": "^8.0.8", "symfony/security-bundle": "^8.0.8", "symfony/serializer": "^8.0.8", - "symfony/stimulus-bundle": "^2.35", "symfony/string": "^8.0.8", "symfony/translation": "^8.0.8", "symfony/twig-bundle": "^8.0.8", - "symfony/uid": "^8.0.8", - "symfony/ux-turbo": "^2.35", - "symfony/validator": "^8.0.8", + "symfony/uid": "^8.0.9", + "symfony/validator": "^8.0.9", "symfony/web-link": "^8.0.8", "symfony/webpack-encore-bundle": "^2.4", - "symfony/workflow": "^8.0.8", + "symfony/workflow": "^8.0.9", "symfony/yaml": "^8.0.8", "twig/extra-bundle": "^2.12|^3.24", "twig/twig": "^2.12|^3.24" @@ -119,15 +117,15 @@ "require-dev": { "doctrine/doctrine-fixtures-bundle": "^4.3.1", "friendsofphp/php-cs-fixer": "^3.95.1", - "phpstan/phpstan": "^2.1.51", + "phpstan/phpstan": "^2.1.54", "phpstan/phpstan-doctrine": "^2.0.21", "phpstan/phpstan-symfony": "^2.0.15", - "phpunit/phpunit": "^13.1.7", + "phpunit/phpunit": "^13.1.8", "symfony/browser-kit": "^8.0.8", - "symfony/css-selector": "^8.0.8", + "symfony/css-selector": "^8.0.9", "symfony/debug-bundle": "^8.0.8", "symfony/maker-bundle": "^1.67", "symfony/stopwatch": "^8.0.8", - "symfony/web-profiler-bundle": "^8.0.8" + "symfony/web-profiler-bundle": "^8.0.9" } } diff --git a/composer.lock b/composer.lock index 8a27a4b..bdf4286 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6b609a0522faa4d1579d30a66de8d54c", + "content-hash": "9d43e18445591ada54de5b3cb560f694", "packages": [ { "name": "async-aws/core", @@ -80,16 +80,16 @@ }, { "name": "async-aws/sqs", - "version": "2.8.1", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/async-aws/sqs.git", - "reference": "e03ac920b8627bf7b402556b28b70d6fdeb190e5" + "reference": "2571a09f41040d38210ae2910396a8314989e12e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/async-aws/sqs/zipball/e03ac920b8627bf7b402556b28b70d6fdeb190e5", - "reference": "e03ac920b8627bf7b402556b28b70d6fdeb190e5", + "url": "https://api.github.com/repos/async-aws/sqs/zipball/2571a09f41040d38210ae2910396a8314989e12e", + "reference": "2571a09f41040d38210ae2910396a8314989e12e", "shasum": "" }, "require": { @@ -105,7 +105,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.8-dev" + "dev-master": "2.9-dev" } }, "autoload": { @@ -126,7 +126,7 @@ "sqs" ], "support": { - "source": "https://github.com/async-aws/sqs/tree/2.8.1" + "source": "https://github.com/async-aws/sqs/tree/2.9.0" }, "funding": [ { @@ -138,7 +138,7 @@ "type": "github" } ], - "time": "2026-02-16T10:24:54+00:00" + "time": "2026-04-29T10:05:33+00:00" }, { "name": "aws/aws-crt-php", @@ -196,16 +196,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.379.7", + "version": "3.379.11", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "2c338cb3f2bcb9e8616ffbac7a36d66034f2ceef" + "reference": "af784a64c1fc91c55bcfa493d8b92328bc3b38bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2c338cb3f2bcb9e8616ffbac7a36d66034f2ceef", - "reference": "2c338cb3f2bcb9e8616ffbac7a36d66034f2ceef", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/af784a64c1fc91c55bcfa493d8b92328bc3b38bf", + "reference": "af784a64c1fc91c55bcfa493d8b92328bc3b38bf", "shasum": "" }, "require": { @@ -287,9 +287,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.379.7" + "source": "https://github.com/aws/aws-sdk-php/tree/3.379.11" }, - "time": "2026-04-24T18:17:06+00:00" + "time": "2026-05-01T18:16:33+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1515,19 +1515,20 @@ }, { "name": "doctrine/persistence", - "version": "4.1.1", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/doctrine/persistence.git", - "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09" + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", - "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", "shasum": "" }, "require": { + "doctrine/deprecations": "^1", "doctrine/event-manager": "^1 || ^2", "php": "^8.1", "psr/cache": "^1.0 || ^2.0 || ^3.0" @@ -1538,13 +1539,13 @@ "phpstan/phpstan-phpunit": "^2", "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "^10.5.58 || ^12", - "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0" + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Persistence\\": "src/Persistence" + "Doctrine\\Persistence\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1588,7 +1589,7 @@ ], "support": { "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/4.1.1" + "source": "https://github.com/doctrine/persistence/tree/4.2.0" }, "funding": [ { @@ -1604,7 +1605,7 @@ "type": "tidelift" } ], - "time": "2025-10-16T20:13:18+00:00" + "time": "2026-04-26T12:12:52+00:00" }, { "name": "doctrine/sql-formatter", @@ -4589,16 +4590,16 @@ }, { "name": "symfony/asset-mapper", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/asset-mapper.git", - "reference": "554b562577a3b23d15388dee12dc482401e45fbd" + "reference": "32f3e4a1754cad53502ae59a13cd7c428baec5b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/554b562577a3b23d15388dee12dc482401e45fbd", - "reference": "554b562577a3b23d15388dee12dc482401e45fbd", + "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/32f3e4a1754cad53502ae59a13cd7c428baec5b2", + "reference": "32f3e4a1754cad53502ae59a13cd7c428baec5b2", "shasum": "" }, "require": { @@ -4646,7 +4647,7 @@ "description": "Maps directories of assets & makes them available in a public directory with versioned filenames.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset-mapper/tree/v8.0.8" + "source": "https://github.com/symfony/asset-mapper/tree/v8.0.9" }, "funding": [ { @@ -4666,20 +4667,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/cache", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78" + "reference": "2866a183cd942bbaa81e9fdbd1ef1ea902c5ee2d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78", - "reference": "8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78", + "url": "https://api.github.com/repos/symfony/cache/zipball/2866a183cd942bbaa81e9fdbd1ef1ea902c5ee2d", + "reference": "2866a183cd942bbaa81e9fdbd1ef1ea902c5ee2d", "shasum": "" }, "require": { @@ -4746,7 +4747,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.0.8" + "source": "https://github.com/symfony/cache/tree/v8.0.9" }, "funding": [ { @@ -4766,7 +4767,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:18:51+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/cache-contracts", @@ -4923,16 +4924,16 @@ }, { "name": "symfony/config", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39" + "reference": "7e712ee3c98ec114f674adc4fbad4c2fe7526b9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/c7369cc1da250fcbfe0c5a9d109e419661549c39", - "reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39", + "url": "https://api.github.com/repos/symfony/config/zipball/7e712ee3c98ec114f674adc4fbad4c2fe7526b9c", + "reference": "7e712ee3c98ec114f674adc4fbad4c2fe7526b9c", "shasum": "" }, "require": { @@ -4977,7 +4978,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v8.0.8" + "source": "https://github.com/symfony/config/tree/v8.0.9" }, "funding": [ { @@ -4997,20 +4998,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/console", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" + "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", - "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d", + "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", "shasum": "" }, "require": { @@ -5067,7 +5068,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.8" + "source": "https://github.com/symfony/console/tree/v8.0.9" }, "funding": [ { @@ -5087,20 +5088,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/dependency-injection", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "3ce58b0fa844dc647ca1d66ea34748af985728c5" + "reference": "85d359a3f72b8e1ae1466b4330149318442e3a8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/3ce58b0fa844dc647ca1d66ea34748af985728c5", - "reference": "3ce58b0fa844dc647ca1d66ea34748af985728c5", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/85d359a3f72b8e1ae1466b4330149318442e3a8c", + "reference": "85d359a3f72b8e1ae1466b4330149318442e3a8c", "shasum": "" }, "require": { @@ -5148,7 +5149,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v8.0.8" + "source": "https://github.com/symfony/dependency-injection/tree/v8.0.9" }, "funding": [ { @@ -5168,7 +5169,7 @@ "type": "tidelift" } ], - "time": "2026-03-31T07:15:36+00:00" + "time": "2026-04-30T18:39:49+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5239,16 +5240,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "a45ac00ffcc763ffc36c791647e93d310142804f" + "reference": "dfe3dddc9c22756b9b145785fb5fd4b0445cd06e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/a45ac00ffcc763ffc36c791647e93d310142804f", - "reference": "a45ac00ffcc763ffc36c791647e93d310142804f", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/dfe3dddc9c22756b9b145785fb5fd4b0445cd06e", + "reference": "dfe3dddc9c22756b9b145785fb5fd4b0445cd06e", "shasum": "" }, "require": { @@ -5317,7 +5318,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v8.0.8" + "source": "https://github.com/symfony/doctrine-bridge/tree/v8.0.9" }, "funding": [ { @@ -5337,7 +5338,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/doctrine-messenger", @@ -5417,16 +5418,16 @@ }, { "name": "symfony/dotenv", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "5ba6337f9a86e78e13b1ac11a89f85689b12cf2c" + "reference": "f75c67be2c2648741c4b163546002e34265bcb91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/5ba6337f9a86e78e13b1ac11a89f85689b12cf2c", - "reference": "5ba6337f9a86e78e13b1ac11a89f85689b12cf2c", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/f75c67be2c2648741c4b163546002e34265bcb91", + "reference": "f75c67be2c2648741c4b163546002e34265bcb91", "shasum": "" }, "require": { @@ -5467,7 +5468,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v8.0.8" + "source": "https://github.com/symfony/dotenv/tree/v8.0.9" }, "funding": [ { @@ -5487,7 +5488,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/error-handler", @@ -5572,16 +5573,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6" + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6", - "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", "shasum": "" }, "require": { @@ -5633,7 +5634,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" }, "funding": [ { @@ -5653,7 +5654,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5800,16 +5801,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a" + "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/66b769ae743ce2d13e435528fbef4af03d623e5a", - "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", + "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", "shasum": "" }, "require": { @@ -5846,7 +5847,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.8" + "source": "https://github.com/symfony/filesystem/tree/v8.0.9" }, "funding": [ { @@ -5866,7 +5867,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/finder", @@ -6011,16 +6012,16 @@ }, { "name": "symfony/form", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "c163f5db2f1ffecb3d5b33a2e662d13115323b20" + "reference": "dd9f73dd3b92e657c97aeeca1f47e981c635ea91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/c163f5db2f1ffecb3d5b33a2e662d13115323b20", - "reference": "c163f5db2f1ffecb3d5b33a2e662d13115323b20", + "url": "https://api.github.com/repos/symfony/form/zipball/dd9f73dd3b92e657c97aeeca1f47e981c635ea91", + "reference": "dd9f73dd3b92e657c97aeeca1f47e981c635ea91", "shasum": "" }, "require": { @@ -6082,7 +6083,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v8.0.8" + "source": "https://github.com/symfony/form/tree/v8.0.9" }, "funding": [ { @@ -6102,20 +6103,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/framework-bundle", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "ce3ee5db0a9c1b6c52f5e3ba16b63a677b18b7df" + "reference": "ec5e749f011a477d9a5d422f50ad6bdc2820c5cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/ce3ee5db0a9c1b6c52f5e3ba16b63a677b18b7df", - "reference": "ce3ee5db0a9c1b6c52f5e3ba16b63a677b18b7df", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/ec5e749f011a477d9a5d422f50ad6bdc2820c5cd", + "reference": "ec5e749f011a477d9a5d422f50ad6bdc2820c5cd", "shasum": "" }, "require": { @@ -6144,6 +6145,7 @@ "symfony/form": "<7.4", "symfony/json-streamer": "<7.4", "symfony/messenger": "<7.4", + "symfony/mime": "<7.4.9|>=8.0,<8.0.9", "symfony/security-csrf": "<7.4", "symfony/serializer": "<7.4", "symfony/translation": "<7.4", @@ -6172,9 +6174,9 @@ "symfony/lock": "^7.4|^8.0", "symfony/mailer": "^7.4|^8.0", "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", + "symfony/mime": "^7.4.9|^8.0.9", "symfony/notifier": "^7.4|^8.0", - "symfony/object-mapper": "^7.4|^8.0", + "symfony/object-mapper": "^7.4.9|^8.0.9", "symfony/polyfill-intl-icu": "^1.0", "symfony/process": "^7.4|^8.0", "symfony/property-info": "^7.4|^8.0", @@ -6222,7 +6224,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v8.0.8" + "source": "https://github.com/symfony/framework-bundle/tree/v8.0.9" }, "funding": [ { @@ -6242,20 +6244,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-30T09:11:52+00:00" }, { "name": "symfony/http-client", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e" + "reference": "537c7f164078975b800f3f1c56810791024e4c77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e", - "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e", + "url": "https://api.github.com/repos/symfony/http-client/zipball/537c7f164078975b800f3f1c56810791024e4c77", + "reference": "537c7f164078975b800f3f1c56810791024e4c77", "shasum": "" }, "require": { @@ -6318,7 +6320,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v8.0.8" + "source": "https://github.com/symfony/http-client/tree/v8.0.9" }, "funding": [ { @@ -6338,7 +6340,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/http-client-contracts", @@ -6773,16 +6775,16 @@ }, { "name": "symfony/messenger", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "8d0e6b2d5e5dc9d484c6e45117395ae98f0a497a" + "reference": "ef41ccc1f80dd2ded64ad9d765b28644964532dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/8d0e6b2d5e5dc9d484c6e45117395ae98f0a497a", - "reference": "8d0e6b2d5e5dc9d484c6e45117395ae98f0a497a", + "url": "https://api.github.com/repos/symfony/messenger/zipball/ef41ccc1f80dd2ded64ad9d765b28644964532dd", + "reference": "ef41ccc1f80dd2ded64ad9d765b28644964532dd", "shasum": "" }, "require": { @@ -6839,7 +6841,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v8.0.8" + "source": "https://github.com/symfony/messenger/tree/v8.0.9" }, "funding": [ { @@ -6859,20 +6861,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-30T13:47:26+00:00" }, { "name": "symfony/mime", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66" + "reference": "a9fcb293650c054b62a5b406f4e92e7b711ea333" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ddff21f14c7ce04b98101b399a9463dce8b0ce66", - "reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66", + "url": "https://api.github.com/repos/symfony/mime/zipball/a9fcb293650c054b62a5b406f4e92e7b711ea333", + "reference": "a9fcb293650c054b62a5b406f4e92e7b711ea333", "shasum": "" }, "require": { @@ -6925,7 +6927,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.0.8" + "source": "https://github.com/symfony/mime/tree/v8.0.9" }, "funding": [ { @@ -6945,20 +6947,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724" + "reference": "4b7249b1520773ad325e99231b08443017729297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/c6efdcbd5cc17cf7618fb4447053b792df6ae724", - "reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/4b7249b1520773ad325e99231b08443017729297", + "reference": "4b7249b1520773ad325e99231b08443017729297", "shasum": "" }, "require": { @@ -7002,7 +7004,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v8.0.8" + "source": "https://github.com/symfony/monolog-bridge/tree/v8.0.9" }, "funding": [ { @@ -7022,7 +7024,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/monolog-bundle", @@ -7331,16 +7333,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -7389,7 +7391,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -7409,11 +7411,11 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-icu", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-icu.git", @@ -7477,7 +7479,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.37.0" }, "funding": [ { @@ -7501,7 +7503,7 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -7564,7 +7566,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" }, "funding": [ { @@ -7588,7 +7590,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -7649,7 +7651,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -7673,7 +7675,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -7734,7 +7736,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -7758,7 +7760,7 @@ }, { "name": "symfony/polyfill-uuid", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", @@ -7817,7 +7819,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -8160,16 +8162,16 @@ }, { "name": "symfony/routing", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4" + "reference": "75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/0de330ec2ea922a7b08ec45615bd51179de7fda4", - "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4", + "url": "https://api.github.com/repos/symfony/routing/zipball/75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038", + "reference": "75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038", "shasum": "" }, "require": { @@ -8216,7 +8218,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.8" + "source": "https://github.com/symfony/routing/tree/v8.0.9" }, "funding": [ { @@ -8236,7 +8238,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/runtime", @@ -8576,16 +8578,16 @@ }, { "name": "symfony/security-http", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "d3918d827ad0d18dcf009cf8fee82fd6e107de92" + "reference": "51fff88fc8436e42b4d92cae005f86b9233e0f88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/d3918d827ad0d18dcf009cf8fee82fd6e107de92", - "reference": "d3918d827ad0d18dcf009cf8fee82fd6e107de92", + "url": "https://api.github.com/repos/symfony/security-http/zipball/51fff88fc8436e42b4d92cae005f86b9233e0f88", + "reference": "51fff88fc8436e42b4d92cae005f86b9233e0f88", "shasum": "" }, "require": { @@ -8639,7 +8641,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v8.0.8" + "source": "https://github.com/symfony/security-http/tree/v8.0.9" }, "funding": [ { @@ -8659,7 +8661,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/serializer", @@ -8845,79 +8847,6 @@ ], "time": "2025-07-15T11:30:57+00:00" }, - { - "name": "symfony/stimulus-bundle", - "version": "v2.35.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/stimulus-bundle.git", - "reference": "05af0259f201dbbd15c103bea289989a4b483b5b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/05af0259f201dbbd15c103bea289989a4b483b5b", - "reference": "05af0259f201dbbd15c103bea289989a4b483b5b", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/config": "^5.4|^6.0|^7.0|^8.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", - "symfony/deprecation-contracts": "^2.0|^3.0", - "symfony/finder": "^5.4|^6.0|^7.0|^8.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0|^8.0", - "twig/twig": "^2.15.3|^3.8" - }, - "require-dev": { - "symfony/asset-mapper": "^6.3|^7.0|^8.0", - "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", - "symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0", - "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", - "zenstruck/browser": "^1.4" - }, - "type": "symfony-bundle", - "autoload": { - "psr-4": { - "Symfony\\UX\\StimulusBundle\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Integration with your Symfony app & Stimulus!", - "keywords": [ - "symfony-ux" - ], - "support": { - "source": "https://github.com/symfony/stimulus-bundle/tree/v2.35.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-03-22T22:21:50+00:00" - }, { "name": "symfony/stopwatch", "version": "v8.0.8", @@ -9443,16 +9372,16 @@ }, { "name": "symfony/type-info", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "622d81551770029d44d16be68969712eb47892f1" + "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/622d81551770029d44d16be68969712eb47892f1", - "reference": "622d81551770029d44d16be68969712eb47892f1", + "url": "https://api.github.com/repos/symfony/type-info/zipball/08723aceb8c3271e8cb3db8b2565728b0c88e866", + "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866", "shasum": "" }, "require": { @@ -9501,7 +9430,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.0.8" + "source": "https://github.com/symfony/type-info/tree/v8.0.9" }, "funding": [ { @@ -9521,20 +9450,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/uid", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "f63fa6096a24147283bce4d29327d285326438e0" + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/f63fa6096a24147283bce4d29327d285326438e0", - "reference": "f63fa6096a24147283bce4d29327d285326438e0", + "url": "https://api.github.com/repos/symfony/uid/zipball/4d9d6510bbe88ebb4608b7200d18606cdf80825c", + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c", "shasum": "" }, "require": { @@ -9579,7 +9508,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v8.0.8" + "source": "https://github.com/symfony/uid/tree/v8.0.9" }, "funding": [ { @@ -9599,123 +9528,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" - }, - { - "name": "symfony/ux-turbo", - "version": "v2.35.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/ux-turbo.git", - "reference": "4309a4299f5f1b9b7ce4c13ed6d1b77a5472c216" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/4309a4299f5f1b9b7ce4c13ed6d1b77a5472c216", - "reference": "4309a4299f5f1b9b7ce4c13ed6d1b77a5472c216", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/stimulus-bundle": "^2.9.1|^3.0" - }, - "conflict": { - "symfony/flex": "<1.13" - }, - "require-dev": { - "dbrekelmans/bdi": "dev-main", - "doctrine/doctrine-bundle": "^2.4.3|^3.0|^4.0", - "doctrine/orm": "^2.8|^3.0", - "php-webdriver/webdriver": "^1.15", - "phpstan/phpstan": "^2.1.17", - "symfony/asset-mapper": "^6.4|^7.0|^8.0", - "symfony/debug-bundle": "^5.4|^6.0|^7.0|^8.0", - "symfony/expression-language": "^5.4|^6.0|^7.0|^8.0", - "symfony/form": "^5.4|^6.0|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/mercure-bundle": "^0.3.7|^0.4.1", - "symfony/messenger": "^5.4|^6.0|^7.0|^8.0", - "symfony/panther": "^2.2", - "symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0", - "symfony/process": "^5.4|6.3.*|^7.0|^8.0", - "symfony/property-access": "^5.4|^6.0|^7.0|^8.0", - "symfony/security-core": "^5.4|^6.0|^7.0|^8.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0|^8.0", - "symfony/twig-bundle": "^6.4|^7.0|^8.0", - "symfony/ux-twig-component": "^2.21|^3.0", - "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0|^8.0" - }, - "type": "symfony-bundle", - "extra": { - "thanks": { - "url": "https://github.com/symfony/ux", - "name": "symfony/ux" - } - }, - "autoload": { - "psr-4": { - "Symfony\\UX\\Turbo\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Kévin Dunglas", - "email": "kevin@dunglas.fr" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Hotwire Turbo integration for Symfony", - "homepage": "https://symfony.com", - "keywords": [ - "hotwire", - "javascript", - "mercure", - "symfony-ux", - "turbo", - "turbo-stream" - ], - "support": { - "source": "https://github.com/symfony/ux-turbo/tree/v2.35.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-04-03T05:13:59+00:00" + "time": "2026-04-30T16:10:06+00:00" }, { "name": "symfony/validator", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "1a559eecc841a6fd3dabdcbff9401a0b8951be90" + "reference": "131dc8322c06595a6c98185787fa756deada20df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/1a559eecc841a6fd3dabdcbff9401a0b8951be90", - "reference": "1a559eecc841a6fd3dabdcbff9401a0b8951be90", + "url": "https://api.github.com/repos/symfony/validator/zipball/131dc8322c06595a6c98185787fa756deada20df", + "reference": "131dc8322c06595a6c98185787fa756deada20df", "shasum": "" }, "require": { @@ -9777,7 +9603,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v8.0.8" + "source": "https://github.com/symfony/validator/tree/v8.0.9" }, "funding": [ { @@ -9797,7 +9623,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-30T16:10:06+00:00" }, { "name": "symfony/var-dumper", @@ -9888,16 +9714,16 @@ }, { "name": "symfony/var-exporter", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6" + "reference": "24cf67be4dd0926e4413635418682f4fff831412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/15776bb07a91b089037da89f8832fa41d5fa6ec6", - "reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/24cf67be4dd0926e4413635418682f4fff831412", + "reference": "24cf67be4dd0926e4413635418682f4fff831412", "shasum": "" }, "require": { @@ -9944,7 +9770,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.0.8" + "source": "https://github.com/symfony/var-exporter/tree/v8.0.9" }, "funding": [ { @@ -9964,7 +9790,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/web-link", @@ -10128,16 +9954,16 @@ }, { "name": "symfony/workflow", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/workflow.git", - "reference": "ec85965010e7299d4538e86dbe4dec97c0b3d343" + "reference": "5b299aa29f72217b4cf6a11ebb76bcc32574a4f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/workflow/zipball/ec85965010e7299d4538e86dbe4dec97c0b3d343", - "reference": "ec85965010e7299d4538e86dbe4dec97c0b3d343", + "url": "https://api.github.com/repos/symfony/workflow/zipball/5b299aa29f72217b4cf6a11ebb76bcc32574a4f9", + "reference": "5b299aa29f72217b4cf6a11ebb76bcc32574a4f9", "shasum": "" }, "require": { @@ -10194,7 +10020,7 @@ "workflow" ], "support": { - "source": "https://github.com/symfony/workflow/tree/v8.0.8" + "source": "https://github.com/symfony/workflow/tree/v8.0.9" }, "funding": [ { @@ -10214,7 +10040,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/yaml", @@ -11552,11 +11378,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.51", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59", - "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { @@ -11601,7 +11427,7 @@ "type": "github" } ], - "time": "2026-04-21T18:22:01+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpstan/phpstan-doctrine", @@ -12138,16 +11964,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.1.7", + "version": "13.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd" + "reference": "f49a2b5e51ffb33421745368cc099cf66830d71b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddd6401641861cdef94b922ef10d484f436e8dcd", - "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f49a2b5e51ffb33421745368cc099cf66830d71b", + "reference": "f49a2b5e51ffb33421745368cc099cf66830d71b", "shasum": "" }, "require": { @@ -12161,7 +11987,7 @@ "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-code-coverage": "^14.1.6", "phpunit/php-file-iterator": "^7.0.0", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.0", @@ -12217,7 +12043,7 @@ "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" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.8" }, "funding": [ { @@ -12225,7 +12051,7 @@ "type": "other" } ], - "time": "2026-04-18T06:14:52+00:00" + "time": "2026-05-01T04:22:45+00:00" }, { "name": "react/cache", @@ -13917,16 +13743,16 @@ }, { "name": "symfony/css-selector", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed" + "reference": "3665cfade90565430909b906394c73c8739e57d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", - "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", + "reference": "3665cfade90565430909b906394c73c8739e57d0", "shasum": "" }, "require": { @@ -13962,7 +13788,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.8" + "source": "https://github.com/symfony/css-selector/tree/v8.0.9" }, "funding": [ { @@ -13982,7 +13808,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/debug-bundle", @@ -14230,16 +14056,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "8a8614df26c6436b47fbb9debeca74ddfa5b8e46" + "reference": "5c3d84efc47982dcce766092ba63d4435ed9f11e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/8a8614df26c6436b47fbb9debeca74ddfa5b8e46", - "reference": "8a8614df26c6436b47fbb9debeca74ddfa5b8e46", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/5c3d84efc47982dcce766092ba63d4435ed9f11e", + "reference": "5c3d84efc47982dcce766092ba63d4435ed9f11e", "shasum": "" }, "require": { @@ -14291,7 +14117,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.0.8" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.0.9" }, "funding": [ { @@ -14311,7 +14137,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/bundles.php b/config/bundles.php index 735bac5..1869732 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -1,16 +1,5 @@ ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], @@ -18,8 +7,6 @@ Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], - Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], - Symfony\UX\Turbo\TurboBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], diff --git a/config/packages/ux_turbo.yaml b/config/packages/ux_turbo.yaml deleted file mode 100644 index c2a6a44..0000000 --- a/config/packages/ux_turbo.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# Enable stateless CSRF protection for forms and logins/logouts -framework: - csrf_protection: - check_header: true diff --git a/config/packages/webpack_encore.yaml b/config/packages/webpack_encore.yaml index b0a2da0..5067dcf 100644 --- a/config/packages/webpack_encore.yaml +++ b/config/packages/webpack_encore.yaml @@ -7,12 +7,6 @@ webpack_encore: # Set attributes that will be rendered on all script and link tags script_attributes: defer: true - # Uncomment (also under link_attributes) if using Turbo Drive - # https://turbo.hotwired.dev/handbook/drive#reloading-when-assets-change - # 'data-turbo-track': reload - # link_attributes: - # Uncomment if using Turbo Drive - # 'data-turbo-track': reload # If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials') # crossorigin: 'anonymous' diff --git a/package-lock.json b/package-lock.json index 0ebf344..5fee886 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,10 +30,6 @@ "@babel/preset-env": "^7.16.0", "@babel/preset-react": "^7.28.5", "@eslint/js": "^9.0.0", - "@hotwired/stimulus": "^3.0.0", - "@hotwired/turbo": "^7.1.0 || ^8.0", - "@symfony/stimulus-bridge": "^3.2.0 || ^4.0.0", - "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets", "@symfony/webpack-encore": "^5.1.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -2731,7 +2727,9 @@ "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz", "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@hotwired/stimulus-webpack-helpers": { "version": "1.0.1", @@ -2739,20 +2737,12 @@ "integrity": "sha512-wa/zupVG0eWxRYJjC1IiPBdt3Lruv0RqGN+/DTMmUWUyMAEB27KXmVY6a8YpUVTM7QwVuaLNGW4EqDgrS2upXQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "@hotwired/stimulus": ">= 3.0" } }, - "node_modules/@hotwired/turbo": { - "version": "8.0.23", - "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.23.tgz", - "integrity": "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3804,6 +3794,8 @@ "integrity": "sha512-+/kSQ4qFXMbZS+HjkhzOxwdN+60pMev7kzzDpQV/Tdm/iIWoxx5GDsVcdLaBb2783BVQHyrBP72JerF2SXTbTg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@hotwired/stimulus-webpack-helpers": "^1.0.1", "@types/webpack-env": "^1.16.4", @@ -3817,10 +3809,6 @@ "@hotwired/stimulus": "^3.0" } }, - "node_modules/@symfony/ux-turbo": { - "resolved": "vendor/symfony/ux-turbo/assets", - "link": true - }, "node_modules/@symfony/webpack-encore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@symfony/webpack-encore/-/webpack-encore-5.3.1.tgz", @@ -4024,6 +4012,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4602,7 +4591,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/bootstrap": { "version": "5.2.10", @@ -4661,13 +4651,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/hotwired__turbo": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@types/hotwired__turbo/-/hotwired__turbo-8.0.6.tgz", - "integrity": "sha512-jqWRyXz+wAa7E3iB3WKcpXjVUgfLt2zGO68x3ANPtM4JSVDOT/q9GhPQzXf3EtPOkdiHfUp7JAC0EU5bSwluFA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -4770,7 +4753,9 @@ "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.8.tgz", "integrity": "sha512-G9eAoJRMLjcvN4I08wB5I7YofOb/kaJNd5uoCMX+LbKXTPCF+ZIHuqTnFaK9Jz1rgs035f9JUPUhNFtqgucy/A==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@types/yargs": { "version": "17.0.35", @@ -6829,7 +6814,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-converter": { "version": "0.2.0", @@ -8331,19 +8317,6 @@ "node": ">= 14" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", @@ -9428,6 +9401,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9732,13 +9706,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10779,6 +10746,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10794,6 +10762,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10806,7 +10775,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prop-types": { "version": "15.8.1", @@ -11537,13 +11507,6 @@ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -11623,13 +11586,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/sass": { "version": "1.97.3", "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", @@ -14034,20 +13990,6 @@ "node": ">=4.0" } }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -14225,28 +14167,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -14320,7 +14240,7 @@ "vendor/symfony/ux-turbo/assets": { "name": "@symfony/ux-turbo", "version": "2.34.0", - "dev": true, + "extraneous": true, "license": "MIT", "devDependencies": { "@hotwired/stimulus": "^3.0.0", @@ -14338,549 +14258,6 @@ "@hotwired/stimulus": "^3.0.0", "@hotwired/turbo": "^7.1.0 || ^8.0" } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.5", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/jsdom": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "vendor/symfony/ux-turbo/assets/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "vendor/symfony/ux-turbo/assets/node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT" - }, - "vendor/symfony/ux-turbo/assets/node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.5", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "vendor/symfony/ux-turbo/assets/node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } } } } diff --git a/package.json b/package.json index 2eaaaaf..979e83e 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,6 @@ "@babel/preset-env": "^7.16.0", "@babel/preset-react": "^7.28.5", "@eslint/js": "^9.0.0", - "@hotwired/stimulus": "^3.0.0", - "@hotwired/turbo": "^7.1.0 || ^8.0", - "@symfony/stimulus-bridge": "^3.2.0 || ^4.0.0", - "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets", "@symfony/webpack-encore": "^5.1.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/symfony.lock b/symfony.lock index 6400db3..b0e5796 100644 --- a/symfony.lock +++ b/symfony.lock @@ -282,21 +282,6 @@ "config/routes/security.yaml" ] }, - "symfony/stimulus-bundle": { - "version": "2.32", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "2.24", - "ref": "3357f2fa6627b93658d8e13baa416b2a94a50c5f" - }, - "files": [ - "assets/controllers.json", - "assets/controllers/csrf_protection_controller.js", - "assets/controllers/hello_controller.js", - "assets/stimulus_bootstrap.js" - ] - }, "symfony/translation": { "version": "7.2", "recipe": { @@ -332,18 +317,6 @@ "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" } }, - "symfony/ux-turbo": { - "version": "2.32", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "2.20", - "ref": "287f7c6eb6e9b65e422d34c00795b360a787380b" - }, - "files": [ - "config/packages/ux_turbo.yaml" - ] - }, "symfony/validator": { "version": "7.2", "recipe": { From 10d301960525ae06f19e8134f5b1664b8a0f7c94 Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 13:09:44 +0200 Subject: [PATCH 02/12] chore(deps): remove unused symfony/amazon-sqs-messenger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All MESSENGER_TRANSPORT_*_DSN values default to doctrine:// in .env, sync:// in .env.test, and docs/installation.md documents Doctrine as the only supported transport. No sqs:// DSN, no AsyncAws/AmazonSqs imports anywhere — the package was a Flex-recipe leftover. Drops symfony/amazon-sqs-messenger and its only transitive deps (async-aws/sqs, async-aws/core). aws/aws-sdk-php stays — it's pulled by league/flysystem-aws-s3-v3 and used in EditorUploadStorageFactory and MosaicStorageFactory for Scaleway S3. --- composer.json | 1 - composer.lock | 214 +------------------------------------------------- 2 files changed, 1 insertion(+), 214 deletions(-) diff --git a/composer.json b/composer.json index 37f5ab2..5365745 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,6 @@ "phpdocumentor/reflection-docblock": "^6.0.3", "phpstan/phpdoc-parser": "^2.3.2", "sentry/sentry-symfony": "^5.10", - "symfony/amazon-sqs-messenger": "^8.0.6", "symfony/asset": "^8.0.8", "symfony/asset-mapper": "^8.0.9", "symfony/console": "^8.0.9", diff --git a/composer.lock b/composer.lock index bdf4286..1b8ac89 100644 --- a/composer.lock +++ b/composer.lock @@ -4,142 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9d43e18445591ada54de5b3cb560f694", + "content-hash": "ff0e22fe94d393fbd371b66b04955413", "packages": [ - { - "name": "async-aws/core", - "version": "1.29.0", - "source": { - "type": "git", - "url": "https://github.com/async-aws/core.git", - "reference": "70899695fcc7b23a9247926ff8b581668583b993" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/async-aws/core/zipball/70899695fcc7b23a9247926ff8b581668583b993", - "reference": "70899695fcc7b23a9247926ff8b581668583b993", - "shasum": "" - }, - "require": { - "ext-hash": "*", - "ext-simplexml": "*", - "php": "^8.2", - "psr/cache": "^1.0 || ^2.0 || ^3.0", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/deprecation-contracts": "^2.1 || ^3.0", - "symfony/http-client": "^4.4.16 || ^5.1.7 || ^6.0 || ^7.0 || ^8.0", - "symfony/http-client-contracts": "^1.1.8 || ^2.0 || ^3.0", - "symfony/service-contracts": "^1.0 || ^2.0 || ^3.0" - }, - "conflict": { - "async-aws/s3": "<1.1", - "symfony/http-client": "5.2.0" - }, - "require-dev": { - "phpunit/phpunit": "^11.5.42", - "symfony/error-handler": "^7.3.2 || ^8.0", - "symfony/phpunit-bridge": "^7.3.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.29-dev" - } - }, - "autoload": { - "psr-4": { - "AsyncAws\\Core\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Core package to integrate with AWS. This is a lightweight AWS SDK provider by AsyncAws.", - "keywords": [ - "amazon", - "async-aws", - "aws", - "sdk", - "sts" - ], - "support": { - "source": "https://github.com/async-aws/core/tree/1.29.0" - }, - "funding": [ - { - "url": "https://github.com/jderusse", - "type": "github" - }, - { - "url": "https://github.com/nyholm", - "type": "github" - } - ], - "time": "2026-04-18T17:06:10+00:00" - }, - { - "name": "async-aws/sqs", - "version": "2.9.0", - "source": { - "type": "git", - "url": "https://github.com/async-aws/sqs.git", - "reference": "2571a09f41040d38210ae2910396a8314989e12e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/async-aws/sqs/zipball/2571a09f41040d38210ae2910396a8314989e12e", - "reference": "2571a09f41040d38210ae2910396a8314989e12e", - "shasum": "" - }, - "require": { - "async-aws/core": "^1.9", - "ext-filter": "*", - "php": "^8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.5.42", - "symfony/error-handler": "^7.3.2 || ^8.0", - "symfony/phpunit-bridge": "^7.3.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.9-dev" - } - }, - "autoload": { - "psr-4": { - "AsyncAws\\Sqs\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "SQS client, part of the AWS SDK provided by AsyncAws.", - "keywords": [ - "amazon", - "async-aws", - "aws", - "sdk", - "sqs" - ], - "support": { - "source": "https://github.com/async-aws/sqs/tree/2.9.0" - }, - "funding": [ - { - "url": "https://github.com/jderusse", - "type": "github" - }, - { - "url": "https://github.com/nyholm", - "type": "github" - } - ], - "time": "2026-04-29T10:05:33+00:00" - }, { "name": "aws/aws-crt-php", "version": "v1.2.7", @@ -4440,84 +4306,6 @@ ], "time": "2026-04-01T14:50:32+00:00" }, - { - "name": "symfony/amazon-sqs-messenger", - "version": "v8.0.6", - "source": { - "type": "git", - "url": "https://github.com/symfony/amazon-sqs-messenger.git", - "reference": "54751d50b8ccc8a18d91b7ba63ce94b773ad2a5c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/amazon-sqs-messenger/zipball/54751d50b8ccc8a18d91b7ba63ce94b773ad2a5c", - "reference": "54751d50b8ccc8a18d91b7ba63ce94b773ad2a5c", - "shasum": "" - }, - "require": { - "async-aws/core": "^1.7", - "async-aws/sqs": "^1.0|^2.0", - "php": ">=8.4", - "psr/log": "^1|^2|^3", - "symfony/messenger": "^7.4|^8.0", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/http-client-contracts": "<2.5" - }, - "require-dev": { - "symfony/http-client-contracts": "^2.5|^3", - "symfony/property-access": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0" - }, - "type": "symfony-messenger-bridge", - "autoload": { - "psr-4": { - "Symfony\\Component\\Messenger\\Bridge\\AmazonSqs\\": "" - }, - "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": "Symfony Amazon SQS extension Messenger Bridge", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/amazon-sqs-messenger/tree/v8.0.6" - }, - "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-02-25T16:59:43+00:00" - }, { "name": "symfony/asset", "version": "v8.0.8", From f5ba3061b716ebe332018b2695c7e93dcb9c5119 Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 13:19:30 +0200 Subject: [PATCH 03/12] chore(infra): add Twig-CS-Fixer for template style enforcement Closes #467. Adds vincentlanglet/twig-cs-fixer ^3.14 as a dev dep with the default TwigCsFixer standard. New `.twig-cs-fixer.php` finder targets templates/, gitignores the cache, and exposes two Make targets next to the existing PHP fixers: make twig-cs-fix # auto-fix template style make twig-cs-check # dry-run for CI Both wired into `lint-all`. CLAUDE.md pre-commit checklist + commands table updated. CI workflow gains a "Twig-CS-Fixer (dry-run)" step right after PHP-CS-Fixer in the lint+unit job. Initial fix pass on 26 of the 103 templates: trailing commas in multi-line arrays/hashes, unnecessary quotes on hash keys (`{'_target_path': ...}` -> `{_target_path: ...}`), and `{% include %}` tag converted to the `{{ include(...) }}` function (preferred since Twig 2.x; same rendered HTML, cleaner scope handling). Functional tests (982/982) confirm template rendering is unchanged. --- .github/workflows/ci.yml | 3 + .gitignore | 4 ++ .twig-cs-fixer.php | 20 ++++++ CLAUDE.md | 3 + Makefile | 10 ++- composer.json | 3 +- composer.lock | 79 ++++++++++++++++++++++- symfony.lock | 9 +++ templates/admin/homepage/editor.html.twig | 4 +- templates/archetype/list.html.twig | 10 +-- templates/archetype/show.html.twig | 2 +- templates/banned_card/list.html.twig | 2 +- templates/base.html.twig | 12 ++-- templates/base_error.html.twig | 2 +- templates/borrow/list.html.twig | 4 +- templates/deck/borrow_history.html.twig | 6 +- templates/deck/list.html.twig | 2 +- templates/deck/show.html.twig | 12 ++-- templates/event/agenda.html.twig | 4 +- templates/event/available_decks.html.twig | 2 +- templates/event/edit.html.twig | 2 +- templates/event/list.html.twig | 10 +-- templates/event/new.html.twig | 2 +- templates/event/show.html.twig | 10 +-- templates/home/blocks.html.twig | 24 +++---- templates/home/blocks/_hero.html.twig | 2 +- templates/home/dashboard.html.twig | 26 ++++---- templates/home/index.html.twig | 6 +- templates/label/pdf_decklist.html.twig | 1 - templates/page/show.html.twig | 2 +- templates/password_reset/forgot.html.twig | 4 +- templates/registration/register.html.twig | 4 +- templates/search/results.html.twig | 10 +-- templates/security/login.html.twig | 6 +- 34 files changed, 213 insertions(+), 89 deletions(-) create mode 100644 .twig-cs-fixer.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5ebf8d..47d65fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: PHP-CS-Fixer (dry-run) run: php vendor/bin/php-cs-fixer fix --dry-run --diff + - name: Twig-CS-Fixer (dry-run) + run: php vendor/bin/twig-cs-fixer lint templates/ + - name: PHPStan run: php vendor/bin/phpstan analyse --memory-limit=512M env: diff --git a/.gitignore b/.gitignore index e645223..e6681eb 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ yarn-error.log /.php-cs-fixer.cache ###< friendsofphp/php-cs-fixer ### +###> vincentlanglet/twig-cs-fixer ### +/.twig-cs-fixer.cache +###< vincentlanglet/twig-cs-fixer ### + # IDE .idea/ .vscode/ diff --git a/.twig-cs-fixer.php b/.twig-cs-fixer.php new file mode 100644 index 0000000..d97f003 --- /dev/null +++ b/.twig-cs-fixer.php @@ -0,0 +1,20 @@ +in(__DIR__.'/templates'); + +$config = new TwigCsFixer\Config\Config(); +$config->setFinder($finder); + +return $config; diff --git a/CLAUDE.md b/CLAUDE.md index c3f8e4b..b955f55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,6 +189,7 @@ Run **before every commit/push**: ```bash make cs-fix # Fix code style (PHP-CS-Fixer) +make twig-cs-fix # Fix Twig template style (Twig-CS-Fixer) make eslint-fix # Fix ESLint issues (TS/JS) make stylelint-fix # Fix SCSS/CSS style issues make phpstan # Static analysis @@ -272,6 +273,8 @@ When creating new features or backlog items, create a GitHub issue with the feat | `make test.front` | Vitest frontend tests | Before every commit (frontend changes) | | `make coverage` | PHPUnit with pcov coverage report | When coverage data is needed | | `make cs-check` | PHP-CS-Fixer dry-run (no changes) | CI / review only | +| `make twig-cs-fix` | Twig-CS-Fixer — auto-fix template style | Before every commit (Twig changes) | +| `make twig-cs-check` | Twig-CS-Fixer dry-run (no changes) | CI / review only | | `make eslint` | ESLint on `assets/` (check only) | CI / review only | | `make eslint-fix` | ESLint on `assets/` with auto-fix | Before every commit (frontend changes) | | `make stylelint` | Lint SCSS and CSS files | Before every commit (frontend changes) | diff --git a/Makefile b/Makefile index 3c3807c..7acc9d7 100644 --- a/Makefile +++ b/Makefile @@ -118,7 +118,7 @@ assets.watch: ## Build frontend assets and watch for changes ## —— Quality ————————————————————————————————————————————————————————— .PHONY: lint-all -lint-all: lint-yaml lint-i18n cs-fix eslint-fix stylelint-fix lint-container phpstan ## Run all linters and fixers +lint-all: lint-yaml lint-i18n cs-fix twig-cs-fix eslint-fix stylelint-fix lint-container phpstan ## Run all linters and fixers .PHONY: test test: ## Run test suite @@ -152,6 +152,14 @@ cs-fix test.phpcs.fix: ## Fix code style with PHP-CS-Fixer cs-check test.phpcs: ## Check code style (dry-run) symfony php vendor/bin/php-cs-fixer fix --dry-run --diff +.PHONY: twig-cs-fix +twig-cs-fix: ## Fix Twig template style with Twig-CS-Fixer + symfony php vendor/bin/twig-cs-fixer lint --fix templates/ + +.PHONY: twig-cs-check +twig-cs-check: ## Check Twig template style (dry-run) + symfony php vendor/bin/twig-cs-fixer lint templates/ + .PHONY: lint-i18n lint-i18n: ## Validate translation files (syntax + content) symfony console lint:xliff translations/ diff --git a/composer.json b/composer.json index 5365745..191bb5e 100644 --- a/composer.json +++ b/composer.json @@ -125,6 +125,7 @@ "symfony/debug-bundle": "^8.0.8", "symfony/maker-bundle": "^1.67", "symfony/stopwatch": "^8.0.8", - "symfony/web-profiler-bundle": "^8.0.9" + "symfony/web-profiler-bundle": "^8.0.9", + "vincentlanglet/twig-cs-fixer": "^3.14" } } diff --git a/composer.lock b/composer.lock index 1b8ac89..8d9fed3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ff0e22fe94d393fbd371b66b04955413", + "content-hash": "6901d9e60dcddc5519617b2e536a4a1a", "packages": [ { "name": "aws/aws-crt-php", @@ -13976,6 +13976,83 @@ } ], "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "vincentlanglet/twig-cs-fixer", + "version": "3.14.0", + "source": { + "type": "git", + "url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git", + "reference": "599f110f192c31af5deb5736d6c1a970afdf51f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/599f110f192c31af5deb5736d6c1a970afdf51f3", + "reference": "599f110f192c31af5deb5736d6c1a970afdf51f3", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0.0", + "ext-ctype": "*", + "php": ">=8.1", + "symfony/console": "^5.4.9 || ^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/string": "^5.4.42 || ^6.4.10 || ~7.0.10 || ^7.1.3 || ^8.0", + "twig/twig": "^3.4", + "webmozart/assert": "^1.10 || ^2.0" + }, + "require-dev": { + "composer/semver": "^3.2.0", + "dereuromark/composer-prefer-lowest": "^0.1.10", + "ergebnis/composer-normalize": "^2.29", + "friendsofphp/php-cs-fixer": "^3.13.0", + "infection/infection": "^0.26.16 || ^0.32.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^9.5.26 || ^11.5.18 || ^12.1.3", + "rector/rector": "^2.0.0", + "shipmonk/composer-dependency-analyser": "^1.6", + "symfony/process": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/twig-bridge": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/ux-twig-component": "^2.2.0", + "twig/cache-extra": "^3.2" + }, + "bin": [ + "bin/twig-cs-fixer" + ], + "type": "coding-standard", + "autoload": { + "psr-4": { + "TwigCsFixer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Vincent Langlet" + } + ], + "description": "A tool to automatically fix Twig code style", + "homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer", + "support": { + "issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues", + "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/3.14.0" + }, + "funding": [ + { + "url": "https://github.com/VincentLanglet", + "type": "github" + } + ], + "time": "2026-02-23T13:21:35+00:00" } ], "aliases": [], diff --git a/symfony.lock b/symfony.lock index b0e5796..7df3730 100644 --- a/symfony.lock +++ b/symfony.lock @@ -384,5 +384,14 @@ }, "twig/extra-bundle": { "version": "v3.23.0" + }, + "vincentlanglet/twig-cs-fixer": { + "version": "3.14", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "3.0", + "ref": "d42582ae1bce86fd43491d6264c738b0867f8ffe" + } } } diff --git a/templates/admin/homepage/editor.html.twig b/templates/admin/homepage/editor.html.twig index 7d5fe76..0b261bb 100644 --- a/templates/admin/homepage/editor.html.twig +++ b/templates/admin/homepage/editor.html.twig @@ -42,7 +42,7 @@ {value: 'carousel', label: 'app.homepage.block_type.carousel'|trans, icon: 'bi-images'}, {value: 'latestPages', label: 'app.homepage.block_type.latest_pages'|trans, icon: 'bi-newspaper'}, {value: 'featuredDeck', label: 'app.homepage.block_type.featured_deck'|trans, icon: 'bi-collection'}, - {value: 'featuredEvent', label: 'app.homepage.block_type.featured_event'|trans, icon: 'bi-calendar-event'} + {value: 'featuredEvent', label: 'app.homepage.block_type.featured_event'|trans, icon: 'bi-calendar-event'}, ]|json_encode }}" data-labels="{{ { addBlock: 'app.homepage.admin.add_block'|trans, @@ -80,7 +80,7 @@ link: 'app.homepage.admin.link'|trans, addItem: 'app.homepage.admin.add_item'|trans, shortTag: 'app.homepage.admin.short_tag'|trans, - eventId: 'app.homepage.admin.event_id'|trans + eventId: 'app.homepage.admin.event_id'|trans, }|json_encode }}"> {% endblock %} diff --git a/templates/archetype/list.html.twig b/templates/archetype/list.html.twig index aac6991..a55c949 100644 --- a/templates/archetype/list.html.twig +++ b/templates/archetype/list.html.twig @@ -24,7 +24,7 @@ {% block opengraph %} {% set og_title = 'app.archetype.catalog_title'|trans ~ ' — ' ~ channel_param('brand_name', 'Expanded Decks') %} {% set og_url = canonical_url('archetypes', 'app_archetype_list') %} - {% include '_partials/opengraph.html.twig' %} + {{ include('_partials/opengraph.html.twig') }} {% endblock %} {% block structured_data %} @@ -55,7 +55,7 @@ {% else %} {% set newTags = currentTags|merge([tag]) %} {% endif %} - + {{ tag }} {% endfor %} @@ -69,14 +69,14 @@
- {% include 'form/_captcha_widget.html.twig' %} + {{ include('form/_captcha_widget.html.twig') }} diff --git a/templates/registration/register.html.twig b/templates/registration/register.html.twig index 7977f65..ff484c9 100644 --- a/templates/registration/register.html.twig +++ b/templates/registration/register.html.twig @@ -12,7 +12,7 @@ {% block javascripts %} {{ parent() }} - {% include 'form/_captcha_scripts.html.twig' %} + {{ include('form/_captcha_scripts.html.twig') }} {% endblock %} {% block body %} @@ -38,7 +38,7 @@ {% set targetPath = app.request.query.get('_target_path', '') %}

- {{ 'app.auth.register.has_account'|trans }} {{ 'app.auth.register.login_link'|trans }} + {{ 'app.auth.register.has_account'|trans }} {{ 'app.auth.register.login_link'|trans }}

diff --git a/templates/search/results.html.twig b/templates/search/results.html.twig index 0d736e8..56bff5e 100644 --- a/templates/search/results.html.twig +++ b/templates/search/results.html.twig @@ -39,21 +39,21 @@ {% set types = {} %} {% if channel.enableArchetypes %} {% set types = types|merge({ - 'archetypes': {'label': 'app.search.type.archetypes'|trans, 'icon': 'bi-trophy'}, - 'variants': {'label': 'app.search.type.variants'|trans, 'icon': 'bi-layers'} + archetypes: {label: 'app.search.type.archetypes'|trans, icon: 'bi-trophy'}, + variants: {label: 'app.search.type.variants'|trans, icon: 'bi-layers'}, }) %} {% endif %} {% set types = types|merge({ - 'pages': {'label': 'app.search.type.pages'|trans, 'icon': 'bi-file-text'} + pages: {label: 'app.search.type.pages'|trans, icon: 'bi-file-text'}, }) %} {% if channel.enableEvents %} {% set types = types|merge({ - 'events': {'label': 'app.search.type.events'|trans, 'icon': 'bi-calendar-event'} + events: {label: 'app.search.type.events'|trans, icon: 'bi-calendar-event'}, }) %} {% endif %} {% if channel.enableDecks %} {% set types = types|merge({ - 'decks': {'label': 'app.search.type.decks'|trans, 'icon': 'bi-collection'} + decks: {label: 'app.search.type.decks'|trans, icon: 'bi-collection'}, }) %} {% endif %} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index d0d3b86..b178b32 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -12,7 +12,7 @@ {% block javascripts %} {{ parent() }} - {% include 'form/_captcha_scripts.html.twig' %} + {{ include('form/_captcha_scripts.html.twig') }} {% endblock %} {% block body %} @@ -44,7 +44,7 @@ - {% include 'form/_captcha_widget.html.twig' %} + {{ include('form/_captcha_widget.html.twig') }} @@ -59,7 +59,7 @@ {% set targetPath = app.request.query.get('_target_path', '') %}

- {{ 'app.auth.login.no_account'|trans }} {{ 'app.auth.login.register_link'|trans }} + {{ 'app.auth.login.no_account'|trans }} {{ 'app.auth.login.register_link'|trans }}

From 725c20c1d2f3bb489d6030d52f7c64b8dfb8854d Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 14:57:40 +0200 Subject: [PATCH 04/12] chore(infra): restore declare(strict_types) + license header in bundles.php Flex rewrote config/bundles.php when unconfiguring the stimulus and ux-turbo recipes, dropping the project's standard PHP header. CI's PHP-CS-Fixer step caught it. --- config/bundles.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config/bundles.php b/config/bundles.php index 1869732..a97e79f 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -1,5 +1,16 @@ ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], From 0496d699e1d08fc305a074a9d3480f2463dd8b68 Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 15:15:22 +0200 Subject: [PATCH 05/12] test(deck): add BannedCardImageResolver, BannedCardEnricher, AdminBannedCardController tests + sync service extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #498. First batch of the F6.14 banned-card coverage backfill: - BannedCardImageResolverTest (14 tests): all four URL-resolution branches — direct image URL, TCGdex CDN from TcgdexCard / parsed TcgdexId, PokemonTCG.io fallback, upstream-set-code fallback via TcgdexSet, plus rarity-tier sort, normalize-CDN dot-strip, all guessSerieIdFromSetId prefixes. - BannedCardEnricherTest (11 tests): no-op when already linked, local hit, TCGdex API hit (with imageUrl-fill protection), alias fallback, all-null path; force-mode reset; reparent identity cache regression for two consecutive printings sharing one identity (placeholder removal verified); empty-name fill on existing canonical parent. - AdminBannedCardControllerTest (12 tests): auth + role gates, active / history tabs, garbage view fallback, edit save round-trip, soft-delete + restore happy paths, CSRF rejection on delete and restore. - BannedCardsSyncServiceTest extensions: empty-printings skip during soft-delete pass and the in-loop parentsByIdentityId cache that prevents duplicate parents across two same-identity entries in one sync run. --- .../AdminBannedCardControllerTest.php | 236 ++++++++++ tests/Service/BannedCardEnricherTest.php | 428 ++++++++++++++++++ tests/Service/BannedCardImageResolverTest.php | 360 +++++++++++++++ tests/Service/BannedCardsSyncServiceTest.php | 103 +++++ 4 files changed, 1127 insertions(+) create mode 100644 tests/Functional/AdminBannedCardControllerTest.php create mode 100644 tests/Service/BannedCardEnricherTest.php create mode 100644 tests/Service/BannedCardImageResolverTest.php diff --git a/tests/Functional/AdminBannedCardControllerTest.php b/tests/Functional/AdminBannedCardControllerTest.php new file mode 100644 index 0000000..00a7755 --- /dev/null +++ b/tests/Functional/AdminBannedCardControllerTest.php @@ -0,0 +1,236 @@ +client->request('GET', '/admin/banned-card'); + + self::assertResponseRedirects('/login'); + } + + public function testListRequiresAdminRole(): void + { + $this->loginAs('borrower@example.com'); + + $this->client->request('GET', '/admin/banned-card'); + + self::assertResponseStatusCodeSame(403); + } + + public function testListAccessibleForAdmin(): void + { + $this->loginAs('admin@example.com'); + $this->persistBannedCard('Pikachu', 'LOT', '90'); + + $this->client->request('GET', '/admin/banned-card'); + + self::assertResponseIsSuccessful(); + } + + public function testListShowsActiveTabByDefault(): void + { + $this->loginAs('admin@example.com'); + $active = $this->persistBannedCard('Active Card', 'LOT', '90'); + $deleted = $this->persistBannedCard('Deleted Card', 'LOT', '91', deletedAt: new \DateTimeImmutable()); + + $this->client->request('GET', '/admin/banned-card'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', $active->getCardName()); + self::assertSelectorTextNotContains('body', $deleted->getCardName()); + } + + public function testListHistoryViewShowsDeletedCards(): void + { + $this->loginAs('admin@example.com'); + $active = $this->persistBannedCard('Active Card', 'LOT', '90'); + $deleted = $this->persistBannedCard('Deleted Card', 'LOT', '91', deletedAt: new \DateTimeImmutable()); + + $this->client->request('GET', '/admin/banned-card?view=history'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', $deleted->getCardName()); + self::assertSelectorTextNotContains('body', $active->getCardName()); + } + + public function testListIgnoresUnknownView(): void + { + $this->loginAs('admin@example.com'); + $this->persistBannedCard('Active Card', 'LOT', '90'); + + $this->client->request('GET', '/admin/banned-card?view=garbage'); + + // Should fall back to "active" view rendering successfully. + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Active Card'); + } + + public function testEditFormAccessible(): void + { + $this->loginAs('admin@example.com'); + $card = $this->persistBannedCard('Pikachu', 'LOT', '90'); + + $this->client->request('GET', '/admin/banned-card/'.$card->getId().'/edit'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form'); + } + + public function testEditSavesAndRedirectsBackToEdit(): void + { + $this->loginAs('admin@example.com'); + $card = $this->persistBannedCard('Pikachu', 'LOT', '90'); + + $crawler = $this->client->request('GET', '/admin/banned-card/'.$card->getId().'/edit'); + $form = $crawler->selectButton('Save')->form(); + $this->client->submit($form); + + self::assertResponseRedirects('/admin/banned-card/'.$card->getId().'/edit'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } + + public function testDeleteSoftDeletesAndRedirects(): void + { + $this->loginAs('admin@example.com'); + $card = $this->persistBannedCard('Pikachu', 'LOT', '90'); + + $crawler = $this->client->request('GET', '/admin/banned-card/'.$card->getId().'/edit'); + $token = $crawler->filter('input[name="_token"][value]')->first()->attr('value'); + + $this->client->request('POST', '/admin/banned-card/'.$card->getId().'/delete', [ + '_token' => $token, + ]); + + self::assertResponseRedirects('/admin/banned-card'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + + $em = $this->getEntityManager(); + $em->clear(); + $reloaded = $em->getRepository(BannedCard::class)->find($card->getId()); + self::assertInstanceOf(BannedCard::class, $reloaded); + self::assertTrue($reloaded->isDeleted()); + } + + public function testDeleteWithInvalidCsrfShowsErrorAndDoesNotDelete(): void + { + $this->loginAs('admin@example.com'); + $card = $this->persistBannedCard('Pikachu', 'LOT', '90'); + + $this->client->request('POST', '/admin/banned-card/'.$card->getId().'/delete', [ + '_token' => 'wrong', + ]); + + self::assertResponseRedirects('/admin/banned-card/'.$card->getId().'/edit'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + + $em = $this->getEntityManager(); + $em->clear(); + $reloaded = $em->getRepository(BannedCard::class)->find($card->getId()); + self::assertInstanceOf(BannedCard::class, $reloaded); + self::assertFalse($reloaded->isDeleted()); + } + + public function testRestoreClearsDeletedAtAndRedirectsBackToEdit(): void + { + $this->loginAs('admin@example.com'); + $card = $this->persistBannedCard('Pikachu', 'LOT', '90', deletedAt: new \DateTimeImmutable()); + + $crawler = $this->client->request('GET', '/admin/banned-card/'.$card->getId().'/edit'); + $token = $crawler->filter('input[name="_token"][value]')->first()->attr('value'); + + $this->client->request('POST', '/admin/banned-card/'.$card->getId().'/restore', [ + '_token' => $token, + ]); + + self::assertResponseRedirects('/admin/banned-card/'.$card->getId().'/edit'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + + $em = $this->getEntityManager(); + $em->clear(); + $reloaded = $em->getRepository(BannedCard::class)->find($card->getId()); + self::assertInstanceOf(BannedCard::class, $reloaded); + self::assertFalse($reloaded->isDeleted()); + } + + public function testRestoreWithInvalidCsrfRedirectsToHistoryWithError(): void + { + $this->loginAs('admin@example.com'); + $card = $this->persistBannedCard('Pikachu', 'LOT', '90', deletedAt: new \DateTimeImmutable()); + + $this->client->request('POST', '/admin/banned-card/'.$card->getId().'/restore', [ + '_token' => 'wrong', + ]); + + self::assertResponseRedirects('/admin/banned-card?view=history'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + + $em = $this->getEntityManager(); + $em->clear(); + $reloaded = $em->getRepository(BannedCard::class)->find($card->getId()); + self::assertInstanceOf(BannedCard::class, $reloaded); + self::assertTrue($reloaded->isDeleted()); + } + + private function getEntityManager(): EntityManagerInterface + { + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get('doctrine.orm.entity_manager'); + + return $entityManager; + } + + private function persistBannedCard( + string $cardName, + string $setCode, + string $cardNumber, + ?\DateTimeImmutable $deletedAt = null, + ): BannedCard { + $em = $this->getEntityManager(); + + $card = new BannedCard(); + $card->setCardName($cardName); + $card->setEffectiveDate(new \DateTimeImmutable('2024-04-01')); + $card->setSourceUrl('https://www.pokemon.com/us/play-pokemon/about/pokemon-tcg-banned-card-list'); + if ($deletedAt instanceof \DateTimeImmutable) { + $card->setDeletedAt($deletedAt); + } + + $printing = new BannedCardPrinting(); + $printing->setSetCode($setCode); + $printing->setCardNumber($cardNumber); + $card->addPrinting($printing); + + $em->persist($printing); + $em->persist($card); + $em->flush(); + + return $card; + } +} diff --git a/tests/Service/BannedCardEnricherTest.php b/tests/Service/BannedCardEnricherTest.php new file mode 100644 index 0000000..723d5c8 --- /dev/null +++ b/tests/Service/BannedCardEnricherTest.php @@ -0,0 +1,428 @@ +createStub(CardPrinting::class); + + $printing = $this->buildBannedPrinting('LOT', '90', $existing); + + $apiClient = $this->createMock(TcgdexApiClient::class); + $apiClient->expects(self::never())->method('findCard'); + + $enricher = $this->buildEnricher(apiClient: $apiClient); + + self::assertTrue($enricher->enrichPrinting($printing)); + self::assertSame($existing, $printing->getCardPrinting()); + } + + public function testEnrichPrintingHitsLocalRepositoryFirst(): void + { + $local = $this->createStub(CardPrinting::class); + + $repo = $this->createStub(CardPrintingRepository::class); + $repo->method('findFirstBySetCodeAndCardNumber')->willReturn($local); + + $apiClient = $this->createMock(TcgdexApiClient::class); + $apiClient->expects(self::never())->method('findCard'); + + $printing = $this->buildBannedPrinting('LOT', '90', null); + + $enricher = $this->buildEnricher(cardPrintingRepository: $repo, apiClient: $apiClient); + + self::assertTrue($enricher->enrichPrinting($printing)); + self::assertSame($local, $printing->getCardPrinting()); + } + + public function testEnrichPrintingFallsBackToTcgdexApi(): void + { + $tcgdexCard = $this->buildTcgdexCardDto(imageUrl: 'https://images.example.com/foo.png'); + $resolved = $this->buildResolvedPrintingWithMutableImageUrl(initialImageUrl: null); + + $apiClient = $this->createStub(TcgdexApiClient::class); + $apiClient->method('findCard')->willReturn($tcgdexCard); + + $identityResolver = $this->createStub(CardIdentityResolver::class); + $identityResolver->method('resolveFromTcgdexCard')->willReturn($resolved); + + $printing = $this->buildBannedPrinting('LOT', '90', null); + + $enricher = $this->buildEnricher(apiClient: $apiClient, identityResolver: $identityResolver); + + self::assertTrue($enricher->enrichPrinting($printing)); + self::assertSame($resolved, $printing->getCardPrinting()); + self::assertSame('https://images.example.com/foo.png', $resolved->getImageUrl()); + } + + public function testEnrichPrintingDoesNotOverwriteExistingImageUrl(): void + { + $tcgdexCard = $this->buildTcgdexCardDto(imageUrl: 'https://images.example.com/new.png'); + $resolved = $this->buildResolvedPrintingWithMutableImageUrl(initialImageUrl: 'https://images.example.com/old.png'); + + $apiClient = $this->createStub(TcgdexApiClient::class); + $apiClient->method('findCard')->willReturn($tcgdexCard); + + $identityResolver = $this->createStub(CardIdentityResolver::class); + $identityResolver->method('resolveFromTcgdexCard')->willReturn($resolved); + + $printing = $this->buildBannedPrinting('LOT', '90', null); + + $enricher = $this->buildEnricher(apiClient: $apiClient, identityResolver: $identityResolver); + + $enricher->enrichPrinting($printing); + + self::assertSame('https://images.example.com/old.png', $resolved->getImageUrl()); + } + + public function testEnrichPrintingFallsBackToAliasLookup(): void + { + $tcgdexCard = $this->buildTcgdexCardDto(imageUrl: null); + $resolved = $this->createStub(CardPrinting::class); + + $apiClient = $this->createMock(TcgdexApiClient::class); + $apiClient->expects(self::once())->method('findCard')->willReturn(null); + $apiClient->expects(self::once())->method('findCardByNameInAliasedSet')->willReturn($tcgdexCard); + + $identityResolver = $this->createStub(CardIdentityResolver::class); + $identityResolver->method('resolveFromTcgdexCard')->willReturn($resolved); + + $ban = new BannedCard(); + $ban->setCardName('Some Card'); + $printing = $this->buildBannedPrinting('LOT', '90', null); + $ban->addPrinting($printing); + + $enricher = $this->buildEnricher(apiClient: $apiClient, identityResolver: $identityResolver); + + self::assertTrue($enricher->enrichPrinting($printing)); + self::assertSame($resolved, $printing->getCardPrinting()); + } + + public function testEnrichPrintingReturnsFalseWhenAllSourcesMiss(): void + { + $apiClient = $this->createStub(TcgdexApiClient::class); + $apiClient->method('findCard')->willReturn(null); + $apiClient->method('findCardByNameInAliasedSet')->willReturn(null); + + $ban = new BannedCard(); + $ban->setCardName('Phantom Card'); + $printing = $this->buildBannedPrinting('UNKNOWN', '0', null); + $ban->addPrinting($printing); + + $enricher = $this->buildEnricher(apiClient: $apiClient); + + self::assertFalse($enricher->enrichPrinting($printing)); + self::assertNull($printing->getCardPrinting()); + } + + public function testEnrichAllActiveCollectsLinkedAndUnresolvedAndFlushesOnce(): void + { + $linkedPrinting = $this->createStub(CardPrinting::class); + $linkedPrinting->method('getCardIdentity')->willReturn($this->buildIdentity(1, 'Pikachu')); + + $repo = $this->createStub(CardPrintingRepository::class); + $repo->method('findFirstBySetCodeAndCardNumber')->willReturnOnConsecutiveCalls($linkedPrinting, null); + + $apiClient = $this->createStub(TcgdexApiClient::class); + $apiClient->method('findCard')->willReturn(null); + $apiClient->method('findCardByNameInAliasedSet')->willReturn(null); + + $ban1 = new BannedCard(); + $ban1->setCardName('Pikachu'); + $printing1 = $this->buildBannedPrinting('LOT', '90', null); + $ban1->addPrinting($printing1); + + $ban2 = new BannedCard(); + $ban2->setCardName('Lost Card'); + $printing2 = $this->buildBannedPrinting('PHF', '99', null); + $ban2->addPrinting($printing2); + + $bannedPrintingRepo = $this->createStub(BannedCardPrintingRepository::class); + $bannedPrintingRepo->method('findAllOrderedBySetAndNumber')->willReturn([$printing1, $printing2]); + + $bannedRepo = $this->createStub(BannedCardRepository::class); + $bannedRepo->method('findOneByCardIdentity')->willReturn(null); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('flush'); + + $enricher = $this->buildEnricher( + apiClient: $apiClient, + cardPrintingRepository: $repo, + bannedCardPrintingRepository: $bannedPrintingRepo, + bannedCardRepository: $bannedRepo, + entityManager: $entityManager, + ); + + [$linked, $unresolved] = $enricher->enrichAllActive(); + + self::assertSame(1, $linked); + self::assertSame(['Lost Card (PHF 99)'], $unresolved); + } + + public function testEnrichAllActiveForceModeResetsExistingLink(): void + { + $stale = $this->createStub(CardPrinting::class); + + $printing = $this->buildBannedPrinting('LOT', '90', $stale); + $ban = new BannedCard(); + $ban->setCardName('Pikachu'); + $ban->addPrinting($printing); + + $bannedPrintingRepo = $this->createStub(BannedCardPrintingRepository::class); + $bannedPrintingRepo->method('findAllOrderedBySetAndNumber')->willReturn([$printing]); + + $apiClient = $this->createStub(TcgdexApiClient::class); + $apiClient->method('findCard')->willReturn(null); + $apiClient->method('findCardByNameInAliasedSet')->willReturn(null); + + $enricher = $this->buildEnricher( + apiClient: $apiClient, + bannedCardPrintingRepository: $bannedPrintingRepo, + ); + + [$linked, $unresolved] = $enricher->enrichAllActive(force: true); + + self::assertSame(0, $linked); + self::assertSame(['Pikachu (LOT 90)'], $unresolved); + self::assertNull($printing->getCardPrinting(), 'force mode should reset existing link before re-enriching'); + } + + public function testReparentPromotesCurrentParentWhenNoCanonicalExists(): void + { + $identity = $this->buildIdentity(42, 'Pikachu'); + + $cardPrinting = $this->createStub(CardPrinting::class); + $cardPrinting->method('getCardIdentity')->willReturn($identity); + + $local = $cardPrinting; // local hit returns the stub + $repo = $this->createStub(CardPrintingRepository::class); + $repo->method('findFirstBySetCodeAndCardNumber')->willReturn($local); + + $ban = new BannedCard(); + $ban->setCardName(''); // placeholder name (not yet enriched) + $printing = $this->buildBannedPrinting('LOT', '90', null); + $ban->addPrinting($printing); + + $bannedPrintingRepo = $this->createStub(BannedCardPrintingRepository::class); + $bannedPrintingRepo->method('findAllOrderedBySetAndNumber')->willReturn([$printing]); + + $bannedRepo = $this->createStub(BannedCardRepository::class); + $bannedRepo->method('findOneByCardIdentity')->willReturn(null); + + $enricher = $this->buildEnricher( + cardPrintingRepository: $repo, + bannedCardPrintingRepository: $bannedPrintingRepo, + bannedCardRepository: $bannedRepo, + ); + + $enricher->enrichAllActive(); + + self::assertSame($identity, $ban->getCardIdentity()); + self::assertSame('Pikachu', $ban->getCardName()); + } + + public function testReparentInLoopCacheReusesParentForSameIdentity(): void + { + // Regression for the unique-constraint trap: two consecutive printings + // for the same identity must reuse the parent created in the same loop + // (DB lookup still empty since flush happens at the end). + $identity = $this->buildIdentity(42, 'Pikachu'); + + $local = $this->createStub(CardPrinting::class); + $local->method('getCardIdentity')->willReturn($identity); + + $repo = $this->createStub(CardPrintingRepository::class); + $repo->method('findFirstBySetCodeAndCardNumber')->willReturn($local); + + // Two distinct parents (placeholders); the second one should end up + // empty after reparenting and get removed. + $parent1 = new BannedCard(); + $parent1->setCardName(''); + $printing1 = $this->buildBannedPrinting('LOT', '90', null); + $parent1->addPrinting($printing1); + + $parent2 = new BannedCard(); + $parent2->setCardName(''); + $printing2 = $this->buildBannedPrinting('LOT', '91', null); + $parent2->addPrinting($printing2); + + $bannedPrintingRepo = $this->createStub(BannedCardPrintingRepository::class); + $bannedPrintingRepo->method('findAllOrderedBySetAndNumber')->willReturn([$printing1, $printing2]); + + // bannedCardRepository::findOneByCardIdentity returns null both times — + // the in-loop cache is what makes the second one find the parent. + $bannedRepo = $this->createStub(BannedCardRepository::class); + $bannedRepo->method('findOneByCardIdentity')->willReturn(null); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('remove')->with($parent2); + $entityManager->expects(self::once())->method('flush'); + + $enricher = $this->buildEnricher( + cardPrintingRepository: $repo, + bannedCardPrintingRepository: $bannedPrintingRepo, + bannedCardRepository: $bannedRepo, + entityManager: $entityManager, + ); + + $enricher->enrichAllActive(); + + // Both printings now live under parent1. + self::assertSame($parent1, $printing1->getBannedCard()); + self::assertSame($parent1, $printing2->getBannedCard()); + self::assertSame('Pikachu', $parent1->getCardName()); + self::assertCount(2, $parent1->getPrintings()); + self::assertCount(0, $parent2->getPrintings()); + } + + public function testReparentFillsEmptyNameOnExistingCanonicalParent(): void + { + $identity = $this->buildIdentity(42, 'Pikachu'); + + $local = $this->createStub(CardPrinting::class); + $local->method('getCardIdentity')->willReturn($identity); + + $repo = $this->createStub(CardPrintingRepository::class); + $repo->method('findFirstBySetCodeAndCardNumber')->willReturn($local); + + $canonical = new BannedCard(); + $canonical->setCardName(''); // empty -> should get filled + $canonical->setCardIdentity($identity); + $printing = $this->buildBannedPrinting('LOT', '90', null); + $canonical->addPrinting($printing); + + $bannedPrintingRepo = $this->createStub(BannedCardPrintingRepository::class); + $bannedPrintingRepo->method('findAllOrderedBySetAndNumber')->willReturn([$printing]); + + // canonical IS the currentParent -> the second branch fires + $bannedRepo = $this->createStub(BannedCardRepository::class); + $bannedRepo->method('findOneByCardIdentity')->willReturn($canonical); + + $enricher = $this->buildEnricher( + cardPrintingRepository: $repo, + bannedCardPrintingRepository: $bannedPrintingRepo, + bannedCardRepository: $bannedRepo, + ); + + $enricher->enrichAllActive(); + + self::assertSame('Pikachu', $canonical->getCardName()); + } + + private function buildEnricher( + ?TcgdexApiClient $apiClient = null, + ?CardIdentityResolver $identityResolver = null, + ?CardPrintingRepository $cardPrintingRepository = null, + ?BannedCardPrintingRepository $bannedCardPrintingRepository = null, + ?BannedCardRepository $bannedCardRepository = null, + ?EntityManagerInterface $entityManager = null, + ): BannedCardEnricher { + if (null === $apiClient) { + $apiClient = $this->createStub(TcgdexApiClient::class); + $apiClient->method('findCard')->willReturn(null); + $apiClient->method('findCardByNameInAliasedSet')->willReturn(null); + } + if (null === $identityResolver) { + $identityResolver = $this->createStub(CardIdentityResolver::class); + } + if (null === $cardPrintingRepository) { + $cardPrintingRepository = $this->createStub(CardPrintingRepository::class); + $cardPrintingRepository->method('findFirstBySetCodeAndCardNumber')->willReturn(null); + } + if (null === $bannedCardPrintingRepository) { + $bannedCardPrintingRepository = $this->createStub(BannedCardPrintingRepository::class); + $bannedCardPrintingRepository->method('findAllOrderedBySetAndNumber')->willReturn([]); + } + if (null === $bannedCardRepository) { + $bannedCardRepository = $this->createStub(BannedCardRepository::class); + $bannedCardRepository->method('findOneByCardIdentity')->willReturn(null); + } + if (null === $entityManager) { + $entityManager = $this->createStub(EntityManagerInterface::class); + } + + return new BannedCardEnricher( + $apiClient, + $identityResolver, + $cardPrintingRepository, + $bannedCardPrintingRepository, + $bannedCardRepository, + $entityManager, + ); + } + + private function buildBannedPrinting(string $setCode, string $cardNumber, ?CardPrinting $cardPrinting): BannedCardPrinting + { + $printing = new BannedCardPrinting(); + $printing->setSetCode($setCode); + $printing->setCardNumber($cardNumber); + $printing->setCardPrinting($cardPrinting); + + return $printing; + } + + private function buildTcgdexCardDto(?string $imageUrl): TcgdexCardDto + { + return new TcgdexCardDto( + id: 'sm-1', + name: 'Pikachu', + category: 'Pokemon', + trainerType: null, + imageUrl: $imageUrl, + isExpandedLegal: true, + ); + } + + private function buildIdentity(int $id, string $name): CardIdentity + { + $identity = $this->createStub(CardIdentity::class); + $identity->method('getId')->willReturn($id); + $identity->method('getName')->willReturn($name); + + return $identity; + } + + private function buildResolvedPrintingWithMutableImageUrl(?string $initialImageUrl): CardPrinting + { + // We need both getImageUrl (initially returns $initialImageUrl) and + // setImageUrl (mutates state). A real CardPrinting works for this. + $resolved = new CardPrinting(); + $resolved->setTcgdexId('sm-1'); + if (null !== $initialImageUrl) { + $resolved->setImageUrl($initialImageUrl); + } + + return $resolved; + } +} diff --git a/tests/Service/BannedCardImageResolverTest.php b/tests/Service/BannedCardImageResolverTest.php new file mode 100644 index 0000000..5fd0197 --- /dev/null +++ b/tests/Service/BannedCardImageResolverTest.php @@ -0,0 +1,360 @@ +buildSetRepository(null)); + + self::assertNull($resolver->resolveForBan(new BannedCard())); + } + + public function testRepresentativePrintingDirectImageUrlWins(): void + { + $printing = $this->buildCardPrinting(imageUrl: 'https://images.example.com/foo.png'); + + $ban = new BannedCard(); + $ban->setRepresentativePrinting($printing); + + $resolver = new BannedCardImageResolver($this->buildSetRepository(null)); + + self::assertSame( + 'https://images.example.com/foo.png', + $resolver->resolveForBan($ban), + ); + } + + public function testRepresentativePrintingDirectImageUrlIsNormalizedForTcgdexCdn(): void + { + $printing = $this->buildCardPrinting( + imageUrl: 'https://assets.tcgdex.net/en/sm/sm3.5/7/high.webp', + ); + + $ban = new BannedCard(); + $ban->setRepresentativePrinting($printing); + + $resolver = new BannedCardImageResolver($this->buildSetRepository(null)); + + // Dotted set ID gets stripped to match TCGdex CDN paths. + self::assertSame( + 'https://assets.tcgdex.net/en/sm/sm35/7/high.webp', + $resolver->resolveForBan($ban), + ); + } + + public function testRepresentativePrintingFallsThroughToTcgdexCdnFromTcgdexCard(): void + { + $printing = $this->buildCardPrinting( + imageUrl: null, + tcgdexCard: $this->buildTcgdexCard('sm', 'sm11.5', '42'), + ); + + $ban = new BannedCard(); + $ban->setRepresentativePrinting($printing); + + $resolver = new BannedCardImageResolver($this->buildSetRepository(null)); + + self::assertSame( + 'https://assets.tcgdex.net/fr/sm/sm115/42/high.webp', + $resolver->resolveForBan($ban, 'fr'), + ); + } + + public function testRepresentativePrintingFallsThroughToTcgdexCdnFromParsedTcgdexId(): void + { + $printing = $this->buildCardPrinting( + imageUrl: null, + tcgdexCard: null, + tcgdexId: 'swsh4-100', + ); + + $ban = new BannedCard(); + $ban->setRepresentativePrinting($printing); + + $resolver = new BannedCardImageResolver($this->buildSetRepository(null)); + + self::assertSame( + 'https://assets.tcgdex.net/en/swsh/swsh4/100/high.webp', + $resolver->resolveForBan($ban), + ); + } + + public function testRepresentativePrintingFallsThroughToPokemontcgioWhenSerieGuessFails(): void + { + // Set ID prefix doesn't match any guessable serie -> CDN is null, + // PokemonTCG.io takes over. + $printing = $this->buildCardPrinting( + imageUrl: null, + tcgdexCard: null, + tcgdexId: 'mc1-3', + ); + + $ban = new BannedCard(); + $ban->setRepresentativePrinting($printing); + + $resolver = new BannedCardImageResolver($this->buildSetRepository(null)); + + self::assertSame( + 'https://images.pokemontcg.io/mc1/3_hires.png', + $resolver->resolveForBan($ban), + ); + } + + public function testRepresentativePrintingFinalFallbackUsesUpstreamSetCodeViaTcgdexSet(): void + { + $printing = $this->buildCardPrinting( + imageUrl: null, + tcgdexCard: null, + tcgdexId: 'no-dash-here', // dash makes parse succeed; value irrelevant since serie guess fails + ); + // Force every direct lookup to fail so we hit the upstream-set-code branch. + $printingNoTcgdex = $this->buildCardPrinting( + imageUrl: null, + tcgdexCard: null, + tcgdexId: 'nodash', // no dash -> parseTcgdexId returns null -> CDN + pokemontcg.io fail + ); + + $ban = new BannedCard(); + $ban->setRepresentativePrinting($printingNoTcgdex); + $ban->addPrinting($this->buildBannedPrinting('LOT', '90', $printingNoTcgdex)); + + $set = $this->buildTcgdexSet('sm', 'sm11.5'); + $resolver = new BannedCardImageResolver($this->buildSetRepository($set)); + + self::assertSame( + 'https://assets.tcgdex.net/en/sm/sm115/90/high.webp', + $resolver->resolveForBan($ban), + ); + } + + public function testRepresentativePrintingFailsThenWalksChildPrintingsByRarity(): void + { + // Representative resolves to nothing. + $representative = $this->buildCardPrinting( + imageUrl: null, + tcgdexCard: null, + tcgdexId: 'nodash', + ); + + // Two child printings: high-rarity tier first in collection, but the + // resolver should pick the lowest rarity tier. + $rare = $this->buildCardPrinting( + imageUrl: 'https://images.example.com/rare.png', + rarityTier: 5, + ); + $common = $this->buildCardPrinting( + imageUrl: 'https://images.example.com/common.png', + rarityTier: 1, + ); + + $ban = new BannedCard(); + $ban->setRepresentativePrinting($representative); + $ban->addPrinting($this->buildBannedPrinting('LOT', '90', $rare)); + $ban->addPrinting($this->buildBannedPrinting('LOT', '91', $common)); + + $resolver = new BannedCardImageResolver($this->buildSetRepository(null)); + + self::assertSame( + 'https://images.example.com/common.png', + $resolver->resolveForBan($ban), + ); + } + + public function testChildWithoutCardPrintingFallsBackToUpstreamSetCode(): void + { + $set = $this->buildTcgdexSet('xy', 'xy7'); + + $ban = new BannedCard(); + // BannedCardPrinting with null cardPrinting -> directly uses setCode lookup. + $ban->addPrinting($this->buildBannedPrinting('PHF', '99', null)); + + $resolver = new BannedCardImageResolver($this->buildSetRepository($set)); + + self::assertSame( + 'https://assets.tcgdex.net/en/xy/xy7/99/high.webp', + $resolver->resolveForBan($ban), + ); + } + + public function testChildWithoutCardPrintingReturnsNullWhenSetCodeUnknown(): void + { + $ban = new BannedCard(); + $ban->addPrinting($this->buildBannedPrinting('UNKNOWN', '1', null)); + + $resolver = new BannedCardImageResolver($this->buildSetRepository(null)); + + self::assertNull($resolver->resolveForBan($ban)); + } + + public function testNullPrintingsSortedAfterPrintingsWithCardPrintingByMaxIntTier(): void + { + // A child without CardPrinting has rarity tier PHP_INT_MAX in the sort, + // so a child with a CardPrinting wins even when its tier is the default 6. + $rare = $this->buildCardPrinting( + imageUrl: 'https://images.example.com/rare.png', + rarityTier: 6, + ); + + $ban = new BannedCard(); + $ban->addPrinting($this->buildBannedPrinting('LOT', '90', null)); + $ban->addPrinting($this->buildBannedPrinting('LOT', '91', $rare)); + + $resolver = new BannedCardImageResolver($this->buildSetRepository(null)); + + self::assertSame( + 'https://images.example.com/rare.png', + $resolver->resolveForBan($ban), + ); + } + + public function testNonTcgdexCdnImageUrlIsReturnedUnchanged(): void + { + // normalizeTcgdexCdnUrl only mutates URLs starting with the TCGdex CDN base. + $printing = $this->buildCardPrinting( + imageUrl: 'https://images.pokemontcg.io/sm115/42_hires.png', + ); + + $ban = new BannedCard(); + $ban->setRepresentativePrinting($printing); + + $resolver = new BannedCardImageResolver($this->buildSetRepository(null)); + + self::assertSame( + 'https://images.pokemontcg.io/sm115/42_hires.png', + $resolver->resolveForBan($ban), + ); + } + + public function testEmptyImageUrlSkipsDirectAndFallsThrough(): void + { + $printing = $this->buildCardPrinting( + imageUrl: '', + tcgdexCard: $this->buildTcgdexCard('bw', 'bw1', '10'), + ); + + $ban = new BannedCard(); + $ban->setRepresentativePrinting($printing); + + $resolver = new BannedCardImageResolver($this->buildSetRepository(null)); + + self::assertSame( + 'https://assets.tcgdex.net/en/bw/bw1/10/high.webp', + $resolver->resolveForBan($ban), + ); + } + + public function testGuessSerieIdSupportsAllKnownPrefixes(): void + { + $cases = [ + ['sv8-1', 'sv', 'sv8'], + ['swsh10-2', 'swsh', 'swsh10'], + ['sm9-3', 'sm', 'sm9'], + ['xy3-4', 'xy', 'xy3'], + ['bw5-6', 'bw', 'bw5'], + ]; + + foreach ($cases as [$tcgdexId, $expectedSerie, $expectedSet]) { + $printing = $this->buildCardPrinting( + imageUrl: null, + tcgdexCard: null, + tcgdexId: $tcgdexId, + ); + + $ban = new BannedCard(); + $ban->setRepresentativePrinting($printing); + + $resolver = new BannedCardImageResolver($this->buildSetRepository(null)); + + $expected = \sprintf( + 'https://assets.tcgdex.net/en/%s/%s/%s/high.webp', + $expectedSerie, + $expectedSet, + explode('-', $tcgdexId)[1], + ); + + self::assertSame($expected, $resolver->resolveForBan($ban), "tcgdexId $tcgdexId"); + } + } + + private function buildSetRepository(?TcgdexSet $set): TcgdexSetRepository + { + $repository = $this->createStub(TcgdexSetRepository::class); + $repository->method('findByPtcgCode')->willReturn($set); + + return $repository; + } + + private function buildCardPrinting( + ?string $imageUrl = null, + ?TcgdexCard $tcgdexCard = null, + string $tcgdexId = 'sm1-1', + int $rarityTier = 6, + ): CardPrinting { + $printing = $this->createStub(CardPrinting::class); + $printing->method('getImageUrl')->willReturn($imageUrl); + $printing->method('getTcgdexCard')->willReturn($tcgdexCard); + $printing->method('getTcgdexId')->willReturn($tcgdexId); + $printing->method('getRarityTier')->willReturn($rarityTier); + + return $printing; + } + + private function buildTcgdexCard(string $serieId, string $setId, string $localId): TcgdexCard + { + $set = $this->buildTcgdexSet($serieId, $setId); + + $tcgdexCard = $this->createStub(TcgdexCard::class); + $tcgdexCard->method('getSet')->willReturn($set); + $tcgdexCard->method('getLocalId')->willReturn($localId); + + return $tcgdexCard; + } + + private function buildTcgdexSet(string $serieId, string $setId): TcgdexSet + { + $serie = $this->createStub(TcgdexSerie::class); + $serie->method('getId')->willReturn($serieId); + + $set = $this->createStub(TcgdexSet::class); + $set->method('getId')->willReturn($setId); + $set->method('getSerie')->willReturn($serie); + + return $set; + } + + private function buildBannedPrinting(string $setCode, string $cardNumber, ?CardPrinting $cardPrinting): BannedCardPrinting + { + $printing = new BannedCardPrinting(); + $printing->setSetCode($setCode); + $printing->setCardNumber($cardNumber); + $printing->setCardPrinting($cardPrinting); + + return $printing; + } +} diff --git a/tests/Service/BannedCardsSyncServiceTest.php b/tests/Service/BannedCardsSyncServiceTest.php index 92c6fb4..4139d9b 100644 --- a/tests/Service/BannedCardsSyncServiceTest.php +++ b/tests/Service/BannedCardsSyncServiceTest.php @@ -280,6 +280,109 @@ public function testSyncWarnsOnUnknownSetName(): void self::assertStringContainsString('Unknown set', $result->warnings[0]); } + public function testSyncSkipsActiveParentsWithEmptyPrintingsDuringSoftDelete(): void + { + // Parent with an empty printings collection must be skipped during the + // soft-delete pass — otherwise findActiveOrderedByEffectiveDate could + // return a transient row that gets mistakenly soft-deleted as "all + // printings missing". + $html = $this->buildHtml( + '
  • Archeops (Black & White—Noble Victories, 67/101)
', + ); + + $httpClient = new MockHttpClient([new MockResponse($html)]); + + $emptyParent = new BannedCard(); + $emptyParent->setCardName('Empty Placeholder'); + + $printingRepo = $this->createStub(BannedCardPrintingRepository::class); + $printingRepo->method('findOneBySetCodeAndCardNumber')->willReturn(null); + + $bannedCardRepo = $this->createStub(BannedCardRepository::class); + $bannedCardRepo->method('findActiveOrderedByEffectiveDate')->willReturn([$emptyParent]); + + $entityManager = $this->createStub(EntityManagerInterface::class); + + $service = new BannedCardsSyncService($httpClient, $bannedCardRepo, $printingRepo, $entityManager, $this->buildEnricherStub(), $this->buildSeedStub()); + $result = $service->sync(); + + self::assertTrue($result->success); + self::assertSame(0, $result->removed); + self::assertNull($emptyParent->getDeletedAt()); + } + + public function testSyncReusesParentForRepeatedIdentityViaInLoopCache(): void + { + // Two distinct (setCode, cardNumber) entries that, after enrichment, + // resolve to the same CardIdentity. The first creates a placeholder + // parent, the second must find that parent through the in-memory cache + // (the DB lookup still returns null since flush is deferred). Both + // printings end up under the same parent — the duplicate placeholder + // is dropped. + $html = $this->buildHtml( + '
  • Unown (Sun & Moon—Lost Thunder, 90/214 and 91/214)
', + ); + + $httpClient = new MockHttpClient([new MockResponse($html)]); + + $identity = $this->createStub(\App\Entity\CardIdentity::class); + $identity->method('getId')->willReturn(42); + $identity->method('getName')->willReturn('Unown'); + + $local = $this->createStub(\App\Entity\CardPrinting::class); + $local->method('getCardIdentity')->willReturn($identity); + + $printingRepo = $this->createStub(BannedCardPrintingRepository::class); + $printingRepo->method('findOneBySetCodeAndCardNumber')->willReturn(null); + + // findOneByCardIdentity always returns null — the in-loop cache is the + // only thing that prevents creating two parents for the same identity. + $bannedCardRepo = $this->createStub(BannedCardRepository::class); + $bannedCardRepo->method('findActiveOrderedByEffectiveDate')->willReturn([]); + $bannedCardRepo->method('findOneByCardIdentity')->willReturn(null); + + $cardPrintingRepository = $this->createStub(\App\Repository\CardPrintingRepository::class); + $cardPrintingRepository->method('findFirstBySetCodeAndCardNumber')->willReturn($local); + + $apiClient = $this->createStub(\App\Service\Tcgdex\TcgdexApiClient::class); + $apiClient->method('findCard')->willReturn(null); + $apiClient->method('findCardByNameInAliasedSet')->willReturn(null); + + $identityResolver = $this->createStub(\App\Service\CardIdentity\CardIdentityResolver::class); + + $enricher = new \App\Service\BannedCardEnricher( + $apiClient, + $identityResolver, + $cardPrintingRepository, + $printingRepo, + $bannedCardRepo, + $this->createStub(EntityManagerInterface::class), + ); + + // Track parent removals — the duplicate placeholder must be dropped. + $removed = []; + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->method('remove')->willReturnCallback(static function (object $entity) use (&$removed): void { + $removed[] = $entity; + }); + $entityManager->expects(self::once())->method('flush'); + + $service = new BannedCardsSyncService( + $httpClient, + $bannedCardRepo, + $printingRepo, + $entityManager, + $enricher, + $this->buildSeedStub(), + ); + + $result = $service->sync(); + + self::assertTrue($result->success); + self::assertSame(2, $result->added); + self::assertCount(1, $removed, 'second placeholder parent should be removed after merge'); + } + public function testSyncParsesCardNameWithEmTagInAltAttribute(): void { $html = $this->buildHtml( From b87c335bee1a64c9441f0f074ef086fc50fa9dd6 Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 15:23:10 +0200 Subject: [PATCH 06/12] test(deck): add BannedCardSeedData, command, repo + form tests Refs #498. Second batch of the F6.14 banned-card coverage backfill, finishing the priority list: - BannedCardSeedDataTest (7 tests): applyTo fills nulls / preserves existing values / no-ops on unknown name; per-printing seeds for Unown LOT 90 vs LOT 91 (distinct ban dates); applyAll counts filled+skipped, single flush, skips when seed exists but all fields already filled. - BannedCardsEnrichCommandTest (3 tests): linked / unresolved reporting, empty result, --force flag wired through. - BannedCardsSeedCommandTest (2 tests): success and empty repo. - AdminTechnicalControllerTest extension (4 tests): banned-cards-enrich auth + CSRF + happy path + force flag. - BannedCardPrintingRepositoryTest (4 tests): findOneBySetCodeAndCardNumber hit/miss, findAllOrderedBySetAndNumber lex order + empty. - CardPrintingRepositoryTest (3 tests): findFirstBySetCodeAndCardNumber prefers Expanded-legal then lowest rarity tier; null on miss. - BannedCardFormTypeTest (4 tests): form binds to BannedCard, exposes all fields, valid payload round-trip, empty optional fields clear existing values. Final and unit/functional totals: 1101 unit / 1005 functional tests pass locally (was 1062 / 982 before this branch). --- .../Command/BannedCardsEnrichCommandTest.php | 124 ++++++++++++ tests/Command/BannedCardsSeedCommandTest.php | 69 +++++++ tests/Form/BannedCardFormTypeTest.php | 84 +++++++++ .../AdminTechnicalControllerTest.php | 52 +++++ .../BannedCardPrintingRepositoryTest.php | 92 +++++++++ .../Functional/CardPrintingRepositoryTest.php | 110 +++++++++++ tests/Service/BannedCardSeedDataTest.php | 178 ++++++++++++++++++ tests/Service/BannedCardsSyncServiceTest.php | 2 +- 8 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 tests/Command/BannedCardsEnrichCommandTest.php create mode 100644 tests/Command/BannedCardsSeedCommandTest.php create mode 100644 tests/Form/BannedCardFormTypeTest.php create mode 100644 tests/Functional/BannedCardPrintingRepositoryTest.php create mode 100644 tests/Functional/CardPrintingRepositoryTest.php create mode 100644 tests/Service/BannedCardSeedDataTest.php diff --git a/tests/Command/BannedCardsEnrichCommandTest.php b/tests/Command/BannedCardsEnrichCommandTest.php new file mode 100644 index 0000000..6284c4e --- /dev/null +++ b/tests/Command/BannedCardsEnrichCommandTest.php @@ -0,0 +1,124 @@ +buildEnricher( + printings: [ + $this->buildBannedPrinting('LOT', '90', 'Card A'), + $this->buildBannedPrinting('PHF', '99', 'Card B'), + ], + ); + + $tester = new CommandTester(new BannedCardsEnrichCommand($enricher)); + $tester->execute([]); + + self::assertSame(0, $tester->getStatusCode()); + $display = $tester->getDisplay(); + self::assertStringContainsString('Enriched 2', $display); + // Both printings unresolved (no local hit, no API hit). + self::assertStringContainsString('Linked 0 / 2', $display); + self::assertStringContainsString('Could not resolve 2', $display); + self::assertStringContainsString('Card A (LOT 90)', $display); + } + + public function testCommandWithEmptyEnrichmentSucceeds(): void + { + $enricher = $this->buildEnricher(printings: []); + + $tester = new CommandTester(new BannedCardsEnrichCommand($enricher)); + $tester->execute([]); + + self::assertSame(0, $tester->getStatusCode()); + $display = $tester->getDisplay(); + self::assertStringContainsString('Enriched 0', $display); + self::assertStringContainsString('Linked 0 / 0', $display); + } + + public function testCommandAcceptsForceFlag(): void + { + $enricher = $this->buildEnricher(printings: []); + + $tester = new CommandTester(new BannedCardsEnrichCommand($enricher)); + $tester->execute(['--force' => true]); + + self::assertSame(0, $tester->getStatusCode()); + } + + /** + * @param list $printings + */ + private function buildEnricher(array $printings): BannedCardEnricher + { + $apiClient = $this->createStub(TcgdexApiClient::class); + $apiClient->method('findCard')->willReturn(null); + $apiClient->method('findCardByNameInAliasedSet')->willReturn(null); + + $identityResolver = $this->createStub(CardIdentityResolver::class); + + $cardPrintingRepository = $this->createStub(CardPrintingRepository::class); + $cardPrintingRepository->method('findFirstBySetCodeAndCardNumber')->willReturn(null); + + $bannedCardPrintingRepository = $this->createStub(BannedCardPrintingRepository::class); + $bannedCardPrintingRepository->method('findAllOrderedBySetAndNumber')->willReturn($printings); + + $bannedCardRepository = $this->createStub(BannedCardRepository::class); + $bannedCardRepository->method('findOneByCardIdentity')->willReturn(null); + + return new BannedCardEnricher( + $apiClient, + $identityResolver, + $cardPrintingRepository, + $bannedCardPrintingRepository, + $bannedCardRepository, + $this->createStub(EntityManagerInterface::class), + ); + } + + private function buildBannedPrinting(string $setCode, string $cardNumber, string $cardName): BannedCardPrinting + { + $parent = new BannedCard(); + $parent->setCardName($cardName); + + $printing = new BannedCardPrinting(); + $printing->setSetCode($setCode); + $printing->setCardNumber($cardNumber); + $parent->addPrinting($printing); + + return $printing; + } +} diff --git a/tests/Command/BannedCardsSeedCommandTest.php b/tests/Command/BannedCardsSeedCommandTest.php new file mode 100644 index 0000000..f4c4102 --- /dev/null +++ b/tests/Command/BannedCardsSeedCommandTest.php @@ -0,0 +1,69 @@ +setCardName('Archeops'); + $printing = new BannedCardPrinting(); + $printing->setSetCode('NVI'); + $printing->setCardNumber('67'); + $card->addPrinting($printing); + + $repository = $this->createStub(BannedCardRepository::class); + $repository->method('findActiveOrderedByEffectiveDate')->willReturn([$card]); + + $seedData = new BannedCardSeedData($repository, $this->createStub(EntityManagerInterface::class)); + + $tester = new CommandTester(new BannedCardsSeedCommand($seedData)); + $tester->execute([]); + + self::assertSame(0, $tester->getStatusCode()); + $display = $tester->getDisplay(); + self::assertStringContainsString('1 card(s) updated', $display); + self::assertStringContainsString('0 skipped', $display); + } + + public function testCommandHandlesEmptyResult(): void + { + $repository = $this->createStub(BannedCardRepository::class); + $repository->method('findActiveOrderedByEffectiveDate')->willReturn([]); + + $seedData = new BannedCardSeedData($repository, $this->createStub(EntityManagerInterface::class)); + + $tester = new CommandTester(new BannedCardsSeedCommand($seedData)); + $tester->execute([]); + + self::assertSame(0, $tester->getStatusCode()); + self::assertStringContainsString('0 card(s) updated', $tester->getDisplay()); + } +} diff --git a/tests/Form/BannedCardFormTypeTest.php b/tests/Form/BannedCardFormTypeTest.php new file mode 100644 index 0000000..23f4002 --- /dev/null +++ b/tests/Form/BannedCardFormTypeTest.php @@ -0,0 +1,84 @@ +factory->create(BannedCardFormType::class); + + self::assertSame(BannedCard::class, $form->getConfig()->getDataClass()); + } + + public function testFormExposesAllExpectedFields(): void + { + $form = $this->factory->create(BannedCardFormType::class); + + self::assertTrue($form->has('cardName')); + self::assertTrue($form->has('effectiveDate')); + self::assertTrue($form->has('sourceUrl')); + self::assertTrue($form->has('explanation')); + } + + public function testSubmittingValidPayloadMapsToEntity(): void + { + $card = new BannedCard(); + $form = $this->factory->create(BannedCardFormType::class, $card); + + $form->submit([ + 'cardName' => 'Pikachu', + 'effectiveDate' => '2024-04-01', + 'sourceUrl' => 'https://www.pokemon.com/us/play-pokemon/about/pokemon-tcg-banned-card-list', + 'explanation' => 'Some rationale', + ]); + + self::assertTrue($form->isSynchronized()); + self::assertTrue($form->isValid()); + self::assertSame('Pikachu', $card->getCardName()); + self::assertInstanceOf(\DateTimeImmutable::class, $card->getEffectiveDate()); + self::assertSame('2024-04-01', $card->getEffectiveDate()->format('Y-m-d')); + self::assertSame('https://www.pokemon.com/us/play-pokemon/about/pokemon-tcg-banned-card-list', $card->getSourceUrl()); + self::assertSame('Some rationale', $card->getExplanation()); + } + + public function testSubmittingWithEmptyOptionalFieldsClearsThem(): void + { + $card = new BannedCard(); + $card->setSourceUrl('https://existing.example.com'); + $card->setExplanation('previous'); + + $form = $this->factory->create(BannedCardFormType::class, $card); + $form->submit([ + 'cardName' => 'Pikachu', + 'effectiveDate' => '', + 'sourceUrl' => '', + 'explanation' => '', + ]); + + self::assertTrue($form->isSynchronized()); + self::assertNull($card->getEffectiveDate()); + self::assertNull($card->getSourceUrl()); + self::assertNull($card->getExplanation()); + } +} diff --git a/tests/Functional/AdminTechnicalControllerTest.php b/tests/Functional/AdminTechnicalControllerTest.php index 5a96fa9..54ac9b2 100644 --- a/tests/Functional/AdminTechnicalControllerTest.php +++ b/tests/Functional/AdminTechnicalControllerTest.php @@ -167,4 +167,56 @@ public function testReenrichCardDispatchesForMatchingCard(): void self::assertResponseRedirects('/admin/technical'); } + + public function testBannedCardsEnrichRequiresAuthentication(): void + { + $this->client->request('POST', '/admin/technical/banned-cards-enrich'); + + self::assertResponseRedirects('/login'); + } + + public function testBannedCardsEnrichRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + + $this->client->request('POST', '/admin/technical/banned-cards-enrich', [ + '_token' => 'wrong', + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testBannedCardsEnrichSucceedsWithValidCsrf(): void + { + $this->loginAs('admin@example.com'); + + $this->client->request('GET', '/admin/technical'); + $csrfToken = $this->getCsrfToken('technical-banned-cards-enrich'); + + $this->client->request('POST', '/admin/technical/banned-cards-enrich', [ + '_token' => $csrfToken, + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + // No banned cards in fixtures -> "Linked 0 / 0" success flash, no warning. + self::assertSelectorExists('.alert-success'); + } + + public function testBannedCardsEnrichAcceptsForceFlag(): void + { + $this->loginAs('admin@example.com'); + + $this->client->request('GET', '/admin/technical'); + $csrfToken = $this->getCsrfToken('technical-banned-cards-enrich'); + + $this->client->request('POST', '/admin/technical/banned-cards-enrich', [ + '_token' => $csrfToken, + 'force' => '1', + ]); + + self::assertResponseRedirects('/admin/technical'); + } } diff --git a/tests/Functional/BannedCardPrintingRepositoryTest.php b/tests/Functional/BannedCardPrintingRepositoryTest.php new file mode 100644 index 0000000..4a883af --- /dev/null +++ b/tests/Functional/BannedCardPrintingRepositoryTest.php @@ -0,0 +1,92 @@ +persistBannedPrinting('LOT', '90'); + $this->persistBannedPrinting('LOT', '91'); + + $found = $this->getRepository()->findOneBySetCodeAndCardNumber('LOT', '90'); + + self::assertInstanceOf(BannedCardPrinting::class, $found); + self::assertSame('90', $found->getCardNumber()); + } + + public function testFindOneBySetCodeAndCardNumberReturnsNullWhenMissing(): void + { + $this->persistBannedPrinting('LOT', '90'); + + self::assertNull($this->getRepository()->findOneBySetCodeAndCardNumber('UNKNOWN', '0')); + } + + public function testFindAllOrderedBySetAndNumberSortsLexicographically(): void + { + $this->persistBannedPrinting('LOT', '91'); + $this->persistBannedPrinting('AOR', '74'); + $this->persistBannedPrinting('LOT', '90'); + + $all = $this->getRepository()->findAllOrderedBySetAndNumber(); + + self::assertCount(3, $all); + self::assertSame('AOR', $all[0]->getSetCode()); + self::assertSame('LOT', $all[1]->getSetCode()); + self::assertSame('90', $all[1]->getCardNumber()); + self::assertSame('LOT', $all[2]->getSetCode()); + self::assertSame('91', $all[2]->getCardNumber()); + } + + public function testFindAllOrderedBySetAndNumberReturnsEmptyWhenNoRows(): void + { + self::assertSame([], $this->getRepository()->findAllOrderedBySetAndNumber()); + } + + private function getRepository(): BannedCardPrintingRepository + { + /** @var BannedCardPrintingRepository $repository */ + $repository = static::getContainer()->get(BannedCardPrintingRepository::class); + + return $repository; + } + + private function persistBannedPrinting(string $setCode, string $cardNumber): BannedCardPrinting + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + $parent = new BannedCard(); + $parent->setCardName(\sprintf('%s %s', $setCode, $cardNumber)); + + $printing = new BannedCardPrinting(); + $printing->setSetCode($setCode); + $printing->setCardNumber($cardNumber); + $parent->addPrinting($printing); + + $em->persist($parent); + $em->persist($printing); + $em->flush(); + + return $printing; + } +} diff --git a/tests/Functional/CardPrintingRepositoryTest.php b/tests/Functional/CardPrintingRepositoryTest.php new file mode 100644 index 0000000..5463f1b --- /dev/null +++ b/tests/Functional/CardPrintingRepositoryTest.php @@ -0,0 +1,110 @@ +persistIdentity('Pikachu'); + + // Three printings sharing (LOT, 90): + // - high-rarity Expanded-legal + // - low-rarity Expanded-legal (this is what we expect) + // - low-rarity NOT Expanded-legal (excluded by ORDER BY isExpandedLegal DESC) + $this->persistPrinting($identity, 'LOT', '90', 'lot-90-rare', 5, isExpandedLegal: true); + $expected = $this->persistPrinting($identity, 'LOT', '90', 'lot-90-common', 1, isExpandedLegal: true); + $this->persistPrinting($identity, 'LOT', '90', 'lot-90-illegal', 1, isExpandedLegal: false); + + $found = $this->getRepository()->findFirstBySetCodeAndCardNumber('LOT', '90'); + + self::assertInstanceOf(CardPrinting::class, $found); + self::assertSame($expected->getId(), $found->getId()); + } + + public function testFindFirstBySetCodeAndCardNumberReturnsNullWhenMissing(): void + { + self::assertNull($this->getRepository()->findFirstBySetCodeAndCardNumber('NOPE', '0')); + } + + public function testFindFirstBySetCodeAndCardNumberPrefersExpandedLegalOverLowerTier(): void + { + // Expanded-legal flag wins over rarity tier in the ORDER BY chain. + $identity = $this->persistIdentity('Charizard'); + $expandedLegalRare = $this->persistPrinting($identity, 'LOT', '5', 'lot-5-a', 6, isExpandedLegal: true); + $this->persistPrinting($identity, 'LOT', '5', 'lot-5-b', 1, isExpandedLegal: false); + + $found = $this->getRepository()->findFirstBySetCodeAndCardNumber('LOT', '5'); + + self::assertInstanceOf(CardPrinting::class, $found); + self::assertSame($expandedLegalRare->getId(), $found->getId()); + } + + private function getRepository(): CardPrintingRepository + { + /** @var CardPrintingRepository $repository */ + $repository = static::getContainer()->get(CardPrintingRepository::class); + + return $repository; + } + + private function persistIdentity(string $name): CardIdentity + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + $identity = new CardIdentity(); + $identity->setName($name); + $identity->setCategory('pokemon'); + $em->persist($identity); + $em->flush(); + + return $identity; + } + + private function persistPrinting( + CardIdentity $identity, + string $setCode, + string $cardNumber, + string $tcgdexId, + int $rarityTier, + bool $isExpandedLegal, + ): CardPrinting { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + $printing = new CardPrinting(); + $printing->setCardIdentity($identity); + $printing->setTcgdexId($tcgdexId); + $printing->setSetCode($setCode); + $printing->setCardNumber($cardNumber); + $printing->setRarityTier($rarityTier); + $printing->setIsExpandedLegal($isExpandedLegal); + $em->persist($printing); + $em->flush(); + + return $printing; + } +} diff --git a/tests/Service/BannedCardSeedDataTest.php b/tests/Service/BannedCardSeedDataTest.php new file mode 100644 index 0000000..086479e --- /dev/null +++ b/tests/Service/BannedCardSeedDataTest.php @@ -0,0 +1,178 @@ +buildCard('Archeops', [['NVI', '67']]); + + $seedData = new BannedCardSeedData( + $this->buildRepository([]), + $this->createStub(EntityManagerInterface::class), + ); + $seedData->applyTo($card); + + self::assertNotNull($card->getEffectiveDate()); + self::assertNotNull($card->getSourceUrl()); + self::assertNotNull($card->getExplanation()); + self::assertSame('2017-08-18', $card->getEffectiveDate()->format('Y-m-d')); + } + + public function testApplyToPreservesExistingValues(): void + { + $card = $this->buildCard('Archeops', [['NVI', '67']]); + $card->setEffectiveDate(new \DateTimeImmutable('2099-01-01')); + $card->setSourceUrl('https://admin.example.com/manual'); + $card->setExplanation('Manual override'); + + $seedData = new BannedCardSeedData( + $this->buildRepository([]), + $this->createStub(EntityManagerInterface::class), + ); + $seedData->applyTo($card); + + self::assertSame('2099-01-01', $card->getEffectiveDate()->format('Y-m-d')); + self::assertSame('https://admin.example.com/manual', $card->getSourceUrl()); + self::assertSame('Manual override', $card->getExplanation()); + } + + public function testApplyToNoOpsForUnknownCardName(): void + { + $card = $this->buildCard('Phantom Card', [['XYZ', '1']]); + + $seedData = new BannedCardSeedData( + $this->buildRepository([]), + $this->createStub(EntityManagerInterface::class), + ); + $seedData->applyTo($card); + + self::assertNull($card->getEffectiveDate()); + self::assertNull($card->getSourceUrl()); + self::assertNull($card->getExplanation()); + } + + public function testApplyToUsesPerPrintingSeedForUnownLot90(): void + { + // LOT 90 → DAMAGE Unown (banned 2019-02-15) + $card = $this->buildCard('Unown', [['LOT', '90']]); + + $seedData = new BannedCardSeedData( + $this->buildRepository([]), + $this->createStub(EntityManagerInterface::class), + ); + $seedData->applyTo($card); + + self::assertNotNull($card->getEffectiveDate()); + self::assertSame('2019-02-15', $card->getEffectiveDate()->format('Y-m-d')); + self::assertStringContainsString('DAMAGE', $card->getExplanation() ?? ''); + } + + public function testApplyToUsesPerPrintingSeedForUnownLot91(): void + { + // LOT 91 → distinct ban date (Cosmic Eclipse rationale) + $card = $this->buildCard('Unown', [['LOT', '91']]); + + $seedData = new BannedCardSeedData( + $this->buildRepository([]), + $this->createStub(EntityManagerInterface::class), + ); + $seedData->applyTo($card); + + self::assertNotNull($card->getEffectiveDate()); + self::assertSame('2019-11-15', $card->getEffectiveDate()->format('Y-m-d')); + } + + public function testApplyAllReturnsFilledAndSkippedCountsAndFlushesOnce(): void + { + $known = $this->buildCard('Archeops', [['NVI', '67']]); + $unknown = $this->buildCard('Phantom Card', [['XYZ', '1']]); + $alreadyFilled = $this->buildCard('Ghetsis', [['PLF', '101']]); + $alreadyFilled->setEffectiveDate(new \DateTimeImmutable('2099-01-01')); + $alreadyFilled->setSourceUrl('https://admin.example.com'); + $alreadyFilled->setExplanation('Manual'); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('flush'); + + $seedData = new BannedCardSeedData( + $this->buildRepository([$known, $unknown, $alreadyFilled]), + $entityManager, + ); + + [$filled, $skipped] = $seedData->applyAll(); + + self::assertSame(1, $filled); + self::assertSame(2, $skipped); + self::assertNotNull($known->getEffectiveDate()); + } + + public function testApplyAllSkipsCardsWithSeedButAllFieldsAlreadyFilled(): void + { + $card = $this->buildCard('Archeops', [['NVI', '67']]); + $card->setEffectiveDate(new \DateTimeImmutable('2017-08-18')); + $card->setSourceUrl('https://www.pokemon.com/seed'); + $card->setExplanation('seed text'); + + $seedData = new BannedCardSeedData( + $this->buildRepository([$card]), + $this->createStub(EntityManagerInterface::class), + ); + + [$filled, $skipped] = $seedData->applyAll(); + + self::assertSame(0, $filled); + self::assertSame(1, $skipped); + } + + /** + * @param list $printings + */ + private function buildCard(string $name, array $printings): BannedCard + { + $card = new BannedCard(); + $card->setCardName($name); + + foreach ($printings as [$setCode, $cardNumber]) { + $printing = new BannedCardPrinting(); + $printing->setSetCode($setCode); + $printing->setCardNumber($cardNumber); + $card->addPrinting($printing); + } + + return $card; + } + + /** + * @param list $cards + */ + private function buildRepository(array $cards): BannedCardRepository + { + $repository = $this->createStub(BannedCardRepository::class); + $repository->method('findActiveOrderedByEffectiveDate')->willReturn($cards); + + return $repository; + } +} diff --git a/tests/Service/BannedCardsSyncServiceTest.php b/tests/Service/BannedCardsSyncServiceTest.php index 4139d9b..ef3daf6 100644 --- a/tests/Service/BannedCardsSyncServiceTest.php +++ b/tests/Service/BannedCardsSyncServiceTest.php @@ -350,7 +350,7 @@ public function testSyncReusesParentForRepeatedIdentityViaInLoopCache(): void $identityResolver = $this->createStub(\App\Service\CardIdentity\CardIdentityResolver::class); - $enricher = new \App\Service\BannedCardEnricher( + $enricher = new BannedCardEnricher( $apiClient, $identityResolver, $cardPrintingRepository, From e546b40a7143276bbf4b9d0699ace4f443736ff1 Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 16:18:25 +0200 Subject: [PATCH 07/12] test(deck): cover sprite subsystem + register tests/Form & tests/Sentry suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coverage fixes in one branch: 1. **Test suite registration** - Adds tests/Form and tests/Sentry to the unit suite in phpunit.xml.dist. Without them, BannedCardFormTypeTest (added in #509) and BeforeSendCallbackTest weren't running in CI. 2. **Sprite subsystem coverage** (F2.26 — was 0% before this PR) - SpriteResolverTest (10 tests): cache-hit short-circuit, CDN-then- PokeAPI fallback, no-CDN-config skip, exception swallow, data-URI encoding, isCached, in-memory pokedex-id memoization. - SpriteMappingSyncServiceTest (5 tests): inserts new + applies SLUG_ALIASES; updates pokedex id when changed; skips unchanged rows; throws on non-200 CSV fetch; CSV parser rejects empty, malformed, and zero-id lines. - SpritesSyncMappingCommandTest (2 tests): success path with counts, RuntimeException -> Command::FAILURE. - SpriteProxyControllerTest (3 tests): 404 on resolver miss, 200 with image/png + content on cache hit, /api/sprites/slugs returns alphabetically-ordered JSON list. - PokemonSpriteMappingRepositoryTest (4 tests): findPokedexIdBySlug hit/miss, findAllSlugs ordering + empty. 3. **Source fix surfaced by the tests** - SpriteMappingSyncService.php: pass empty $escape to str_getcsv() — PHP 8.4+ deprecates the implicit default. Was hidden at 0% coverage; the new tests trigger the deprecation, which the project's failOnDeprecation=true would fail in CI. Test totals: 1129 unit (was 1112), 1012 functional (was 1005). --- phpunit.xml.dist | 2 + .../Sprite/SpriteMappingSyncService.php | 2 +- .../Command/SpritesSyncMappingCommandTest.php | 75 ++++++ .../PokemonSpriteMappingRepositoryTest.php | 73 ++++++ .../Functional/SpriteProxyControllerTest.php | 109 +++++++++ .../Sprite/SpriteMappingSyncServiceTest.php | 164 +++++++++++++ tests/Service/Sprite/SpriteResolverTest.php | 226 ++++++++++++++++++ 7 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 tests/Command/SpritesSyncMappingCommandTest.php create mode 100644 tests/Functional/PokemonSpriteMappingRepositoryTest.php create mode 100644 tests/Functional/SpriteProxyControllerTest.php create mode 100644 tests/Service/Sprite/SpriteMappingSyncServiceTest.php create mode 100644 tests/Service/Sprite/SpriteResolverTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9644186..7aac646 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -26,8 +26,10 @@ tests/Entity tests/Enum tests/EventListener + tests/Form tests/MessageHandler tests/Security + tests/Sentry tests/Service tests/Twig tests/Validator diff --git a/src/Service/Sprite/SpriteMappingSyncService.php b/src/Service/Sprite/SpriteMappingSyncService.php index aed5200..672ed63 100644 --- a/src/Service/Sprite/SpriteMappingSyncService.php +++ b/src/Service/Sprite/SpriteMappingSyncService.php @@ -117,7 +117,7 @@ private function parseCsv(string $csvContent): array continue; // skip header and empty lines } - $columns = str_getcsv($line); + $columns = str_getcsv($line, escape: ''); if (\count($columns) < 2) { continue; } diff --git a/tests/Command/SpritesSyncMappingCommandTest.php b/tests/Command/SpritesSyncMappingCommandTest.php new file mode 100644 index 0000000..0536bfe --- /dev/null +++ b/tests/Command/SpritesSyncMappingCommandTest.php @@ -0,0 +1,75 @@ +createStub(PokemonSpriteMappingRepository::class); + $repository->method('findAll')->willReturn([]); + + $entityManager = $this->createStub(EntityManagerInterface::class); + + $service = new SpriteMappingSyncService( + $repository, + $entityManager, + new MockHttpClient([new MockResponse($csv)]), + ); + + $tester = new CommandTester(new SpritesSyncMappingCommand($service)); + $tester->execute([]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + $display = $tester->getDisplay(); + // 1 CSV row + 2 SLUG_ALIASES = 3 inserted. + self::assertStringContainsString('3 inserted', $display); + self::assertStringContainsString('0 updated', $display); + self::assertStringContainsString('3 total', $display); + } + + public function testCommandReturnsFailureWhenSyncThrows(): void + { + $repository = $this->createStub(PokemonSpriteMappingRepository::class); + $repository->method('findAll')->willReturn([new PokemonSpriteMapping()]); + + $service = new SpriteMappingSyncService( + $repository, + $this->createStub(EntityManagerInterface::class), + new MockHttpClient([new MockResponse('', ['http_code' => 500])]), + ); + + $tester = new CommandTester(new SpritesSyncMappingCommand($service)); + $tester->execute([]); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('HTTP 500', $tester->getDisplay()); + } +} diff --git a/tests/Functional/PokemonSpriteMappingRepositoryTest.php b/tests/Functional/PokemonSpriteMappingRepositoryTest.php new file mode 100644 index 0000000..cf9fd46 --- /dev/null +++ b/tests/Functional/PokemonSpriteMappingRepositoryTest.php @@ -0,0 +1,73 @@ +persistMapping('pikachu', 25); + $this->persistMapping('bulbasaur', 1); + + self::assertSame(25, $this->getRepository()->findPokedexIdBySlug('pikachu')); + } + + public function testFindPokedexIdBySlugReturnsNullWhenSlugMissing(): void + { + self::assertNull($this->getRepository()->findPokedexIdBySlug('nonexistent-pokemon')); + } + + public function testFindAllSlugsReturnsListOrderedAlphabetically(): void + { + $this->persistMapping('pikachu', 25); + $this->persistMapping('arceus', 493); + $this->persistMapping('bulbasaur', 1); + + $slugs = $this->getRepository()->findAllSlugs(); + + self::assertSame(['arceus', 'bulbasaur', 'pikachu'], $slugs); + } + + public function testFindAllSlugsReturnsEmptyListWhenNoMappings(): void + { + self::assertSame([], $this->getRepository()->findAllSlugs()); + } + + private function getRepository(): PokemonSpriteMappingRepository + { + /** @var PokemonSpriteMappingRepository $repository */ + $repository = static::getContainer()->get(PokemonSpriteMappingRepository::class); + + return $repository; + } + + private function persistMapping(string $slug, int $pokedexId): void + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + $mapping = new PokemonSpriteMapping(); + $mapping->setSlug($slug); + $mapping->setPokedexId($pokedexId); + $em->persist($mapping); + $em->flush(); + } +} diff --git a/tests/Functional/SpriteProxyControllerTest.php b/tests/Functional/SpriteProxyControllerTest.php new file mode 100644 index 0000000..f94d016 --- /dev/null +++ b/tests/Functional/SpriteProxyControllerTest.php @@ -0,0 +1,109 @@ + sprite files written during the test, cleaned up in tearDown */ + private array $writtenSprites = []; + + protected function tearDown(): void + { + foreach ($this->writtenSprites as $path) { + if (file_exists($path)) { + unlink($path); + } + } + $this->writtenSprites = []; + + parent::tearDown(); + } + + public function testPokemonReturnsCachedSpriteWithImagePngHeaders(): void + { + $this->writeSpriteToCache(self::TEST_SLUG, 'fake-png-bytes'); + + $this->client->request('GET', '/sprites/pokemon/'.self::TEST_SLUG.'.png'); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'image/png'); + self::assertSame('fake-png-bytes', $this->client->getResponse()->getContent()); + // Cache-Control is intentionally not asserted: Symfony's SessionListener + // rewrites it to no-store/private for any request that touched the + // session, which WebTestCase does on every dispatch. + } + + public function testPokemonReturns404WhenSpriteCannotBeResolved(): void + { + // Slug not in mapping table and not in cache — resolver returns null. + $this->client->request('GET', '/sprites/pokemon/phantom-pokemon-no-mapping.png'); + + self::assertResponseStatusCodeSame(404); + } + + public function testSlugsReturnsJsonListOfMappedSlugs(): void + { + $this->persistMapping('pikachu', 25); + $this->persistMapping('bulbasaur', 1); + + $this->client->request('GET', '/api/sprites/slugs'); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/json'); + + /** @var list $slugs */ + $slugs = json_decode((string) $this->client->getResponse()->getContent(), true); + self::assertIsArray($slugs); + self::assertContains('pikachu', $slugs); + self::assertContains('bulbasaur', $slugs); + // Repository orders alphabetically. + self::assertSame('bulbasaur', $slugs[0]); + } + + private function writeSpriteToCache(string $slug, string $content): void + { + $projectDir = static::getContainer()->getParameter('kernel.project_dir'); + \assert(\is_string($projectDir)); + $cacheDir = $projectDir.'/var/cache/sprites'; + + if (!is_dir($cacheDir)) { + mkdir($cacheDir, 0o777, true); + } + + $path = $cacheDir.'/'.$slug.'.png'; + file_put_contents($path, $content); + + $this->writtenSprites[] = $path; + } + + private function persistMapping(string $slug, int $pokedexId): void + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + $mapping = new PokemonSpriteMapping(); + $mapping->setSlug($slug); + $mapping->setPokedexId($pokedexId); + $em->persist($mapping); + $em->flush(); + } +} diff --git a/tests/Service/Sprite/SpriteMappingSyncServiceTest.php b/tests/Service/Sprite/SpriteMappingSyncServiceTest.php new file mode 100644 index 0000000..0a2d2f8 --- /dev/null +++ b/tests/Service/Sprite/SpriteMappingSyncServiceTest.php @@ -0,0 +1,164 @@ +createStub(PokemonSpriteMappingRepository::class); + $repository->method('findAll')->willReturn([]); + + $persisted = []; + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { + $persisted[] = $entity; + }); + $entityManager->expects(self::once())->method('flush'); + + $service = new SpriteMappingSyncService($repository, $entityManager, $client); + $result = $service->sync(); + + // 2 from CSV + 2 SLUG_ALIASES (calyrex-shadow-rider, calyrex-ice-rider) + self::assertSame(4, $result['inserted']); + self::assertSame(0, $result['updated']); + self::assertSame(4, $result['total']); + self::assertCount(4, $persisted); + + $slugs = array_map(static fn (PokemonSpriteMapping $m): string => $m->getSlug(), $persisted); + self::assertContains('bulbasaur', $slugs); + self::assertContains('pikachu', $slugs); + self::assertContains('calyrex-shadow-rider', $slugs); + self::assertContains('calyrex-ice-rider', $slugs); + } + + public function testSyncUpdatesPokedexIdWhenChanged(): void + { + $csv = "id,identifier\n25,pikachu\n"; + + $existing = new PokemonSpriteMapping(); + $existing->setSlug('pikachu'); + $existing->setPokedexId(999); // wrong value, should be corrected to 25 + + $repository = $this->createStub(PokemonSpriteMappingRepository::class); + $repository->method('findAll')->willReturn([$existing]); + + $persisted = []; + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { + $persisted[] = $entity; + }); + $entityManager->expects(self::once())->method('flush'); + + $service = new SpriteMappingSyncService( + $repository, + $entityManager, + new MockHttpClient([new MockResponse($csv)]), + ); + + $result = $service->sync(); + + // pikachu updates the existing row; the two calyrex aliases are new inserts. + self::assertSame(1, $result['updated']); + self::assertSame(2, $result['inserted']); + self::assertSame(25, $existing->getPokedexId()); + self::assertCount(2, $persisted); + } + + public function testSyncSkipsRowsWithMatchingPokedexId(): void + { + $csv = "id,identifier\n25,pikachu\n"; + + $existing = new PokemonSpriteMapping(); + $existing->setSlug('pikachu'); + $existing->setPokedexId(25); // already matches CSV + + $repository = $this->createStub(PokemonSpriteMappingRepository::class); + $repository->method('findAll')->willReturn([$existing]); + + $service = new SpriteMappingSyncService( + $repository, + $this->createStub(EntityManagerInterface::class), + new MockHttpClient([new MockResponse($csv)]), + ); + + $result = $service->sync(); + + self::assertSame(0, $result['updated']); + } + + public function testSyncThrowsOnNon200CsvFetch(): void + { + $service = new SpriteMappingSyncService( + $this->createStub(PokemonSpriteMappingRepository::class), + $this->createStub(EntityManagerInterface::class), + new MockHttpClient([new MockResponse('', ['http_code' => 500])]), + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('HTTP 500'); + + $service->sync(); + } + + public function testParseCsvSkipsEmptyAndMalformedLines(): void + { + // Header + empty line + valid + 1-column malformed + zero pokedex id + valid. + $csv = "id,identifier\n" + ."\n" + ."1,bulbasaur\n" + ."only_one_column\n" + ."0,zero_dex\n" + ."25,pikachu\n"; + + $client = new MockHttpClient([new MockResponse($csv)]); + + $repository = $this->createStub(PokemonSpriteMappingRepository::class); + $repository->method('findAll')->willReturn([]); + + $persisted = []; + $entityManager = $this->createStub(EntityManagerInterface::class); + $entityManager->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { + $persisted[] = $entity; + }); + + $service = new SpriteMappingSyncService($repository, $entityManager, $client); + $result = $service->sync(); + + // Only bulbasaur + pikachu pass parsing + 2 alias inserts. + $slugs = array_map(static fn (PokemonSpriteMapping $m): string => $m->getSlug(), $persisted); + self::assertContains('bulbasaur', $slugs); + self::assertContains('pikachu', $slugs); + self::assertNotContains('only_one_column', $slugs); + self::assertNotContains('zero_dex', $slugs); + self::assertSame(4, $result['inserted']); + } +} diff --git a/tests/Service/Sprite/SpriteResolverTest.php b/tests/Service/Sprite/SpriteResolverTest.php new file mode 100644 index 0000000..2e6772b --- /dev/null +++ b/tests/Service/Sprite/SpriteResolverTest.php @@ -0,0 +1,226 @@ +tempDir = sys_get_temp_dir().'/sprite-resolver-test-'.uniqid('', true); + mkdir($this->tempDir, 0o777, true); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + public function testResolveReturnsCachedPathWhenFileExists(): void + { + // Pre-populate the cache. + $cacheDir = $this->tempDir.'/var/cache/sprites'; + mkdir($cacheDir, 0o777, true); + $cached = $cacheDir.'/pikachu.png'; + file_put_contents($cached, 'fake-png-bytes'); + + $resolver = new SpriteResolver( + $this->createStub(PokemonSpriteMappingRepository::class), + new MockHttpClient(), + $this->tempDir, + ); + + self::assertSame($cached, $resolver->resolve('pikachu')); + } + + public function testResolveFetchesFromCdnFirstThenCachesLocally(): void + { + $client = new MockHttpClient([new MockResponse('cdn-png-bytes')]); + + $resolver = new SpriteResolver( + $this->createStub(PokemonSpriteMappingRepository::class), + $client, + $this->tempDir, + 'https://cdn.example.com', + ); + + $path = $resolver->resolve('pikachu'); + + self::assertNotNull($path); + self::assertSame($this->tempDir.'/var/cache/sprites/pikachu.png', $path); + self::assertSame('cdn-png-bytes', file_get_contents($path)); + } + + public function testResolveFallsBackToPokeapiWhenCdnFailsAndPokedexIdKnown(): void + { + // Two HTTP calls: CDN 404, PokeAPI 200. + $client = new MockHttpClient([ + new MockResponse('', ['http_code' => 404]), + new MockResponse('pokeapi-png-bytes'), + ]); + + $repository = $this->createStub(PokemonSpriteMappingRepository::class); + $repository->method('findPokedexIdBySlug')->willReturn(25); + + $resolver = new SpriteResolver($repository, $client, $this->tempDir, 'https://cdn.example.com'); + + $path = $resolver->resolve('pikachu'); + + self::assertNotNull($path); + self::assertSame('pokeapi-png-bytes', file_get_contents($path)); + } + + public function testResolveReturnsNullWhenCdnFailsAndPokedexIdMissing(): void + { + $client = new MockHttpClient([ + new MockResponse('', ['http_code' => 404]), + ]); + + $repository = $this->createStub(PokemonSpriteMappingRepository::class); + $repository->method('findPokedexIdBySlug')->willReturn(null); + + $resolver = new SpriteResolver($repository, $client, $this->tempDir, 'https://cdn.example.com'); + + self::assertNull($resolver->resolve('phantom-pokemon')); + } + + public function testResolveSkipsCdnWhenNoCdnBaseUrlConfigured(): void + { + // No CDN URL → goes straight to PokeAPI; only one HTTP call expected. + $client = new MockHttpClient([new MockResponse('pokeapi-png-bytes')]); + + $repository = $this->createStub(PokemonSpriteMappingRepository::class); + $repository->method('findPokedexIdBySlug')->willReturn(25); + + $resolver = new SpriteResolver($repository, $client, $this->tempDir); + + $path = $resolver->resolve('pikachu'); + + self::assertNotNull($path); + self::assertSame('pokeapi-png-bytes', file_get_contents($path)); + } + + public function testResolveSwallowsHttpExceptionsAndReturnsNull(): void + { + $client = new MockHttpClient(static function (): MockResponse { + throw new \RuntimeException('network down'); + }); + + $repository = $this->createStub(PokemonSpriteMappingRepository::class); + $repository->method('findPokedexIdBySlug')->willReturn(null); + + $resolver = new SpriteResolver($repository, $client, $this->tempDir, 'https://cdn.example.com'); + + self::assertNull($resolver->resolve('pikachu')); + } + + public function testResolveAsDataUriEncodesContentAsBase64Png(): void + { + $client = new MockHttpClient([new MockResponse('png-bytes-here')]); + + $resolver = new SpriteResolver( + $this->createStub(PokemonSpriteMappingRepository::class), + $client, + $this->tempDir, + 'https://cdn.example.com', + ); + + $dataUri = $resolver->resolveAsDataUri('pikachu'); + + self::assertSame('data:image/png;base64,'.base64_encode('png-bytes-here'), $dataUri); + } + + public function testResolveAsDataUriReturnsNullWhenResolveFails(): void + { + $client = new MockHttpClient([new MockResponse('', ['http_code' => 404])]); + + $repository = $this->createStub(PokemonSpriteMappingRepository::class); + $repository->method('findPokedexIdBySlug')->willReturn(null); + + $resolver = new SpriteResolver($repository, $client, $this->tempDir, 'https://cdn.example.com'); + + self::assertNull($resolver->resolveAsDataUri('phantom-pokemon')); + } + + public function testIsCachedReturnsTrueOnlyWhenFilePresent(): void + { + $resolver = new SpriteResolver( + $this->createStub(PokemonSpriteMappingRepository::class), + new MockHttpClient(), + $this->tempDir, + ); + + self::assertFalse($resolver->isCached('pikachu')); + + $cacheDir = $this->tempDir.'/var/cache/sprites'; + mkdir($cacheDir, 0o777, true); + file_put_contents($cacheDir.'/pikachu.png', 'x'); + + self::assertTrue($resolver->isCached('pikachu')); + } + + public function testPokedexIdLookupIsMemoizedAcrossCalls(): void + { + // First call hits the repo, second uses the in-memory cache. + $client = new MockHttpClient([ + new MockResponse('', ['http_code' => 404]), + new MockResponse('first'), + new MockResponse('', ['http_code' => 404]), + new MockResponse('second'), + ]); + + $repository = $this->createMock(PokemonSpriteMappingRepository::class); + $repository->expects(self::once())->method('findPokedexIdBySlug')->with('pikachu')->willReturn(25); + + $resolver = new SpriteResolver($repository, $client, $this->tempDir, 'https://cdn.example.com'); + + // Two calls to the same slug — repository should only be hit once. + $resolver->resolve('pikachu'); + // Wipe the cached file so resolve has to fetch again, exercising the + // pokedex-id memoization branch. + unlink($this->tempDir.'/var/cache/sprites/pikachu.png'); + $resolver->resolve('pikachu'); + } + + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + foreach (scandir($path) ?: [] as $entry) { + if ('.' === $entry || '..' === $entry) { + continue; + } + $full = $path.'/'.$entry; + if (is_dir($full)) { + $this->removeDirectory($full); + } else { + unlink($full); + } + } + + rmdir($path); + } +} From ee2f0b2d7ef3177e2dbe4f9f2880997561eb97bb Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 20:17:30 +0200 Subject: [PATCH 08/12] test(api): backfill admin Page + MenuCategory controller coverage Two new functional test files extending the headline tests in the same namespace (which only covered auth + a couple of happy paths). AdminPageControllerCoverageTest (13 tests + 1 skip): - list with q, category, channel filters; pagination beyond page 1 - reorder accepts JSON id list, rejects non-array payload (400) - new page prefilled from channel + category query params - new page submit creates row + redirects with success flash - saveTranslation: existing locale update on welcome (en), new-locale creation on a freshly-persisted app-channel page (fr), rejects locale not in channel (404) - delete + duplicate reject invalid CSRF (flash danger) - duplicate clones every translation, sets isPublished=false, preserves noIndex + ogImage + menuCategory AdminMenuCategoryControllerCoverageTest (14 tests): - list with view=footer, view=garbage (falls back to menu), channel=app filter - reorder accepts category-id array, returns {ok:true} - new submit creates category + redirects to edit - new with view=footer flags category as footer - new with channel=app attaches the channel - edit GET renders form; edit POST saves + redirects with flash - saveTranslation: existing-locale update, new-locale creation on a fresh app-channel category, rejects locale not in channel (404) - delete with valid CSRF removes the row, with invalid CSRF rejects with danger flash and keeps the row Test totals: 1040 functional (was 1012), 1129 unit unchanged. Both controllers were 28-46 % covered before this PR. --- ...dminMenuCategoryControllerCoverageTest.php | 302 ++++++++++++++++ .../AdminPageControllerCoverageTest.php | 338 ++++++++++++++++++ 2 files changed, 640 insertions(+) create mode 100644 tests/Functional/AdminMenuCategoryControllerCoverageTest.php create mode 100644 tests/Functional/AdminPageControllerCoverageTest.php diff --git a/tests/Functional/AdminMenuCategoryControllerCoverageTest.php b/tests/Functional/AdminMenuCategoryControllerCoverageTest.php new file mode 100644 index 0000000..09b0023 --- /dev/null +++ b/tests/Functional/AdminMenuCategoryControllerCoverageTest.php @@ -0,0 +1,302 @@ +loginAs('admin@example.com'); + + $this->client->request('GET', '/admin/menu-categories?view=footer'); + + self::assertResponseIsSuccessful(); + } + + public function testListFallsBackToMenuViewOnUnknownView(): void + { + $this->loginAs('admin@example.com'); + + $this->client->request('GET', '/admin/menu-categories?view=garbage'); + + self::assertResponseIsSuccessful(); + } + + public function testListAcceptsChannelFilter(): void + { + $this->loginAs('admin@example.com'); + + $this->client->request('GET', '/admin/menu-categories?channel=app'); + + self::assertResponseIsSuccessful(); + } + + public function testReorderAcceptsCategoryIdsArray(): void + { + $this->loginAs('admin@example.com'); + + $news = $this->getCategoryByName('News'); + $rules = $this->getCategoryByName('Rules & Info'); + + $this->client->request( + 'POST', + '/admin/menu-categories/reorder', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode([$rules->getId(), $news->getId()]), + ); + + self::assertResponseIsSuccessful(); + self::assertJsonStringEqualsJsonString( + '{"ok":true}', + (string) $this->client->getResponse()->getContent(), + ); + } + + public function testNewSubmitCreatesCategoryAndRedirectsToEdit(): void + { + $this->loginAs('admin@example.com'); + + $crawler = $this->client->request('GET', '/admin/menu-categories/new'); + $form = $crawler->filter('form')->first()->form(); + + $this->client->submit($form); + + self::assertResponseRedirects(); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + + // Verify a row was actually inserted. + $em = $this->getEntityManager(); + $em->clear(); + self::assertGreaterThan(2, \count($em->getRepository(MenuCategory::class)->findAll())); + } + + public function testNewWithFooterViewQueryParamFlagsCategoryAsFooter(): void + { + $this->loginAs('admin@example.com'); + + $crawler = $this->client->request('GET', '/admin/menu-categories/new?view=footer'); + self::assertResponseIsSuccessful(); + + // Submit the form — the controller should mark the new category as a + // footer category because of the view query param. + $form = $crawler->filter('form')->first()->form(); + $this->client->submit($form); + + self::assertResponseRedirects(); + $this->client->followRedirect(); + + $em = $this->getEntityManager(); + $em->clear(); + $footerCategories = $em->getRepository(MenuCategory::class)->findBy(['isFooter' => true]); + self::assertNotEmpty($footerCategories); + } + + public function testNewWithChannelQueryParamAttachesCategoryToChannel(): void + { + $this->loginAs('admin@example.com'); + + $this->client->request('GET', '/admin/menu-categories/new?channel=app'); + + self::assertResponseIsSuccessful(); + } + + public function testEditGetRendersFormForExistingCategory(): void + { + $this->loginAs('admin@example.com'); + + $category = $this->getCategoryByName('News'); + + $this->client->request('GET', '/admin/menu-categories/'.$category->getId()); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form'); + } + + public function testEditSaveUpdatesCategoryAndRedirectsBackToEdit(): void + { + $this->loginAs('admin@example.com'); + + $category = $this->getCategoryByName('News'); + + $crawler = $this->client->request('GET', '/admin/menu-categories/'.$category->getId()); + // The first
on the edit page is the channel/settings form + // (translation forms come after it). + $form = $crawler->filter('form')->first()->form(); + + $this->client->submit($form); + + self::assertResponseRedirects('/admin/menu-categories/'.$category->getId()); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } + + public function testSaveTranslationCreatesNewLocaleOnMultiLocaleChannel(): void + { + $this->loginAs('admin@example.com'); + + // The fixture's existing categories live on the content channel + // (en-only). Persist a fresh category on the "app" channel to exercise + // the multi-locale translation flow. + $category = $this->createAppChannelCategory(); + + $crawler = $this->client->request('GET', '/admin/menu-categories/'.$category->getId()); + $action = \sprintf('/admin/menu-categories/%d/translation/fr', $category->getId()); + $frForm = $crawler->filter(\sprintf('form[action$="%s"]', $action))->form(); + $frForm['menu_category_translation_form[name]']->setValue('Catégorie FR'); + + $this->client->submit($frForm); + + self::assertResponseRedirects(); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } + + public function testSaveTranslationUpdatesExistingLocale(): void + { + $this->loginAs('admin@example.com'); + + $category = $this->getCategoryByName('News'); + + $crawler = $this->client->request('GET', '/admin/menu-categories/'.$category->getId()); + $action = \sprintf('/admin/menu-categories/%d/translation/en', $category->getId()); + $enForm = $crawler->filter(\sprintf('form[action$="%s"]', $action))->form(); + $enForm['menu_category_translation_form[name]']->setValue('Updated News'); + + $this->client->submit($enForm); + + self::assertResponseRedirects(); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } + + public function testSaveTranslationRejectsLocaleNotInChannel(): void + { + $this->loginAs('admin@example.com'); + + $category = $this->getCategoryByName('News'); + + $this->client->request('POST', \sprintf('/admin/menu-categories/%d/translation/de', $category->getId()), [ + 'menu_category_translation_form' => ['name' => 'irrelevant'], + ]); + + self::assertResponseStatusCodeSame(404); + } + + public function testDeleteWithValidCsrfRemovesCategory(): void + { + $this->loginAs('admin@example.com'); + + $category = $this->createAppChannelCategory(); + + $crawler = $this->client->request('GET', '/admin/menu-categories/'.$category->getId()); + // The delete form is the last on the edit page. + $deleteForm = $crawler->filter(\sprintf('form[action$="/%d/delete"]', $category->getId()))->first(); + $token = $deleteForm->filter('input[name="_token"]')->attr('value'); + + $this->client->request('POST', '/admin/menu-categories/'.$category->getId().'/delete', [ + '_token' => $token, + ]); + + self::assertResponseRedirects('/admin/menu-categories'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + + $em = $this->getEntityManager(); + $em->clear(); + self::assertNull($em->getRepository(MenuCategory::class)->find($category->getId())); + } + + public function testDeleteRejectsInvalidCsrfAndKeepsCategory(): void + { + $this->loginAs('admin@example.com'); + + $category = $this->getCategoryByName('News'); + + $this->client->request('POST', '/admin/menu-categories/'.$category->getId().'/delete', [ + '_token' => 'wrong', + ]); + + self::assertResponseRedirects('/admin/menu-categories/'.$category->getId()); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + + $em = $this->getEntityManager(); + $em->clear(); + self::assertNotNull($em->getRepository(MenuCategory::class)->find($category->getId())); + } + + private function getEntityManager(): EntityManagerInterface + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + return $em; + } + + private function getCategoryByName(string $name): MenuCategory + { + $em = $this->getEntityManager(); + /** @var MenuCategory|null $category */ + $category = $em->getRepository(MenuCategory::class)->createQueryBuilder('c') + ->join('c.translations', 't') + ->where('t.name = :name') + ->setParameter('name', $name) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + \assert($category instanceof MenuCategory); + + return $category; + } + + private function createAppChannelCategory(): MenuCategory + { + $em = $this->getEntityManager(); + /** @var Channel $appChannel */ + $appChannel = $em->getRepository(Channel::class)->findOneBy(['code' => 'app']); + \assert($appChannel instanceof Channel); + + $category = new MenuCategory(); + $category->setChannel($appChannel); + $category->setPosition(99); + + $en = new MenuCategoryTranslation(); + $en->setMenuCategory($category); + $en->setLocale('en'); + $en->setName('Coverage Cat'); + $category->addTranslation($en); + + $em->persist($category); + $em->persist($en); + $em->flush(); + + return $category; + } +} diff --git a/tests/Functional/AdminPageControllerCoverageTest.php b/tests/Functional/AdminPageControllerCoverageTest.php new file mode 100644 index 0000000..df9591c --- /dev/null +++ b/tests/Functional/AdminPageControllerCoverageTest.php @@ -0,0 +1,338 @@ +loginAs('admin@example.com'); + + $this->client->request('GET', '/admin/pages?q=welcome'); + + self::assertResponseIsSuccessful(); + } + + public function testListAcceptsCategoryFilter(): void + { + $this->loginAs('admin@example.com'); + + $category = $this->getNewsCategory(); + + $this->client->request('GET', '/admin/pages?category='.$category->getId()); + + self::assertResponseIsSuccessful(); + } + + public function testListAcceptsChannelFilter(): void + { + $this->loginAs('admin@example.com'); + + $this->client->request('GET', '/admin/pages?channel=app'); + + // The "app" channel has no menu categories — should still render an + // empty-state success response (no categories branch). + self::assertResponseIsSuccessful(); + } + + public function testListPaginationBeyondPageOneDisablesSortable(): void + { + $this->loginAs('admin@example.com'); + + $this->client->request('GET', '/admin/pages?page=2'); + + self::assertResponseIsSuccessful(); + } + + public function testReorderAcceptsValidJsonPayload(): void + { + $this->loginAs('admin@example.com'); + + $welcome = $this->getPageBySlug('welcome'); + + $this->client->request( + 'POST', + '/admin/pages/reorder', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode([$welcome->getId()]), + ); + + self::assertResponseIsSuccessful(); + self::assertJsonStringEqualsJsonString( + '{"ok":true}', + (string) $this->client->getResponse()->getContent(), + ); + } + + public function testReorderRejectsNonArrayPayload(): void + { + $this->loginAs('admin@example.com'); + + $this->client->request( + 'POST', + '/admin/pages/reorder', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + '"not-an-array"', + ); + + self::assertResponseStatusCodeSame(400); + } + + public function testNewPagePrefilledFromChannelAndCategoryQueryParams(): void + { + $this->loginAs('admin@example.com'); + + $category = $this->getNewsCategory(); + + $this->client->request('GET', \sprintf('/admin/pages/new?channel=content&category=%d', $category->getId())); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form'); + } + + public function testNewPageSubmitCreatesPageAndRedirects(): void + { + $this->loginAs('admin@example.com'); + + $crawler = $this->client->request('GET', '/admin/pages/new'); + // The new-page form has a single tag — fetch it directly. + $form = $crawler->filter('form')->first()->form(); + + $titleField = $crawler->filter('input[name$="[title]"]')->first(); + self::assertGreaterThan(0, $titleField->count(), 'New page form should expose a title field.'); + $titleName = $titleField->attr('name') ?? ''; + $form[$titleName]->setValue('Test Coverage Page'); + + // The slug field is required and auto-populated client-side; set it + // explicitly since the test runner doesn't execute JS. + $form[str_replace('[title]', '[slug]', $titleName)]->setValue('test-coverage-page'); + + $this->client->submit($form); + + self::assertResponseRedirects(); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } + + public function testSaveTranslationUpdatesExistingLocale(): void + { + $this->loginAs('admin@example.com'); + + $page = $this->getPageBySlug('welcome'); + + // Pull the EN translation form (welcome lives on the content channel + // which has only ['en']). Filter by the form's action URL since + // createNamed namespaces the field-name prefix but not the id. + $crawler = $this->client->request('GET', '/admin/pages/'.$page->getId()); + $action = \sprintf('/admin/pages/%d/translation/en', $page->getId()); + $enForm = $crawler->filter(\sprintf('form[action$="%s"]', $action))->form(); + $enForm['page_translation_form_en[title]']->setValue('Welcome (updated)'); + + $this->client->submit($enForm); + + self::assertResponseRedirects(); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } + + public function testSaveTranslationCreatesNewLocaleOnMultiLocaleChannel(): void + { + $this->loginAs('admin@example.com'); + + // The "app" channel has ['en', 'fr']; persist a fresh page on it so + // the FR translation form renders and we can exercise the + // "create new translation" branch in saveTranslation. + $page = $this->createAppChannelPage('coverage-fr-target'); + + $crawler = $this->client->request('GET', '/admin/pages/'.$page->getId()); + $action = \sprintf('/admin/pages/%d/translation/fr', $page->getId()); + $frForm = $crawler->filter(\sprintf('form[action$="%s"]', $action))->form(); + $frForm['page_translation_form_fr[title]']->setValue('Cible FR'); + $frForm['page_translation_form_fr[content]']->setValue('

Contenu FR

'); + + $this->client->submit($frForm); + + self::assertResponseRedirects(); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } + + public function testSaveTranslationRejectsLocaleNotInChannel(): void + { + $this->loginAs('admin@example.com'); + + $page = $this->getPageBySlug('welcome'); + + $this->client->request('POST', \sprintf('/admin/pages/%d/translation/de', $page->getId()), [ + 'page_translation_form_de' => ['title' => 'irrelevant'], + ]); + + self::assertResponseStatusCodeSame(404); + } + + public function testDeleteRejectsInvalidCsrfAndKeepsPage(): void + { + $this->loginAs('admin@example.com'); + + $page = $this->getPageBySlug('welcome'); + + $this->client->request('POST', '/admin/pages/'.$page->getId().'/delete', [ + '_token' => 'wrong', + ]); + + self::assertResponseRedirects('/admin/pages/'.$page->getId()); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + + $em = $this->getEntityManager(); + $em->clear(); + $reloaded = $em->getRepository(Page::class)->find($page->getId()); + self::assertInstanceOf(Page::class, $reloaded); + self::assertNull($reloaded->getDeletedAt()); + } + + public function testDuplicateRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + + $page = $this->getPageBySlug('welcome'); + + $this->client->request('POST', '/admin/pages/'.$page->getId().'/duplicate', [ + '_token' => 'wrong', + ]); + + self::assertResponseRedirects('/admin/pages'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testDuplicateClonesPageAndAllTranslations(): void + { + $this->loginAs('admin@example.com'); + + $page = $this->getPageBySlug('welcome'); + $originalTranslations = $page->getTranslations()->count(); + + $this->client->request('GET', '/admin/pages'); + $crawler = $this->client->getCrawler(); + $duplicateForm = $crawler->filter('form[action$="/duplicate"]')->first(); + if (0 === $duplicateForm->count()) { + self::markTestSkipped('No duplicate form rendered.'); + } + $token = $duplicateForm->filter('input[name="_token"]')->attr('value'); + + $this->client->request('POST', '/admin/pages/'.$page->getId().'/duplicate', [ + '_token' => $token, + ]); + + self::assertResponseRedirects(); + $this->client->followRedirect(); + + // Verify the clone exists and copied every translation. + $em = $this->getEntityManager(); + $em->clear(); + $copies = $em->getRepository(Page::class)->findBy(['slug' => $page->getSlug().'-copy-%']); + // findBy doesn't accept LIKE; use a query that checks for the prefix. + $qb = $em->createQueryBuilder(); + $qb->select('p')->from(Page::class, 'p')->where('p.slug LIKE :slug') + ->setParameter('slug', $page->getSlug().'-copy-%'); + /** @var list $copies */ + $copies = $qb->getQuery()->getResult(); + + self::assertCount(1, $copies); + $copy = $copies[0]; + self::assertSame($originalTranslations, $copy->getTranslations()->count()); + foreach ($copy->getTranslations() as $translation) { + self::assertInstanceOf(PageTranslation::class, $translation); + self::assertStringEndsWith('(copy)', $translation->getTitle()); + } + self::assertFalse($copy->isPublished()); + } + + private function createAppChannelPage(string $slug): Page + { + $em = $this->getEntityManager(); + /** @var \App\Entity\Channel $appChannel */ + $appChannel = $em->getRepository(\App\Entity\Channel::class)->findOneBy(['code' => 'app']); + \assert($appChannel instanceof \App\Entity\Channel); + + $page = new Page(); + $page->setChannel($appChannel); + $page->setSlug($slug); + $page->setIsPublished(false); + + $en = new PageTranslation(); + $en->setLocale('en'); + $en->setTitle('Coverage Target'); + $en->setPage($page); + $page->addTranslation($en); + + $em->persist($page); + $em->persist($en); + $em->flush(); + + return $page; + } + + private function getEntityManager(): EntityManagerInterface + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + return $em; + } + + private function getPageBySlug(string $slug): Page + { + $page = $this->getEntityManager()->getRepository(Page::class)->findOneBy(['slug' => $slug]); + \assert($page instanceof Page); + + return $page; + } + + private function getNewsCategory(): MenuCategory + { + $em = $this->getEntityManager(); + /** @var MenuCategory|null $category */ + $category = $em->getRepository(MenuCategory::class)->createQueryBuilder('c') + ->join('c.translations', 't') + ->where('t.name = :name') + ->setParameter('name', 'News') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + \assert($category instanceof MenuCategory); + + return $category; + } +} From 41547d12a07ee557caf621804167e2dac77c14e6 Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 22:24:18 +0200 Subject: [PATCH 09/12] test(deck): cover PdfDecklistGenerator + PdfLabelGenerator (319 LOC at 0%) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The naive "PDF generation is hard to test" framing missed the obvious seam: both generators run a Twig render to produce HTML, then call Dompdf on it. Stubbing Twig::render with willReturnCallback to capture the template context lets us assert on every data-prep branch (grouping, sorting, font-size auto-fit, name localization, set-symbol fetch, sprite embedding) without booting the real templates. Dompdf still runs against the captured-then-passed-through tiny HTML so renderPdf() is exercised too; on a 6-line body it's effectively free (~5ms per test). PdfDecklistGeneratorTest (15 tests): - generateAnonymous / generatePersonal happy paths produce %PDF-* output - mode flag, playerName, trigram, gravatar URI propagation - gravatar 404 keeps gravatarDataUri null (graceful degradation) - deck without current version still renders with empty groups - card grouping + qty-desc-then-name-asc sort across all sections - trainer fallback bucket when subtype is null - font size shrinks under load, clamps to 9pt ceiling for tiny decks - localized card name preferred when deck has a single language - English subtitle added when display name differs from English name - set-symbol .png fetch with content-type normalization - HTTP exception during symbol fetch swallowed gracefully - TcgdexCardRepository fallback path with in-memory caching - multi-language deck falls back to English locale PdfLabelGeneratorTest (9 tests): - generate produces %PDF-* using the simple-label template - QR code data URI + base URL extraction (scheme + host) - sprite resolution + slug title-case (incl. dash → space conversion) - sprite resolver returning null skips that sprite - generateFoldable uses the foldable template - foldable handles deck without current version (default 6pt size) - foldable card grouping by subtype with fixed section ordering - foldable font size shrinks for large decks, clamps to 7pt ceiling Test totals: 1153 unit (was 1129). --- .../Label/PdfDecklistGeneratorTest.php | 415 ++++++++++++++++++ tests/Service/Label/PdfLabelGeneratorTest.php | 237 ++++++++++ 2 files changed, 652 insertions(+) create mode 100644 tests/Service/Label/PdfDecklistGeneratorTest.php create mode 100644 tests/Service/Label/PdfLabelGeneratorTest.php diff --git a/tests/Service/Label/PdfDecklistGeneratorTest.php b/tests/Service/Label/PdfDecklistGeneratorTest.php new file mode 100644 index 0000000..8a52b79 --- /dev/null +++ b/tests/Service/Label/PdfDecklistGeneratorTest.php @@ -0,0 +1,415 @@ +|null captured by the most recent Twig::render call */ + private ?array $capturedTemplateData = null; + + public function testGenerateAnonymousReturnsValidPdfBytes(): void + { + $deck = $this->buildDeck([$this->buildCard('pokemon', 'Pikachu', 4, 'LOT', '79')]); + + $output = $this->buildGenerator()->generateAnonymous($deck); + + self::assertStringStartsWith('%PDF-', $output); + self::assertGreaterThan(500, \strlen($output)); + + // The mode flag tells the template to render anonymous fields. + self::assertSame('anonymous', $this->capturedTemplateData['mode']); + self::assertNull($this->capturedTemplateData['playerName']); + self::assertNull($this->capturedTemplateData['trigram']); + } + + public function testGeneratePersonalIncludesPlayerInfoTrigramAndLabels(): void + { + $deck = $this->buildDeck([$this->buildCard('pokemon', 'Pikachu', 4, 'LOT', '79')]); + + $user = $this->createStub(User::class); + $user->method('getFirstName')->willReturn('Ash'); + $user->method('getLastName')->willReturn('Ketchum'); + $user->method('getEmail')->willReturn('ash@example.com'); + $user->method('getPlayerId')->willReturn('1234567'); + $user->method('getYearOfBirth')->willReturn(1996); + + // Gravatar fetch should succeed. + $http = new MockHttpClient([new MockResponse('jpeg-bytes', ['response_headers' => ['content-type' => 'image/jpeg']])]); + + $output = $this->buildGenerator(httpClient: $http)->generatePersonal($deck, $user); + + self::assertStringStartsWith('%PDF-', $output); + self::assertSame('personal', $this->capturedTemplateData['mode']); + self::assertSame('Ash Ketchum', $this->capturedTemplateData['playerName']); + self::assertSame('1234567', $this->capturedTemplateData['playerId']); + self::assertSame(1996, $this->capturedTemplateData['yearOfBirth']); + // Trigram = first letter of firstName + first letter of lastName + last letter of lastName + self::assertSame('AKM', $this->capturedTemplateData['trigram']); + // Gravatar data URI was embedded. + self::assertNotNull($this->capturedTemplateData['gravatarDataUri']); + self::assertStringStartsWith('data:image/', (string) $this->capturedTemplateData['gravatarDataUri']); + } + + public function testGeneratePersonalGravatarFailureKeepsNull(): void + { + $deck = $this->buildDeck([$this->buildCard('pokemon', 'Pikachu', 1, 'LOT', '79')]); + + $user = $this->createStub(User::class); + $user->method('getFirstName')->willReturn('Ash'); + $user->method('getLastName')->willReturn('Ketchum'); + $user->method('getEmail')->willReturn('ash@example.com'); + + // Gravatar 404. + $http = new MockHttpClient([new MockResponse('', ['http_code' => 404])]); + + $this->buildGenerator(httpClient: $http)->generatePersonal($deck, $user); + + self::assertNull($this->capturedTemplateData['gravatarDataUri']); + } + + public function testGenerateHandlesDeckWithoutCurrentVersion(): void + { + $deck = $this->createStub(Deck::class); + $deck->method('getCurrentVersion')->willReturn(null); + $deck->method('getFormat')->willReturn(DeckFormat::Expanded); + $deck->method('getLanguages')->willReturn([]); + + $this->buildGenerator()->generateAnonymous($deck); + + self::assertSame([], $this->capturedTemplateData['pokemonRows']); + self::assertSame(0, $this->capturedTemplateData['pokemonCount']); + self::assertSame(0, $this->capturedTemplateData['trainerCount']); + self::assertSame(0, $this->capturedTemplateData['energyCount']); + } + + public function testCardsAreGroupedAndSortedByQuantityThenName(): void + { + $deck = $this->buildDeck([ + $this->buildCard('pokemon', 'Charizard', 2, 'LOT', '20'), + $this->buildCard('pokemon', 'Bulbasaur', 4, 'LOT', '10'), + $this->buildCard('pokemon', 'Aerodactyl', 4, 'LOT', '5'), + $this->buildCard('trainer', 'Boss', 2, 'LOT', '50', trainerSubtype: 'supporter'), + $this->buildCard('trainer', 'Switch', 4, 'LOT', '60', trainerSubtype: 'item'), + $this->buildCard('energy', 'Lightning', 6, 'LOT', '70'), + ]); + + $this->buildGenerator()->generateAnonymous($deck); + + // Pokemon: qty desc (4, 4, 2), then name asc → Aerodactyl, Bulbasaur, Charizard + $pokemonNames = array_column($this->capturedTemplateData['pokemonRows'], 'name'); + self::assertSame(['Aerodactyl', 'Bulbasaur', 'Charizard'], $pokemonNames); + + // Trainer sections come back ordered: supporter, item, ... (per controller's fixed order) + $trainerSubtypes = array_column($this->capturedTemplateData['trainerSections'], 'subtype'); + self::assertSame(['supporter', 'item'], $trainerSubtypes); + + self::assertSame(10, $this->capturedTemplateData['pokemonCount']); + self::assertSame(6, $this->capturedTemplateData['trainerCount']); + self::assertSame(6, $this->capturedTemplateData['energyCount']); + } + + public function testTrainerCardWithoutSubtypeFallsBackToTrainerBucket(): void + { + $deck = $this->buildDeck([ + $this->buildCard('trainer', 'Mystery Trainer', 1, 'LOT', '80', trainerSubtype: null), + ]); + + $this->buildGenerator()->generateAnonymous($deck); + + $trainerSubtypes = array_column($this->capturedTemplateData['trainerSections'], 'subtype'); + self::assertContains('trainer', $trainerSubtypes); + } + + public function testFontSizeShrinksForLargeCardLists(): void + { + $cards = []; + // 80 distinct cards comfortably forces the auto-fit logic below the + // 9pt ceiling on an A4 page. + for ($i = 1; $i <= 80; ++$i) { + $cards[] = $this->buildCard('pokemon', 'Card '.$i, 1, 'LOT', (string) $i); + } + $deck = $this->buildDeck($cards); + + $this->buildGenerator()->generateAnonymous($deck); + + $fontSize = $this->capturedTemplateData['fontSize']; + self::assertGreaterThanOrEqual(6.0, $fontSize); + self::assertLessThan(9.0, $fontSize); + } + + public function testFontSizeStaysWithinBoundsForSmallDeck(): void + { + $deck = $this->buildDeck([$this->buildCard('pokemon', 'Single', 1, 'LOT', '1')]); + + $this->buildGenerator()->generateAnonymous($deck); + + $fontSize = $this->capturedTemplateData['fontSize']; + // Small decks clamp to the 9.0 ceiling per computeFontSize. + self::assertSame(9.0, $fontSize); + } + + public function testLocalizedCardNameIsPreferredWhenLocaleMatchesAndTcgdexCardKnown(): void + { + $tcgdexCard = $this->createStub(TcgdexCard::class); + $tcgdexCard->method('getName')->willReturn(['en' => 'Pikachu', 'fr' => 'Pikachu (FR)']); + $tcgdexCard->method('getNameEn')->willReturn('Pikachu'); + + $card = $this->buildCard( + 'pokemon', 'Pikachu', 1, 'LOT', '79', + cardPrinting: $this->buildCardPrintingWithTcgdex($tcgdexCard), + ); + + $deck = $this->buildDeck([$card], languages: ['fr']); + + $this->buildGenerator()->generateAnonymous($deck); + + $row = $this->capturedTemplateData['pokemonRows'][0]; + self::assertSame('Pikachu (FR)', $row['name']); + } + + public function testEnglishNameSubtitleAddedWhenDisplayNameDiffers(): void + { + $tcgdexCard = $this->createStub(TcgdexCard::class); + $tcgdexCard->method('getName')->willReturn(['en' => 'Pikachu', 'fr' => 'Pikachu (FR)']); + $tcgdexCard->method('getNameEn')->willReturn('Pikachu'); + + $card = $this->buildCard( + 'pokemon', 'Pikachu', 1, 'LOT', '79', + cardPrinting: $this->buildCardPrintingWithTcgdex($tcgdexCard), + ); + + $deck = $this->buildDeck([$card], languages: ['fr']); + + $this->buildGenerator()->generateAnonymous($deck); + + self::assertSame('Pikachu', $this->capturedTemplateData['pokemonRows'][0]['englishName']); + } + + public function testSetSymbolFetchEmbedsBase64DataUri(): void + { + $set = $this->createStub(TcgdexSet::class); + $set->method('getSymbolUrl')->willReturn('https://assets.tcgdex.net/symbols/lot'); + $set->method('getPtcgCode')->willReturn('LOT'); + + $tcgdexCard = $this->createStub(TcgdexCard::class); + $tcgdexCard->method('getName')->willReturn(['en' => 'Pikachu']); + $tcgdexCard->method('getNameEn')->willReturn('Pikachu'); + $tcgdexCard->method('getSet')->willReturn($set); + + $card = $this->buildCard( + 'pokemon', 'Pikachu', 1, 'LOT', '79', + cardPrinting: $this->buildCardPrintingWithTcgdex($tcgdexCard), + ); + + $deck = $this->buildDeck([$card]); + + // The fetch should append .png to the symbol URL. + $http = new MockHttpClient([new MockResponse('png-bytes', ['response_headers' => ['content-type' => 'image/png']])]); + + $this->buildGenerator(httpClient: $http)->generateAnonymous($deck); + + $row = $this->capturedTemplateData['pokemonRows'][0]; + self::assertNotNull($row['symbolDataUri']); + self::assertStringStartsWith('data:image/png;base64,', (string) $row['symbolDataUri']); + self::assertSame('LOT', $row['setCode']); + } + + public function testSetSymbolFetchSwallowsHttpExceptionGracefully(): void + { + $set = $this->createStub(TcgdexSet::class); + $set->method('getSymbolUrl')->willReturn('https://assets.tcgdex.net/symbols/lot'); + + $tcgdexCard = $this->createStub(TcgdexCard::class); + $tcgdexCard->method('getName')->willReturn(['en' => 'Pikachu']); + $tcgdexCard->method('getNameEn')->willReturn('Pikachu'); + $tcgdexCard->method('getSet')->willReturn($set); + + $card = $this->buildCard( + 'pokemon', 'Pikachu', 1, 'LOT', '79', + cardPrinting: $this->buildCardPrintingWithTcgdex($tcgdexCard), + ); + + $deck = $this->buildDeck([$card]); + + $http = new MockHttpClient(static function (): MockResponse { + throw new \RuntimeException('network down'); + }); + + $this->buildGenerator(httpClient: $http)->generateAnonymous($deck); + + // Symbol unfetched → text-only fallback (null URI). + self::assertNull($this->capturedTemplateData['pokemonRows'][0]['symbolDataUri']); + } + + public function testTcgdexCardLookupViaRepositoryFallback(): void + { + $tcgdexCard = $this->createStub(TcgdexCard::class); + $tcgdexCard->method('getName')->willReturn(['en' => 'Pikachu']); + $tcgdexCard->method('getNameEn')->willReturn('Pikachu'); + + $repository = $this->createMock(TcgdexCardRepository::class); + $repository->expects(self::once())->method('find')->with('lot-79')->willReturn($tcgdexCard); + + $printing = $this->createStub(\App\Entity\CardPrinting::class); + $printing->method('getTcgdexCard')->willReturn(null); + $printing->method('getTcgdexId')->willReturn('lot-79'); + + $card = $this->buildCard('pokemon', 'Pikachu', 1, 'LOT', '79', cardPrinting: $printing); + $deck = $this->buildDeck([$card]); + + $this->buildGenerator(repository: $repository)->generateAnonymous($deck); + + // Lookup hit was verified via `expects(self::once())`. + self::assertNotNull($this->capturedTemplateData['pokemonRows'][0]); + } + + public function testTcgdexCardLookupCachesResultsAcrossCalls(): void + { + // Two cards sharing the same tcgdexId — repository should be called once. + $tcgdexCard = $this->createStub(TcgdexCard::class); + $tcgdexCard->method('getName')->willReturn(['en' => 'Pikachu']); + $tcgdexCard->method('getNameEn')->willReturn('Pikachu'); + + $repository = $this->createMock(TcgdexCardRepository::class); + $repository->expects(self::once())->method('find')->willReturn($tcgdexCard); + + $printing = $this->createStub(\App\Entity\CardPrinting::class); + $printing->method('getTcgdexCard')->willReturn(null); + $printing->method('getTcgdexId')->willReturn('lot-79'); + + $cards = [ + $this->buildCard('pokemon', 'Pikachu', 1, 'LOT', '79', cardPrinting: $printing), + $this->buildCard('pokemon', 'Pikachu B', 1, 'LOT', '80', cardPrinting: $printing), + ]; + + $this->buildGenerator(repository: $repository)->generateAnonymous($this->buildDeck($cards)); + } + + public function testLocaleResolutionFallsBackToEnglishOnMultiLanguage(): void + { + $tcgdexCard = $this->createStub(TcgdexCard::class); + $tcgdexCard->method('getName')->willReturn(['en' => 'Pikachu', 'fr' => 'Pikachu (FR)']); + $tcgdexCard->method('getNameEn')->willReturn('Pikachu'); + + $card = $this->buildCard( + 'pokemon', 'Pikachu', 1, 'LOT', '79', + cardPrinting: $this->buildCardPrintingWithTcgdex($tcgdexCard), + ); + + // Two languages → falls back to English (display name unchanged). + $deck = $this->buildDeck([$card], languages: ['en', 'fr']); + + $this->buildGenerator()->generateAnonymous($deck); + + self::assertSame('Pikachu', $this->capturedTemplateData['pokemonRows'][0]['name']); + } + + private function buildGenerator( + ?MockHttpClient $httpClient = null, + ?TcgdexCardRepository $repository = null, + ): PdfDecklistGenerator { + $twig = $this->createMock(Environment::class); + $twig->method('render')->willReturnCallback(function (string $template, array $context = []): string { + $this->capturedTemplateData = $context; + + return '

tiny pdf body

'; + }); + + $translator = $this->createStub(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(static fn (string $key): string => $key); + + return new PdfDecklistGenerator( + $twig, + $httpClient ?? new MockHttpClient([]), + $translator, + $repository ?? $this->createStub(TcgdexCardRepository::class), + ); + } + + /** + * @param list $cards + * @param list $languages + */ + private function buildDeck(array $cards, array $languages = ['en']): Deck + { + $version = $this->createStub(DeckVersion::class); + $version->method('getCards')->willReturn(new ArrayCollection($cards)); + + $deck = $this->createStub(Deck::class); + $deck->method('getCurrentVersion')->willReturn($version); + $deck->method('getFormat')->willReturn(DeckFormat::Expanded); + $deck->method('getLanguages')->willReturn($languages); + + return $deck; + } + + private function buildCard( + string $cardType, + string $name, + int $quantity, + string $setCode, + string $cardNumber, + ?string $trainerSubtype = null, + ?\App\Entity\CardPrinting $cardPrinting = null, + ): DeckCard { + // Stub method overrides aren't allowed — pre-bind every getter the + // generator exercises so individual tests can pick the right values + // through the helper signature instead of mutating the stub later. + $card = $this->createStub(DeckCard::class); + $card->method('getCardType')->willReturn($cardType); + $card->method('getCardName')->willReturn($name); + $card->method('getQuantity')->willReturn($quantity); + $card->method('getSetCode')->willReturn($setCode); + $card->method('getCardNumber')->willReturn($cardNumber); + $card->method('getTrainerSubtype')->willReturn($trainerSubtype); + $card->method('getCardPrinting')->willReturn($cardPrinting); + + return $card; + } + + private function buildCardPrintingWithTcgdex(TcgdexCard $tcgdexCard): \App\Entity\CardPrinting + { + $printing = $this->createStub(\App\Entity\CardPrinting::class); + $printing->method('getTcgdexCard')->willReturn($tcgdexCard); + $printing->method('getTcgdexId')->willReturn('lot-79'); + + return $printing; + } +} diff --git a/tests/Service/Label/PdfLabelGeneratorTest.php b/tests/Service/Label/PdfLabelGeneratorTest.php new file mode 100644 index 0000000..ef768c9 --- /dev/null +++ b/tests/Service/Label/PdfLabelGeneratorTest.php @@ -0,0 +1,237 @@ +|null captured template context for the most recent render call */ + private ?array $capturedTemplateData = null; + + /** @var string|null captured template name */ + private ?string $capturedTemplateName = null; + + public function testGenerateReturnsValidPdfWithQrCodeAndDeckUrl(): void + { + $deck = $this->buildDeck(shortTag: 'A1B2C3', cards: []); + + $output = $this->buildGenerator()->generate($deck); + + self::assertStringStartsWith('%PDF-', $output); + self::assertSame('label/pdf_label.html.twig', $this->capturedTemplateName); + + // QR code is encoded inline as a PNG data URI. + self::assertNotNull($this->capturedTemplateData['qrCodeDataUri']); + self::assertStringStartsWith('data:image/png;base64,', (string) $this->capturedTemplateData['qrCodeDataUri']); + + // Base URL is derived from the absolute deck URL (scheme + host only). + self::assertSame('https://example.test', $this->capturedTemplateData['baseUrl']); + } + + public function testGenerateEmbedsResolvedSpritesAsDataUris(): void + { + $deck = $this->buildDeck(shortTag: 'A1B2C3', cards: [], pokemonSlugs: ['pikachu', 'charizard']); + + $resolver = $this->createStub(SpriteResolver::class); + $resolver->method('resolveAsDataUri')->willReturnCallback(static fn (string $slug): ?string => 'data:image/png;base64,FAKE-'.$slug); + + $this->buildGenerator(spriteResolver: $resolver)->generate($deck); + + $sprites = $this->capturedTemplateData['spriteDataUris']; + self::assertCount(2, $sprites); + self::assertSame('data:image/png;base64,FAKE-pikachu', $sprites[0]['dataUri']); + // Slug is title-cased for display. + self::assertSame('Pikachu', $sprites[0]['name']); + self::assertSame('Charizard', $sprites[1]['name']); + } + + public function testGenerateSkipsSpritesWhenResolverReturnsNull(): void + { + $deck = $this->buildDeck(shortTag: 'A1B2C3', cards: [], pokemonSlugs: ['phantom-pokemon']); + + $resolver = $this->createStub(SpriteResolver::class); + $resolver->method('resolveAsDataUri')->willReturn(null); + + $this->buildGenerator(spriteResolver: $resolver)->generate($deck); + + self::assertSame([], $this->capturedTemplateData['spriteDataUris']); + } + + public function testSlugWithDashesGetsTitleCased(): void + { + $deck = $this->buildDeck(shortTag: 'A1B2C3', cards: [], pokemonSlugs: ['mr-mime']); + + $resolver = $this->createStub(SpriteResolver::class); + $resolver->method('resolveAsDataUri')->willReturn('data:image/png;base64,FAKE'); + + $this->buildGenerator(spriteResolver: $resolver)->generate($deck); + + self::assertSame('Mr Mime', $this->capturedTemplateData['spriteDataUris'][0]['name']); + } + + public function testGenerateFoldableReturnsValidPdfAndUsesFoldableTemplate(): void + { + $deck = $this->buildDeck(shortTag: 'A1B2C3', cards: [ + $this->buildCard('pokemon', 'Pikachu', 4), + ]); + + $output = $this->buildGenerator()->generateFoldable($deck); + + self::assertStringStartsWith('%PDF-', $output); + self::assertSame('label/pdf_label_foldable.html.twig', $this->capturedTemplateName); + + // The template gets a grouped card map. + self::assertArrayHasKey('groupedCards', $this->capturedTemplateData); + self::assertArrayHasKey('decklistFontSize', $this->capturedTemplateData); + } + + public function testGenerateFoldableHandlesDeckWithoutCurrentVersion(): void + { + $deck = $this->buildDeck(shortTag: 'A1B2C3', cards: [], hasVersion: false); + + $this->buildGenerator()->generateFoldable($deck); + + self::assertSame([], $this->capturedTemplateData['groupedCards']); + self::assertSame(6.0, $this->capturedTemplateData['decklistFontSize']); + } + + public function testGroupedCardsSplitTrainersBySubtypeAndSortByQuantityThenName(): void + { + $deck = $this->buildDeck(shortTag: 'A1B2C3', cards: [ + $this->buildCard('pokemon', 'Charizard', 2), + $this->buildCard('pokemon', 'Bulbasaur', 4), + $this->buildCard('trainer', 'Boss', 2, trainerSubtype: 'supporter'), + $this->buildCard('trainer', 'Switch', 4, trainerSubtype: 'item'), + $this->buildCard('trainer', 'Mystery', 1, trainerSubtype: null), + $this->buildCard('energy', 'Lightning', 6), + ]); + + $this->buildGenerator()->generateFoldable($deck); + + $grouped = $this->capturedTemplateData['groupedCards']; + + // Section keys are emitted in the controller's fixed order. + self::assertSame(['pokemon', 'supporter', 'item', 'trainer', 'energy'], array_keys($grouped)); + + // Pokemon sorted by quantity desc, then name asc. + $names = array_map(static fn (DeckCard $c): string => $c->getCardName(), $grouped['pokemon']); + self::assertSame(['Bulbasaur', 'Charizard'], $names); + + // Trainer with null subtype falls into the generic "trainer" bucket. + self::assertCount(1, $grouped['trainer']); + self::assertSame('Mystery', $grouped['trainer'][0]->getCardName()); + } + + public function testFoldableFontSizeShrinksWhenManyCards(): void + { + $cards = []; + for ($i = 1; $i <= 60; ++$i) { + $cards[] = $this->buildCard('pokemon', 'Card '.$i, 1); + } + + $this->buildGenerator()->generateFoldable($this->buildDeck(shortTag: 'A1B2C3', cards: $cards)); + + $fontSize = $this->capturedTemplateData['decklistFontSize']; + self::assertGreaterThanOrEqual(4.0, $fontSize); + self::assertLessThan(7.0, $fontSize); + } + + public function testFoldableFontSizeStaysAtCeilingForSmallDeck(): void + { + $deck = $this->buildDeck(shortTag: 'A1B2C3', cards: [ + $this->buildCard('pokemon', 'Single', 1), + ]); + + $this->buildGenerator()->generateFoldable($deck); + + // Tiny deck clamps to the 7pt ceiling. + self::assertSame(7.0, $this->capturedTemplateData['decklistFontSize']); + } + + private function buildGenerator(?SpriteResolver $spriteResolver = null): PdfLabelGenerator + { + $twig = $this->createMock(Environment::class); + $twig->method('render')->willReturnCallback(function (string $template, array $context = []): string { + $this->capturedTemplateName = $template; + $this->capturedTemplateData = $context; + + return '

tiny pdf body

'; + }); + + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('https://example.test/d/A1B2C3'); + + if (null === $spriteResolver) { + $spriteResolver = new SpriteResolver( + $this->createStub(PokemonSpriteMappingRepository::class), + new MockHttpClient([]), + sys_get_temp_dir(), + ); + } + + return new PdfLabelGenerator($twig, $urlGenerator, $spriteResolver); + } + + /** + * @param list $cards + * @param list $pokemonSlugs + */ + private function buildDeck(string $shortTag, array $cards, array $pokemonSlugs = [], bool $hasVersion = true): Deck + { + $deck = $this->createStub(Deck::class); + $deck->method('getShortTag')->willReturn($shortTag); + $deck->method('getEffectivePokemonSlugs')->willReturn($pokemonSlugs); + + if ($hasVersion) { + $version = $this->createStub(DeckVersion::class); + $version->method('getCards')->willReturn(new ArrayCollection($cards)); + $deck->method('getCurrentVersion')->willReturn($version); + } else { + $deck->method('getCurrentVersion')->willReturn(null); + } + + return $deck; + } + + private function buildCard(string $cardType, string $name, int $quantity, ?string $trainerSubtype = null): DeckCard + { + $card = $this->createStub(DeckCard::class); + $card->method('getCardType')->willReturn($cardType); + $card->method('getCardName')->willReturn($name); + $card->method('getQuantity')->willReturn($quantity); + $card->method('getTrainerSubtype')->willReturn($trainerSubtype); + + return $card; + } +} From 05fb6d2b5961de073c83047d578b2f2b6c04edc0 Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 22:39:53 +0200 Subject: [PATCH 10/12] test(api): cover AdminTechnical actions, DeckShow PDF routes, mosaic handler branches Three coverage targets from the medium-tier list: AdminTechnicalControllerCoverageTest (16 tests): - enrich-retry / flush-reenrich auth + CSRF reject branches - mosaic-generate CSRF reject + happy path (info/success flash) - sprite-mapping-rebuild CSRF reject branch - tcgdex-sync-insert / tcgdex-sync-update CSRF reject branches - banned-cards-sync CSRF reject branch - clear-cache CSRF reject + happy path - clear-app-cache CSRF reject + happy path (cache->clear) - clear-cache-key CSRF reject + empty-key warning + valid delete Note: dispatch happy paths for Messenger-backed actions (set-mappings, tcgdex-sync, flush-reenrich, banned-cards-sync, sprite-mapping-rebuild) are not asserted because the test env runs sync transports and the handlers reach external services (TCGdex / pokemon.com / PokeAPI) without try/catch in the controller. The CSRF + auth branches are the testable surface; the controller code beyond them is one-line dispatch. DeckShowPdfRoutesTest (9 tests): - /deck/{tag}/label.pdf returns application/pdf for owner; 403 otherwise - /deck/{tag}/label-foldable.pdf same access pattern + valid PDF body - /deck/{tag}/decklist.pdf personal mode for owner, ?anonymous=1 variant, 403 for non-owner - /deck/{tag}/re-enrich requires ROLE_TECHNICAL_ADMIN; invalid CSRF -> 403 GenerateMinifiedMosaicHandlerTest extensions (4 tests): - Catch branch: exception inside the pipeline is logged at error level and rethrown - MINIFIED_PRINTING_OVERRIDES static map short-circuits resolveMinifiedImage (GEN|73 -> XY 129 with no CardPrinting reference) - Two cards with the same name + image collapse into one tile via the buildMergedTiles dedup keyed on `name|imageUrl` - Tile sort order pokemon -> trainer -> energy with qty-desc + name-asc within each type Test totals: 1133 unit (+4) / 1065 functional (+25). --- .../AdminTechnicalControllerCoverageTest.php | 246 ++++++++++++++++++ tests/Functional/DeckShowPdfRoutesTest.php | 137 ++++++++++ .../GenerateMinifiedMosaicHandlerTest.php | 226 ++++++++++++++++ 3 files changed, 609 insertions(+) create mode 100644 tests/Functional/AdminTechnicalControllerCoverageTest.php create mode 100644 tests/Functional/DeckShowPdfRoutesTest.php diff --git a/tests/Functional/AdminTechnicalControllerCoverageTest.php b/tests/Functional/AdminTechnicalControllerCoverageTest.php new file mode 100644 index 0000000..454b024 --- /dev/null +++ b/tests/Functional/AdminTechnicalControllerCoverageTest.php @@ -0,0 +1,246 @@ +client->getSession(); + self::assertNotNull($session, 'Session must exist — make a GET request first.'); + $session->start(); + + /** @var \Symfony\Component\HttpFoundation\RequestStack $requestStack */ + $requestStack = static::getContainer()->get('request_stack'); + + $synthetic = new \Symfony\Component\HttpFoundation\Request(); + $synthetic->setSession($session); + $requestStack->push($synthetic); + + try { + /** @var \Symfony\Component\Security\Csrf\CsrfTokenManagerInterface $tokenManager */ + $tokenManager = static::getContainer()->get('security.csrf.token_manager'); + + return $tokenManager->getToken($tokenId)->getValue(); + } finally { + $requestStack->pop(); + } + } + + public function testEnrichRetryRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/enrich-retry', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testEnrichRetrySucceedsWithValidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/enrich-retry', [ + '_token' => $this->getCsrfToken('technical-enrich-retry'), + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + // Either info (none pending) or success (dispatched). + self::assertSelectorExists('.alert-info, .alert-success'); + } + + public function testFlushReenrichRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/flush-reenrich', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + // Note: flushAndReenrich, setMappingsRebuild, tcgdexSyncInsert, and + // tcgdexSyncUpdate dispatch Messenger messages on transports that are + // configured `sync://` in the test env. The handlers reach external + // services (TCGdex / PokeAPI) and the controller doesn't catch handler + // exceptions, so happy-path tests for these actions are inherently flaky. + // We cover the auth + CSRF-rejection branches only. + + public function testMosaicGenerateRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/mosaic-generate', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testMosaicGenerateSucceedsWithValidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/mosaic-generate', [ + '_token' => $this->getCsrfToken('technical-mosaic-generate'), + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + // Either info (0 pending) or success (N dispatched) — both branches of mosaicGenerate. + self::assertSelectorExists('.alert-info, .alert-success'); + } + + public function testSpriteMappingRebuildRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/sprite-mapping-rebuild', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testTcgdexSyncInsertRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/tcgdex-sync-insert', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testTcgdexSyncUpdateRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/tcgdex-sync-update', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testBannedCardsSyncRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/banned-cards-sync', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testClearCacheRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/clear-cache', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testClearCacheInvalidatesMenuRuntime(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/clear-cache', [ + '_token' => $this->getCsrfToken('technical-clear-cache'), + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } + + public function testClearAppCacheRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/clear-app-cache', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testClearAppCacheClearsCachePool(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/clear-app-cache', [ + '_token' => $this->getCsrfToken('technical-clear-app-cache'), + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } + + public function testClearCacheKeyRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/clear-cache-key', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testClearCacheKeyShowsWarningOnEmptyInput(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/clear-cache-key', [ + '_token' => $this->getCsrfToken('technical-clear-cache-key'), + 'cache_key' => ' ', + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-warning'); + } + + public function testClearCacheKeyDeletesNamedKey(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/clear-cache-key', [ + '_token' => $this->getCsrfToken('technical-clear-cache-key'), + 'cache_key' => 'test_cache_key', + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } +} diff --git a/tests/Functional/DeckShowPdfRoutesTest.php b/tests/Functional/DeckShowPdfRoutesTest.php new file mode 100644 index 0000000..96e7c4c --- /dev/null +++ b/tests/Functional/DeckShowPdfRoutesTest.php @@ -0,0 +1,137 @@ +loginAs('admin@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/label.pdf', $shortTag)); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/pdf'); + self::assertStringStartsWith('%PDF-', (string) $this->client->getResponse()->getContent()); + } + + public function testLabelPdfDeniedForNonOwner(): void + { + $this->loginAs('borrower@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/label.pdf', $shortTag)); + + self::assertResponseStatusCodeSame(403); + } + + public function testLabelFoldablePdfReturnsPdfForOwner(): void + { + $this->loginAs('admin@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/label-foldable.pdf', $shortTag)); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/pdf'); + self::assertStringStartsWith('%PDF-', (string) $this->client->getResponse()->getContent()); + } + + public function testLabelFoldablePdfDeniedForNonOwner(): void + { + $this->loginAs('borrower@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/label-foldable.pdf', $shortTag)); + + self::assertResponseStatusCodeSame(403); + } + + public function testDecklistPdfReturnsPersonalPdfForOwner(): void + { + $this->loginAs('admin@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/decklist.pdf', $shortTag)); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/pdf'); + self::assertStringStartsWith('%PDF-', (string) $this->client->getResponse()->getContent()); + } + + public function testDecklistPdfReturnsAnonymousVariantWithQueryFlag(): void + { + $this->loginAs('admin@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/decklist.pdf?anonymous=1', $shortTag)); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/pdf'); + } + + public function testDecklistPdfDeniedForNonOwner(): void + { + $this->loginAs('borrower@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/decklist.pdf', $shortTag)); + + self::assertResponseStatusCodeSame(403); + } + + public function testReEnrichRequiresTechnicalAdminRole(): void + { + $this->loginAs('borrower@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('POST', \sprintf('/deck/%s/re-enrich', $shortTag), ['_token' => 'irrelevant']); + + self::assertResponseStatusCodeSame(403); + } + + public function testReEnrichRequiresValidCsrf(): void + { + $this->loginAs('admin@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('POST', \sprintf('/deck/%s/re-enrich', $shortTag), ['_token' => 'wrong']); + + // Invalid CSRF -> AccessDeniedException -> 403. + self::assertResponseStatusCodeSame(403); + } + + private function getDeckShortTag(string $name): string + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + $deck = $em->getRepository(Deck::class)->findOneBy(['name' => $name]); + \assert($deck instanceof Deck); + + return $deck->getShortTag(); + } +} diff --git a/tests/MessageHandler/GenerateMinifiedMosaicHandlerTest.php b/tests/MessageHandler/GenerateMinifiedMosaicHandlerTest.php index 0d4b8e5..d41d71f 100644 --- a/tests/MessageHandler/GenerateMinifiedMosaicHandlerTest.php +++ b/tests/MessageHandler/GenerateMinifiedMosaicHandlerTest.php @@ -270,4 +270,230 @@ public function testTilesCarryCardPrintingForFallbackResolution(): void self::assertSame(2, $tile->quantity); self::assertSame($printing, $tile->printing, 'Tile must carry the CardPrinting for fallback-aware image resolution'); } + + /** + * The catch-block re-raises after logging — exercises the error + * branch and the rethrow. + */ + public function testExceptionInPipelineIsLoggedAndRethrown(): void + { + $card = new DeckCard(); + $card->setCardName('Pikachu'); + $card->setSetCode('BRS'); + $card->setCardNumber('50'); + $card->setCardType('pokemon'); + $card->setQuantity(2); + + $version = new DeckVersion(); + $version->setEnrichmentStatus('done'); + $version->addCard($card); + + $versionRepository = $this->createStub(DeckVersionRepository::class); + $versionRepository->method('find')->willReturn($version); + + $mosaicGenerator = $this->createStub(MosaicGenerator::class); + $mosaicGenerator->method('generateFromTiles')->willThrowException(new \RuntimeException('disk full')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error'); + + $handler = new GenerateMinifiedMosaicHandler( + $mosaicGenerator, + $this->createStub(MosaicUrlResolver::class), + $this->createStub(CardPrintingRepository::class), + $this->createStub(CardIdentityResolver::class), + $versionRepository, + $this->createStub(EntityManagerInterface::class), + $logger, + $this->createStub(MinifiedCardViewBuilder::class), + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('disk full'); + + $handler(new GenerateMinifiedMosaicMessage(1)); + } + + /** + * MINIFIED_PRINTING_OVERRIDES short-circuits the resolution chain with + * a static URL and no CardPrinting. + */ + public function testStaticOverrideShortCircuitsImageResolution(): void + { + // GEN|73 is in DeckListParser::MINIFIED_PRINTING_OVERRIDES. + $card = new DeckCard(); + $card->setCardName('Energy Switch'); + $card->setSetCode('GEN'); + $card->setCardNumber('73'); + $card->setCardType('trainer'); + $card->setQuantity(4); + + $version = new DeckVersion(); + $version->setEnrichmentStatus('done'); + $version->addCard($card); + + $versionRepository = $this->createStub(DeckVersionRepository::class); + $versionRepository->method('find')->willReturn($version); + + // The override should mean the printingRepository is never asked. + $printingRepository = $this->createMock(CardPrintingRepository::class); + $printingRepository->expects(self::never())->method('findLowestRarityForIdentity'); + + $capturedTiles = null; + $mosaicGenerator = $this->createStub(MosaicGenerator::class); + $mosaicGenerator->method('generateFromTiles')->willReturnCallback(static function ($v, $tiles) use (&$capturedTiles): string { + $capturedTiles = $tiles; + + return 'mosaic/1/1_minified.webp'; + }); + + $mosaicUrlResolver = $this->createStub(MosaicUrlResolver::class); + $mosaicUrlResolver->method('resolveForVersion')->willReturn('https://example.com/mosaic.webp'); + + $handler = new GenerateMinifiedMosaicHandler( + $mosaicGenerator, + $mosaicUrlResolver, + $printingRepository, + $this->createStub(CardIdentityResolver::class), + $versionRepository, + $this->createStub(EntityManagerInterface::class), + $this->createStub(LoggerInterface::class), + $this->createStub(MinifiedCardViewBuilder::class), + ); + + $handler(new GenerateMinifiedMosaicMessage(1)); + + self::assertNotNull($capturedTiles); + self::assertCount(1, $capturedTiles); + // Static override: tile.printing is null and image URL is from the override map. + self::assertNull($capturedTiles[0]->printing); + self::assertNotNull($capturedTiles[0]->imageUrl); + } + + /** + * Cards sharing the same name + image URL collapse into one tile with + * summed quantity. This is the de-dup path in buildMergedTiles. + */ + public function testTilesWithSameImageAndNameMergeWithSummedQuantity(): void + { + // Two cards with the same name + same override image collapse into one. + $cardA = new DeckCard(); + $cardA->setCardName('Energy Switch'); + $cardA->setSetCode('GEN'); + $cardA->setCardNumber('73'); + $cardA->setCardType('trainer'); + $cardA->setQuantity(2); + + $cardB = new DeckCard(); + $cardB->setCardName('Energy Switch'); + $cardB->setSetCode('GEN'); + $cardB->setCardNumber('73'); + $cardB->setCardType('trainer'); + $cardB->setQuantity(2); + + $version = new DeckVersion(); + $version->setEnrichmentStatus('done'); + $version->addCard($cardA); + $version->addCard($cardB); + + $versionRepository = $this->createStub(DeckVersionRepository::class); + $versionRepository->method('find')->willReturn($version); + + $capturedTiles = null; + $mosaicGenerator = $this->createStub(MosaicGenerator::class); + $mosaicGenerator->method('generateFromTiles')->willReturnCallback(static function ($v, $tiles) use (&$capturedTiles): string { + $capturedTiles = $tiles; + + return 'mosaic/1/1_minified.webp'; + }); + + $mosaicUrlResolver = $this->createStub(MosaicUrlResolver::class); + $mosaicUrlResolver->method('resolveForVersion')->willReturn('https://example.com/mosaic.webp'); + + $handler = new GenerateMinifiedMosaicHandler( + $mosaicGenerator, + $mosaicUrlResolver, + $this->createStub(CardPrintingRepository::class), + $this->createStub(CardIdentityResolver::class), + $versionRepository, + $this->createStub(EntityManagerInterface::class), + $this->createStub(LoggerInterface::class), + $this->createStub(MinifiedCardViewBuilder::class), + ); + + $handler(new GenerateMinifiedMosaicMessage(1)); + + self::assertNotNull($capturedTiles); + self::assertCount(1, $capturedTiles, 'Two cards with same name + image must merge into one tile.'); + self::assertSame(4, $capturedTiles[0]->quantity, 'Merged tile carries the summed quantity.'); + } + + /** + * Tile sort order: pokemon (qty desc, name asc) → trainer (no subtype + * differentiation here) → energy. Sets the subtype-based ordering + * branch via the cardType key alone since trainerSubtype is computed + * from CardPrinting -> CardIdentity (out of scope for this assertion). + */ + public function testTilesAreSortedByTypeThenQuantityThenName(): void + { + $cards = [ + $this->buildSimpleCard('Lightning', 'energy', 5, 'BRS', 'E1'), + $this->buildSimpleCard('Switch', 'trainer', 4, 'BRS', '60'), + $this->buildSimpleCard('Boss', 'trainer', 2, 'BRS', '50'), + $this->buildSimpleCard('Charizard', 'pokemon', 2, 'BRS', '20'), + $this->buildSimpleCard('Bulbasaur', 'pokemon', 4, 'BRS', '10'), + ]; + + $version = new DeckVersion(); + $version->setEnrichmentStatus('done'); + foreach ($cards as $card) { + $version->addCard($card); + } + + $versionRepository = $this->createStub(DeckVersionRepository::class); + $versionRepository->method('find')->willReturn($version); + + $capturedTiles = null; + $mosaicGenerator = $this->createStub(MosaicGenerator::class); + $mosaicGenerator->method('generateFromTiles')->willReturnCallback(static function ($v, $tiles) use (&$capturedTiles): string { + $capturedTiles = $tiles; + + return 'mosaic/1/1_minified.webp'; + }); + + $mosaicUrlResolver = $this->createStub(MosaicUrlResolver::class); + $mosaicUrlResolver->method('resolveForVersion')->willReturn('https://example.com/mosaic.webp'); + + $handler = new GenerateMinifiedMosaicHandler( + $mosaicGenerator, + $mosaicUrlResolver, + $this->createStub(CardPrintingRepository::class), + $this->createStub(CardIdentityResolver::class), + $versionRepository, + $this->createStub(EntityManagerInterface::class), + $this->createStub(LoggerInterface::class), + $this->createStub(MinifiedCardViewBuilder::class), + ); + + $handler(new GenerateMinifiedMosaicMessage(1)); + + self::assertNotNull($capturedTiles); + $names = array_map(static fn (MosaicTile $t): string => $t->cardName, $capturedTiles); + // Type order pokemon → trainer → energy. + // Within pokemon: qty desc (4 Bulbasaur, 2 Charizard). + // Within trainer: qty desc (4 Switch, 2 Boss). + self::assertSame(['Bulbasaur', 'Charizard', 'Switch', 'Boss', 'Lightning'], $names); + } + + private function buildSimpleCard(string $name, string $type, int $quantity, string $setCode, string $number): DeckCard + { + $card = new DeckCard(); + $card->setCardName($name); + $card->setCardType($type); + $card->setQuantity($quantity); + $card->setSetCode($setCode); + $card->setCardNumber($number); + + return $card; + } } From a84a57eaa1331a3f9a3b578f04dc6a9820e19c0e Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 22:52:01 +0200 Subject: [PATCH 11/12] fix(deck): render format + latestSet at the right spot in deck forms Both fields were missing from the explicit form_row calls in the deck new and edit templates, so form_end was emitting them at the bottom of the form (after the submit button area in some layouts). - new.html.twig: format goes right after name; latestSet sits between the languages island and the public checkbox (matching edit.html.twig). - edit.html.twig: format added right after name. latestSet was already in place. --- templates/deck/edit.html.twig | 1 + templates/deck/new.html.twig | 3 +++ 2 files changed, 4 insertions(+) diff --git a/templates/deck/edit.html.twig b/templates/deck/edit.html.twig index 7564516..78d530a 100644 --- a/templates/deck/edit.html.twig +++ b/templates/deck/edit.html.twig @@ -20,6 +20,7 @@
{{ form_start(form) }} {{ form_row(form.name) }} + {{ form_row(form.format) }} {{ form_row(form.notes) }} {# Archetype — React island #} diff --git a/templates/deck/new.html.twig b/templates/deck/new.html.twig index 569f649..85244d3 100644 --- a/templates/deck/new.html.twig +++ b/templates/deck/new.html.twig @@ -20,6 +20,7 @@
{{ form_start(form) }} {{ form_row(form.name) }} + {{ form_row(form.format) }} {{ form_row(form.notes) }} {# Archetype — React island #} @@ -47,6 +48,8 @@ {{ form_widget(form.languages) }}
+ {{ form_row(form.latestSet) }} +
{{ form_row(form.public) }}
From 8bb218e47c4ddd75dd85618015fd7475e37b86e8 Mon Sep 17 00:00:00 2001 From: Julien Bourdin Date: Sun, 3 May 2026 23:34:31 +0200 Subject: [PATCH 12/12] docs(changelog): add 1.9.4 release notes --- docs/changelog.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 9568bca..ab8b505 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -16,6 +16,34 @@ Items marked *(partial)* have scaffolding or basic functionality but are not yet --- +## [1.9.4] — 2026-05-03 + +Patch release: a deck-form field-order fix and a project-wide test coverage push from 85.87 % to ~92.4 %. + +### Bug Fixes + +- **Deck form field order** — `format` (Expanded vs Standard) was missing from the explicit `form_row` list in both `templates/deck/new.html.twig` and `templates/deck/edit.html.twig`, and `latestSet` was missing from the new template. `form_end(form)` was emitting them at the bottom of the form. Both fields now render in their intended positions: `format` right after `name`, `latestSet` between the languages island and the `public` checkbox. ([#514](https://github.com/jbourdin/expandedDecks/pull/514)) + +### Infrastructure + +- **Drop unused `symfony/stimulus-bundle` + `symfony/ux-turbo`** — the project frontend is React/Mantine + Twig/Bootstrap with no Stimulus controllers, no `` elements, and no `data-controller` attributes; both bundles were Flex-recipe leftovers that Dependabot kept churning major-bump PRs against. Removed the composer packages, the four orphaned npm deps (`@hotwired/{stimulus,turbo}`, `@symfony/{stimulus-bridge,ux-turbo}`), `config/packages/ux_turbo.yaml`, and the dead Turbo Drive comment boilerplate in `webpack_encore.yaml`. Closed Dependabot PRs #488 and #489. ([#508](https://github.com/jbourdin/expandedDecks/pull/508)) +- **Drop unused `symfony/amazon-sqs-messenger`** — every `MESSENGER_TRANSPORT_*_DSN` defaults to Doctrine; no `sqs://` DSN, no `AsyncAws` imports anywhere. Removed the package and its transitive `async-aws/{sqs,core}`. `aws/aws-sdk-php` stays — it's used by Flysystem for Scaleway S3 storage. ([#508](https://github.com/jbourdin/expandedDecks/pull/508)) +- **Add Twig-CS-Fixer for template style enforcement** — `vincentlanglet/twig-cs-fixer ^3.14` as a dev dep with the default standard, `make twig-cs-fix` / `twig-cs-check` targets wired into `lint-all`, CI Twig dry-run step. Initial pass on 26/103 templates: trailing commas, hash-key quote cleanup (`{'_target_path': ...}` → `{_target_path: ...}`), and `{% include %}` tag → `{{ include() }}` function (closes #467). ([#508](https://github.com/jbourdin/expandedDecks/pull/508)) +- **Composer dependencies refresh** — Symfony 8.0.8 → 8.0.9 across components, doctrine/persistence 4.1.1 → 4.2.0, async-aws/sqs 2.8.1 → 2.9.0 (before removal), phpstan 2.1.54, phpunit 13.1.8, aws-sdk-php 3.379.11, polyfills 1.37. ([#508](https://github.com/jbourdin/expandedDecks/pull/508)) +- **Restore strict_types + license header on `config/bundles.php`** — Flex stripped the project's standard PHP header when unconfiguring stimulus/ux-turbo recipes; PHP-CS-Fixer caught the regression in CI and the header is now back. ([#508](https://github.com/jbourdin/expandedDecks/pull/508)) + +### Testing & Quality + +- **F6.14 banned-card coverage backfill (closes #498)** — 62 new tests across 9 files: `BannedCardImageResolverTest` (14, all four URL-resolution branches + rarity-tier sort + serie-prefix guesses), `BannedCardEnricherTest` (11, local hit / TCGdex API hit / alias fallback / null path / force-mode reset / reparent identity-cache regression), `AdminBannedCardControllerTest` (12, auth + role + active/history tabs + CRUD + CSRF rejection), `BannedCardSeedDataTest` (7, applyTo / per-printing seeds for Unown LOT 90 vs LOT 91 / applyAll counts), `BannedCardsEnrichCommandTest` (3), `BannedCardsSeedCommandTest` (2), `AdminTechnicalControllerTest` extension (4 for banned-cards-enrich), `BannedCardPrintingRepositoryTest` (4), `CardPrintingRepositoryTest` (3 for `findFirstBySetCodeAndCardNumber`), `BannedCardFormTypeTest` (4), plus two extensions to `BannedCardsSyncServiceTest` (in-loop `parentsByIdentityId` cache regression + empty-printings soft-delete branch). Patch coverage on the next touch ≥ 87.48 %. ([#509](https://github.com/jbourdin/expandedDecks/pull/509)) +- **Sprite subsystem coverage (F2.26 — was 0 %)** — `SpriteResolverTest` (10 tests covering cache-hit short-circuit, CDN→PokeAPI fallback, in-memory pokedex-id memoization, exception swallow, data-URI encoding), `SpriteMappingSyncServiceTest` (5 tests for CSV parsing, alias merging, insert/update counts, fetch-failure throw), `SpritesSyncMappingCommandTest` (2), `SpriteProxyControllerTest` (3, 404 on resolver miss + 200 with `image/png` body + JSON slug listing), `PokemonSpriteMappingRepositoryTest` (4). Also added `tests/Form` and `tests/Sentry` to `phpunit.xml.dist`'s unit suite — 17 previously-orphaned tests were not being run by CI. Source-side fix: `str_getcsv($line, escape: '')` to silence a PHP 8.4+ deprecation surfaced once tests started exercising the parser. ([#510](https://github.com/jbourdin/expandedDecks/pull/510)) +- **Admin Page + MenuCategory controller coverage** — 28 new functional tests covering list filters (q/category/channel + view variants), reorder JSON endpoints (valid + invalid payload), new-form submit with channel/category/footer prefill, edit GET + POST round-trip, saveTranslation existing-locale + new-locale + 404, delete + duplicate CSRF rejection, and the cross-translation duplicate clone path. ([#511](https://github.com/jbourdin/expandedDecks/pull/511)) +- **PDF generator coverage (was 0 %, 319 LOC)** — `PdfDecklistGeneratorTest` (15, every data-prep branch + Dompdf rendering on tiny stubbed HTML so `renderPdf` is also exercised), `PdfLabelGeneratorTest` (9, simple + foldable variants, sprite resolution, slug title-casing, font-size auto-fit). Tests stub `Twig::render` with a context-capturing callback to assert on grouping, sorting, font-size, gravatar/symbol embedding without touching real templates. ([#512](https://github.com/jbourdin/expandedDecks/pull/512)) +- **AdminTechnical + DeckShow PDF + mosaic handler branches** — `AdminTechnicalControllerCoverageTest` (16, every action's CSRF reject path plus happy paths for cache-clearing actions where the handler doesn't reach external services), `DeckShowPdfRoutesTest` (9, label/foldable/decklist routes return `application/pdf` for owners and 403 otherwise; re-enrich requires `ROLE_TECHNICAL_ADMIN` + valid CSRF), and 4 new `GenerateMinifiedMosaicHandlerTest` cases (catch branch with logged + rethrown exception, static printing-overrides short-circuit, tile dedup with summed quantity, type+quantity sort order). ([#513](https://github.com/jbourdin/expandedDecks/pull/513)) + +Project-wide coverage moved from **85.87 % → ~92.4 %** across this batch (about 1,000 newly-covered lines). + +--- + ## [1.9.3] — 2026-05-03 Patch release: typo fix on the empty-channel coming-soon screen and a CLI memory bump for `cache:clear`.