* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\Validator\Test; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Constraint\IsIdentical; use PHPUnit\Framework\Constraint\IsInstanceOf; use PHPUnit\Framework\Constraint\IsNull; use PHPUnit\Framework\Constraint\LogicalOr; use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\GroupSequence; use Symfony\Component\Validator\Constraints\NotNull; use Symfony\Component\Validator\Constraints\Valid; use Symfony\Component\Validator\ConstraintValidatorInterface; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationInterface; use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\ConstraintViolationListInterface; use Symfony\Component\Validator\Context\ExecutionContext; use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Mapping\ClassMetadata; use Symfony\Component\Validator\Mapping\MetadataInterface; use Symfony\Component\Validator\Mapping\PropertyMetadata; use Symfony\Component\Validator\Validator\ContextualValidatorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Contracts\Translation\TranslatorInterface; /** * A test case to ease testing Constraint Validators. * * @author Bernhard Schussek * * @template T of ConstraintValidatorInterface */ abstract class ConstraintValidatorTestCase extends TestCase { protected ExecutionContextInterface $context; /** * @var T */ protected ConstraintValidatorInterface $validator; protected string $group; protected ?MetadataInterface $metadata; protected mixed $object; protected mixed $value; protected mixed $root; protected string $propertyPath; protected Constraint $constraint; protected ?string $defaultTimezone = null; private string $defaultLocale; private array $expectedViolations; private int $call; protected function setUp(): void { $this->group = 'MyGroup'; $this->metadata = null; $this->object = null; $this->value = 'InvalidValue'; $this->root = 'root'; $this->propertyPath = 'property.path'; // Initialize the context with some constraint so that we can // successfully build a violation. $this->constraint = new NotNull(); $this->context = $this->createContext(); $this->validator = $this->createValidator(); $this->validator->initialize($this->context); if (class_exists(\Locale::class)) { $this->defaultLocale = \Locale::getDefault(); \Locale::setDefault('en'); } $this->expectedViolations = []; $this->call = 0; $this->setDefaultTimezone('UTC'); } protected function tearDown(): void { $this->restoreDefaultTimezone(); if (class_exists(\Locale::class)) { \Locale::setDefault($this->defaultLocale); } } protected function setDefaultTimezone(?string $defaultTimezone) { // Make sure this method cannot be called twice before calling // also restoreDefaultTimezone() if (null === $this->defaultTimezone) { $this->defaultTimezone = date_default_timezone_get(); date_default_timezone_set($defaultTimezone); } } protected function restoreDefaultTimezone() { if (null !== $this->defaultTimezone) { date_default_timezone_set($this->defaultTimezone); $this->defaultTimezone = null; } } protected function createContext() { $translator = $this->createMock(TranslatorInterface::class); $translator->expects($this->any())->method('trans')->willReturnArgument(0); $validator = $this->createMock(ValidatorInterface::class); $validator->expects($this->any()) ->method('validate') ->willReturnCallback(fn () => $this->expectedViolations[$this->call++] ?? new ConstraintViolationList()); $context = new ExecutionContext($validator, $this->root, $translator); $context->setGroup($this->group); $context->setNode($this->value, $this->object, $this->metadata, $this->propertyPath); $context->setConstraint($this->constraint); $contextualValidatorMockBuilder = $this->getMockBuilder(AssertingContextualValidator::class) ->setConstructorArgs([$context]); $contextualValidatorMethods = [ 'atPath', 'validate', 'validateProperty', 'validatePropertyValue', 'getViolations', ]; $contextualValidatorMockBuilder->onlyMethods($contextualValidatorMethods); $contextualValidator = $contextualValidatorMockBuilder->getMock(); $contextualValidator->expects($this->any()) ->method('atPath') ->willReturnCallback(fn ($path) => $contextualValidator->doAtPath($path)); $contextualValidator->expects($this->any()) ->method('validate') ->willReturnCallback(fn ($value, $constraints = null, $groups = null) => $contextualValidator->doValidate($value, $constraints, $groups)); $contextualValidator->expects($this->any()) ->method('validateProperty') ->willReturnCallback(fn ($object, $propertyName, $groups = null) => $contextualValidator->validateProperty($object, $propertyName, $groups)); $contextualValidator->expects($this->any()) ->method('validatePropertyValue') ->willReturnCallback(fn ($objectOrClass, $propertyName, $value, $groups = null) => $contextualValidator->doValidatePropertyValue($objectOrClass, $propertyName, $value, $groups)); $contextualValidator->expects($this->any()) ->method('getViolations') ->willReturnCallback(fn () => $contextualValidator->doGetViolations()); $validator->expects($this->any()) ->method('inContext') ->with($context) ->willReturn($contextualValidator); return $context; } protected function setGroup(?string $group) { $this->group = $group; $this->context->setGroup($group); } protected function setObject(mixed $object) { $this->object = $object; $this->metadata = \is_object($object) ? new ClassMetadata($object::class) : null; $this->context->setNode($this->value, $this->object, $this->metadata, $this->propertyPath); } protected function setProperty(mixed $object, string $property) { $this->object = $object; $this->metadata = \is_object($object) ? new PropertyMetadata($object::class, $property) : null; $this->context->setNode($this->value, $this->object, $this->metadata, $this->propertyPath); } protected function setValue(mixed $value) { $this->value = $value; $this->context->setNode($this->value, $this->object, $this->metadata, $this->propertyPath); } protected function setRoot(mixed $root) { $this->root = $root; $this->context = $this->createContext(); $this->validator->initialize($this->context); } protected function setPropertyPath(string $propertyPath) { $this->propertyPath = $propertyPath; $this->context->setNode($this->value, $this->object, $this->metadata, $this->propertyPath); } protected function expectNoValidate() { $validator = $this->context->getValidator()->inContext($this->context); $validator->expectNoValidate(); } protected function expectValidateAt(int $i, string $propertyPath, mixed $value, string|GroupSequence|array|null $group) { $validator = $this->context->getValidator()->inContext($this->context); $validator->expectValidation($i, $propertyPath, $value, $group, function ($passedConstraints) { $expectedConstraints = LogicalOr::fromConstraints(new IsNull(), new IsIdentical([]), new IsInstanceOf(Valid::class)); Assert::assertThat($passedConstraints, $expectedConstraints); }); } protected function expectValidateValue(int $i, mixed $value, array $constraints = [], string|GroupSequence|array|null $group = null) { $contextualValidator = $this->context->getValidator()->inContext($this->context); $contextualValidator->expectValidation($i, null, $value, $group, function ($passedConstraints) use ($constraints) { if (\is_array($constraints) && !\is_array($passedConstraints)) { $passedConstraints = [$passedConstraints]; } Assert::assertEquals($constraints, $passedConstraints); }); } protected function expectFailingValueValidation(int $i, mixed $value, array $constraints, string|GroupSequence|array|null $group, ConstraintViolationInterface $violation) { $contextualValidator = $this->context->getValidator()->inContext($this->context); $contextualValidator->expectValidation($i, null, $value, $group, function ($passedConstraints) use ($constraints) { if (\is_array($constraints) && !\is_array($passedConstraints)) { $passedConstraints = [$passedConstraints]; } Assert::assertEquals($constraints, $passedConstraints); }, $violation); } protected function expectValidateValueAt(int $i, string $propertyPath, mixed $value, Constraint|array $constraints, string|GroupSequence|array|null $group = null) { $contextualValidator = $this->context->getValidator()->inContext($this->context); $contextualValidator->expectValidation($i, $propertyPath, $value, $group, function ($passedConstraints) use ($constraints) { Assert::assertEquals($constraints, $passedConstraints); }); } protected function expectViolationsAt(int $i, mixed $value, Constraint $constraint) { $context = $this->createContext(); $validatorClassname = $constraint->validatedBy(); $validator = new $validatorClassname(); $validator->initialize($context); $validator->validate($value, $constraint); $this->expectedViolations[] = $context->getViolations(); return $context->getViolations(); } protected function assertNoViolation() { $this->assertSame(0, $violationsCount = \count($this->context->getViolations()), sprintf('0 violation expected. Got %u.', $violationsCount)); } protected function buildViolation(string|\Stringable $message): ConstraintViolationAssertion { return new ConstraintViolationAssertion($this->context, $message, $this->constraint); } /** * @return T */ abstract protected function createValidator(): ConstraintValidatorInterface; } final class ConstraintViolationAssertion { private ExecutionContextInterface $context; /** * @var ConstraintViolationAssertion[] */ private array $assertions; private string $message; private array $parameters = []; private mixed $invalidValue = 'InvalidValue'; private string $propertyPath = 'property.path'; private ?int $plural = null; private ?string $code = null; private ?Constraint $constraint; private mixed $cause = null; /** * @internal */ public function __construct(ExecutionContextInterface $context, string $message, ?Constraint $constraint = null, array $assertions = []) { $this->context = $context; $this->message = $message; $this->constraint = $constraint; $this->assertions = $assertions; } /** * @return $this */ public function atPath(string $path): static { $this->propertyPath = $path; return $this; } /** * @return $this */ public function setParameter(string $key, string $value): static { $this->parameters[$key] = $value; return $this; } /** * @return $this */ public function setParameters(array $parameters): static { $this->parameters = $parameters; return $this; } /** * @return $this */ public function setTranslationDomain(?string $translationDomain): static { // no-op for BC return $this; } /** * @return $this */ public function setInvalidValue(mixed $invalidValue): static { $this->invalidValue = $invalidValue; return $this; } /** * @return $this */ public function setPlural(int $number): static { $this->plural = $number; return $this; } /** * @return $this */ public function setCode(string $code): static { $this->code = $code; return $this; } /** * @return $this */ public function setCause(mixed $cause): static { $this->cause = $cause; return $this; } public function buildNextViolation(string $message): self { $assertions = $this->assertions; $assertions[] = $this; return new self($this->context, $message, $this->constraint, $assertions); } public function assertRaised(): void { $expected = []; foreach ($this->assertions as $assertion) { $expected[] = $assertion->getViolation(); } $expected[] = $this->getViolation(); $violations = iterator_to_array($this->context->getViolations()); Assert::assertSame($expectedCount = \count($expected), $violationsCount = \count($violations), sprintf('%u violation(s) expected. Got %u.', $expectedCount, $violationsCount)); reset($violations); foreach ($expected as $violation) { Assert::assertEquals($violation, current($violations)); next($violations); } } private function getViolation(): ConstraintViolation { return new ConstraintViolation( $this->message, $this->message, $this->parameters, $this->context->getRoot(), $this->propertyPath, $this->invalidValue, $this->plural, $this->code, $this->constraint, $this->cause ); } } /** * @internal */ class AssertingContextualValidator implements ContextualValidatorInterface { private ExecutionContextInterface $context; private bool $expectNoValidate = false; private int $atPathCalls = -1; private array $expectedAtPath = []; private int $validateCalls = -1; private array $expectedValidate = []; public function __construct(ExecutionContextInterface $context) { $this->context = $context; } public function __destruct() { if ($this->expectedAtPath) { throw new ExpectationFailedException('Some expected validation calls for paths were not done.'); } if ($this->expectedValidate) { throw new ExpectationFailedException('Some expected validation calls for values were not done.'); } } public function atPath(string $path): static { throw new \BadMethodCallException(); } /** * @return $this */ public function doAtPath(string $path): static { Assert::assertFalse($this->expectNoValidate, 'No validation calls have been expected.'); if (!isset($this->expectedAtPath[++$this->atPathCalls])) { throw new ExpectationFailedException(sprintf('Validation for property path "%s" was not expected.', $path)); } $expectedPath = $this->expectedAtPath[$this->atPathCalls]; unset($this->expectedAtPath[$this->atPathCalls]); Assert::assertSame($expectedPath, $path); return $this; } public function validate(mixed $value, Constraint|array|null $constraints = null, string|GroupSequence|array|null $groups = null): static { throw new \BadMethodCallException(); } /** * @return $this */ public function doValidate(mixed $value, Constraint|array|null $constraints = null, string|GroupSequence|array|null $groups = null): static { Assert::assertFalse($this->expectNoValidate, 'No validation calls have been expected.'); if (!isset($this->expectedValidate[++$this->validateCalls])) { return $this; } [$expectedValue, $expectedGroup, $expectedConstraints, $violation] = $this->expectedValidate[$this->validateCalls]; unset($this->expectedValidate[$this->validateCalls]); Assert::assertSame($expectedValue, $value); $expectedConstraints($constraints); Assert::assertSame($expectedGroup, $groups); if (null !== $violation) { $this->context->addViolation($violation->getMessage(), $violation->getParameters()); } return $this; } public function validateProperty(object $object, string $propertyName, string|GroupSequence|array|null $groups = null): static { throw new \BadMethodCallException(); } /** * @return $this */ public function doValidateProperty(object $object, string $propertyName, string|GroupSequence|array|null $groups = null): static { return $this; } public function validatePropertyValue(object|string $objectOrClass, string $propertyName, mixed $value, string|GroupSequence|array|null $groups = null): static { throw new \BadMethodCallException(); } /** * @return $this */ public function doValidatePropertyValue(object|string $objectOrClass, string $propertyName, mixed $value, string|GroupSequence|array|null $groups = null): static { return $this; } public function getViolations(): ConstraintViolationListInterface { throw new \BadMethodCallException(); } public function doGetViolations(): ConstraintViolationListInterface { return $this->context->getViolations(); } public function expectNoValidate(): void { $this->expectNoValidate = true; } public function expectValidation(string $call, ?string $propertyPath, mixed $value, string|GroupSequence|array|null $group, callable $constraints, ?ConstraintViolationInterface $violation = null): void { if (null !== $propertyPath) { $this->expectedAtPath[$call] = $propertyPath; } $this->expectedValidate[$call] = [$value, $group, $constraints, $violation]; } }