diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index 0191871c07..70a03e8b5b 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -14,11 +14,15 @@ namespace ApiPlatform\Doctrine\Odm\Extension; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; +use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ParameterNotFound; +use Doctrine\Bundle\MongoDBBundle\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; /** * Reads operation parameters and execute its filter. @@ -29,14 +33,20 @@ final class ParameterExtension implements AggregationCollectionExtensionInterfac { use ParameterValueExtractorTrait; - public function __construct(private readonly ContainerInterface $filterLocator) - { + public function __construct( + private readonly ContainerInterface $filterLocator, + private readonly ?ManagerRegistry $managerRegistry = null, + ) { } + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass = null, ?Operation $operation = null, array &$context = []): void { foreach ($operation->getParameters() ?? [] as $parameter) { - if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { + if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { continue; } @@ -45,7 +55,8 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass continue; } - $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + $filter = $this->getFilter($filterId, $values); + if ($filter instanceof FilterInterface) { $filterContext = ['filters' => $values, 'parameter' => $parameter]; $filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); @@ -72,4 +83,26 @@ public function applyToItem(Builder $aggregationBuilder, string $resourceClass, { $this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context); } + + /** + * @param $values array + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function getFilter(AbstractFilter|string|null $filterId, array $values = []): ?FilterInterface + { + if ($filterId instanceof AbstractFilter) { + $filterId->setManagerRegistry($this->managerRegistry); + $filterId->setProperties($values); + + return $filterId; + } + + if (\is_string($filterId) && $this->filterLocator->has($filterId)) { + return $this->filterLocator->get($filterId); + } + + return null; + } } diff --git a/src/Doctrine/Odm/Filter/AbstractFilter.php b/src/Doctrine/Odm/Filter/AbstractFilter.php index 87c30390c3..ac5f13eaf7 100644 --- a/src/Doctrine/Odm/Filter/AbstractFilter.php +++ b/src/Doctrine/Odm/Filter/AbstractFilter.php @@ -36,8 +36,12 @@ abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInt use PropertyHelperTrait; protected LoggerInterface $logger; - public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) - { + public function __construct( + protected ?ManagerRegistry $managerRegistry = null, + ?LoggerInterface $logger = null, + protected ?array $properties = null, + protected ?NameConverterInterface $nameConverter = null, + ) { $this->logger = $logger ?? new NullLogger(); } @@ -56,11 +60,16 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera */ abstract protected function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void; - protected function getManagerRegistry(): ManagerRegistry + protected function getManagerRegistry(): ?ManagerRegistry { return $this->managerRegistry; } + public function setManagerRegistry(?ManagerRegistry $managerRegistry): ?ManagerRegistry + { + return $this->managerRegistry = $managerRegistry; + } + protected function getProperties(): ?array { return $this->properties; diff --git a/src/Doctrine/Odm/Filter/BooleanFilter.php b/src/Doctrine/Odm/Filter/BooleanFilter.php index babe3309ed..ad2af67b22 100644 --- a/src/Doctrine/Odm/Filter/BooleanFilter.php +++ b/src/Doctrine/Odm/Filter/BooleanFilter.php @@ -14,7 +14,12 @@ namespace ApiPlatform\Doctrine\Odm\Filter; use ApiPlatform\Doctrine\Common\Filter\BooleanFilterTrait; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\Types\Type as MongoDbType; @@ -104,7 +109,7 @@ * @author Teoh Han Hui * @author Alan Poulain */ -final class BooleanFilter extends AbstractFilter +final class BooleanFilter extends AbstractFilter implements OpenApiParameterFilterInterface, JsonSchemaFilterInterface { use BooleanFilterTrait; @@ -139,4 +144,27 @@ protected function filterProperty(string $property, $value, Builder $aggregation $aggregationBuilder->match()->field($matchField)->equals($value); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return $parameter->getSchema() ?? ['type' => 'boolean']; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter( + name: $key, + in: $in, + required: false, + schema: ['type' => 'boolean'], + ), + ]; + } } diff --git a/src/Doctrine/Odm/PropertyHelperTrait.php b/src/Doctrine/Odm/PropertyHelperTrait.php index e1c7693f2b..6e73db7893 100644 --- a/src/Doctrine/Odm/PropertyHelperTrait.php +++ b/src/Doctrine/Odm/PropertyHelperTrait.php @@ -27,7 +27,7 @@ */ trait PropertyHelperTrait { - abstract protected function getManagerRegistry(): ManagerRegistry; + abstract protected function getManagerRegistry(): ?ManagerRegistry; /** * Splits the given property into parts. @@ -39,9 +39,9 @@ abstract protected function splitPropertyParts(string $property, string $resourc */ protected function getClassMetadata(string $resourceClass): ClassMetadata { - $manager = $this - ->getManagerRegistry() - ->getManagerForClass($resourceClass); + /** @var ?ManagerRegistry $managerRegistry */ + $managerRegistry = $this->getManagerRegistry(); + $manager = $managerRegistry?->getManagerForClass($resourceClass); if ($manager) { return $manager->getClassMetadata($resourceClass); diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php index aa65efbe57..548665d65d 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -14,12 +14,16 @@ namespace ApiPlatform\Doctrine\Orm\Extension; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; +use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ParameterNotFound; use Doctrine\ORM\QueryBuilder; +use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Symfony\Bridge\Doctrine\ManagerRegistry; /** * Reads operation parameters and execute its filter. @@ -30,17 +34,22 @@ final class ParameterExtension implements QueryCollectionExtensionInterface, Que { use ParameterValueExtractorTrait; - public function __construct(private readonly ContainerInterface $filterLocator) - { + public function __construct( + private readonly ContainerInterface $filterLocator, + private readonly ?ManagerRegistry $managerRegistry = null, + ) { } /** * @param array $context + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($operation?->getParameters() ?? [] as $parameter) { - if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { + if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { continue; } @@ -49,9 +58,12 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter continue; } - $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + $filter = $this->getFilter($filterId, $values); + if ($filter instanceof FilterInterface) { - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context); + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, + ['filters' => $values, 'parameter' => $parameter] + $context + ); } } } @@ -71,4 +83,26 @@ public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterf { $this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } + + /** + * @param $values array + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + private function getFilter(AbstractFilter|string|null $filterId, array $values = []): ?FilterInterface + { + if ($filterId instanceof AbstractFilter) { + $filterId->setManagerRegistry($this->managerRegistry); + $filterId->setProperties($values); + + return $filterId; + } + + if (\is_string($filterId) && $this->filterLocator->has($filterId)) { + return $this->filterLocator->get($filterId); + } + + return null; + } } diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index 4ec704638a..4a223c0db7 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -30,8 +30,12 @@ abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInt use PropertyHelperTrait; protected LoggerInterface $logger; - public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) - { + public function __construct( + protected ?ManagerRegistry $managerRegistry = null, + ?LoggerInterface $logger = null, + protected ?array $properties = null, + protected ?NameConverterInterface $nameConverter = null, + ) { $this->logger = $logger ?? new NullLogger(); } @@ -53,19 +57,19 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q */ abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; - protected function getManagerRegistry(): ManagerRegistry + protected function getManagerRegistry(): ?ManagerRegistry { return $this->managerRegistry; } - protected function getProperties(): ?array + public function setManagerRegistry(?ManagerRegistry $managerRegistry): ?ManagerRegistry { - return $this->properties; + return $this->managerRegistry = $managerRegistry; } - protected function getLogger(): LoggerInterface + public function getProperties(): ?array { - return $this->logger; + return $this->properties; } /** @@ -76,6 +80,11 @@ public function setProperties(array $properties): void $this->properties = $properties; } + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + /** * Determines whether the given property is enabled. */ diff --git a/src/Doctrine/Orm/Filter/BooleanFilter.php b/src/Doctrine/Orm/Filter/BooleanFilter.php index e9f0a8373e..8ec65dd4ab 100644 --- a/src/Doctrine/Orm/Filter/BooleanFilter.php +++ b/src/Doctrine/Orm/Filter/BooleanFilter.php @@ -15,7 +15,12 @@ use ApiPlatform\Doctrine\Common\Filter\BooleanFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -106,7 +111,7 @@ * @author Amrouche Hamza * @author Teoh Han Hui */ -final class BooleanFilter extends AbstractFilter +final class BooleanFilter extends AbstractFilter implements OpenApiParameterFilterInterface, JsonSchemaFilterInterface { use BooleanFilterTrait; @@ -145,4 +150,27 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return $parameter->getSchema() ?? ['type' => 'boolean']; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter( + name: $key, + in: $in, + required: false, + schema: ['type' => 'boolean'], + ), + ]; + } } diff --git a/src/Doctrine/Orm/PropertyHelperTrait.php b/src/Doctrine/Orm/PropertyHelperTrait.php index 8431e3e168..670f7a71ec 100644 --- a/src/Doctrine/Orm/PropertyHelperTrait.php +++ b/src/Doctrine/Orm/PropertyHelperTrait.php @@ -29,7 +29,7 @@ */ trait PropertyHelperTrait { - abstract protected function getManagerRegistry(): ManagerRegistry; + abstract protected function getManagerRegistry(): ?ManagerRegistry; /** * Splits the given property into parts. @@ -43,7 +43,7 @@ protected function getClassMetadata(string $resourceClass): ClassMetadata { $manager = $this ->getManagerRegistry() - ->getManagerForClass($resourceClass); + ?->getManagerForClass($resourceClass); if ($manager) { return $manager->getClassMetadata($resourceClass); diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index e4206ea097..d6485bf1d1 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -137,7 +137,7 @@ - + diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index d6b3b1ffee..6e00d88882 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -150,6 +150,7 @@ + diff --git a/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php new file mode 100644 index 0000000000..efa2428f86 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Doctrine\Odm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource] +#[GetCollection( + parameters: [ + 'active' => new QueryParameter( + filter: new BooleanFilter(), + ), + 'enabled' => new QueryParameter( + filter: new BooleanFilter(), + property: 'active', + ), + ], +)] +#[ODM\Document] +class FilteredBooleanParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'bool', nullable: true)] + public ?bool $active = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(?bool $active): void + { + $this->active = $active; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php new file mode 100644 index 0000000000..6ba24650a7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[GetCollection( + parameters: [ + 'active' => new QueryParameter( + filter: new BooleanFilter(), + ), + 'enabled' => new QueryParameter( + filter: new BooleanFilter(), + property: 'active', + ), + ], +)] +#[ORM\Entity] +class FilteredBooleanParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?bool $active = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(?bool $isActive): void + { + $this->active = $isActive; + } +} diff --git a/tests/Functional/Parameters/BooleanFilterTest.php b/tests/Functional/Parameters/BooleanFilterTest.php new file mode 100644 index 0000000000..66c7f4f869 --- /dev/null +++ b/tests/Functional/Parameters/BooleanFilterTest.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredBooleanParameter as FilteredBooleanParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredBooleanParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +final class BooleanFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredBooleanParameter::class]; + } + + public function setUp(): void + { + $resource = $this->isMongoDB() ? FilteredBooleanParameterDocument::class : FilteredBooleanParameter::class; + + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + #[DataProvider('validBooleanFilterProvider')] + public function testBooleanFilterWithValidValues(string $url, int $expectedCount): void + { + $this->assertBooleanFilterResponse($url, $expectedCount); + } + + public static function validBooleanFilterProvider(): \Generator + { + yield 'active_true' => ['/filtered_boolean_parameters?active=true', 2]; + yield 'active_false' => ['/filtered_boolean_parameters?active=false', 1]; + yield 'active_1' => ['/filtered_boolean_parameters?active=1', 2]; + yield 'active_0' => ['/filtered_boolean_parameters?active=0', 1]; + yield 'enabled_true' => ['/filtered_boolean_parameters?enabled=true', 2]; + yield 'enabled_false' => ['/filtered_boolean_parameters?enabled=false', 1]; + yield 'enabled_1' => ['/filtered_boolean_parameters?enabled=1', 2]; + yield 'enabled_0' => ['/filtered_boolean_parameters?enabled=0', 1]; + } + + /** + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + */ + #[DataProvider('invalidBooleanFilterProvider')] + public function testBooleanFilterWithInvalidValues(string $url): void + { + if (str_contains($url, '=null')) { + $this->assertBooleanFilterResponse($url, 3); // <=> ignoré + + return; + } + + $this->assertValidationErrorResponse($url); + } + + public static function invalidBooleanFilterProvider(): \Generator + { + yield 'null_value' => ['/filtered_boolean_parameters?active=null']; + yield 'empty_value' => ['/filtered_boolean_parameters?active=']; + yield 'null_value_alias' => ['/filtered_boolean_parameters?enabled=null']; + yield 'empty_value_alias' => ['/filtered_boolean_parameters?enabled=']; + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + private function assertBooleanFilterResponse(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $entities = $data['hydra:member']; + + $this->assertCount($expectedCount, $entities, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + if (!str_contains($url, '=null')) { + $expectedValue = str_contains($url, '=true') || str_contains($url, '=1'); + foreach ($entities as $entity) { + $errorMessage = \sprintf("Expected 'active' to be %s", $expectedValue ? 'true' : 'false'); + $this->assertSame($expectedValue, $entity['active'], $errorMessage); + } + } + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + private function assertValidationErrorResponse(string $url): void + { + $response = self::createClient()->request('GET', $url); + + $this->assertEquals(422, $response->getStatusCode(), \sprintf('Expected status code 422 for URL %s', $url)); + $this->assertJsonContains([ + '@context' => '/contexts/ConstraintViolationList', + '@type' => 'ConstraintViolationList', + 'status' => 422, + 'violations' => [ + [ + 'propertyPath' => 'active', + 'message' => 'This value should not be blank.', + ], + ], + ]); + } + + private function loadFixtures(string $resource): void + { + $manager = $this->getManager(); + + $entitiesData = [true, true, false, null]; + foreach ($entitiesData as $activeValue) { + $entity = new $resource(active: $activeValue); + $manager->persist($entity); + } + + $manager->flush(); + } +}