diff --git a/.atoum.php b/.atoum.php index 17cbc1a..54030a8 100644 --- a/.atoum.php +++ b/.atoum.php @@ -1,10 +1,28 @@ addDefaultReport(); $coverageField = new atoum\report\fields\runner\coverage\html('Ting', __DIR__ . '/tests/coverage/'); $script->noCodeCoverageForClasses('Symfony\Component\Validator\Constraint', 'Symfony\Component\Validator\ConstraintValidator', 'Symfony\Component\DependencyInjection\Extension\Extension', 'Symfony\Component\HttpKernel\DependencyInjection\Extension'); $coverageField->setRootUrl('file://' . __DIR__ . '/tests/coverage/index.html'); $report->addField($coverageField); -$runner->addTestsFromDirectory('tests/units'); +/** + * @var $runner \atoum\atoum\scripts\runner + */ +$testsDirectory = __DIR__ . '/tests/units/TingBundle'; +$subDirectories = glob($testsDirectory . '/*', GLOB_ONLYDIR); +$files = glob($testsDirectory . '/*.php'); + +if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) { + // Exclude ArgumentResolver from tests, available only in SF6+ + $subDirectories = array_filter($subDirectories, fn($directory) => $directory !== $testsDirectory . '/ArgumentResolver' ); +} +foreach ($subDirectories as $directory) { + $runner->addTestsFromPattern($directory . '/*'); +} +foreach ($files as $file) { + $runner->addTestsFromPattern($files); +} diff --git a/CHANGELOG b/CHANGELOG index 1cfbce7..8cb7d0c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,6 @@ +3.9.0 (unreleased): + * Feature: (SF6.2+) Supports automatic value resolving in controllers (with or without MapEntity), see [https://github.com/symfony/symfony/blob/7.2/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php](Doctrine Bridge) + 3.8.1 (2025-01-06): * PHP 8.4: Implicit nullable parameters are now deprecated * Add return type matching SF 7 deprecation diff --git a/composer.json b/composer.json index c253062..fbc5925 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,10 @@ "require-dev": { "atoum/atoum": "^4.2", "atoum/stubs": "^2.2", - "brick/geo": ">=0.5 <=1.0" + "brick/geo": ">=0.5 <=1.0", + "symfony/expression-language": "^6.3 || ^7.2", + "symfony/uid": "^6.0 || ^7.0", + "symfony/security-bundle": "^6.0 || ^7.0" }, "autoload": { "psr-4" : { diff --git a/src/TingBundle/ArgumentResolver/EntityValueResolver.php b/src/TingBundle/ArgumentResolver/EntityValueResolver.php new file mode 100644 index 0000000..bef55fc --- /dev/null +++ b/src/TingBundle/ArgumentResolver/EntityValueResolver.php @@ -0,0 +1,201 @@ +defaults = $defaults ?? new MapEntity(); + } + + public function resolve(Request $request, ArgumentMetadata $argument): array + { + if (\is_object($request->attributes->get($argument->getName()))) { + return []; + } + + $options = $argument->getAttributes(MapEntity::class, ArgumentMetadata::IS_INSTANCEOF); + $options = ($options[0] ?? $this->defaults)->withDefaults($this->defaults, $argument->getType()); + + if (!$options->class || $options->disabled) { + return []; + } + $repository = null; + + $this->metadataRepository->findMetadataForEntity($options->class, function (Metadata $metadata) use (&$repository) { + $repository = $this->repositoryFactory->get($metadata->getRepository()); + }, fn () => null); + if ($repository === null) { + return []; + } + + $message = ''; + if (null !== $options->expr) { + if (null === $object = $this->findViaExpression($repository, $request, $options)) { + $message = \sprintf(' The expression "%s" returned null.', $options->expr); + } + // find by identifier? + } elseif (false === $object = $this->find($repository, $request, $options, $argument)) { + // find by criteria + if (!$criteria = $this->getCriteria($request, $options, $argument)) { + return []; + } + try { + $object = $repository->getOneBy($criteria); + } catch (Exception $e) { + $object = null; + } + } + + if (null === $object && !$argument->isNullable()) { + throw new NotFoundHttpException($options->message ?? (\sprintf('"%s" object not found by "%s".', $options->class, self::class).$message)); + } + + return [$object]; + } + + private function find(Repository $repository, Request $request, MapEntity $options, ArgumentMetadata $argument): false|object|null + { + if ($options->mapping || $options->exclude) { + return false; + } + + $id = $this->getIdentifier($request, $options, $argument); + if (false === $id || null === $id) { + return $id; + } + if (\is_array($id) && \in_array(null, $id, true)) { + return null; + } + try { + return $repository->get($id, $options->forcePrimary); + } catch (Exception $e) { + return null; + } + } + + private function getIdentifier(Request $request, MapEntity $options, ArgumentMetadata $argument): mixed + { + if (\is_array($options->id)) { + $id = []; + foreach ($options->id as $field) { + // Convert "%s_uuid" to "foobar_uuid" + if (str_contains($field, '%s')) { + $field = \sprintf($field, $argument->getName()); + } + + $id[$field] = $request->attributes->get($field); + } + + return $id; + } + + if ($options->id) { + return $request->attributes->get($options->id) ?? ($options->stripNull ? false : null); + } + + $name = $argument->getName(); + + if ($request->attributes->has($name)) { + if (\is_array($id = $request->attributes->get($name))) { + return false; + } + + foreach ($request->attributes->get('_route_mapping') ?? [] as $parameter => $attribute) { + if ($name === $attribute) { + $options->mapping = [$name => $parameter]; + + return false; + } + } + + return $id ?? ($options->stripNull ? false : null); + } + + if ($request->attributes->has('id')) { + return $request->attributes->get('id') ?? ($options->stripNull ? false : null); + } + + return false; + } + + private function getCriteria(Request $request, MapEntity $options, ArgumentMetadata $argument): array + { + if (!($mapping = $options->mapping) && \is_array($criteria = $request->attributes->get($argument->getName()))) { + foreach ($options->exclude as $exclude) { + unset($criteria[$exclude]); + } + + if ($options->stripNull) { + $criteria = array_filter($criteria, static fn ($value) => null !== $value); + } + + return $criteria; + } elseif (null === $mapping) { + throw new \RuntimeException("Auto-mapping is not supported for Ting entities. Declare the identifier using either the #[MapEntity] attribute or mapped route parameters."); + } + + if ($mapping && array_is_list($mapping)) { + $mapping = array_combine($mapping, $mapping); + } + + foreach ($options->exclude as $exclude) { + unset($mapping[$exclude]); + } + if (!$mapping) { + return []; + } + + $criteria = []; + + foreach ($mapping as $attribute => $field) { + $criteria[$field] = $request->attributes->get($attribute); + } + + if ($options->stripNull) { + $criteria = array_filter($criteria, static fn ($value) => null !== $value); + } + + return $criteria; + } + + private function findViaExpression(Repository $repository, Request $request, MapEntity $options): object|iterable|null + { + if (!$this->expressionLanguage) { + throw new \LogicException(\sprintf('You cannot use the "%s" if the ExpressionLanguage component is not available. Try running "composer require symfony/expression-language".', __CLASS__)); + } + + $variables = array_merge($request->attributes->all(), [ + 'repository' => $repository, + 'request' => $request, + ]); + + try { + return $this->expressionLanguage->evaluate($options->expr, $variables); + } catch (Exception $e) { + return null; + } + } +} \ No newline at end of file diff --git a/src/TingBundle/Attribute/MapEntity.php b/src/TingBundle/Attribute/MapEntity.php new file mode 100644 index 0000000..1cf2be5 --- /dev/null +++ b/src/TingBundle/Attribute/MapEntity.php @@ -0,0 +1,72 @@ +|null $mapping Configures the properties and values to use with the getOneBy() method + * The key is the route placeholder name and the value is the property name + * @param string[]|null $exclude Configures the properties that should be used in the getOneBy() method by excluding + * one or more properties so that not all are used + * @param bool|null $stripNull Whether to prevent null values from being used as parameters in the query (defaults to false) + * @param string[]|string|null $id If an id option is configured and matches a route parameter, then the resolver will find by the primary key + * @param bool $forcePrimary If true, forces Ting to always use the primary / master to retrieve the object + */ + public function __construct( + public ?string $class = null, + public ?string $expr = null, + public ?array $mapping = null, + public ?array $exclude = null, + public ?bool $stripNull = null, + public array|string|null $id = null, + public bool $forcePrimary = false, + bool $disabled = false, + string $resolver = EntityValueResolver::class, + public ?string $message = null, + ) { + parent::__construct($resolver, $disabled); + $this->selfValidate(); + } + + public function withDefaults(self $defaults, ?string $class): static + { + $clone = clone $this; + $clone->class ??= class_exists($class ?? '') ? $class : null; + $clone->expr ??= $defaults->expr; + $clone->mapping ??= $defaults->mapping; + $clone->exclude ??= $defaults->exclude ?? []; + $clone->stripNull ??= $defaults->stripNull ?? false; + $clone->id ??= $defaults->id; + $clone->forcePrimary ??= $defaults->forcePrimary ?? false; + $clone->message ??= $defaults->message; + + $clone->selfValidate(); + + return $clone; + } + + private function selfValidate(): void + { + if (!$this->id) { + return; + } + if ($this->mapping) { + throw new \LogicException('The "id" and "mapping" options cannot be used together on #[MapEntity] attributes.'); + } + if ($this->exclude) { + throw new \LogicException('The "id" and "exclude" options cannot be used together on #[MapEntity] attributes.'); + } + $this->mapping = []; + } +} \ No newline at end of file diff --git a/src/TingBundle/DependencyInjection/EntityFactory.php b/src/TingBundle/DependencyInjection/EntityFactory.php new file mode 100644 index 0000000..6ab1088 --- /dev/null +++ b/src/TingBundle/DependencyInjection/EntityFactory.php @@ -0,0 +1,39 @@ +setDefinition($id, new ChildDefinition('ting.security.user_provider')) + ->addArgument($config['class']) + ->addArgument($config['property']) + ; + } + + public function getKey(): string + { + return 'ting'; + } + + public function addConfiguration(NodeDefinition $builder): void + { + $builder + ->children() + ->scalarNode('class') + ->isRequired() + ->info('The full entity class name of your user class.') + ->cannotBeEmpty() + ->end() + ->scalarNode('property')->defaultNull()->end() + ->end() + ; + } +} diff --git a/src/TingBundle/DependencyInjection/TingExtension.php b/src/TingBundle/DependencyInjection/TingExtension.php index f54e6f3..85ff5e7 100644 --- a/src/TingBundle/DependencyInjection/TingExtension.php +++ b/src/TingBundle/DependencyInjection/TingExtension.php @@ -25,6 +25,7 @@ namespace CCMBenchmark\TingBundle\DependencyInjection; use CCMBenchmark\Ting\Repository\Metadata; +use CCMBenchmark\TingBundle\ArgumentResolver\EntityValueResolver; use CCMBenchmark\TingBundle\Schema\Column; use CCMBenchmark\TingBundle\Schema\Table; use Doctrine\Common\Cache\VoidCache; @@ -37,6 +38,8 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpKernel\Attribute\ValueResolver; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\Config\FileLocator; use Symfony\Component\Uid\Uuid; @@ -129,6 +132,20 @@ public function load(array $configs, ContainerBuilder $container): void $definition = $container->getDefinition('ting.cache_data_collector'); $definition->addMethodCall('setCacheLogger', [$reference]); } + + if (interface_exists(ValueResolverInterface::class) && class_exists(ValueResolver::class)) { + $definition = new Definition(EntityValueResolver::class); + + $definition->setArguments([ + new Reference('ting.metadatarepository'), + new Reference('ting'), + (new Reference('Symfony\Component\ExpressionLanguage\ExpressionLanguage', ContainerInterface::NULL_ON_INVALID_REFERENCE)) + ]); + + $definition->addTag('controller.argument_value_resolver', ['priority' => 110]); + + $container->setDefinition('ting.entity_value_resolver', $definition); + } } /** diff --git a/src/TingBundle/Resources/config/services.xml b/src/TingBundle/Resources/config/services.xml index 9f6caca..b8674a2 100644 --- a/src/TingBundle/Resources/config/services.xml +++ b/src/TingBundle/Resources/config/services.xml @@ -106,5 +106,10 @@ + + + + + diff --git a/src/TingBundle/Security/EntityUserProvider.php b/src/TingBundle/Security/EntityUserProvider.php new file mode 100644 index 0000000..63659a1 --- /dev/null +++ b/src/TingBundle/Security/EntityUserProvider.php @@ -0,0 +1,137 @@ + + */ +class EntityUserProvider implements UserProviderInterface, PasswordUpgraderInterface +{ + public function __construct( + private readonly MetadataRepository $metadataRepository, + private readonly RepositoryFactory $repositoryFactory, + private readonly string $class, + private readonly ?string $property = null, + ) { + } + + public function loadUserByIdentifier(string $identifier): UserInterface + { + $repository = $this->getRepository(); + if (null !== $this->property) { + $user = $repository->getOneBy([$this->property => $identifier]); + } else { + if (!$repository instanceof UserLoaderInterface) { + throw new \InvalidArgumentException(\sprintf('You must either make the "%s" entity Ting Repository ("%s") implement "CCMBenchmark\TingBundle\Security\UserLoaderInterface" or set the "property" option in the corresponding entity provider configuration.', $this->class, get_debug_type($repository))); + } + + $user = $repository->loadUserByIdentifier($identifier); + } + + if (null === $user) { + $e = new UserNotFoundException(\sprintf('User "%s" not found.', $identifier)); + $e->setUserIdentifier($identifier); + + throw $e; + } + + return $user; + } + + public function refreshUser(UserInterface $user): UserInterface + { + if (!$user instanceof $this->class) { + throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user))); + } + + $repository = $this->getRepository(); + if ($repository instanceof UserProviderInterface) { + $refreshedUser = $repository->refreshUser($user); + } else { + // The user must be reloaded via the primary key as all other data + // might have changed without proper persistence in the database. + // That's the case when the user has been changed by a form with + // validation errors. + if (!$id = $this->getIdentifierValues($user)) { + throw new \InvalidArgumentException('You cannot refresh a user from the EntityUserProvider that does not contain an identifier. The user object has to be serialized with its own identifier mapped by Doctrine.'); + } + + $refreshedUser = $repository->get($id); + if (null === $refreshedUser) { + $e = new UserNotFoundException('User with id '.json_encode($id).' not found.'); + $e->setUserIdentifier(json_encode($id)); + + throw $e; + } + } + + return $refreshedUser; + } + + public function supportsClass(string $class): bool + { + return $class === $this->class || is_subclass_of($class, $this->class); + } + + /** + * @final + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof $this->class) { + throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user))); + } + + $repository = $this->getRepository(); + if ($repository instanceof PasswordUpgraderInterface) { + $repository->upgradePassword($user, $newHashedPassword); + } + } + + private function getMetadata(): Metadata + { + $metadata = null; + $this->metadataRepository->findMetadataForEntity($this->class, function (Metadata $innerMetadata) use (&$metadata) { + $metadata = $innerMetadata; + }, fn () => null); + + if ($metadata === null) { + throw new \InvalidArgumentException(\sprintf('No metadata found for entity "%s".', $this->class)); + } + + return $metadata; + } + + private function getRepository(): Repository + { + return $this->repositoryFactory->get($this->getMetadata()->getRepository()); + } + + private function getIdentifierValues($user): ?array + { + $metadata = $this->getMetadata(); + $primaries = $metadata->getPrimaries(); + if ($primaries === []) { + return null; + } + $identifierValues = []; + foreach ($primaries as $primary) { + $identifierValues[$primary] = $metadata->getEntityPropertyByFieldName($user, $primary); + } + + return $identifierValues; + } +} diff --git a/src/TingBundle/Security/UserLoaderInterface.php b/src/TingBundle/Security/UserLoaderInterface.php new file mode 100644 index 0000000..4064174 --- /dev/null +++ b/src/TingBundle/Security/UserLoaderInterface.php @@ -0,0 +1,15 @@ +hasExtension('security')) { + $container->getExtension('security')->addUserProviderFactory(new EntityFactory()); + } + } } diff --git a/tests/fixtures/EntityWithAttributesRepository.php b/tests/fixtures/EntityWithAttributesRepository.php index f26f8c3..f1e6a2f 100644 --- a/tests/fixtures/EntityWithAttributesRepository.php +++ b/tests/fixtures/EntityWithAttributesRepository.php @@ -1,6 +1,6 @@ metadataRepository = new \mock\CCMBenchmark\Ting\MetadataRepository(new SerializerFactory()); + $connectionPool = new ConnectionPool(); + $queryFactory = new QueryFactory(); + $unitOfWork = new UnitOfWork($connectionPool, $this->metadataRepository, $queryFactory); + $hydrator = new Hydrator(); + $this->repositoryFactory = new \mock\CCMBenchmark\TingBundle\Repository\RepositoryFactory( + $connectionPool, + $this->metadataRepository, + new QueryFactory(), + new CollectionFactory($this->metadataRepository, $unitOfWork, $hydrator), + $unitOfWork, + new \CCMBenchmark\Ting\Cache\Cache(), + new SerializerFactory() + ); + $this->expressionLanguage = new \mock\Symfony\Component\ExpressionLanguage\ExpressionLanguage(); + + $this->resolver = new \CCMBenchmark\TingBundle\ArgumentResolver\EntityValueResolver( + $this->metadataRepository, + $this->repositoryFactory, + $this->expressionLanguage + ); + } + + public function testReturnsEmptyArrayWhenArgumentIsAlreadyObject(): void + { + if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) { + return; + } + $this + ->given($request = new Request(['argumentName' => new \stdClass()])) + ->and($argument = new ArgumentMetadata('argumentName', null, false, false, null)) + + ->when($result = $this->resolver->resolve($request, $argument)) + + ->then + ->array($result) + ->isEmpty(); + } + + public function testReturnsEmptyArrayWhenMapEntityIsDisabled(): void + { + if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) { + return; + } + $this + ->given($mapEntity = new MapEntity(class: 'TestEntity', disabled: true)) + ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity)) + ->and($request = new Request()) + + ->when($result = $this->resolver->resolve($request, $argument)) + + ->then + ->array($result) + ->isEmpty(); + } + + public function testWithRouteMapping(): void + { + if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) { + return; + } + $this + ->given($argumentCity = $this->createArgumentMetadataForAttributes(new MapEntity(class: 'City'), 'city')) + ->and($argumentCountry = $this->createArgumentMetadataForAttributes(new MapEntity(class: 'Country'), 'country')) + ->and($request = new Request(attributes: ['city' => 'Paris', 'country' => 'France', '_route_mapping' => ['slug' => 'city', 'country' => 'country']])) + ->and($city = new \stdClass()) + ->and($country = new \stdClass()) + + ->and($repository = new \mock\tests\fixtures\SimpleRepository()) + ->and($this->calling($repository)->getOneBy = static fn($criteria) => match($criteria) { + ['slug' => 'Paris'] => $city, + ['country' => 'France'] => $country, + } ) + ->and($this->mockRepositoryFactoryAndMetadata($repository)) + + ->when($result = $this->resolver->resolve($request, $argumentCity)) + ->then + ->array($result) + ->hasSize(1) + ->contains($city) + ->when($result = $this->resolver->resolve($request, $argumentCountry)) + ->then + ->array($result) + ->hasSize(1) + ->contains($country) + ; + } + + public function testThrowsNotFoundHttpExceptionWhenObjectCannotBeFound(): void + { + if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) { + return; + } + $simpleRepository = new \mock\tests\fixtures\SimpleRepository(); + $this->calling($simpleRepository)->get = null; + $this + ->given($mapEntity = new MapEntity(class: 'TestEntity', id: 'id')) + ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity)) + ->and($request = new Request(attributes: ['id' => 123])) + + ->and($this->calling($this->metadataRepository)->findMetadataForEntity = + fn($class, $success) => $success(new \mock\CCMBenchmark\Ting\Repository\Metadata(new SerializerFactory()))) + + ->and($this->calling($this->repositoryFactory)->get = $simpleRepository) + + ->exception(function () use ($request, $argument) { + $this->resolver->resolve($request, $argument); + }) + ->isInstanceOf(NotFoundHttpException::class); + } + + public function testReturnsObjectWhenFoundInRepository() + { + if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) { + return; + } + $this + ->given($mapEntity = new MapEntity(class: 'TestEntity', id: 'id')) + ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity)) + ->and($request = new Request(attributes: ['id' => 123])) + + ->and($repository = new \mock\tests\fixtures\SimpleRepository()) + ->and($this->calling($repository)->get = $object = new \stdClass()) + ->and($this->mockRepositoryFactoryAndMetadata($repository)) + + ->when($result = $this->resolver->resolve($request, $argument)) + + ->then + ->array($result) + ->hasSize(1) + ->contains($object); + } + + public function testEvaluatesExpressionWhenProvided() + { + if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) { + return; + } + $this + ->given($mapEntity = new MapEntity(class: 'TestEntity', expr: 'repository.find(id)')) + ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity)) + ->and($request = new Request(attributes: ['id' => 123])) + + ->and($repository = new \mock\tests\fixtures\SimpleRepository()) + ->and($this->mockRepositoryFactoryAndMetadata($repository)) + ->and($this->calling($this->expressionLanguage)->evaluate = $object = new \stdClass()) + + ->when($result = $this->resolver->resolve($request, $argument)) + + ->then + ->array($result) + ->hasSize(1) + ->contains($object); + } + + public function testExpressionFailureReturns404() + { + if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) { + return; + } + $this + ->given($mapEntity = new MapEntity(class: 'TestEntity', expr: 'repository.find(id)')) + ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity)) + ->and($request = new Request(attributes: ['id' => 123])) + + ->and($repository = new \mock\tests\fixtures\SimpleRepository()) + ->and($this->mockRepositoryFactoryAndMetadata($repository)) + ->and($this->calling($this->expressionLanguage)->evaluate = null) + + ->exception(function () use ($request, $argument) { + $this->resolver->resolve($request, $argument); + }) + ->isInstanceOf(NotFoundHttpException::class); + } + + public function testReturnsEmptyArrayWhenCriteriaCannotBeDetermined() + { + if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) { + return; + } + $this + ->given($mapEntity = new MapEntity(class: 'TestEntity', mapping: [])) + ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity)) + ->and($request = new Request()) + + ->and($repository = new \mock\tests\fixtures\SimpleRepository()) + ->and($this->calling($repository)->getOneBy->doesNothing()) + ->and($this->mockRepositoryFactoryAndMetadata($repository)) + + ->when($result = $this->resolver->resolve($request, $argument)) + + ->then + ->array($result) + ->isEmpty(); + } + + public function testReturnsObjectWhenCriteriaCanBeDetermined() + { + if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) { + return; + } + $this + ->given($mapEntity = new MapEntity(class: 'TestEntity', mapping: ['name' => 'name', 'id' => 'id'])) + ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity)) + ->and($request = new Request(attributes: ['id' => 123, 'name' => 'foo'])) + + ->and($repository = new \mock\tests\fixtures\SimpleRepository()) + ->and($this->calling($repository)->getOneBy = $object = new \stdClass()) + ->and($this->mockRepositoryFactoryAndMetadata($repository)) + + ->when($result = $this->resolver->resolve($request, $argument)) + + ->then + ->array($result) + ->hasSize(1) + ->contains($object); + } + + private function createArgumentMetadataForAttributes(MapEntity $attribute, string $argumentName = 'argumentName'): ArgumentMetadata + { + return new ArgumentMetadata($argumentName, $attribute->class, false, false, null, false, [$attribute]); + } + + private function mockRepositoryFactoryAndMetadata($repository): void + { + $metadata = new \mock\CCMBenchmark\Ting\Repository\Metadata(new SerializerFactory()); + $this->calling($metadata)->getRepository = 'RepositoryClass'; + + $this->calling($this->metadataRepository)->findMetadataForEntity = + fn($class, $success) => $success($metadata); + + $this->calling($this->repositoryFactory)->get = $repository; + } +} \ No newline at end of file diff --git a/tests/units/TingBundle/DependencyInjection/TingExtension.php b/tests/units/TingBundle/DependencyInjection/TingExtension.php index d15cec1..a9b64dd 100644 --- a/tests/units/TingBundle/DependencyInjection/TingExtension.php +++ b/tests/units/TingBundle/DependencyInjection/TingExtension.php @@ -32,15 +32,6 @@ class TingExtension extends \atoum { - public function testEmpty() - { - // Minimum test to ensure code execution - $this - ->if($containerBuilder = new ContainerBuilder(new ParameterBag(['kernel.debug' => false]))) - ->then($this->newTestedInstance->load([], $containerBuilder)) - ; - } - public function testAutoConfigurationWithAttributes() { $fixtureInstance = new Definition(EntityWithAttributes::class); @@ -86,4 +77,15 @@ public function testAutoConfigurationWithAttributes() ]) ; } + + public function testContainerShouldDeclareValueResolverIfAvailable() + { + $this + ->if($containerBuilder = new ContainerBuilder(new ParameterBag(['kernel.debug' => false]))) + ->then($this->newTestedInstance->load([], $containerBuilder)) + ->and($shouldBeDeclared = interface_exists(ValueResolverInterface::class) && class_exists(ValueResolver::class)) + ->boolean($containerBuilder->hasDefinition('ting.argumentvalueresolver')) + ->isEqualTo($shouldBeDeclared) + ; + } }