diff --git a/README.md b/README.md index e2984b3..07652a0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[](https://github.com/chamber-orchestra/form-bundle/actions/workflows/php.yml) + # ChamberOrchestra Form Bundle Symfony bundle that streamlines JSON‑first form handling for APIs. It provides helper traits for controller flow, specialized form types, reusable transformers, and standardized error views (RFC 7807 style) to keep API responses consistent. diff --git a/src/Validator/Constraints/UniqueFieldValidator.php b/src/Validator/Constraints/UniqueFieldValidator.php index bd30026..8b8d9c6 100644 --- a/src/Validator/Constraints/UniqueFieldValidator.php +++ b/src/Validator/Constraints/UniqueFieldValidator.php @@ -132,7 +132,7 @@ private function addComparisonToCriteria( private function buildCriteria(UniqueField $constraint, $value, $origin): Criteria { - $criteria = Criteria::create(); + $criteria = Criteria::create(true); // build includes fields with OR join foreach ($constraint->fields as $field) { diff --git a/tests/Integrational/TestKernel.php b/tests/Integrational/TestKernel.php index 5f3d828..d013c10 100644 --- a/tests/Integrational/TestKernel.php +++ b/tests/Integrational/TestKernel.php @@ -21,6 +21,7 @@ use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; final class TestKernel extends Kernel { @@ -57,6 +58,9 @@ protected function configureContainer(ContainerConfigurator $container): void $container->services() ->alias(EntityManagerInterface::class, 'doctrine.orm.entity_manager') ->public(); + $container->services() + ->alias(ValidatorInterface::class, 'validator') + ->public(); if (\class_exists(DoctrineBundle::class)) { $container->extension('doctrine', [ diff --git a/tests/Integrational/UniqueFieldValidatorIntegrationTest.php b/tests/Integrational/UniqueFieldValidatorIntegrationTest.php new file mode 100644 index 0000000..eb0739d --- /dev/null +++ b/tests/Integrational/UniqueFieldValidatorIntegrationTest.php @@ -0,0 +1,46 @@ +markTestSkipped('doctrine/doctrine-bundle is required for this integration test.'); + } + + self::bootKernel(); + $container = self::getContainer(); + $em = $container->get(EntityManagerInterface::class); + $validator = $container->get(ValidatorInterface::class); + + $schemaTool = new SchemaTool($em); + $schemaTool->dropSchema([$em->getClassMetadata(TestUser::class)]); + $schemaTool->createSchema([$em->getClassMetadata(TestUser::class)]); + + $user = new TestUser('user@example.com'); + $em->persist($user); + $em->flush(); + + $constraint = new UniqueField(); + $constraint->entityClass = TestUser::class; + $constraint->fields = ['email']; + + $violations = $validator->validate('user@example.com', $constraint); + $noViolations = $validator->validate('unique@example.com', $constraint); + + self::assertCount(1, $violations); + self::assertSame(UniqueField::ALREADY_USED_ERROR, $violations[0]->getCode()); + self::assertCount(0, $noViolations); + } +} diff --git a/tests/Unit/FormTraitTest.php b/tests/Unit/FormTraitTest.php index dfb59ef..a9b2a88 100644 --- a/tests/Unit/FormTraitTest.php +++ b/tests/Unit/FormTraitTest.php @@ -10,10 +10,15 @@ use ChamberOrchestra\ViewBundle\View\DataView; use ChamberOrchestra\ViewBundle\View\ResponseView; use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\Forms; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Validator\ConstraintViolation; final class FormTraitTest extends TestCase @@ -180,4 +185,168 @@ public function exposeSerializeFormErrors(FormInterface $form): array self::assertSame('urn:uuid:3f238b83-2c8a-4f58-b7a6-3d54a3bd93ed', $violations[0]->type); self::assertSame(['{{ value }}' => 'invalid'], $violations[0]->parameters); } + + public function testCreateRedirectResponseReturnsViewForXmlHttpRequest(): void + { + $request = new Request(); + $request->headers->set('X-Requested-With', 'XMLHttpRequest'); + + $stack = new RequestStack(); + $stack->push($request); + + $container = $this->createStub(ContainerInterface::class); + $container->method('get')->with('request_stack')->willReturn($stack); + + $host = new class($container) { + use FormTrait; + + protected \Psr\Container\ContainerInterface $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + protected function redirect(string $url, int $status = 302): RedirectResponse + { + return new RedirectResponse('', $status, ['Location' => $url]); + } + + public function exposeCreateRedirectResponse( + string $url, + int $status + ): Response|\ChamberOrchestra\FormBundle\View\RedirectView { + return $this->createRedirectResponse($url, $status); + } + }; + + $response = $host->exposeCreateRedirectResponse('/target', Response::HTTP_FOUND); + + self::assertInstanceOf(\ChamberOrchestra\FormBundle\View\RedirectView::class, $response); + self::assertSame('/target', $response->location); + self::assertSame(Response::HTTP_FOUND, $response->status); + } + + public function testCreateRedirectResponseReturnsResponseForNonXmlHttpRequest(): void + { + $request = new Request(); + $stack = new RequestStack(); + $stack->push($request); + + $container = $this->createStub(ContainerInterface::class); + $container->method('get')->with('request_stack')->willReturn($stack); + + $host = new class($container) { + use FormTrait; + + protected \Psr\Container\ContainerInterface $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + protected function redirect(string $url, int $status = 302): RedirectResponse + { + return new RedirectResponse($url, $status, ['Location' => $url]); + } + + public function exposeCreateRedirectResponse(string $url, int $status): Response + { + return $this->createRedirectResponse($url, $status); + } + }; + + $response = $host->exposeCreateRedirectResponse('/target', Response::HTTP_FOUND); + + self::assertInstanceOf(Response::class, $response); + self::assertSame('/target', $response->headers->get('Location')); + self::assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + } + + public function testCreateSuccessHtmlResponseHandlesXmlHttpRequest(): void + { + $request = new Request(); + $request->headers->set('X-Requested-With', 'XMLHttpRequest'); + + $stack = new RequestStack(); + $stack->push($request); + + $container = $this->createStub(ContainerInterface::class); + $container->method('get')->with('request_stack')->willReturn($stack); + + $host = new class($container) { + use FormTrait; + + protected \Psr\Container\ContainerInterface $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + public function renderView(string $view, array $parameters = []): string + { + return '
html
'; + } + + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + return new Response('html'); + } + + public function exposeCreateSuccessHtmlResponse( + string $view, + array $parameters = [] + ): Response|\ChamberOrchestra\FormBundle\View\SuccessHtmlView { + return $this->createSuccessHtmlResponse($view, $parameters); + } + }; + + $response = $host->exposeCreateSuccessHtmlResponse('template.html.twig'); + + self::assertInstanceOf(\ChamberOrchestra\FormBundle\View\SuccessHtmlView::class, $response); + self::assertSame(['html' => 'html
'], $response->data); + } + + public function testCreateSuccessHtmlResponseHandlesNonXmlHttpRequest(): void + { + $request = new Request(); + $stack = new RequestStack(); + $stack->push($request); + + $container = $this->createStub(ContainerInterface::class); + $container->method('get')->with('request_stack')->willReturn($stack); + + $host = new class($container) { + use FormTrait; + + protected \Psr\Container\ContainerInterface $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + public function renderView(string $view, array $parameters = []): string + { + return 'html
'; + } + + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + return new Response('html'); + } + + public function exposeCreateSuccessHtmlResponse(string $view, array $parameters = []): Response + { + return $this->createSuccessHtmlResponse($view, $parameters); + } + }; + + $response = $host->exposeCreateSuccessHtmlResponse('template.html.twig'); + + self::assertInstanceOf(Response::class, $response); + self::assertSame('html', $response->getContent()); + } }